本文中默认对数组从小到大排序,数组大小为n。
基于比较的排序算法
基于比较的排序算法最坏时间复杂度下界是O(nlgn),这是钛合金天花板,突破不了。
插入排序
算法思想:每次从未排序的序列中选择一个元素插入到已排序好的序列中。初始所有元素都处于未排序序列中,当未排序序列为空时,序列已排好。
时间复杂度:每个元素都会被选择一次,时间是O(n),为每个元素寻找一个合适位置可以用二分查找,时间复杂度为O(lgn),但是需要移动数组元素,时间复杂度为O(n),所以总体时间复杂度为O(n2)。如果是要对链表排序的话,不能使用二分查找寻找位置,时间复杂度为O(n),插入的时间为O(1),总体时间复杂度依然为O(n2)。数组完全正序时有最优时间复杂度O(n)(希尔排序利用这一点对插入排序进行改进),完全逆序时有最坏复杂度O(n2)
额外开销:需要O(1)的存储空间存放当前要插入的元素,以方便元素移动。
稳定性分析:数组中排在前面的元素先被选择;每个元素被插入到第一个小于等于它的元素的后面(从后往前),故插入排序是稳定的。
代码如下:
1 | void insertSort(vector<int> &array) { |
归并排序
算法思想:分治。将数组分成等长的两部分,分别对两部分使用归并排序排序。然后将排序好的两部分合并。
时间复杂度:归并排序的递归式可以表示为T(n)=2T(n2)+O(n),这里的O(n)为合并时间复杂度,运用主定理知复杂度为O(nlgn)。由于归并排序每次都是均等划分,不会有像快排一样的退化问题。所以归并排序是一个渐进最优排序算法。
顺便说一下主定理,对于递归式T(n)=aT(nb)+f(n),其时间复杂度为:
- 若nlgba>f(n),则复杂度为nlgba
- 若nlgba=f(n),则复杂度为nlogbalgn
- 若nlgba<f(n),则复杂度为f(n)
这里的大小是指阶数的大小
直观证明:
上述递归式的递归树深度为logbn,叶子节点的个数为alogbn。
因为叶子节点的时间复杂度T(1)=O(1),故所有叶子节点复杂度之和为O(alogbn)=O(nlogba)(这个的证明方法是两边对b取lg)。
每一层都有合并操作。第i层的合并复杂度为aif(nbi),则所有合并操作的复杂度为O(f(n))。
总体时间复杂度为这两部分的加和,哪部分的阶数高总体复杂度就由哪部分决定。至于第二种情况,记住就可以了(算法导论上有数学证明)。
在归并排序中,nlgba=nlg22=n,与f(n)相同,则复杂度为O(nlgn)
额外开销:采用不用回写的方法实现,开销为O(n),每次从一个数组导到另一个数组。
稳定性分析:合并两个数组的时候,因前一个数组的元素均出现在后一个数组之前,两者头节点取小于等于的那个可保证相同元素保持原始顺序,故是稳定的。
递归代码(回写)如下:
1 | void merge(vector<int> &array, int begin, int mid, int end) { |
非递归代码(不回写)如下:
1 | void mergeToArray(vector<int> &from, vector<int> &to, int begin, int mid, int end) { |
快速排序
算法思想:也是分治的思想,和归并排序的不同在于他的划分方法。快排是每次选择一个元素,将小于这个它的元素放在它左边,大于它的放右边,然后再分别对两边使用快排。两边排好之后就整个数组就排好了。
时间复杂度:快排的递归式为T(n)=T(a)+T(n−a−1)+O(n),其中a为左边数组的大小,O(n)为划分问题的代价。如果a=n2,则快排可取得和归并问题一样的复杂度O(nlgn)。但是如果每次划分不均匀,快排会退化。特别是每次划分a=0时,快排会取得最坏时间复杂度O(n2)(如果原始数组有序,每次取最后一个元素作为枢轴,最坏情况就会出现)。但每次点都这么背概率也是极小的,而且只要划分不是偏差太离谱(或者说,两边的数组长度比例是常数),快排也是渐进O(nlgn)的(证明见算法导论)。所以快排的平均时间复杂度为O(nlgn)
改进:当然你不能保证你的点没有那么背(人品坏了什么都不好说),所以还是要有个保障手段。快排的一种改进就是每次随机选择枢轴。
额外开销:快排划分的时候,需要两个指针,如果是填坑法还需要一个变量保存key,所以空间复杂度为O(1)
稳定性分析:当枢轴取相同元素中的一个元素时,就有可能出现不稳定。
代码如下:
1 | inline int randIndex(const int &begin, const int &end) { |
partition
的实现有两种方法:
前后俩指针想中间移动,停在不符合元素的位置上,然后交换两个元素。重复上述动作直到相遇。
1
2
3
4
5
6
7
8
9
10
11
12
13int partion2(vector<int> &array, int begin, int end) {
int pivot = randIndex(begin, end);
swap(array[pivot], array[end]);
int l = begin, r = end, key = array[end];
while (l < r) {
while (l < r && array[l] <= key) l++;
if (l < r) array[r--] = array[l];
while (l < r && array[r] >= array[l]) r--;
if (l < r) array[l++] = array[r];
}
array[l] = key;
return l;
}一快一慢俩指针,均向前移动,慢指针指向最后一个小于枢轴元素的位置,快指针寻找小于枢轴的元素,当找到后放到慢指针后面。下面代码中
small
是慢指针,i
是快指针。1
2
3
4
5
6
7
8
9
10
11
12
13
14int partion(vector<int> &array, int begin, int end) {
int small = begin - 1;
int pivot = randIndex(begin, end);
swap(array[pivot], array[end]);
for (int i = begin; i <= end; i++) {
// 如果是小于号,for循环结束后还需要交换array[end]和array[++small]
if (array[i] <= array[end]) {
small++;
if (i == small) continue;
swap(array[i], array[small]);
}
}
return small;
}由于这种方法都是向一个方向前进,可以用这种方法实现单链表快排。
堆排序
算法思想:利用大顶堆的性质,每次从堆顶取出一个元素,同时堆的大小减一,当堆为空时数组排好序。
时间复杂度:
首先是建堆,有两种建堆方式:
- 筛选法建堆(向下调整):从最后一个元素的父节点开始由下网上,由右往左调整,保持当前子树的大顶堆性质。使用这种方法,当前节点的左右两棵子树满足大顶堆的性质。比较当前元素与左右子树的根节点,如果不满足堆的性质,与最大的那个根交换。然后递归地调整那棵子树。如果当前子树的高度为h,则调整的复杂度为lgh,直观上这种方法建堆的复杂度为nlgn,但由于低层节点的树高度小于lgn,可以证明这种方法建堆的复杂度近似线性O(n)(证明见算法导论)。
- 插入法建堆(向上调整):初始堆大小为0,每次向堆里插入一个元素,该元素想上冒泡直到到达根或父节点元素比它大。最坏情况下(完全有序,每次都需要将插入节点冒泡到根),这种建堆方法的复杂度为nlgn。
看到这里你可能就觉得奇怪了,插入法建堆前面的元素冒泡的层数不也是小于n吗,为什么复杂度不是O(n)呢。数学公式的证明这里就不给出了,我来讲一下直观的理解。
考虑每个元素,最坏的情况下它会既向上移动又会向下移动。一开始插入进来的时候觉得自己是最大的,就冒泡到根节点,后来发现有比自己更大的,就被更大的挤下去了。如果序列一开始完全有序,考虑第x号元素,刚插入进去时它会向上冒泡lgx层,最后它前面应该有n−x号元素,它又会向下移动lg(n−x)层。总共移动层数为lgx+lg(n−x),这个是lg(n)量级。所以插入法建堆的最坏复杂度是O(nlgn)而不是O(n)。
建完堆后才开始排序过程:每次将堆顶元素与最后一个元素交换,将堆的大小减1,调整堆。需这样做n−1次,每次调整的时间为lgn,时间复杂度为O(nlgn)。
额外开销:堆排序所有只涉及交换,不需要额外存储空间
稳定性分析:因为每次都会和最后一个元素交换,可能会将最后一个元素提前。可以假设堆里元素全是一样的,这样最后一个元素会被提前。
代码如下(筛选法建堆):
1 | inline int parent(int i) { |
非基于比较的排序算法
下面这些算法的复杂度渐进线性,挣脱了nlg(n)的束缚,但是它们都有各自的缺陷或因基于一些假设有各自的限制。
计数排序
算法思想:
元素x的独白:我为什么要比呢,我只要知道有多少个元素排在我前面不就可以找到我的位置了吗?
假设待排序数组里每个数出现在一个小范围内,如[0,k),则我们可以开辟一个长度为k的数组count
,统计每个数出现的次数,count[i]
表示数字i
出现的次数。然后做一遍累加:count[i]=∑ij=0count[j]。这样i
就应该排在count[i]
这个位置(如果只有一个i
的话,多个稍作处理)。根据count
数组,将原数组排到另一数组中即可排好序。
时间复杂度:统计次数O(n),累加O(k),排序O(n)。总体时间复杂度为O(n+k)
限制与缺陷:这种方法必须知道k,且k要较小,否则会占用大量空间。这种方法不能原地排序。
额外开销:count
数组O(k),排好序的数组O(n),总体O(n+k)
稳定性分析:最后排序的时候,从后往前扫,后面的元素先放下来,所以是稳定排序。
代码如下:
1 | void countingSort(vector<int> &array, int k) { |
基数排序
算法思想:类似于字符串的字典序排序,将数字的每一位(可以是多位)看做一个键,从低位到高位(这与字典序不同)依次根据键排一遍后就排好了。对每个键排序可以用计数排序,毕竟知道范围了。
时间复杂度:假设数字有d位,r位为一个键,则复杂度为O(drO(n+2r)),选择r=lgn可取得线性时间复杂度。
限制与缺陷:使用了计数排序,不能原地排序;虽然渐进时间复杂度为O(n),但常数因子很大。不说你需要获取到每一位键所需要的时间,但就上面的复杂度公式而言,当r=lgn时,时间复杂度为O(2dlgnn)。
额外开销:主要是计数排序消耗的空间,O(n+2r)
稳定性分析:计数排序是稳定的,所以基数排序也是稳定的
桶排序
算法思想:和分治有些类似,将长度为n的数组分配到n个桶里,分别对n个桶排序,再将所有桶连起来就排好序了。
时间复杂度:假设m个桶,时间复杂度为O(n+nlgnm),当m接近n时,接近线性
限制与缺陷:要求数据均匀分布,如果数据都到一个桶里了就退化成普通排序了。算法导论上说这个限制可以放宽:只要每个桶的尺寸的平方和与元素总个数呈线性关系即可。虽然不懂它在说啥,大概应该是要求数据尽可能均匀分布。
额外开销:需要将数据都放到桶里,总的空间为O(n)
稳定性分析:用链表的话,用尾插法,可以保证稳定
总结
时间复杂度 | 额外开销 | 是否稳定 | 优点 | 缺点 | 改进 | |
---|---|---|---|---|---|---|
插入排序 | O(n2) | O(1) | 是 | 数组有序时会很快 | 数组有序很难满足,平均复杂度也是O(n2) | 希尔排序 |
归并排序 | O(nlgn) | O(n) | 是 | 最坏时间复杂度渐进最优 | 额外开销大 | |
快速排序 | O(nlgn) | O(1) | 否 | 可以原地排序 | 划分不均匀会退化 | 随机选择枢轴 |
堆排序 | O(nlgn) | 无 | 否 | 最坏时间复杂度渐进最优 | 时间常数大(需要建堆,只能想到这点了) | |
计数排序 | O(n+k) | O(n+k) | 是 | 稳定,线性复杂度 | 额外开销大,不能原地排序,要求k较小 | |
基数排序 | O(drO(n+2r)) | O(n+2r) | 是 | 稳定,线性复杂度 | 时间常数大,额外开销大,不能原地排序 | |
桶排序 | O(n+nlgnm) | O(n) | 是 | 稳定,线性复杂度 | 要求数据尽可能均匀分布 |