排序算法
最近结合学习了排序算法,进行一下总结以供后续复习。(每个算法基于数组实现,升序排序。)
- 所有排序算法的总结比较:
排序算法 | 平均时间复杂度 | 最坏情况时间复杂度 | 额外空间复杂度 | 稳定性 |
---|---|---|---|---|
冒泡排序 | O ( n 2 ) {O(n^2)} O(n2) | O ( n 2 ) {O(n^2)} O(n2) | O ( 1 ) {O(1)} O(1) | 稳定 |
选择排序 | O ( n 2 ) {O(n^2)} O(n2) | O ( n 2 ) {O(n^2)} O(n2) | O ( 1 ) {O(1)} O(1) | 不稳定 |
插入排序 | O ( n 2 ) {O(n^2)} O(n2) | O ( n 2 ) {O(n^2)} O(n2) | O ( 1 ) {O(1)} O(1) | 稳定 |
希尔排序 | O ( n d ) {O(n^d)} O(nd) | O ( n 2 ) {O(n^2)} O(n2) | O ( 1 ) {O(1)} O(1) | 不稳定 |
堆排序 | O ( n l o g n ) {O(nlogn)} O(nlogn) | O ( n l o g n ) {O(nlogn)} O(nlogn) | O ( 1 ) {O(1)} O(1) | 不稳定 |
归并排序 | O ( n l o g n ) {O(nlogn)} O(nlogn) | O ( n l o g n ) {O(nlogn)} O(nlogn) | O ( n ) {O(n)} O(n) | 稳定 |
快速排序 | O ( n l o g n ) {O(nlogn)} O(nlogn) | O ( n 2 ) {O(n^2)} O(n2) | O ( l o g n ) {O(logn)} O(logn) | 不稳定 |
计数排序 | O ( n + k ) {O(n+k)} O(n+k) | O ( n 2 ) {O(n^2)} O(n2) | O ( n ) {O(n)} O(n) | 稳定 |
桶排序 | O ( n + k ) {O(n+k)} O(n+k) | O ( n + k ) {O(n+k)} O(n+k) | O ( n + k ) {O(n+k)} O(n+k) | 稳定 |
基数排序 | O ( n ∗ k ) {O(n*k)} O(n∗k) | O ( n ∗ k ) {O(n*k)} O(n∗k) | O ( n ∗ k ) {O(n*k)} O(n∗k) | 稳定 |
- 插入排序、冒泡排序、希尔排序、快速排序的效率与待排数据的原始状态有关;
- 当待排记录序列按关键字顺序有序时,插入排序、冒泡排序能达到0 ( n ) 的时间复杂度;快速排序为 О ( n ^2 );
- 采用希尔方法排序时,若关键字的排列杂乱无序,则效率最高;
- 选择排序、堆排序、归并排序的效率与待排数据的原始状态无关;
T = O ( n 2 ) {T=O(n^2)} T=O(n2)
冒泡排序(Bubble Sort)
- 一个最简单的冒泡排序:每次找到待排序列中的最小元素,放到前面。(但这个算法相比于真正的冒泡排序来说效率还是非常低的)
for( i = 0; i < length - 1; i++) // 循环length次,每次找到对应待排序列中最小的元素放到a[i]
for( j = i + 1; j < length; j++) //这个循环目的是找到 i+1 至 length-1 间最小元素放到a[i]
if(a[j] < a[i] ) 交换;
- 下面是正宗的冒泡排序:
status flag = true;
for( i = 0; i < length - 1 && flag; i++) { // i 从 0 到倒数第 2
flag=false;
for( j = length - 2; j >= i; j--) { // j 从倒数第 2 到 i 往前循环
if( a[j+1] < a[j] ) {
交换;
flag = true;
}
}
}
- 举例
有个数组 a[] = {9,1,5,8,3,7,4,6,2},length = 9。注:a的下标是从 0 到 length-1 的。
比如说下面的排序: (标亮的是待排序列,没标亮的是排好的序列)
9,1,5,8,3,7,4,6,2
1,9,2,5,8,3,7,4,6
1,2,9,3,5,8,4,7,6
1,2,3,9,4,5,8,6,7
1,2,3,4,9,5,6,8,7
1,2,3,4,5,9,6,7,8
1,2,3,4,5,6,9,7,8
1,2,3,4,5,6,7,9,8
1,2,3,4,5,6,7,8,9(排序完成)
每次找待排序列中最小的元素提到前面来,就像每次剩下的气泡中,最小的气泡慢慢浮到上面来一样。 - 分析一下这个算法比上面的改进:上面的算法只会每次找到最小的元素,也可能在每次循环时不知不觉增加了逆序对的个数。而这个算法只会把不断地把较小的元素交换到前面,肯定会减少逆序对的个数,总的交换次数肯定比前者少,因此效率较高!
(注:对于下标 i < j , 如果 A [ i ] > A [ j ] , 则称 ( i , j ) 是一对逆序对 (inversion)。要提高算法的效率,就得在每次交换元素时尽可能多的消去逆序对!) - 核心思想:两两比较相邻元素,如果反序则交换,直到没有反序的记录为止。
- 冒泡排序最好情况下,比较次数为 n-1,交换次数为 0,时间复杂度为 O ( N ) {O(N)} O(N);最坏情况下,比较次数为 n ( n − 1 ) 2 \frac{n(n-1)}{2} 2n(n−1),交换次数为 n ( n − 1 ) 2 \frac{n(n-1)}{2} 2n(n−1),时间复杂度为 O ( N 2 ) {O(N^2)} O(N2)。
- 算法稳定。
选择排序(Selection Sort)
for( i = 0; i < length - 1; i++) { // i 从 0 到倒数第 2
for( j = i + 1; j < length; j++) { // j 从 i+1 到最后一个元素
if(a[j] < a[min] ) {
min = j;
}
}
if(min != i) 交换a[i]与a[min];
}
- 核心思想:每次找出待排序列中最小元素的下标,再和第 i 个元素交换。
- 选择排序无论最好还是最坏情况都比较次数为 n ( n − 1 ) 2 \frac{n(n-1)}{2} 2n(n−1),最好的时候交换次数为 0,最坏的时候交换次数为n-1。时间复杂度为 O ( N 2 ) {O(N^2)} O(N2)。
- 算法不稳定。
- 选择排序相对于冒泡排序,交换数据次数相当少,因此选择排序性能要优于冒泡排序。
插入排序(Insertion Sort)
void InsertSort(int a[], int length) {
int i, j, tmp;
for (i = 1; i < length; i++) { // i 从第 2 个元素到最后一个元素
tmp = a[i];
for (j = i; j > 0 && tmp < a[j - 1]; j--) { // j从i 到tmp不再小于a[j-1]之时
a[j] = a[j - 1]; // a[j-1]向后移
}
a[j] = tmp; // 最后 tmp 插入到正确位置
}
}
- 核心思想:将一个元素插入到有序表中
- 插入排序在最好的情况下,比较次数为 n-1,移动次数为 0,时间复杂度 O(N);最坏的情况下,比较次数为 2 + 3 + 4 + … … + n = ( n + 2 ) ( n − 1 ) 2 2+3+4+……+n=\frac{(n+2)(n-1)}{2} 2+3+4+……+n=2(n+2)(n−1),移动次数为 ∑ i = 1 n ( i + 1 ) = ( n + 4 ) ( n − 1 ) 2 \sum_{i=1}^{n}{(i+1)} = \frac{(n+4)(n-1)}{2} ∑i=1n(i+1)=2(n+4)(n−1),平均比较和移动次数为 n 2 4 \frac{n^2}{4} 4n2,时间复杂度为 O ( N 2 ) {O(N^2)} O(N2)。
- 算法稳定。
- 插入排序 比 冒泡排序 和 选择排序 的比较和移动次数都有降低,性能相对好一些。
T = O ( n d ) {T = O(n^d)} T=O(nd)
希尔排序(Shell Sort)
for(k = 0, d = increment[k]; k < increment的长度; d = increment[++k]) { // 希尔增量序列
// 插入排序
for(i = d; i < length; i++) { // i 从 d 到 length-1
tmp = a[i];
for(j = i; j >= d && tmp < a[j-d]; j-=d) { // j从i 到tmp不再小于a[j-d]之时
a[j] = a[j-d]; // a[j-d]向后移
}
a[j] = tmp; // 最后 tmp 插入到正确位置
}
}
- 举例
对序列:{ 9 1 5 8 3 7 4 6 2 } 进行排序:
- 核心思想:将相距某个增量的序列组成一个子序列,分别在每个子序列中进行插入排序,这样得到的总序列基本有序(所谓基本有序,就是小的关键字基本在前面,大的关键字基本在后面,不大不小的基本在中间,这样就可以有效减少逆序对的个数。)最后再对全体进行一次插入排序。
- 如果使用 Hibbard 增量序列 D k = 2 k − 1 {D_k=2^k-1} Dk=2k−1(也就是相邻元素互质)的时候,可以取得不错的效率,这时最坏时间复杂度为 Θ ( N 3 2 ) {Θ(N^\frac{3}{2})} Θ(N23);平均时间复杂度约为 O ( N 5 4 ) {O(N^\frac{5}{4})} O(N45)。除此之外还有其他增量序列,不再一一列举。
- 由于元素的比较与交换是跳跃式进行,因此算法不稳定
T = O ( n l o g n ) {T=O(nlogn)} T=O(nlogn)
堆排序(Heap Sort)
void swap(int& a, int& b) {
int tmp = a; a = b; b = tmp;
}
// 调节堆:从 s根节点开始沿较大的孩子结点向下调整堆(建立在大顶堆已构建的基础上)
void HeapAdjust(int a[], int s, int m) {
int temp, j;
temp = a[s]; // 取出根节点 s 存放的值
// 主要思想:沿较大的孩子结点向下筛选
for (j = 2 * s + 1; j <= m; j = 2 * j + 1) {
// 把j调整成较大的孩子的下标
if (j < m && a[j] < a[j + 1]) ++j;
/* 将较大孩子与父结点比较,大的话就把值赋给父结点,
然后下滤 temp 直到给 temp 找到了合适的位置————
也就是 temp 大于它的两个孩子的时候 break 退出循环,
或者等到循环结束的时候让 temp 变为叶节点。 */
if (a[j] >= temp) {
a[s] = a[j];
s = j;
}
else break;
}
// 把 temp 放到最终的位置
a[s] = temp;
}
// 堆排序(适用于元素从0开始的情况)
void HeapSort(int a[], int length) {
int i;
// 把 a 调成一个大根堆(从最后一个根节点开始自底向上,对每个结点 HeapAdjust)
for (i = (length - 2) / 2; i >= 0; --i)
HeapAdjust(a, i, length - 1);
// 循环执行下面两步:
for (i = length - 1; i > 0; i--) {
swap(a[0], a[i]); // 将根节点(最大的元素)与最后一个元素交换
HeapAdjust(a, 0, i - 1); // 将除去最大元素之后的 a[0..i-1] 重新调整为大根堆
}
}
- 相关概念
- 大顶堆:每个结点的值都大于等于其左右孩子结点的值;
- 小顶堆:每个结点的值都小于等于其左右孩子结点的值
- 在堆排序中,元素下标从1开始,则对于下标为 i 的元素,其左、右孩子的下标分别为2i, 2i+1,如果 i>1,则其双亲是结点 i/2;
- 在堆排序中,元素下标从0开始,则对于下标为 i 的元素,其左、右孩子的下标分别为2i+1, 2i+2,如果 i>1,则其双亲是结点 (i-1)/2;
- 如果按层序遍历的方式给结点从1开始编号,那么节点之间满足如下关系:
k i ≥ k 2 i , k i ≥ k 2 i + 1 ( 或 者 是 ≤ ) {k_i \ge k_{2i},k_i \ge k_{2i+1} (或者是\leq)} ki≥k2i,ki≥k2i+1(或者是≤) ,并且 1 ≤ i ≤ n / 2 {1 \leq i \leq n/2} 1≤i≤n/2
- 算法流程:将序列建成最大堆(从最后一个根节点开始自底向上,对每个结点 HeapAdjust),然后循环进行以下步骤:【将根节点(也就是最大元素)与最后一个元素交换,换到最后排除出去,再把剩下的元素继续 HeapAdjust 调整成最大堆】。这样最后得到的序列就是升序序列。
- 堆排序最好、最坏情况时间复杂度都是 O ( n l o g n ) {O(nlogn)} O(nlogn)
- 由于元素的比较与交换是跳跃式进行,因此算法不稳定。
- 堆排序在性能上要远远好过
O
(
n
2
)
{O(n^2)}
O(n2)的算法,不过虽然堆排序平均时间复杂度较小,但其实实际效果不如用增量序列的希尔排序。另外,由于初始堆的构建比较次数较多,堆排序并不适合序列个数较少的情况。
归并排序(Merging Sort)
- 归并排序(递归)
// 将有序的 a[L..M] 和 a[M+1..L] 归并为有序的 tmp[L..R]
void Merge(int a[], int tmp[], int L, int M, int R) {
int LBegin = L, RBegin = M + 1;
int k = L;
while (LBegin <= M && RBegin <= R) { // 将 a 中记录由小到大地并入 tmp
if (a[LBegin] < a[RBegin]) {
tmp[k++] = a[LBegin++];
}
else {
tmp[k++] = a[RBegin++];
}
}
while (LBegin <= M) { // 直接复制左边剩下的
tmp[k++] = a[LBegin++];
}
while (RBegin <= R) { // 或者直接复制右边剩下的
tmp[k++] = a[RBegin++];
}
}
// 将SR[s..t]归并排序为TR1[s..t]
void MSort(int SR[], int TR1[], int s, int t) {
int m;
int TR2[MAX + 1];
if (s == t) // 递归出口:L == R(只剩一个元素时)
TR1[s] = SR[s];
else {
m = (s + t) / 2;
MSort(SR, TR2, s, m); // 递归解决左边
MSort(SR, TR2, m + 1, t); // 递归解决右边
Merge(TR2, TR1, s, m, t); // 将归并两段有序序列
}
}
// 归并排序(递归)对外接口
void MergeSort(int a[],int n) {
int* tmp = (int*)malloc((n - 1) * sizeof(int));
MSort(a, a, 0, n - 1); //将a[]归并排序为tmp[]
}
- 归并排序(非递归)
// 将有序的 a[L..M] 和 a[M+1..L] 归并为有序的 tmp[L..R]
void Merge(int a[], int tmp[], int L, int M, int R) {
int LBegin = L, RBegin = M + 1;
int k = L;
while (LBegin <= M && RBegin <= R) { // 将 a 中记录由小到大地并入 tmp
if (a[LBegin] < a[RBegin]) {
tmp[k++] = a[LBegin++];
}
else {
tmp[k++] = a[RBegin++];
}
}
while (LBegin <= M) { // 直接复制左边剩下的
tmp[k++] = a[LBegin++];
}
while (RBegin <= R) { // 或者直接复制右边剩下的
tmp[k++] = a[RBegin++];
}
}
// 将SR[]中相邻长度为s的子序列两两归并到TR[]
void MergePass(int SR[], int TR[], int k, int n) {
int i = 0, j;
while (i + 2 * k < n) { // 两两归并
Merge(SR, TR, i, i + k - 1, i + 2 * k - 1);
i += 2 * k;
}
if (i + k - 1 < n - 1) { // 归并最后两个序列
Merge(SR, TR, i, i + k - 1, n - 1);
}
else { // 若最后只剩下单个子序列
for (j = i; j < n; j++) {
TR[j] = SR[j];
}
}
}
// 归并排序(非递归)
void MergeSort2(int a[], int n) {
int* tmp = (int*)malloc((n - 1) * sizeof(int));
int k = 1; // 子序列长度
while (k <= n) {
MergePass(a, tmp, k, n); k *= 2; // 子序列长度加倍
MergePass(tmp, a, k, n); k *= 2; // 子序列长度加倍
}
}
- 主函数测试
#include<iostream>
using namespace std;
#define MAX 100
int main() {
int a1[] = { 50,10,90,30,70,40,80,60,20 };
int a2[] = { 50,10,90,30,70,40,80,60,20 };
int length = 9;
int i;
MergeSort(a1, length);
MergeSort2(a2, length);
for (i = 0; i < length; i++) cout << a1[i] << " ";
cout << endl;
for (i = 0; i < length; i++) cout << a2[i] << " ";
return 0;
}
- 归并排序图示
- 核心思想:有序子列的归并。每次比较A指针和B指针指向位置的哪个元素更小,放到C指针位置。
- 归并排序需要额外空间,适用于外排序。
- 不管什么时候,时间复杂度都是 O ( n l o g n ) {O(nlogn)} O(nlogn)。使用递归的空间复杂度是 O ( n + l o g n ) {O(n+logn)} O(n+logn),非递归的的空间复杂度是 O ( n ) {O(n}) O(n)。因归并排序的非递归算法性能较好。
- 算法稳定
快速排序(Quick Sort)
void swap(int& a, int& b) {
int tmp = a; a = b; b = tmp;
}
int Partition(int a[], int low, int high) {
// 三数取中
int mid = low + (high - low) / 2;
if (a[low] > a[high]) swap(a[low], a[high]);
if (a[mid] > a[high]) swap(a[high], a[mid]);
if (a[mid] > a[low]) swap(a[mid], a[low]);
int pivotkey = a[low];
// 从表的两端交替地向中间扫描
while (low < high) {
while (low < high && a[high] >= pivotkey) high--;
a[low] = a[high];
while (low < high && a[low] <= pivotkey) low++;
a[high] = a[low];
}
a[low] = pivotkey;
return low; // 返回基准所在位置
}
void QuickSort(int a[], int low, int high) {
int pivot;
if (low < high) {
pivot = Partition(a, low, high); // 分区
QuickSort(a, low, pivot - 1); // 左边递归排序
QuickSort(a, pivot + 1, high); // 右边递归排序
}
}
- 基本思想:通过一趟排序将待排记录分割成独立的两部分,其中一部分记录的关键字均比另一部分记录的关键字小,则可分别对这两部分记录继续进行排序,以达到整个序列有序的目的。
- 最好情况下,Partition每次都划分的很均匀,时间复杂度为 O ( n l o g n ) {O(nlogn)} O(nlogn);最坏情况下,序列是正序或逆序的,时间复杂度为 O ( n 2 ) {O(n^2)} O(n2)。平均情况 , 时间复杂度为 O ( n l o g n ) {O(nlogn)} O(nlogn)。
- 就空间复杂度来说 , 主要是递归造成的栈空间的使用 , 最好情况 , 递归树的深度为 l o g 2 n {log_2n} log2n , 其空间复杂度也就为 O ( l o g n ) {O(logn)} O(logn) , 最坏情况 , 需要进行 n -1 次递归调用 , 其空间复杂度为 O ( n ) {O(n)} O(n) , 平均情况 , 空间复杂度也为 O ( n l o g n ) {O(nlogn)} O(nlogn)。
- 算法不稳定。
- 快速排序优化:
- 由于快速排序用到了递归操作,如果数组非常小,快速排序反而不如直接插入排序来得更好(直接插入是简单排序中性能最好的 ) 。因此我们增加了一个判断 , 当 high - low 不大于某个常数时 ( 有资料认为 7 比较合适 ,也有认为 50 更合理 , 实际应用可适当调整 ) , 就用直接插入排序 , 这样就能保证最大化地利用两种排序的优势来完成排序工作。
- QuickSort 函数在其尾部有两次递归操作,如果待排序的序列划分极端不平衡 , 递归深度将趋近于 n , 而不是平衡时的 log2n , 这就不仅仅是速度快慢的问题了。栈的大小是很有限的 , 每次递归调用都会耗费一定的栈空间 , 函数的参数越多 , 每次递归耗费的空间也越多。因此如果能减少递归 , 将会大大提高性能。
// 快速排序优化算法
void QuickSort1(int a[], int low, int high, int n) {
int pivot;
if ((high - low) > MAX_LENGTH_INSERT_SORT) {
while (low < high) {
pivot = Partition(a, low, high); // 分区
QuickSort1(a, low, pivot - 1,n); // 左边递归排序
low = pivot + 1; // 尾递归
}
}
else InsertSort(a, n);
}
T = O ( n + k ) {T=O(n+k)} T=O(n+k)
计数排序(Counting Sort)
- 计数排序不是基于比较的排序算法,其核心在于将输入的数据值转化为键存储在额外开辟的数组空间中。 作为一种线性时间复杂度的排序,计数排序要求输入的数据必须是有确定范围的整数。
- 算法流程:
- 找出待排序的数组中最大和最小的元素;
- 统计数组中每个值为i的元素出现的次数,存入数组C的第i项;
- 对所有的计数累加(从C中的第一个元素开始,每一项和前一项相加);
- 反向填充目标数组:将每个元素i放在新数组的第C(i)项,每放一个元素就将C(i)减去1。
- 计数排序是一个稳定的排序算法。当输入的元素是 n 个 0到 k 之间的整数时,时间复杂度是O(n+k),空间复杂度也是O(n+k),其排序速度快于任何比较排序算法。当k不是很大并且序列比较集中时,计数排序是一个很有效的排序算法。
桶排序(Bucket Sort)
- 桶排序是计数排序的升级版。它利用了函数的映射关系,高效与否的关键就在于这个映射函数的确定。桶排序 (Bucket sort)的工作的原理:假设输入数据服从均匀分布,将数据分到有限数量的桶里,每个桶再分别排序(有可能再使用别的排序算法或是以递归方式继续使用桶排序进行排)。
- 算法流程:
- 设置一个定量的数组当作空桶;
- 遍历输入数据,并且把数据一个一个放到对应的桶里去;
- 对每个不是空的桶进行排序;
- 从不是空的桶里把排好序的数据拼接起来。
- 桶排序最好情况下使用线性时间O(n),桶排序的时间复杂度,取决与对各个桶之间数据进行排序的时间复杂度,因为其它部分的时间复杂度都为O(n)。很显然,桶划分的越小,各个桶之间的数据越少,排序所用的时间也会越少,但相应的空间消耗就会增大。
T = O ( n ∗ k ) {T=O(n*k)} T=O(n∗k)
基数排序(Radix Sort)
- 基数排序是按照低位先排序,然后收集;再按照高位排序,然后再收集;依次类推,直到最高位。有时候有些属性是有优先级顺序的,先按低优先级排序,再按高优先级排序。最后的次序就是高优先级高的在前,高优先级相同的低优先级高的在前。
- 算法流程:
- 取得数组中的最大数,并取得位数;
- arr为原始数组,从最低位开始取每个位组成radix数组;
- 对radix进行计数排序(利用计数排序适用于小范围数的特点);
- 基数排序基于分别排序,分别收集,所以是稳定的。但基数排序的性能比桶排序要略差,每一次关键字的桶分配都需要O(n)的时间复杂度,而且分配之后得到新的关键字序列又需要O(n)的时间复杂度。假如待排数据可以分为d个关键字,则基数排序的时间复杂度将是O(d*2n) ,当然d要远远小于n,因此基本上还是线性级别的。
- 基数排序的空间复杂度为O(n+k),其中k为桶的数量。一般来说n>>k,因此额外空间需要大概n个左右。