前言
看到这个标题应该会有很多人一下子就懂了,也会有些人感到迷惑,简简单单排序怎么会奔溃呢?我第一次接触这个问题还是很久以前刚刚参加工作的时候,当时也是写出了导致程序崩溃的代码,通过上网查询解决了问题,至此以后就对这个 sort
函数警惕了一些,一直记得就是在sort的自定义函数中判断条件不要加等号,至于本质的原因一直没有去探究,正好最近又改了一个相关的问题,所以决定从源码和定义的角度来看看为什么会出现这个问题。
sort的使用
sort函数真的挺好用,比如像下面这样:
1 |
|
只是 std::sort(values.begin(), values.end());
这样简简单单的一句就完成了vector数据从小到达的排序,运行结果如下:
1 | albert@home-pc:/data/cpp$ g++ testsort.cpp --std=c++11 |
自定义比较函数
上面举的例子是从小到大排序,这是 sort 函数的默认行为,所以不需要额外的参数,如果是想从大到小排序,那么就需要定义一个比较函数了,方法也比较简单,写一个lambda表达式就可以了,比如像下面这样:
1 | int main() |
按照比较函数定义,我们把数据按照前面大于等于后面的方式排序就完成了从大到小的排序的要求,看看这样写有没有什么问题?如果这里的等号 =
已经引起了你的不适,说明你可能踩过这里的坑,是的,这样写容易造成崩溃,我们来运行一下。
1 | albert@home-pc:/data/cpp$ g++ testsort.cpp --std=c++11 |
咦?怎么没事,我之前用MSVC测试还会崩溃的,难道和编译器有关?
当我们增大数据量
1 | std::vector<int> values{3,5,4,4,1,5,4,5,4,5,4,5,4,5,4,5,4,4,5,4,4,4,5,4,5,5,4,5,4,4,5,4,5,4,5,5,5}; |
这次终于崩溃了,但显示确实内存越界问题,并且排序后第一个元素是0,这不是我们vector中的元素啊,看来肯定是出问题了
反复尝试几次又找到一个测试用例:
1 | std::vector<int>values{3,5,4,4,5,1,4,5,1,4,5,1,4,5,1,4,5,1,4,5,1,4,5,1,4,5,1,4,5,1,4,5,1,4,5,1,4,5}; |
运行之后直接得到了 Segmentation fault (core dumped)
错误,没错,这就是我想要的,来从 sort
源码中看看为什么加了 =
就会出现崩溃
sort源码崩溃分析
sort 函数的源码还不算太长,我就一点点来看了
1 | template<typename _RandomAccessIterator, typename _Compare> |
这算是个入口函数,做了一些类型检查,然后就调用了内部的 std::__sort
函数
1 | template<typename _RandomAccessIterator, typename _Compare> |
当排序范围不为空时,函数会对传入的范围进行排序,为了最大程度的提高效率,结合了快排、堆排和插入排序等多种排序方法,分为 std::__introsort_loop
和 std::__final_insertion_sort
两个阶段。
第一阶段使用“快排+堆排”的方法,当元素个数小于等于 _S_threshold
(enum { _S_threshold = 16 }
)时,不做处理,交给第二阶段来做,对于元素个数大于_S_threshold的序列,执行快排,当快排的递归深入到一定深度 __depth_limit
(通过元素个数计算出来的)时,不再递归深入,对待排序元素执行堆排序,代码如下:
1 | /// This is a helper function for the sort routine. |
第二阶段使用“插入排序”,当元素个数小于等于 _S_threshold
(enum { _S_threshold = 16 }
)时,执行普通的插入排序,当大于 _S_threshold
时,执行两次的“插入”排序操作,首先使用普通的插入排序来排 [first, _S_threshold)
这个范围的元素,然后使用无保护的插入排序,完成 [_S_threshold, last)
这个范围的排序。
1 | template<typename _RandomAccessIterator, typename _Compare> |
其中的普通插入排序没有什么特别的地方,就是遍历前边小于等于_S_threshold
个元素进行普通的插入排序,而后面这个无保护的插入排序 std::__unguarded_insertion_sort
往往就是出现问题的地方,代码如下:
1 | template<typename _RandomAccessIterator, typename _Compare> |
这段代码看 __unguarded_insertion_sort
还没有什么问题,但是 __unguarded_linear_insert
中的逻辑就比较迷幻了,只有当 __comp(__val, __next)
的值为false时才会停止。
其中 __comp
就是我们之前自定义的lambda表达式,我们当时写的是 return v1 >= v2;
,翻译过来也就是当!(val >= __next)
时,即后一个元素小于前一个元素的时候停止,那么为什么会出问题呢?
我们知道前_S_threshold
个元素我们之前已经按照从大到小排好序了,那么按道理遍历到这个区域就会找到后一个元素小于前一个元素的情况,也就是插入排序遍历到这就会停止,等等!好像有什么不对劲,如果这里的元素都相等就找不到停止的情况了,这就会造成访问的越界,这就是程序崩溃的本质原因了。
那么去掉等号会是个什么情况呢?运行到这里就是要找到满足条件的 !(val > __next)
元素时停止,也就是找到后一个元素小于等于前一个元素的时候停止,因为前_S_threshold
个元素已经排好序,这个条件是肯定满足的,所以不会出现越界情况,这就是为什么自定义比较函数中,两个元素相等时一定要返回false了。
为什么使用无保护的插入排序
既然这里这么容易越界,为什么不判断一下边界条件来防止越界,而是用这种无保护的插入排序呢?
这里使用无保护的插入排序原因很简单,就是为了提升效率,因为省略掉越界的检查,少了很多次的比较操作,效率肯定有了提升,它的前提是左边必须有已经排好序的元素,所以在函数 __unguarded_insertion_sort
函数之前先调用 __insertion_sort
来完成了[0, _S_threshold)
这个范围的元素排序,便是为了后面这个无保护插入排序的使用。
C++标准要求
说到这里sort函数的自定义比较函数还是太容易出错了,有没有什么实现标准呢?其实标准中对这个比较函数的要求写的很详细,具体可以参考 Compare的实现要求。
Compare 是一些标准库函数针对用户提供的函数对象类型所期待的一组要求,其实就是要满足严格若排序关系,翻译成人话就是自定义的比较函数 comp
需要下面三条要求:
- 对于任意元素a,需满足
comp(a, a) == true
- 对于任意两个元素a和b,若
comp(a, b)==true
则要满足comp(b, a)==false
- 对于任意三个元素a、b和c,
若 comp(a, b)==true
且comp(b, c)==true
则需要满足comp(a, c)==true
从这条规则也能看出我们之前定义的问题:
1 | std::sort(values.begin(), values.end(), [](int v1, int v2){ |
这个自定义的比较函数,当 v1 和 v2 相等时,comp(v1, v2)==true
, 但是 comp(v2, v1)
的值也是 true
,当我们把代码中的等号 =
去掉以后,也就满足了条件2,另外在复杂的比价逻辑中,条件3的传递性问题也是需要注意的问题。
构造一个崩溃的示例
理解了前面崩溃的原因,我们就不需要猜了,可以直接构造一个百分之百奔溃的测试用例,因为前16(_S_threshold
)个元素会使用正常的插入排序,后面的元素才会使用无保护的插入排序,我们其实构造一个17个相同元素的vector就可以了,下面我们来试一下:
1 | int main() |
运行结果如下:
1 | albert@home-pc:/data/cpp$ g++ testsort.cpp --std=c++11 -g |
完全符合预期,如果再删除vector中的一个元素就不会崩溃了。
平台差异
这篇文章的代码编译和运行都是在Linux下完成的,但是我之前在Windows上测试时,可不需要最少17个元素的前提,这是为什么呢?因为在微软这一套编译环境下,直接检测了Compare中的条件2,并且是以断言的方式给出提示的,所以与Linux上的运行表现还有一些差异。
总结
- 使用
std::sort
函数自定义比较函数时,需要满足严格弱排序性,若comp(a, b)==true
则comp(b, a)==false
,那么在比较函数中两个元素相等的情况要返回false - 使用
std::sort
函数出现崩溃是往往是不满足严格若排序性,但是在复杂的比较函数中也可能不满足传递性 std::sort
为了把排序效率提高到极致,综合使用了快排、堆排、插入排序等多种排序方法std::sort
在不同的平台实现不同,当比较函数不满足严格若排序时,gcc环境下至少有17个元素才会崩溃,而MSVC
则在Debug时没有元素个数限制,会通过断言直接判断这个条件是否满足
可是啊 总有那风吹不散的认真 总有大雨也不能抹去的泪痕~
2021-8-8 23:57:53