零、总论:
本篇总结一下常见的经典的排序算法,资料来源于《算法导论》和各个博客,都非常简单。
排序的数据集合使用数组,算法执行时围绕数组的下标计算。算法执行后小的元素排前面,大的排后面。
经典排序算法已经被研究得差不多了,未来排序算法的挑战可能来源于多处理器上的并行排序和数据规模上。
一、分类方式:
按数据存放位置:内部排序(数据全部在内存中)和外部排序(允许部分数据在磁盘)
按是否进行大小比较:比较排序(快排、堆排序、归并排序等)和非比较排序(计数、基数、桶排序)
按是否稳定(相等大小的数的相对位置是否变化):稳定排序和非稳定排序
按时间复杂度:O(n), O(nlogn), O(n*n)
按空间复杂度:O(1), O(k), O(n), 就地排序in-place是O(1)
按处理器数量,还有并行排序算法
二、比较排序的七种算法(冒泡、选择、插入、希尔、堆、归并、快排)
1. 冒泡排序
冒泡排序bubbleSort()直观上可以解释为小的元素像气泡一样浮到前面来,大的元素沉到后面去。算法设计思想是两层循环,外层循环每一次操作将最大元素沉底,内层循环每次处理的数据数量减一,沉底的元素就不考虑了。
时间复杂度:O(n*n)
空间复杂度:O(1)
稳定性:稳定
效率低下原因:
每次元素比较都只比较相邻的两个。重复比较次数太多。
优化方向:
1. 设置flag标记,可以减少比较次数
2. 记录无序区,有序区无需比较
2. 选择排序
冒泡排序做了很多次无意义的重复工作(相邻元素比较),而选择排序侧重的是一遍循环选中最小值然后直接放置在数组前端。
时间复杂度:O(n*n)
空间复杂度:O(1)
稳定性:不稳定,如5 9 5 2。
效率低下原因:每次循环只确定一个数的位置,忽略了其他信息。
优化方向:
1. 每次循环可以同时找到最大值和最小值,时间减少一半。冒泡排序也可以。
3. 插入排序
类似于玩扑克牌,现实生活中人类直观上就是这么排序的,思想很自然。
时间复杂度:O(n*n)
空间复杂度:O(1)
稳定性:稳定
效率低下原因:每次只将数据移动一位
优化方向:
1.二分插入排序(插入每个元素用二分查找)
2. 希尔排序
4. 希尔排序(递减增量排序)
与插入排序思想一样,但是考虑到直接插入排序的不足(每次值移动一位),因而设定步长是一个很直观的优化方法。
希尔排序的步长设定为递减增量,如1,4,13,40等
时间复杂度:略好于O(n*n),可能是O(n(logn)^k)
空间复杂度:O(1)
稳定性:不稳定。虽然插入排序是稳定的,但是希尔排序相当于是将数据分了组,稳定性得不到保证。
优化方向:步长的选择
5. 堆排序
先说说什么是堆:
从二叉树说起,在计算机领域中,二叉树是每个节点最多有两个子树的树结构(可以是不满的,可以是不完全的),常被用来实现二叉查找树和二叉堆。
满二叉树:一棵深度为k,节点数为2^k-1的树。如0,1,2; 0,1,2,3,4,5,6,7; 0…15;0…31等
完全二叉树:深度为k,节点数为n,当且仅当每个节点都与深度为k的满二叉树中序号1至n的节点对应时。如0,1; 0,1,2,3,4,5;等(也就是只有最底层的最后一系列元素没有)
堆就可以视作一棵完全二叉树。由于完全二叉树的性质,堆可以用数组来表示(普通的树是用链表来存储)。
最大堆:最大元素是根节点,每个父节点的值都大于等于两个孩子节点。最小堆相反。
再说说堆排序的原理:
buildHeap():创建最大堆,from n/2 to 1执行heapify()。时间复杂度O(n)
heapify():最大堆调整,维持堆的性质。时间复杂度O(logn)
heapSort():from n to 2执行exchange A[1] with A[i] and heapify()。时间复杂度O(nlogn)
空间复杂度:O(1)
稳定性:不稳定。堆结构上的排序是不稳定的
流程图:
6. 归并排序
时间复杂度:O(nlogn)
空间复杂度:O(n)
稳定性:稳定
7. 快速排序
递归算法,通常是实际排序应用中最好的选择。
时间复杂度:O(nlogn)
空间复杂度:O(1)
稳定性:不稳定。不稳定发生在pivot与A[i+1]交换的时刻
8. 总结比较
三、非比较排序的三中算法(计数、基数、桶排序)
1. 计数排序


