各排序算法的总结
排序算法分两类:比较排序和非比较排序。
文章目录
一、比较排序算法
比较排序就是通过比较数组中元素的大小并进行一系列的数据交换操作来达到有序。
其中对于他们的性能(复杂度)对比如图下所示:
排序方法 | 平均情况 | 最优情况 | 最坏情况 | 辅助空间 | 稳定性 |
---|---|---|---|---|---|
冒泡排序 | O(n²) | O(n) | O(n²) | O(1) | 稳定 |
鸡尾酒排序 | O(n²) | O(n) | O(n²) | O(1) | 稳定 |
选择排序 | O(n²) | O(n²) | O(n²) | O(1) | 不稳定 |
插入排序 | O(n²) | O(n) | O(n²) | O(1) | 稳定 |
二分插入排序 | O(n²) | O(nlogn) | O(n²) | O(1) | 稳定 |
希尔排序 | O(nlogn~n²) | O(n1.3) | O(n²) | O(1) | 不稳定 |
归并排序 | O(nlogn) | O(nlogn) | O(nlogn) | O(n) | 稳定 |
堆排序 | O(nlog2n) | O(nlog2n) | O(nlog2n) | O(1) | 不稳定 |
快速排序 | O(nlogn) | O(nlogn) | O(n²) | O(logn~n) | 不稳定 |
排序不稳定指的是:使相等元素的相对次序发生变化。
为了方便描述,我将交换程序swap放在这里:
有些朋友用异或操作来交换数据,以为少了一个变量程序就会变快,殊不知产生相反的效果。感兴趣的话可以看这篇文章:用异或来交换两个变量是错误的。这位博主从不同系统和编译器层面来分析为什么会造成这种结果。
void swap(int A[], int i, int j)
{
int temp = A[i];
A[i] = A[j];
A[j] = temp;
}
(一)、冒泡算法
遍历整个数组,依次比较元素,错序就执行swap,知道没有元素需要交换。
如果能在内部循环第一次运行时,使用一个flag来表示交换的可能性,可以把最优时间复杂度降低到O(n)。
void BubbleSort(int A[], int n)
{
for (int j = 0; j < n - 1; j++)
{
for (int i = 0; i < n - 1 - j; i++)
{
if (A[i] > A[i + 1]) // 从小到大冒泡排序
swap(A, i, i + 1);
}
}
}
(二)、鸡尾酒排序(冒泡排序改进版)
这个算法与冒泡排序的不同在于它从低->高->低排序,而冒泡排序仅从低->高去比较。从某种程度上它比冒泡好一些。(such as sequence [3,8,1])
void CocktailSort(int A[], int n) {
int left = 0; //初始化左边界
int right = n - 1; //初始化右边界
while (left < right)
{
for (int i = left; i < right; i++) //前半部分,将最大元素放在后面
{
if (A[i] > A[i + 1])
{
swap(A, i, i + 1);
}
}
right--;
for (int i = right; i > left; i--) //后半部分,将最小元素放到前面
{
if (A[i-1] > A[i])
{
swap(A, i - 1, i);
}
}
left++;
}
}
(三)、选择排序
与冒泡排序不同的是,选择排序记住每次遍历时的min(max)值,到最后只需要执行一次交换操作即可。
void SelectionSort(int A[], int n)
{
for (int i = 0; i < n - 1; i++) // i为已排序序列的末尾
{
int min = i; // 记住最小值的位置
for (int j = i + 1; j < n; j++)
{
if (A[j] < A[min]) // 找出未排序序列中最小值
{
min = j; //此时A[j]为min
}
}
if(min!=i)
{
swap(A, min, i);
}
}
}
(四)、插入排序
插入排序类似于玩扑克牌时排序操作。( 在STL的sort算法和stdlib的qsort算法中,都将插入排序作为快速排序的补充,不适用于大数据量的场合(<1k) )
void InsertionSort(int A[], int n)
{
for (int i = 1; i < n; i++) { // 类似抓扑克牌排序
int get = A[i]; // 右手抓到一张扑克牌
int j = i - 1; // 拿在左手上的牌总是排序好的
while (j >= 0 && A[j] > get) { // 将抓到的牌与手牌从右向左进行比较
A[j + 1] = A[j];
j--; //再上一张手牌
}
A[j + 1] = get; // 直到该手牌比抓到的牌小(或二者相等),
// 将抓到的牌插入到该手牌右边(相等元素的相对次序未变,所以插入排序是稳定的)
}
}
(五)、二分法插入排序
由于左手上的牌是已经排好序的了,将已排序的数据分成两部分进行比较。如果右手上未排序的牌大于(小于)就将此数据跟左手牌中间的右(左)进行比较。
直接插入最优情况 < 二分法插入比较次数 << 直接插入最差情况
void InsertionSortDichotomy(int A[], int n) {
for (int i = 1; i < n; i++)
{
int get = A[i]; // 右手抓到一张扑克牌
int left = 0; // 拿在左手上的牌总是排序好的,所以可以用二分法
int right = i - 1; // 手牌左右边界进行初始化
while (left <= right)
{
int mid = (left + right) / 2; // 采用二分法定位新牌的位置
if (A[mid] > get)
right = mid - 1;
else
left = mid + 1;
}
for (int j = i - 1; j >= left; j--)
{
A[j + 1] = A[j]; // 将欲插入新牌位置右边的牌整体向右移动一个单位
}
A[left] = get; // 将抓到的牌插入手牌
}
}
(六)、希尔排序(递减增量排序)(插入排序升级版)
希尔排序和插入排序原理差不多,其实就是将每次右手摸牌的“步距”从大慢慢变小
(N/3->…->1)(N/3为参考值,它的选择会直接影响总共需要交换的次数)
Q:为什么插入排序稳定,但是希尔排序却不稳定呢?
这是由于希尔排序是多次插入排序,我们知道一次插入排序是稳定的,不会改变相同元素的相对顺序,但在不同的插入排序过程中,相同的元素可能在各自的插入排序中移动,最后其稳定性就会被打乱,所以它是不稳定的。
void ShellSort(int A[], int n)
{
int h = 0;
int i, j, k;
int get; //抽出的牌
while (h < n / 3) //生成初始增量
h = 3 * h + 1;
for (; h >= 1; h = (h - 1) / 3) // 计算下一次递减增量
{
for (i = h; i < n; i++)
{
get = A[i];
for ( j = i - h; j >= 0 && A[j] > get; j = j - h)
A[j + h] = A[j];
A[j + h] = get;
}
}
}
(七)、归并排序
将大问题分解为小问题,用小答案组成整体答案。(分治策略)
1->2->4…->n(将1分割为n小部分) n->n/2->…->1(两两归并,进行比较并排序) 归并排序有递归(自顶而下)和非递归(自底而上)两种实现方式。
归并排序算法主要依赖归并(Merge)操作。归并操作指的是将两个已经排序的序列合并成一个序列的操作,归并操作步骤如下:
1、申请空间,使其大小为两个已经排序序列之和,该空间用来存放合并后的序列
2、设定两个指针,最初位置分别为两个已经排序序列的起始位置
3、比较两个指针所指向的元素,选择相对小的元素放入到合并空间,并移动指针到下一位置
4、重复步骤3直到某一指针到达序列尾
5、将另一序列剩下的所有元素直接复制到合并序列尾
void Merge(int A[], int left, int mid, int right)
// 合并两个已排好序的数组A[left...mid]和A[mid+1...right]
{
int len = right - left + 1;
int *temp = new int[len]; // 辅助空间O(n)
int index = 0;
int i = left; // 前一数组的起始元素
int j = mid + 1; // 后一数组的起始元素
while (i <= mid && j <= right)
{
temp[index++] = A[i] <= A[j] ? A[i++] : A[j++];
// 带等号保证归并排序的稳定性
}
while (i <= mid)
{
temp[index++] = A[i++];
}
while (j <= right)
{
temp[index++] = A[j++];
}
for (int k = 0; k < len; k++)
{
A[left++] = temp[k];
}
}
void MergeSortRecursion(int A[], int left, int right) // 递归实现的归并排序(自顶向下)
{
if (left == right) // 当待排序的序列长度为1时,递归开始回溯,进行merge操作
return;
int mid = (left + right) / 2;
MergeSortRecursion(A, left, mid);
MergeSortRecursion(A, mid + 1, right);
Merge(A, left, mid, right);
}
void MergeSortIteration(int A[], int len) // 非递归(迭代)实现的归并排序(自底向上)
{
int left, mid, right;
// 子数组索引,前一个为A[left...mid],后一个子数组为A[mid+1...right]
for (int i = 1; i < len; i *= 2) // 子数组的大小i初始为1,每轮翻倍
{
left = 0;
while (left + i < len) // 后一个子数组存在(需要归并)
{
mid = left + i - 1;
right = mid + i < len ? mid + i : len - 1;
// 后一个子数组大小可能不够,设置上限值
Merge(A, left, mid, right);
left = right + 1; // 前一个子数组索引向后移动
}
}
}
(八)、堆排序
利用完全二叉树特点,父节点总大于(小于)子节点。每次将数组堆化,然后将堆顶元素与堆尾元素互换。
我们可以很容易的定义堆排序的过程:
1、由输入的无序数组构造一个最大堆,作为初始的无序区
2、把堆顶元素(最大值)和堆尾元素互换
3、把堆(无序区)的尺寸缩小1,并调用heapify(A, 0)从新的堆顶元素开始进行堆调整
4、重复步骤2,直到堆的尺寸为1
void Heapify(int A[], int i, int size) { // 从A[i]向下进行堆调整
int left_child = 2 * i + 1; // 左孩子索引
int right_child = 2 * i + 2; // 右孩子索引
int max = i; // 选出当前结点与其左右孩子三者之中的最大值
if (left_child < size&&A[left_child]>A[max])
max = left_child;
if (right_child<size&&A[right_child]>A[max])
max = right_child;
if (max != i) {
swap(A, i, max); // 把当前结点和它的最大(直接)子节点进行交换
Heapify(A, max, size); // 递归调用,继续从当前结点向下进行堆调整
}
}
int BuildHeap(int A[], int n) { // 建堆,时间复杂度O(n)
int heap_size = n;
for (int i = heap_size / 2 - 1; i >= 0; i--) // 从每一个非叶结点开始向下进行堆调整
{
Heapify(A, i, heap_size);
}
return heap_size;
}
void HeapSort(int A[], int n) {
int heap_size = BuildHeap(A, n);// 建立一个最大堆,相当于初始化
while (heap_size > 1) { // 堆(无序区)元素个数大于1,未完成排序
// 将堆顶元素与堆的最后一个元素互换,并从堆中去掉最后一个元素
// 此处交换操作很有可能把后面元素的稳定性打乱,所以堆排序是不稳定的排序算法
swap(A, 0, --heap_size);
Heapify(A, 0, heap_size); // 从新的堆顶元素开始向下进行堆调整,时间复杂度O(logn)
}
}
(九)、快速排序
选出基准,并将小于等于基准的值放到前一子数组后,每次遍历完也将基准放到前一子数组后。
快速排序使用分治策略(Divide and Conquer)来把一个序列分为两个子序列。步骤为:
1、从序列中挑出一个元素,作为"基准"(pivot).
2、把所有比基准值小的元素放在基准前面,所有比基准值大的元素放在基准的后面(相同的数可以到任一边),这个称为分区(partition)操作。
3、对每个分区递归地进行步骤1~2,递归的结束条件是序列的大小是0或1,这时整体已经被排好序了。
int Partition(int A[], int left, int right) // 划分函数
{
int pivot = A[right]; //这里每次都选择最后一个元素作为基准
int tail = left - 1; //tail为小于基准的子数组作为最后一个元素的索引
for (int i = left; i < right; i++)
{
if (A[i] <= pivot) //判断一下i是否为前一数组末尾tail可提高运算速度
{
++tail;
if(tail!=i) swap(A, tail, i);
}
}
// 最后把基准放到前一个子数组的后边,剩下的子数组既是大于基准的子数组
swap(A, tail + 1, right);
// 该操作很有可能把后面元素的稳定性打乱,所以快速排序是不稳定的排序算法
return tail + 1; // 返回基准的索引
}
void QuickSort(int A[], int left, int right) {
if (left >= right) {
return;
}
int pivot_index = Partition(A, left, right); //基准的索引
QuickSort(A, left, pivot_index - 1);
QuickSort(A, pivot_index + 1, right);
}
Java系统提供的Arrays.sort函数。对于基础类型,底层使用快速排序。对于非基础类型,底层使用归并排序。请问是为什么?
答:这是考虑到排序算法的稳定性。对于基础类型,相同值是无差别的,排序前后相同值的相对位置并不重要,所以选择更为高效的快速排序,尽管它是不稳定的排序算法;而对于非基础类型,排序前后相等实例的相对位置不宜改变,所以选择稳定的归并排序。
二、非比较排序算法
非比较排序就是不通过比较数组元素的大小来达到有序效果,而是用更巧妙的方法来实现。
排序方法 | 平均情况 | 最优情况 | 最坏情况 | 辅助空间 | 稳定性 |
---|---|---|---|---|---|
计数排序 | O(n + k) | O(n + k) | O(n + k) | O(n + k) | 稳定 |
基数排序 | O(n * dn) | O(n * dn) | O(n * dn) | O(n * dn) | 稳定 |
桶排序 | O(n) | O(n) | O(nlogn~n^2) | O(n) | 稳定 |
(一)、计数排序
计数排序的步骤如下:
1、统计数组A中每个值A[i]出现的次数,存入C[A[i]]
2、从前向后,使数组C中的每个值等于其与前一项相加,这样数组C[A[i]]就变成了代表数组A中小于等于A[i]的元素个数
3、反向填充目标数组B:将数组元素A[i]放在数组B的第C[A[i]]个位置(下标为C[A[i]] - 1),每放一个元素就将C[A[i]]递减
const int k = 100; //基数为100,排序[0,99]内的整数
int C[k] = {0}; //创建并初始化计数数组
void CountingSort(int A[], int n) {
int i;
for (i = 0; i < n; i++)
C[A[i]]++;// 使C[i]保存着等于i的元素个数
for (i = 1; i < k; i++)
// 使C[i]保存着小于等于i的元素个数,排序后元素i就放在第C[i]个输出位置上
C[i] += C[i - 1];
int *B = (int*)malloc((n) * sizeof(int));// 分配临时空间,长度为n,用来暂存中间数据
for (i = n - 1; i >= 0; i--)
B[--C[A[i]]] = A[i]; // 从后向前扫描保证计数排序的稳定性(重复元素相对次序不变)
for (i = 0; i < n; i++) // 把临时空间B中的数据拷贝回A
A[i] = B[i];
free(B); // 释放临时空间
}
(二)、基数排序
比较数据每一位大小并排序,重复数据的数量级次(如有百位数据就3次),每一位的排序方法同计数排序。
基数排序的时间复杂度是O(n * dn),其中n是待排序元素个数,dn是数字位数。这个时间复杂度不一定优于O(n log n),dn的大小取决于数字位的选择(比如比特位数),和待排序数据所属数据类型的全集的大小;dn决定了进行多少轮处理,而n是每轮处理的操作数目。
如果考虑和比较排序进行对照,基数排序的形式复杂度虽然不一定更小,但由于不进行比较,因此其基本操作的代价较小,而且如果适当的选择基数,dn一般不大于log n,所以基数排序一般要快过基于比较的排序,比如快速排序。由于整数也可以表达字符串(比如名字或日期)和特定格式的浮点数,所以基数排序并不是只能用于整数排序。
const int dn = 3; // 待排序的元素为三位数及以下
const int k = 10; // 基数为10,每一位的数字都是[0,9]内的整数
int C[k];
int GetDigit(int x, int d) {
int radix[] = { 1,1,10,100 };// 最大为三位数,所以这里只要到百位就满足了
return(x / radix[d]) % 10;
}
void CountingSort(int A[], int n, int d) {
// 依据元素的第d位数字,对A数组进行计数排序
int i;
for (i = 0; i < k; i++)
{
C[i] = 0; //初始化
}
for (i = 0; i < n; i++)
C[GetDigit(A[i], d)]++; //存放每位数字的数目
for (i = 1; i < k; i++)
C[i] += C[i - 1];
int *B = (int*)malloc(n * sizeof(int));
for (i = n - 1; i >= 0; i--)
{
int digit = GetDigit(A[i], d);// 元素A[i]当前位数字为dight
// 根据当前位数字,把每个元素A[i]放到它在输出数组B中的正确位置上
B[--C[digit]] = A[i];
// 当再遇到当前位数字同为dight的元素时,
// 会将其放在当前元素的前一个位置上保证计数排序的稳定性
}
for (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进行计数排序
}
(三)、桶排序
桶即决定数据大小范围,把数据分类放好并排序,最后合并即可。每个桶内数据排序同计数排序。
工作的原理是将数组元素映射到有限数量个桶里,利用计数排序可以定位桶的边界,每个桶再各自进行桶内排序(使用其它排序算法或以递归方式继续使用桶排序)。
桶排序不是比较排序,不受到O(nlogn)下限的影响,它是鸽巢排序的一种归纳结果,当所要排序的数组值分散均匀的时候,桶排序拥有线性的时间复杂度。
// 这里排序[0,49]的元素,使用5个桶就够了,也可以根据输入动态确定桶的数量
const int bn = 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)
{
int i;
for (i = 0; i < bn; i++)
{
C[i] = 0;
}
for (i = 0; i < n; i++)// 使C[i]保存着i号桶中元素的个数
{
C[MapToBucket(A[i])]++;
}
for (i = 1; i < bn; i++)
{
C[i] += C[i - 1];
}
int *B = (int *)malloc(n * sizeof(int));
for (i = n - 1; i >= 0; i--) // 从后向前扫描保证计数排序的稳定性(重复元素相对次序不变)
{
int b = MapToBucket(A[i]); // 元素A[i]位于b号桶
B[--C[b]] = A[i]; // 把每个元素A[i]放到它在输出数组B中的正确位置上
//这里C[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);
}
}