前言
好好地总结一下排序算法啦,虽然在大多数情况下意义不大,毕竟 C++ 自带 sort,但巩固下基础总没有错。
子目录列表
1、时间复杂度
2、选择排序
3、冒泡排序
4、插入排序
5、计数排序
6、基数排序
7、快速排序
7、归并排序
9、堆排序
10、桶排序
11、希尔排序
12、归纳总结
2.4 排序十讲
1、时间复杂度
排序,顾名思义,将数组中的元素按照一定次序进行排列,其算法多种多样,性质也大多不同,各有优劣。但是,不管如何,我们对排序算法的需求最高的就是低时间复杂度。
时间复杂度是一个用来衡量某种算法的运行时间和其输入规模之间关系的函数,同理还有空间复杂度,表示方式为 O(复杂度值)。在说时间复杂度前,先说另一个概念:基本操作执行次数 T。
家里有 10 颗桃子,小明一天吃 1 颗,那么小明吃桃子的执行次数为 10;
家里有 n 颗桃子,小明一天吃 1 颗,那么小明吃桃子的执行次数为 T(n) = n;
家里有 n 颗桃子,小明一天吃 m 颗,那么小明吃桃子的执行次数为 T(n, m) = n / m;
家里有 n 箱桃子,一箱有 n 颗,小明一天吃 m 颗,那么小明吃桃子的执行次数为 T(n, m) = O(n ^ 2 / m)。
所以这两者有什么关系?下面是定义:
如果存在一个函数 f(n),使得当 n 趋近于 ∞ 时,T(n) / f(n) 极限值为不等于 0 的常数,则 f(n) 是 T(n) 的同数量级函数。记作 T(n) = O(f(n)),称 O(f(n)) 为算法的渐进时间复杂度,简称时间复杂度。
看起来很深奥的感觉,说简单点,时间复杂度是在基本操作执行次数的基础上省去了系数项和较低阶。比如某个算法的执行次数为 T(n) = 5 * n,则时间复杂度为 O(n);另一个算法执行次数为 T(n) = 2 * n ^ 3 + 2 * n ^ 2 + n,则时间复杂度为 O(n ^ 3)。
回归到算法。一般情况下,计算时间复杂度可以通过递推 / 递归的层数粗略估计,比如:
for (int i = 1; i <= n; i++) a[i] = a[i - 1] + b[i];
这个求前缀和的循环的时间复杂度为 O(n)。再比如:
for (int i = 1; i <= 2 * n; i++) for (int j = 1; j <= n; j++) a[i][j] = a[i - 1][j] + a[i][j - 1];
这个不知何意义的过程的时间复杂度为 O(n ^ 2)。
但并非都是这么简单的循环,还有各种其他的情形,一般可能出现如下时间复杂度:
O(1), O(log n), O(n), O(n log n), O(n * m), O(n ^ 2), O(n!), ...
最后,我们回到排序的问题上来。各类排序算法的时间复杂度是不一致的,那么在所有排序方法说完后,会统一进行归纳总结。
注意,所有算法都按从小到大排序。
2、选择排序
① 基本思想
每次从第 i 个数到第 n 个数中找到最小的元素,将这个元素和第 i 个位置上的元素交换。时间复杂度显然为 O(n ^ 2)。
② 代码
1 void selectionSort() { 2 for (int i = 1; i <= n; i++) { 3 int mi = 0; 4 for (int j = i + 1; j <= n; j++) 5 if (a[j] < a[mi]) mi = j; 6 swap(a[i], a[mi]); 7 } 8 }
③ 稳定性
假设排序前,序列为a[] = {3, 3, 2},根据选择排序的思想,将 a[1] = 3, a[2] = 2 交换,变成 {2, 3, 3};而 a[2] = a[3] = 3,不用交换,排序完成。我们注意到,排序前有两个相等的元素 3,排序后这两个元素的相对顺序发生改变了,这样的排序方法我们称之为不稳定排序。反之,如果使用某种排序算法排序后,任何相等元素的相对顺序没有改变,则称之为稳定排序。
由此可见,选择排序是不稳定排序。
3、冒泡排序
① 基本思想
从第 1 位开始扫描,检查相邻的两个元素,如果前一个元素大于后一个元素,则交换两个元素的位置,一直扫描到第 n 位。扫描完后,能且仅能确定第 n 个元素为整个序列的最大值,则下一轮扫描为从第 1 位到第 n - 1 位,并确定第 n - 1 位为倒数第二大元素,以此类推,扫描 n - 1 次后,完成排序。
② 代码
1 void bubbleSort() { 2 for (int i = 1; i <= n - 1; i++) 3 for (int j = 1; j <= n - i; j++) 4 if (a[j] > a[j + 1]) swap(a[j], a[j + 1]); 5 }
③ 最好与最坏时间复杂度
从代码来看,时间复杂度显然为 O(n ^ 2),但其实这种说法并不严谨。具体而言,时间复杂度分为平均时间复杂度,最坏时间复杂度,最好时间复杂度。本题的平均时间复杂度和最坏时间复杂度(考虑倒序的情况)确实为 O(n ^ 2),但最好呢?
如果序列为正序的话,其实只需要遍历一遍数组而不用执行任何交换操作,但是上面的代码似乎并看不出这一点,因为不管如何都执行了两重 n 循环,所以原代码还有可以优化的地方:如果在本轮扫描中没有出现前面的元素大于后面的元素的情况,则意味着已经满足条件,可以直接退出循环,这样,最好时间复杂度即为 O(n)。
不过,在一般情况中,最坏时间复杂度才有意义,它是算法的下限,只要下限不会超时,不论如何都不会有问题了;在不知道数据是什么样的情况下,最好时间复杂度参考意义不大,这样的优化也只是锦上添花。
④ 最终代码
1 void bubbleSort() { 2 int f = 1; 3 for (int i = 1; i <= n - 1; i++) { 4 f = 1; 5 for (int j = 1; j <= n - i; j++) 6 if (a[j] > a[j + 1]) f = 0, swap(a[j], a[j + 1]); 7 if (f) break; 8 } 9 }
4、插入排序
① 基本思想
从第 i 位开始,每次循环将前 1 到 i - 1 位视作已排序部分,i 到 n 位为未排序部分,在已排序部分中将第 i 位的元素插入到合适的位置,以此类推,共需要循环 n - 1 次。
插入过程最坏时间复杂度为 O(n),故最终时间复杂度也为 O(n ^ 2)。
② 代码
1 void insertSort() { 2 for (int i = 2; i <= n; i++) { 3 int o = a[i]; 4 for (int j = 1; j <= i - 1; j++) 5 if (a[j] >= o) { 6 for (int k = i; k >= j + 1; k--) 7 a[k] = a[k - 1]; 8 a[j] = o; 9 break; 10 } 11 } 12 }
上述三种排序是最为基础的排序,也是一般情况下效率最低的排序。
5、计数排序
① 基本思想
统计出每个数出现的次数,再求得该次数的前缀和 sum,则排序前的数组的第 i 位 a[i] 应该在排序后的数组的第 sum[a[i]] 位。如果有相等元素,假设 a[i] = a[i - 1],则将 sum[a[i]]--,即 a[i - 1] 应该在排序后数组的第 sum[a[i]] - 1 位。
看起来有点绕,看代码然后测试几组数据会更好理解。
注意到,既然要统计每个数出现次数且还要计算前缀和,说明时间和空间上都与序列中数的大小挂钩,假设值域为 [1, w],则时间复杂度为 O(n + w),所以不难明白,计数排序非常适用于 n 较大而 w 较小,即元素很多但比较密集的情况,其时间复杂度会远优于 O(n ^ 2) 甚至 O(n log n) 的排序算法。
特别地,如果值域不从 1 开始且较为密集,比如 {92, 94, 91, 95, ...},则在统计次数和前缀和的时候可以采用偏移数据,比如 sum[1..r - l + 1] 来表示 [l, r] 的次数前缀和。
② 代码
1 void countingSort() { 2 for (int i = 1; i <= n; i++) sum[a[i]]++; 3 for (int i = 1; i <= w; i++) sum[i] += sum[i - 1]; 4 for (int i = n; i >= 1; i--) b[sum[a[i]]--] = a[i]; 5 }