一朋友面试的时候被问到了STL里的sort函数,被怼到怀疑人生,我听了那些问题发现也不会,研究了好久,网上也没有详细解释的,今天突然灵感爆发,想明白了几个问题
可能有的人会觉得sort这么简单, 有什么好问的, 那你可以看看如下几个问题你能否答得上来
sort是用什么排序实现的?(或者说sort如何优化?)
实际上,STL中的sort是一种混合排序,它应用了快速排序、堆排序和插入排序,以下是各个排序应用时的情况:
- 开始时使用的是快速排序
- 当递归深度超过logn时,为了防止快速排序退化,sort会改用为堆排序
- 当递归深度小于logn时,但是区间长度小于等于16时,改用插入排序
当你回答出这些时,面试官就可以继续深入问下去了
快速排序是怎么实现的?(时空复杂度)
这个问题我一般都这么回答:快速排序是基于分治法的一种排序法,对于整个排序区间,先找一个枢纽,比如这个区间的第一个数,然后把比这个枢纽小的数放左边,比枢纽大的放右边,对枢纽的左右两个区间进行刚刚相同的步骤,当区间长度都为1时,就排好序了
时间复杂度为nlogn,空间复杂度为logn
快速排序最差复杂度?如何优化?
快速排序最差复杂度为n^2
快速排序优化就只有一个地方,那就时枢纽的选择,优化方法有两种,一种是在区间内随机选取一个枢纽,第二种就是STL中sort快速排序部分使用的优化:取区间第一个数,中间的数以及最后一个数的中位数作为枢纽
快速排序中如何实现你说的“小的放枢纽左边,大的放枢纽右边”这个操作?
实际上这个操作在STL里有个函数可以专门实现——partition函数,它的复杂度是o(n)的,它的实现过程如下:
取区间第一个数,中间的数以及最后一个数的中位数作为枢纽,把该枢纽与区间第一个数位置交换一下,用一个临时变量储存枢纽,然后使用双指针的思想,i 指针指向区间第一个数,j 指针指向区间最后一个数,接下来有两个操作
- j 指针从后往前跑,当找到一个比枢纽小的数,便将该数放到i指针的位置(直接覆盖)
- i 指针从前往后跑,当找到一个比枢纽大的数,便将该数放到j指针的位置(直接覆盖)
交替重复以上两个操作,当i和j指针相遇时,把枢纽放入相遇位置就行了
为什么sort要用堆排序?
这个时候就不要再回答为了防止快速排序退化了,其实面试官想问的是nlogn复杂度的排序算法还有比如归并排序,那为什么要选择堆排序?
这个时候就要从空间复杂度上回答了,堆排序是可以原地实现的,空间复杂度为o(1),而归并排序空间复杂度为o(n)
堆排序具体怎么实现的?
这时候就不要说什么用堆实现,不要就讲一下堆的结构什么的,面试官都问具体实现了,那么建堆操作也是要具体讲清楚的,不多解释,直接上代码
//代码来自https://github.com/huihut/interview/blob/master/Algorithm/HeapSort.cpp
#include <iostream>
#include <algorithm>
using namespace std;
// 堆排序:(最大堆,有序区)。从堆顶把根卸出来放在有序区之前,再恢复堆。
void max_heapify(int arr[], int start, int end) {
//建立父节点指标和子节点指标
int dad = start;
int son = dad * 2 + 1; //它数组从0开始,所以堆中父亲左右儿子是dad*2+1和dad*2+2
while (son <= end) { //若子节点指标在范围内才做比较
if (son + 1 <= end && arr[son] < arr[son + 1]) //先比较两个子节点大小,选择最大的
son++;
if (arr[dad] > arr[son]) //如果父节点大于子节点代表调整完毕,直接跳出函数
return;
else { //否则交换父子内容再继续子节点和孙节点比较
swap(arr[dad], arr[son]);
dad = son;
son = dad * 2 + 1;
}
}
}
void heap_sort(int arr[], int len) {
//初始化,i从最后一个父节点开始调整(就是从叶子的父亲开始调整)
for (int i = len / 2 - 1; i >= 0; i--)
max_heapify(arr, i, len - 1);
//先将第一个元素和已经排好的元素前一位做交换,再从新调整(刚调整的元素之前的元素),直到排序完毕(想到于不断地把堆顶取出来放后面,类似选择排序的过程,只不过用堆进行了优化)
for (int i = len - 1; i > 0; i--) {
swap(arr[0], arr[i]);
max_heapify(arr, 0, i - 1);
}
}
int main() {
int arr[] = { 3, 5, 3, 0, 8, 6, 1, 5, 8, 6, 2, 4, 9, 4, 7, 0, 1, 8, 9, 7, 3, 1, 2, 5, 9, 7, 4, 0, 2, 6 };
int len = (int) sizeof(arr) / sizeof(*arr);
heap_sort(arr, len);
for (int i = 0; i < len; i++)
cout << arr[i] << ' ';
cout << endl;
return 0;
}
为什么不一开始就用堆排序?
为什么面试官会这么问呢?因为他已经问了你堆排序和快速排序的实现,这个时候你对他们的时空复杂度已经很清楚了,这时候你意识到其实堆排序的空间复杂度比快速排序还要低(堆排序o(1),快速排序o(logn) ),而且堆排序还不会退化。
所以面试官想知道为什么快速排序效率要比堆排序高?
这个问题如果没去了解过,真的很难回答
实际上我们学习算法,研究的都是理论复杂度,而实际工程应用时,还会考虑算法实际的效率,这不仅涉及到算法的理论复杂度,还涉及到硬件、操作系统等问题
而快速排序就是一个典型的例子
同样是nlogn的时间复杂度,为什么堆排序和归并排序整体效率不如快速排序呢?这是因为计算机硬件中有一个高速缓存区(cache),它的访问读取速度非常快(比内存还要快很多),它通常集成在CPU上,所以其容量十分有限,通常CPU会把经常访问到的数暂存到缓存里,CPU找数据也会先从缓存里找。
快速排序因为其用到了一个枢纽,这个枢纽的访问次数非常之多,那么其就会被放入缓存中,那么访问枢纽的效率就会非常高,所以快速排序整体效率会比其他几个排序要高。
为什么sort要用插入排序? (或者说为什么插入排序在大致有序的情况下效率会比较高 ?)
当数列大致有序时,比如我们现在每个区间的大小已经排好了,但是区间内16个数字还没有排好,这时候插入排序的表现会更好。
但是为什么呢?插入排序的复杂度为n^2,即使区间长度比较小,但是其复杂度并不会因此降低啊,这个问题困扰了我很久,但是突然有一天我想明白了
插入排序复杂度为n^2,那我们考虑最坏情况,每个区间的复杂度都是
即插入排序移动次数之和
那么这时候其实就只有 n / 16个大小为16的区间,那么实际上用插入排序 排序这n / 16个区间的复杂度为
平均复杂度假设是最坏复杂度的一半,即
再回到快速排序部分,按一般情况,递归到区间长度为16时候的复杂度为
如果n比较大,(3)可以约等于nlogn
那么总的复杂度就是
是不是有点意想不到?以上推导很多博客都只是说一笔略过,说什么插入排序在大致有序的情况下效率更高,但是为什么却没有讲,很多东西不清楚原理,面试的时候就很容易露馅了
总结
没怎么接触工程的时候,我们总是仅仅考虑算法的理论复杂度,实际上,理论复杂度往往只是工程代码设计上的一个参考,要考虑整体效率,往往还要考虑诸如计算机硬件、编译器、操作系统等,再对算法进行优化