排序算法总结

本文中默认对数组从小到大排序,数组大小为n

基于比较的排序算法

基于比较的排序算法最坏时间复杂度下界是O(nlgn),这是钛合金天花板,突破不了。

插入排序

算法思想:每次从未排序的序列中选择一个元素插入到已排序好的序列中。初始所有元素都处于未排序序列中,当未排序序列为空时,序列已排好。

时间复杂度:每个元素都会被选择一次,时间是O(n),为每个元素寻找一个合适位置可以用二分查找,时间复杂度为O(lgn),但是需要移动数组元素,时间复杂度为O(n),所以总体时间复杂度为O(n2)。如果是要对链表排序的话,不能使用二分查找寻找位置,时间复杂度为O(n),插入的时间为O(1),总体时间复杂度依然为O(n2)。数组完全正序时有最优时间复杂度O(n)(希尔排序利用这一点对插入排序进行改进),完全逆序时有最坏复杂度O(n2)

额外开销:需要O(1)的存储空间存放当前要插入的元素,以方便元素移动。

稳定性分析:数组中排在前面的元素先被选择;每个元素被插入到第一个小于等于它的元素的后面(从后往前),故插入排序是稳定的。

代码如下:

1
2
3
4
5
6
7
8
9
10
11
void insertSort(vector<int> &array) {
int len = array.size();
for (int i = 1; i < len; i++) {
int key = array[i], j = i - 1;
while (j >= 0 && key < array[j]) {
array[j + 1] = array[j];
j--;
}
array[j + 1] = key;
}
}

归并排序

算法思想:分治。将数组分成等长的两部分,分别对两部分使用归并排序排序。然后将排序好的两部分合并。

时间复杂度:归并排序的递归式可以表示为T(n)=2T(n2)+O(n),这里的O(n)为合并时间复杂度,运用主定理知复杂度为O(nlgn)。由于归并排序每次都是均等划分,不会有像快排一样的退化问题。所以归并排序是一个渐进最优排序算法。

顺便说一下主定理,对于递归式T(n)=aT(nb)+f(n),其时间复杂度为:

  1. nlgba>f(n),则复杂度为nlgba
  2. nlgba=f(n),则复杂度为nlogbalgn
  3. nlgba<f(n),则复杂度为f(n)

这里的大小是指阶数的大小

直观证明:

上述递归式的递归树深度为logbn,叶子节点的个数为alogbn

因为叶子节点的时间复杂度T(1)=O(1),故所有叶子节点复杂度之和为O(alogbn)=O(nlogba)(这个的证明方法是两边对blg)。

每一层都有合并操作。第i层的合并复杂度为aif(nbi),则所有合并操作的复杂度为O(f(n))

总体时间复杂度为这两部分的加和,哪部分的阶数高总体复杂度就由哪部分决定。至于第二种情况,记住就可以了(算法导论上有数学证明)。

在归并排序中,nlgba=nlg22=n,与f(n)相同,则复杂度为O(nlgn)

额外开销:采用不用回写的方法实现,开销为O(n),每次从一个数组导到另一个数组。

稳定性分析:合并两个数组的时候,因前一个数组的元素均出现在后一个数组之前,两者头节点取小于等于的那个可保证相同元素保持原始顺序,故是稳定的。

递归代码(回写)如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void merge(vector<int> &array, int begin, int mid, int end) {
vector<int> tmp;
int i = begin, j = mid;
while (i < mid && j < end) {
if (array[i] <= array[j]) tmp.push_back(array[i++]);
else tmp.push_back(array[j++]);
}
while (i < mid) tmp.push_back(array[i++]);
while (j < end) tmp.push_back(array[j++]);
for (int i = 0; i < tmp.size(); i++) {
array[begin + i] = tmp[i];
}
}
void mergeSortRecursive(vector<int> &array, int begin, int end) {
if (begin == end - 1) return;
int mid = (end - begin) / 2 + begin;
mergeSortRecursive(array, begin, mid);
mergeSortRecursive(array, mid, end);
merge(array, begin, mid, end);
}