#include<iostream> using namespace std; // 分类 ------------ 内部非比较排序 // 数据结构 --------- 数组 // 最差时间复杂度 ---- O(n + k) // 最优时间复杂度 ---- O(n + k) // 平均时间复杂度 ---- O(n + k) // 所需辅助空间 ------ O(n + k) // 稳定性 ----------- 稳定 const int k = 100; // 基数为100,排序[0,99]内的整数 int C[k]; // 计数数组 void CountingSort(int A[], int n) { for (int i = 0; i < k; i++) // 初始化,将数组C中的元素置0(此步骤可省略,整型数组元素默认值为0) { C[i] = 0; } for (int i = 0; i < n; i++) // 使C[i]保存着等于i的元素个数 { C[A[i]]++; } for (int i = 1; i < k; i++) // 使C[i]保存着小于等于i的元素个数,排序后元素i就放在第C[i]个输出位置上 { C[i] = C[i] + C[i - 1]; } int *B = (int *)malloc((n) * sizeof(int));// 分配临时空间,长度为n,用来暂存中间数据 for (int i = n - 1; i >= 0; i--) // 从后向前扫描保证计数排序的稳定性(重复元素相对次序不变) { B[--C[A[i]]] = A[i]; // 把每个元素A[i]放到它在输出数组B中的正确位置上 // 当再遇到重复元素时会被放在当前元素的前一个位置上保证计数排序的稳定性 } for (int i = 0; i < n; i++) // 把临时空间B中的数据拷贝回A { A[i] = B[i]; } free(B); // 释放临时空间 } int main() { int A[] = { 15, 22, 19, 46, 27, 73, 1, 19, 8 }; // 针对计数排序设计的输入,每一个元素都在[0,100]上且有重复元素 int n = sizeof(A) / sizeof(int); CountingSort(A, n); printf("计数排序结果:"); for (int i = 0; i < n; i++) { printf("%d ", A[i]); } printf("\n"); return 0; }
思想:统计某个元素在所有元素中是第几大,就可以确定该元素的位置,这通过计数器而非元素之间的比较实现。
k是该元素集合的极差range。当k不大于n时,计数排序非常有效,如统计某省高考的排名,n约为二十万到一百万,而k为750。
时间复杂度:O(n+k)
空间复杂度:O(n+k)
稳定性:稳定
2. 基数排序


#include<iostream> using namespace std; // 分类 ------------- 内部非比较排序 // 数据结构 ---------- 数组 // 最差时间复杂度 ---- O(n * dn) // 最优时间复杂度 ---- O(n * dn) // 平均时间复杂度 ---- O(n * dn) // 所需辅助空间 ------ O(n * dn) // 稳定性 ----------- 稳定 const int dn = 3; // 待排序的元素为三位数及以下 const int k = 10; // 基数为10,每一位的数字都是[0,9]内的整数 int C[k]; int GetDigit(int x, int d) // 获得元素x的第d位数字 { int radix[] = { 1, 1, 10, 100 };// 最大为三位数,所以这里只要到百位就满足了 return (x / radix[d]) % 10; } void CountingSort(int A[], int n, int d)// 依据元素的第d位数字,对A数组进行计数排序 { for (int i = 0; i < k; i++) { C[i] = 0; } for (int i = 0; i < n; i++) { C[GetDigit(A[i], d)]++; } for (int i = 1; i < k; i++) { C[i] = C[i] + C[i - 1]; } int *B = (int*)malloc(n * sizeof(int)); for (int i = n - 1; i >= 0; i--) { int dight = GetDigit(A[i], d); // 元素A[i]当前位数字为dight B[--C[dight]] = A[i]; // 根据当前位数字,把每个元素A[i]放到它在输出数组B中的正确位置上 // 当再遇到当前位数字同为dight的元素时,会将其放在当前元素的前一个位置上保证计数排序的稳定性 } for (int i = 0; i < n; i++) { A[i] = B[i]; } free(B); } void LsdRadixSort(int A[], int n) // 最低位优先基数排序 { for (int d = 1; d <= dn; d++) // 从低位到高位 CountingSort(A, n, d); // 依据第d位数字对A进行计数排序 } int main() { int A[] = { 20, 90, 64, 289, 998, 365, 852, 123, 789, 456 };// 针对基数排序设计的输入 int n = sizeof(A) / sizeof(int); LsdRadixSort(A, n); printf("基数排序结果:"); for (int i = 0; i < n; i++) { printf("%d ", A[i]); } printf("\n"); return 0; }
时间复杂度:O(n*d)
空间复杂度:O(n*d)
稳定性:稳定
d为数字位数,上图中为3。当d不大于logn时计数排序还是比比较排序快的。
试用范围:整数的排序,某些字符串或者特定格式的数据(可以将他们转化为整数)
3. 桶排序


