STL之sort函数详解 ( 为什么sort要用插入排序? 为什么插入排序在大致有序的情况下效率会比较高 ? )

一朋友面试的时候被问到了STL里的sort函数,被怼到怀疑人生,我听了那些问题发现也不会,研究了好久,网上也没有详细解释的,今天突然灵感爆发,想明白了几个问题
可能有的人会觉得sort这么简单, 有什么好问的, 那你可以看看如下几个问题你能否答得上来

sort是用什么排序实现的?(或者说sort如何优化?)

实际上,STL中的sort是一种混合排序,它应用了快速排序、堆排序和插入排序,以下是各个排序应用时的情况:

  • 开始时使用的是快速排序
  • 当递归深度超过logn时,为了防止快速排序退化,sort会改用为堆排序
  • 当递归深度小于logn时,但是区间长度小于等于16时,改用插入排序

当你回答出这些时,面试官就可以继续深入问下去了

快速排序是怎么实现的?(时空复杂度)

这个问题我一般都这么回答:快速排序是基于分治法的一种排序法,对于整个排序区间,先找一个枢纽,比如这个区间的第一个数,然后把比这个枢纽小的数放左边,比枢纽大的放右边,对枢纽的左右两个区间进行刚刚相同的步骤,当区间长度都为1时,就排好序了

时间复杂度为nlogn,空间复杂度为logn

快速排序最差复杂度?如何优化?

快速排序最差复杂度为n^2

快速排序优化就只有一个地方,那就时枢纽的选择,优化方法有两种,一种是在区间内随机选取一个枢纽,第二种就是STL中sort快速排序部分使用的优化:取区间第一个数,中间的数以及最后一个数的中位数作为枢纽

快速排序中如何实现你说的“小的放枢纽左边,大的放枢纽右边”这个操作?

实际上这个操作在STL里有个函数可以专门实现——partition函数,它的复杂度是o(n)的,它的实现过程如下:

取区间第一个数,中间的数以及最后一个数的中位数作为枢纽,把该枢纽与区间第一个数位置交换一下,用一个临时变量储存枢纽,然后使用双指针的思想,i 指针指向区间第一个数,j 指针指向区间最后一个数,接下来有两个操作

  1. j 指针从后往前跑,当找到一个比枢纽小的数,便将该数放到i指针的位置(直接覆盖)
  2. 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,那我们考虑最坏情况,每个区间的复杂度都是
15+14++1+0=15(15+1)/21 15 + 14 + …… + 1 + 0 = 15 * (15 + 1)/2………………………………(1)
即插入排序移动次数之和

那么这时候其实就只有 n / 16个大小为16的区间,那么实际上用插入排序 排序这n / 16个区间的复杂度为
(1)n/16=7.5n (1) * n / 16 = 7.5n
平均复杂度假设是最坏复杂度的一半,即
7.5n/2=3.25n2 7.5n / 2 = 3.25n………………………………(2)
再回到快速排序部分,按一般情况,递归到区间长度为16时候的复杂度为
n(lognlog16)3 n*(logn - log16)………………………………(3)
如果n比较大,(3)可以约等于nlogn

那么总的复杂度就是
(2)+(3)=(0.75+logn)n4 (2) + (3) = (-0.75 + logn)*n………………………………(4)

是不是有点意想不到?以上推导很多博客都只是说一笔略过,说什么插入排序在大致有序的情况下效率更高,但是为什么却没有讲,很多东西不清楚原理,面试的时候就很容易露馅了

总结

没怎么接触工程的时候,我们总是仅仅考虑算法的理论复杂度,实际上,理论复杂度往往只是工程代码设计上的一个参考,要考虑整体效率,往往还要考虑诸如计算机硬件、编译器、操作系统等,再对算法进行优化

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章