非递归代码(不回写)如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
void mergeToArray(vector<int> &from, vector<int> &to, int begin, int mid, int end) {
int i = begin, j = mid, k = begin;
while (i < mid && j < end) {
if (from[i] < from[j]) to[k++] = from[i++];
else to[k++] = from[j++];
}
while (i < mid) to[k++] = from[i++];
while (j < end) to[k++] = from[j++];
}

void mergeSortNonRecursive(vector<int> &array) {
int step = 1, len = array.size();
vector<int> tmp_array(len, 0);
bool inArray = true;
while (step < len) {
// i <= len - step 保证 m 不会大于 len
for (int i = 0; i <= len - step; i += step * 2) {
int l = i, m = l + step, r = m + step;
if (inArray) mergeToArray(array, tmp_array, l, m, min(r, len));
else mergeToArray(tmp_array, array, l, m, min(r, len));
}
step <<= 1;
inArray = !inArray;
}
// important
if (!inArray) array.swap(tmp_array);
}

快速排序

算法思想:也是分治的思想,和归并排序的不同在于他的划分方法。快排是每次选择一个元素,将小于这个它的元素放在它左边,大于它的放右边,然后再分别对两边使用快排。两边排好之后就整个数组就排好了。

时间复杂度:快排的递归式为T(n)=T(a)+T(na1)+O(n),其中a为左边数组的大小,O(n)为划分问题的代价。如果a=n2,则快排可取得和归并问题一样的复杂度O(nlgn)。但是如果每次划分不均匀,快排会退化。特别是每次划分a=0时,快排会取得最坏时间复杂度O(n2)(如果原始数组有序,每次取最后一个元素作为枢轴,最坏情况就会出现)。但每次点都这么背概率也是极小的,而且只要划分不是偏差太离谱(或者说,两边的数组长度比例是常数),快排也是渐进O(nlgn)的(证明见算法导论)。所以快排的平均时间复杂度为O(nlgn)

改进:当然你不能保证你的点没有那么背(人品坏了什么都不好说),所以还是要有个保障手段。快排的一种改进就是每次随机选择枢轴。

额外开销:快排划分的时候,需要两个指针,如果是填坑法还需要一个变量保存key,所以空间复杂度为O(1)

稳定性分析:当枢轴取相同元素中的一个元素时,就有可能出现不稳定。

代码如下:

1
2
3
4
5
6
7
8
9
inline int randIndex(const int &begin, const int &end) {
return rand() % (end - begin + 1) + begin;
}
void quickSort(vector<int> &array, int begin, int end) {
if (begin >= end) return;
int index = partion2(array, begin, end);
quickSort(array, begin, index - 1);
quickSort(array, index + 1, end);
}

partition的实现有两种方法:

  1. 前后俩指针想中间移动,停在不符合元素的位置上,然后交换两个元素。重复上述动作直到相遇。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    int 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;
    }
  2. 一快一慢俩指针,均向前移动,慢指针指向最后一个小于枢轴元素的位置,快指针寻找小于枢轴的元素,当找到后放到慢指针后面。下面代码中small是慢指针,i是快指针。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    int 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;
    }

    由于这种方法都是向一个方向前进,可以用这种方法实现单链表快排。

堆排序

算法思想:利用大顶堆的性质,每次从堆顶取出一个元素,同时堆的大小减一,当堆为空时数组排好序。

时间复杂度

首先是建堆,有两种建堆方式:

  1. 筛选法建堆(向下调整):从最后一个元素的父节点开始由下网上,由右往左调整,保持当前子树的大顶堆性质。使用这种方法,当前节点的左右两棵子树满足大顶堆的性质。比较当前元素与左右子树的根节点,如果不满足堆的性质,与最大的那个根交换。然后递归地调整那棵子树。如果当前子树的高度为h,则调整的复杂度为lgh,直观上这种方法建堆的复杂度为nlgn,但由于低层节点的树高度小于lgn,可以证明这种方法建堆的复杂度近似线性O(n)(证明见算法导论)。
  2. 插入法建堆(向上调整):初始堆大小为0,每次向堆里插入一个元素,该元素想上冒泡直到到达根或父节点元素比它大。最坏情况下(完全有序,每次都需要将插入节点冒泡到根),这种建堆方法的复杂度为nlgn

