std::sort函数隐藏着BUG,不知道能安心刷题吗
导读:刷题经常用的
std::sort
函数的比较器隐藏着一个bug,不小心就容易导致coredump。
今天来聊聊STL中一个隐藏BUG的函数: std::sort
。
前言
这个问题呢,实际上是来自于交流群小伙伴@Tgive
在刷leetcode-56时发现的(完整的代码见附录),最终定位到是std::sort
函数的第三个参数写错了,即比较器Compartor
写的不对:
Compartor
将==
判断为true
则无法通过;更改为false
,能accept。
std::sort
部分的代码如下所示:区别仅仅在于lhs[0] == rhs[0]
时返回的是false
还是true
:
std::vector<std::vector<int>>& intervals; // 未通过版本 std::sort(intervals.begin(), intervals.end(), [](const std::vector<int> &lhs, const std::vector<int> &rhs) { return lhs.at(0) <= rhs.at(0); // lhs[0] == rhs[0],返回 true }); // 通过版本 std::sort(intervals.begin(), intervals.end(), [](const std::vector<int> &lhs, const std::vector<int> &rhs) { return lhs.at(0) < rhs.at(0); // lhs[0] == rhs[0],返回 false });
我把这个同学的代码运行了一次后,发现leetcode的编译器(GCC)报错如下:
terminate called after throwing an instance of 'std::out_of_range' what(): vector::_M_range_check: __n (which is 0) >= this->size() (which is 0)
一开始我以为是这个同学的代码问题,没考虑到一些边界情况,导致越界。后来自己测试下,发现确实是std::sort
问题,但是自己写了几个简单的demo,std::sort
函数又表现正常,令人匪夷所思。
『源码面前,了无秘密』 --- 侯捷
于是乎,开启 std::sort
函数的源码探索之路。
- 更好阅读体验,点击:踩坑记 (1) | std::sort函数隐藏着BUG,你知道吗
- 更多硬核知识,点击并关注: look_code_art,精彩等你发现
- 欢迎加入 [C++学习交流](https://w.url.cn/s/AEPtI***---
std::sort
STL的std::sort
函数是基于Musser在1996年提出的内省排序(Introspective sort
)算法实现。这个算法是个缝合怪,它汲取了插入排序、堆排序以及快排的优点:
- 针对大数据量,使用快排,时间复杂度是
O(NlogN)
; - 若快排递归深度超过阈值
__depth_limit
,改用堆排序,防止快排递归过深,同时保持时间复杂度仍是O(NlogN)
; - 当数据规模小于阈值
_S_threshold
时,改用插入排序。
下面从源码角度分析 std::sort
函数是怎么实现这一过程的。
std::__sort
std::sort
函数在内部就是直接调用的std::__sort
函数。因此下面,直接从std::__sort
函数开始分析。
std::__sort
代码如下。
template <typename _RandomAccessIterator, typename _Compare> inline void __sort(_RandomAccessIterator __first, _RandomAccessIterator __last, _Compare __comp) { if (__first != __last) { // 先是完成局部有序 std::__introsort_loop(__first, __last, std::__lg(__last - __first) * 2, __comp); // 再完全有序 std::__final_insertion_sort(__first, __last, __comp); } }
可以看出,std::__sort
主体上分为两个部分:
- 首先,由
__introsort_loop
函数使得[__first, __last)
区间在多个局部有序; - 其次,对第一步的结果,再进行一次插入排序
std::__final_insertion_sort
,保证整个[__first, __last)
区间有序。
下面从以上两点进行详述。
为便于讲解,在下面的描述中,将比较器
_Compare
当作默认的std::less
来处理。
std::__introsort_loop
__introsort_loop
函数,其代码实现如下:
template <typename _RandomAccessIterator, typename _Size, typename _Compare> void __introsort_loop(_RandomAccessIterator __first, _RandomAccessIterator __last, _Size __depth_limit, _Compare __comp) { // 限制条件1 while (__last - __first > int(_S_threshold)) { // 限制条件2 if (__depth_limit == 0) { std::__partial_sort(__first, __last, __last, __comp); // 堆排序 return; } //下面是快排 --__depth_limit; _RandomAccessIterator __cut = std::__unguarded_partition_pivot(__first, __last, __comp); // 寻找轴点 std::__introsort_loop(__cut, __last, __depth_limit, __comp); // 递归 __last = __cut; } }
从上面可以看出,__introsort_loop
函数是个递归函数,并且存在两个限制条件,或者说是递归基:
每次递归时,
[__first, __last)
区间的元素个数必须大于_S_threshold
:当不满足这个条件时,
__introsort_loop
函数就开始返回,这会导致元素个数小于阈值_S_threshold
的小区间仍然是无序的。那么这部分小区间怎么实现有序呢???
最大递归深度
__depth_limit
:当
__depth_limit==0
时,快排递归深度达到限制,为避免递归层数过深,STL就对当前的[__first, __last)
区间进行堆排序。从另一个角度看,相当于通过
__depth_limit
限制条件,将__introsort_loop
函数划分为快排、堆排两部分,而且不一定每次都会调用堆排,需要满足__depth_limit
限制条件。
因此,__introsort_loop
函数能正常递归,需要满足以下条件:
__last - __first > int(_S_threshold) && __depth_limit > 0
下面就从这两个限制点继续讲解。
Sthreshold
下面,开始讲解第【1】个限制条件_S_threshold
。
我们把先深度限制条件__depth_limit
去掉,只看快排部分。那么 __introsort_loop
函数的while
循环可简洁如下:
while (__last - __first > int(_S_threshold)) { // ... // 寻找分割区间的轴点 auto __cut = std::__unguarded_partition_pivot(__first, __last, __comp); // 在右分支递归 std::__introsort_loop(__cut, __last, __depth_limit, __comp); // 进入左分支 __last = __cut; }
有没有发现,这个快排的实现异常简洁?
在__introsort_loop
函数实现中,第一眼看上去似乎只有右分支递归,而忽略了左侧分支?
这一切都是为了效率。
STL将 __introsort_loop
放在了while
循环中,寻找到分割 [__first, __last)
区间的分割点__cut
后,每次先进入右分支递归,在右侧分支递归回来之后,下一步是:
__last = __cut;
这样,在下一个循环中进入左分支。
相比较常规快排实现,最直观的感受,就是减少一次函数调用的开销。此外,进入左分支后,可能就不满足__last - __first > int(_S_threshold)
限制条件,那么当前循环就退出了,避免递归。
因此,当 __introsort_loop
函数因不满足_S_threshold
阈值条件,逐层返回到std::__sort
函数中时,完整的[__first, __last)
区间中会有许多元素个数小于_S_threshold
的无序区间,他们的有序性留给最后的插入排序__final_insertion_sort
函数来完成。
std::__unguarded_partition_pivot
快排的最后一点,我们来看看STL是如何寻找分割位置__cut
的。这一部分是由__unguarded_partition_pivot
函数实现。
template <typename _RandomAccessIterator, typename _Compare> inline _RandomAccessIterator __unguarded_partition_pivot(_RandomAccessIterator __first, _RandomAccessIterator __last, _Compare __comp) { 0 + (5-0)/2 = 2 // 中间位置 _RandomAccessIterator __mid = __first + (__last - __first) / 2; // 取三点的中值,并置于 __first 处 std::__move_median_to_first(__first, __first + 1, __mid, __last - 1, __comp); // 对 [__first, __last) 进行分割,并返回分割点 __cut return std::__unguarded_partition(__first + 1, __last, __first, __comp); }
__unguarded_partition_pivot
的实现分为两步:
- 选择一个轴点
pivot
,用于后续比较。 - 基于该轴点
pivot
分割[__first, __last)
区间,使得[__first, __cut)
区间的元素不大于pivot
,[__cut, __last)
区间的元素不小于pivot
。
下面从这两点进行讲解。
std::__move_median_to_first
__move_median_to_first
函数,用意很明显,就是找出__a
、__b
、__c
的中值,并将这个中值放在__result
位置处。因此,根据传入参数,就是找出__first+1
、__mid
、__last-1
三个位置的中值,并将中值和__first
位置的值进行交换。
这个函数比较简单,其中的iter_swap
函数是交换两个迭代器指向的值,具体代码解释如下。
template <typename _Iterator, typename _Compare> void __move_median_to_first(_Iterator __result, _Iterator __a, _Iterator __b, _Iterator __c, _Compare __comp) { // __a < __b if (__comp(__a, __b)) { // __a < __b < __c if (__comp(__b, __c)) std::iter_swap(__result, __b); // __b // __a < __c <= _b else if (__comp(__a, __c)) std::iter_swap(__result, __c); // _c else // __c <= __a < __b std::iter_swap(__result, __a); // _a } // __b <= __a < __c else if (__comp(__a, __c)) std::iter_swap(__result, __a); // __a // __b <= c <= a else if (__comp(__b, __c)) std::iter_swap(__result, __c); // _c else // __c <= _b <= __a std::iter_swap(__result, __b); // _b }
std::__unguarded_partition
上面找到待比较的轴点pivot
之后,下面就是分割[__first, __last)
区间。
分割的核心思想:寻找分割位置__cut
,使得[__first, __cut)
区间的元素都不大于 pviot
,[__cut, __last)
区间的元素都不小于pivot
,然后返回分割点__cut
的位置。
这部分代码讲解,见下面代码注释。
template <typename _RandomAccessIterator, typename _Compare> _RandomAccessIterator __unguarded_partition(_RandomAccessIterator __first, _RandomAccessIterator __last, _RandomAccessIterator __pivot, _Compare __comp) { while (true) { while (__comp(__first, __pivot)) ++__first; // *__first < *__pivot /*** 跳出循环时,满足:*__first >= *__pivot ***/ --__last; while (__comp(__pivot, __last)) --__last; // *__pivot < *__last /*** 跳出循环时,满足:*__pivot >= *__last ***/ if (!(__first < __last)) // __fist >= __last ,表示左右边界相遇,此轮遍历结束 return __first; // 返回分割点位置 /** 经过上面两个while循环, * 此时: *__first >= *__pivot && *__pivot >= *__last * 不满足分割要求,因此需要交换__first, __last 两点的值 */ std::iter_swap(__first, __last); ++__first; } }
by the way
__unguarded_partition
函数,有个__unguarded
前缀,表示这个函数里没有越界检测。
那么问题来了,为什么可以没有越界检测?
以pivot
左侧的区间为例:
// 无越界检测版本 while (__comp(__first, __pivot)) ++__first; // 常见实现版本 while (__first < __last && __comp(__first, __pivot)) ++__first;
首先,明确两点:
__unguarded_partition
中传入参数__first
,实际上是__unguarded_partition_pivot
函数中的__first+1
位置;__unguarded_partition
中传入参数__pivot
,实际上是__unguarded_partition_pivot
函数中的__first
位置。
__pivot
是__unguarded_partition_pivot
函数中的三个点(__first+1
、__mid
、__last-1
)的中值,即__pivot
值肯定并不是最大的。
因此,在__unguarded_partition
函数中:
++__first
时,肯定存在一个点__pos
,其值不小于__pivot
处的值;- 要使得
__comp(__first, __pivot)
为true
,必须是*__first < *__pivot
; - 又因为至少存在一个点
__pos
,满足*__pos >= *__pivot
,使得__comp(__first, __last)
为false,
因此,__first > __last
的越界情况就不会发生,最终__first
会在某个位置停下。同理右侧区间的判断。
因此,即使不要边界检测,也不会发生越界错误。
隐藏的BUG
但是注意了,__unguarded_partition
函数,没有引入边界检测仍能正确运行,是基于正确的比较器算法。可当用户传入错误的比较器算法时,比如本文开篇自定义的比较器算法,就容易产生BUG,还是难以检测的BUG。
打开cppreference
,可以看到STL要求std::sort
的比较器是符合严格弱序性质的,其中容易导致BUG的是这么一条:
当比较器对象comp
传入两个相等对象,返回值必须是false
!!!
如果不符合严格弱序性质,则会在某些数据下会导致coredump。
假设某个小区间,数据分布如下:
数据: 1 1 1 2 1 索引: 0 1 2 3 4 ^ ^ ^ p f l
按照__move_median_to_first
函数中轴点选择法,最终选出的pivot
是索引为1处的值,然后索引0、1值互换。
为便于下文分析叙述:pivot
简写为p
,first
简写为f
,last
简写为l
。
case1:比较器符合严格弱序关系
当std::sort
传入的比较器Compare符合严格弱序关系,对该数据执行到__unguarded_partition
函数时,迭代流程如下:
第一轮迭代后:
数据: 1 1 1 2 1 索引: 0 1 2 3 4 ^ ^ ^ p f l
第二轮迭代后:
此时last
指针,到了first
的位置,迭代结束。
数据: 1 1 1 2 1 索引: 0 1 2 3 4 ^ ^ p f/l
此时,分割点__cut
就是first
,其左侧的元素不比pivot
大,其右侧不比pivot
小。
case 2: 比较器不符合严格弱序关系
当std::sort
传入的比较器Compare符合严格弱序关系,即comp(a,a)==true
,再执行到__unguarded_partition_pivot
函数时,迭代流程如下:
第一轮迭代:
实际上,在第一轮迭代中,last
指针就越界了。
因为last
在左移的过程中,其取值依次是2
、1
、1
、1
,都会使得comp(pivot, last)
返回true
,进而导致语句 while (__comp(__pivot, __last)) --__last;
一直执行,最终就导致last
越界。
数据: 1 1 1 2 1 索引: 0 1 2 3 4 ^ ^ l p f
总结来说,严格弱序关系,能保证 :
while (__comp(__first, __pivot)) ++__first;
在++__first
的过程中,不会越界。原理还是上面分析的那样,即使
__first+1
、__mid
、__last-1
三个值都是相等,取得的轴点pivot
在弱序关系中,使得comp(__first, __pivot)
一直为false,这样while循环就进行不下去。如果没有严格弱序关系保证,则就会越界。
while (__comp(__pivot, __last)) --__last;
原理同上。
为了验证上面这个猜想,我自己写了个demo。
在数组vec
里全是一样的数字,数组元素个数必须超过_S_threshold
阈值(默认值16)才能触发std::sort
的快排行为。
注意,代码必须在MSVC下编译运行,因为GCC对于某些越界行为并不报错,不如MSVC严格(没有测试clang)。
#include <vector> #include <algorithm> int main(int argc, char const* argv[]) { std::vector<int> vec{1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1}; // 全是一样的元素,且必须超过16个元素 std::sort(vec.begin(), vec.end(), [](const int& lhs, const int& rhs) { return lhs <= rhs; // BUG,修改为: return lhs < rhs; 才行 }); return 0; }
__depth_limit
好嘞,下面开始讲解第【2】个限制条件__depth_limit
。
当快排的递归深度,达到阈值 __depth_limit
时,STL使用堆完成当前[__first, __last)
区间的排序。下面我们把快排部分去掉,只看堆排序部分,那么 __introsort_loop
函数的while
循环可简洁如下:
while (__last - __first > int(_S_threshold)) { if (__depth_limit == 0) { std::__partial_sort(__first, __last, __last, __comp); return; } //... }
显而易见,堆排是由__partial_sort
函数完成。
std::__partial_sort
__partial_sort
函数,旨在取出 [__first, __last)
区间前__middle - _frist
个最小元素,并将其按照_Cpmpare
比较策略进行排序后,有序存放在 [__first, __middle)
区间,其余的节点放在[__middle, __last)
区间。
__partial_sort
函数实现如下(关于堆的实现,可以参考侯捷老师的《STL源码剖析》书籍)。
// 上层调用 std::__partial_sort(__first, __last, __last, __comp); // 函数原型 template <typename _RandomAccessIterator, typename _Compare> inline void __partial_sort(_RandomAccessIterator __first, _RandomAccessIterator __middle, _RandomAccessIterator __last, _Compare __comp) { // 先建堆,并使 [first, middle) 区间存放前 middle-first 个最小值 std::__heap_select(__first, __middle, __last, __comp); // 对 [first, middle) 区间元素进行排序 std::__sort_heap(__first, __middle, __comp); }
因此,经过 __partial_sort
函数后:
[__first, __middle)
区间,有序存储了[__first, __last)
区间的前__middle - _frist
个最小元素;[__middle, __last)
区间,无序存储[__first, __last)
区间剩余元素。
在 __introsort_loop
函数中调用 __partial_sort
函数时,__middle
参数和__last
参数都是__last
,因此实现的就是[__first, __last)
区间的全排序。
std::__final_insertion_sort
当 __introsort_loop
函数执行完毕,最后一步需要将整个数据变得有序,这由__final_insertion_sort
函数完成。
在讨论 __final_insertion_sort
之前,先回顾下 __introsort_loop
函数,它返回有两种可能:
_S_threshold
:递归到某个[__first, __last)
区间时,其元素个数__last - __first <= _S_threshold
时,结束递归。__introsort_loop
函数返回到std::__sort
函数时,整个大的区间中还存在一些元素个数不足_S_threshold
的小区间仍然是无序的。__depth_limit
:递归层次超过限制__depth_limit
。
因此,当执行__final_insertion_sort
函数时,当前大区间[__first, __last)
只存在局部无序,主体上是有序的。这种情况,非常适用于插入排序,此时时间复杂度是O(N)
。
为了进一步优化,加速排序的速度,STL针对两种情况分别求解。
template <typename _RandomAccessIterator, typename _Compare> void __final_insertion_sort(_RandomAccessIterator __first, _RandomAccessIterator __last, _Compare __comp) { // 大数据量 if (__last - __first > int(_S_threshold)) { std::__insertion_sort(__first, __first + int(_S_threshold), __comp); std::__unguarded_insertion_sort(__first + int(_S_threshold), __last, __comp); } else // 小数量量直接使用插入排序,不用经过快排、堆排 std::__insertion_sort(__first, __last, __comp); }
std::__insertion_sort
__insertion_sort
函数是按照标准的插入排序实现。
插入排序的核心思想:将整个序列视为两个部分,『有序的前缀』和『无序的后缀』,再通过循环迭代,不断地将后缀的首元素转移插入到前缀中,保持前缀仍然有序。当后缀为空,整个序列有序。
时间复杂度:如果当前序列已经完全有序,则插入排序时间复杂度是
O(N)
,完全逆序则O(N^2)
。
运行到 __insertion_sort
时,整个数据规模主体保持有序,局部小区间无序,非常适合使用插入排序,算法步骤如下:
- 初始状态下,前缀序列中只有
__first
一个元素,后缀序列则是[__first+1, __last)
区间的元素; - 对
[__first+1, __last)
区间的元素进行遍历,不断将后缀序列的首元素,插入到前缀序列中。
在 __insertion_sort
函数中,对于后缀序列[__first+1, __last)
区间的每个元素__i
:
先判断
__i
位置的元素是否小于__first
;如果是,则直接将
[__first, __i)
区间的元素整体后移一位,再将__i
位置的元素插入到原先__first
位置处。这样就避免了从__i
位置一路比较到__first
位置,才找到__i
在前缀序列中的待插入位置,即节省了比较开销。如果否,则需要在
(__first, __i)
区间寻找合适的位置__pos
,使得*(__pos-1) <= *__i < *__pos
这个部分是由
__unguarded_linear_insert
函数完成,这个函数前面也有__unguarded
前缀,也是没有边界检测的意思。
__insertion_sort
函数,整个代码解释如下。
template <typename _RandomAccessIterator, typename _Compare> void __insertion_sort(_RandomAccessIterator __first, _RandomAccessIterator __last, _Compare __comp) { if (__first == __last) return; /*** 下面要将 (fist, last) 区间的每个元素 __i 都插入到前缀序列中 ***/ for (_RandomAccessIterator __i = __first + 1; __i != __last; ++__i) { /* case 1: *__i < *__first 时,则将 [__first, __i)区间后移, * 再将 *__i 插入到原先 __first 位置 */ if (__comp(__i, __first)) { auto __val = std::move(*__i); // 待插入的值 std::move_backward(__first, __i, __i + 1); // 将 [_first, _i) 区间的元素朝后移动一个位置 *__first = std::move(__val); // 将 __i 位置处的值移动到 __first 处 } else { /* case 2: *__i >= *__fist 时, * 此时需要在有序前缀 (fist,_i)区间中,寻找合适的位置再插入 */ std::__unguarded_linear_insert(__i, __gnu_cxx::__ops::__val_comp_iter(__comp)); } } // for-end }
std::__unguarded_linear_insert
__unguarded_linear_insert
函数,在有序前缀(__first, __last)
中寻找合适的位置__pos
,将__i
位置的值插入到__pos
处。
那什么叫合适的位置呢?
从__i-1
的位置开始遍历,第一个出现逆序对的位置__pos
,即:
__pos < __i
,且*(__pos-1) <= *__i < *__pos
。
当找到这么个位置,需要将[__pos, __i)
区间的元素,后移一位,然后将__i
位置的元素插入到__pos
。为了实现这一步,STL在 __unguarded_linear_insert
函数中,边遍历、边把当前位置的元素向后移动。
下面是__unguarded_linear_insert
函数的源码分析。
template <typename _RandomAccessIterator, typename _Compare> void __unguarded_linear_insert(_RandomAccessIterator __last, _Compare __comp) { auto __val = std::move(*__last); // 当前待插入的值 __val _RandomAccessIterator __next = __last; --__next; // 需要在有序前缀区间 (first, last)中遍历,因此开始遍历的位置需要 --next /* __comp(__val, __next) 为false时,表示出现逆序: * + __next 指向当前遍历位置 * + __last 用于指向 __next 的上一个位置 */ while (__comp(__val, __next)) { /** 下面是将当前遍历位置 __next 存储到 __last ***/ *__last = std::move(*__next); // 将当前位置的值后移动一位 __last = __next; // __last 前移 --__next; // __next 前移 } /* 以上while循环,相当于将 [__last, __i) 区间的元素,后移动了一位, * 现在找到合适位置,此时:*__next <= val && val < *last * 因此,将 val 插入到上一个节点位置 */ *__last = std::move(__val); }
__unguarded_linear_insert
为啥能不用检测是否越界?
因为,之所以能进入__unguarded_linear_insert
函数,是因为在__insertion_sort
函数中有了 __i >= __first
。因此在此函数中,再不济,while(__comp(__val, __next))
也会在__first
后一个位置停下来,最终插入在___first+1
处。
免去边界检测,可以实现一定程度上的优化(STL对性能真的是锱铢必较)。
在这,你可能在想这个 __unguarded_partition
函数,会存在之前在 __unguarded_partition
中出现的BUG吗?
理论上是应该要出BUG的,这也是符合CPP标准,但是从上面的GCC的源码实现可以看出,GCC下不会出现BUG。下面的代码在MSVC中运行被中止,而GCC的实现却可以正常运行:
#include <vector> #include <algorithm> int main(int argc, char const* argv[]) { std::vector<int> vec{1,1}; std::sort(vec.begin(), vec.end(), [](const int& lhs, const int& rhs) { return lhs <= rhs; }); return 0; }
因为,在GCC下,会进入__insertion_sort
函数的if (__comp(__i, __first))
条件分支中,不断地将[__first, __i]
区间元素后移动一位,避免了报错。尽管避免了报错,但实际上却不符合CPP标准。
因此,在书写自定义比较器算法时,要使其符合「严格弱序关系」,代码才具有移植性。
到此,std::sort
的整个运行流程大致分析完毕。
附录
题目是leetcode-56。以下是源码:
class Solution { public: std::vector<std::vector<int>> merge(std::vector<std::vector<int>>& intervals) { if(intervals.size() ==1) return intervals; std::sort(intervals.begin(), intervals.end(), [](const std::vector<int> &lhs,const std::vector<int> &rhs) { return lhs.at(0) <= rhs.at(0); // BUG }); std::vector<std::vector<int>> ans; ans.emplace_back(std::move(intervals[0])); for(int i=1; i < intervals.size(); ++i) { auto&& interval = std::move(intervals[i]); if(interval[0] > ans.back()[1]) { ans.emplace_back(std::move(interval)); } else if(interval[1] <= ans.back()[1]) { continue; } else { ans.back()[1] = std::move(interval[1]); } } return ans; } };
写完这篇blog,脑袋距离小卤蛋又进了一步。
#学习路径##C++工程师#