#include<iostream> using namespace std; // 分类 ------------- 内部非比较排序 // 数据结构 --------- 数组 // 最差时间复杂度 ---- O(nlogn)或O(n^2),只有一个桶,取决于桶内排序方式 // 最优时间复杂度 ---- O(n),每个元素占一个桶 // 平均时间复杂度 ---- O(n),保证各个桶内元素个数均匀即可 // 所需辅助空间 ------ O(n + bn) // 稳定性 ----------- 稳定 /* 本程序用数组模拟桶 */ const int bn = 5; // 这里排序[0,49]的元素,使用5个桶就够了,也可以根据输入动态确定桶的数量 int C[bn]; // 计数数组,存放桶的边界信息 void InsertionSort(int A[], int left, int right) { for (int i = left + 1; i <= right; i++) // 从第二张牌开始抓,直到最后一张牌 { int get = A[i]; int j = i - 1; while (j >= left && A[j] > get) { A[j + 1] = A[j]; j--; } A[j + 1] = get; } } int MapToBucket(int x) { return x / 10; // 映射函数f(x),作用相当于快排中的Partition,把大量数据分割成基本有序的数据块 } void CountingSort(int A[], int n) { for (int i = 0; i < bn; i++) { C[i] = 0; } for (int i = 0; i < n; i++) // 使C[i]保存着i号桶中元素的个数 { C[MapToBucket(A[i])]++; } for (int i = 1; i < bn; i++) // 定位桶边界:初始时,C[i]-1为i号桶最后一个元素的位置 { C[i] = C[i] + C[i - 1]; } int *B = (int *)malloc((n) * sizeof(int)); for (int i = n - 1; i >= 0; i--)// 从后向前扫描保证计数排序的稳定性(重复元素相对次序不变) { int b = MapToBucket(A[i]); // 元素A[i]位于b号桶 B[--C[b]] = A[i]; // 把每个元素A[i]放到它在输出数组B中的正确位置上 // 桶的边界被更新:C[b]为b号桶第一个元素的位置 } for (int i = 0; i < n; i++) { A[i] = B[i]; } free(B); } void BucketSort(int A[], int n) { CountingSort(A, n); // 利用计数排序确定各个桶的边界(分桶) for (int i = 0; i < bn; i++) // 对每一个桶中的元素应用插入排序 { int left = C[i]; // C[i]为i号桶第一个元素的位置 int right = (i == bn - 1 ? n - 1 : C[i + 1] - 1);// C[i+1]-1为i号桶最后一个元素的位置 if (left < right) // 对元素个数大于1的桶进行桶内插入排序 InsertionSort(A, left, right); } } int main() { int A[] = { 29, 25, 3, 49, 9, 37, 21, 43 };// 针对桶排序设计的输入 int n = sizeof(A) / sizeof(int); BucketSort(A, n); printf("桶排序结果:"); for (int i = 0; i < n; i++) { printf("%d ", A[i]); } printf("\n"); return 0; }
时间复杂度:O(n)
空间复杂度:O(n+d)
稳定性:稳定的
工作的原理是将数组元素映射到有限数量个桶里,利用计数排序可以定位桶的边界,每个桶再各自进行桶内排序(使用其它排序算法或以递归方式继续使用桶排序)
试用范围:系统应用中的算法思想。