看到这里你可能就觉得奇怪了,插入法建堆前面的元素冒泡的层数不也是小于n吗,为什么复杂度不是O(n)呢。数学公式的证明这里就不给出了,我来讲一下直观的理解。

考虑每个元素,最坏的情况下它会既向上移动又会向下移动。一开始插入进来的时候觉得自己是最大的,就冒泡到根节点,后来发现有比自己更大的,就被更大的挤下去了。如果序列一开始完全有序,考虑第x号元素,刚插入进去时它会向上冒泡lgx层,最后它前面应该有nx号元素,它又会向下移动lg(nx)层。总共移动层数为lgx+lg(nx),这个是lg(n)量级。所以插入法建堆的最坏复杂度是O(nlgn)而不是O(n)

建完堆后才开始排序过程:每次将堆顶元素与最后一个元素交换,将堆的大小减1,调整堆。需这样做n1次,每次调整的时间为lgn,时间复杂度为O(nlgn)

额外开销:堆排序所有只涉及交换,不需要额外存储空间

稳定性分析:因为每次都会和最后一个元素交换,可能会将最后一个元素提前。可以假设堆里元素全是一样的,这样最后一个元素会被提前。

代码如下(筛选法建堆):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
inline int parent(int i) {
return (i - 1) >> 1;
}
inline int lChild(int i) {
return (i << 1) + 1;
}
inline int rChild(int i) {
return (i << 1) + 2;
}
/*
节点i的子树上最多有2n/3的节点(左子树,最后一层满,右子树最后一层没有节点)
则T(n) <= T(2n/3) + O(1), 运用主定理知复杂度为O(lgn)
*/
void maxHeapify(vector<int> &heap, int i, int heap_size) {
int l = lChild(i), r = rChild(i), largest = i;
if (l < heap_size && heap[l] > heap[i]) largest = l;
if (r < heap_size && heap[r] > heap[largest]) largest = r;
if (largest != i) {
swap(heap[i], heap[largest]);
maxHeapify(heap, largest, heap_size);
}
}

/*
可以证明时间复杂度上界是O(n)
*/
void buildHeap(vector<int> &heap) {
int heap_size = heap.size();
int last_parent = parent(heap_size - 1);
for (int i = last_parent; i >= 0; i--) {
maxHeapify(heap, i, heap_size);
}
}

void heapSort(vector<int> &heap) {
buildHeap(heap);
int heap_size = heap.size();
while (heap_size > 1) {
swap(heap[heap_size - 1], heap[0]);
heap_size--;
maxHeapify(heap, 0, heap_size);
}
}

非基于比较的排序算法

下面这些算法的复杂度渐进线性,挣脱了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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void countingSort(vector<int> &array, int k) {
int len = array.size();
vector<int> tmp_array(len, 0), count(k, 0);
for (int i = 0; i < len; i++) {
count[array[i]] += 1;
}
for (int i = 1; i < k; i++) {
count[i] += count[i - 1];
}
// 注意, tmp_array数组是从0开始
for (int i = len - 1; i >= 0; i--) {
tmp_array[count[array[i]] - 1] = array[i];
count[array[i]]--;
}
array.swap(tmp_array);
}

基数排序

算法思想:类似于字符串的字典序排序,将数字的每一位(可以是多位)看做一个键,从低位到高位(这与字典序不同)依次根据键排一遍后就排好了。对每个键排序可以用计数排序,毕竟知道范围了。

时间复杂度:假设数字有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) 稳定,线性复杂度 要求数据尽可能均匀分布