文章目录
-
- [0. 排序算法总览](#0. 排序算法总览)
- [1. 直接插入排序](#1. 直接插入排序)
-
- [1.1 核心思想](#1.1 核心思想)
- [1.2 为什么要用 `end`](#1.2 为什么要用
end) - [1.3 推荐代码](#1.3 推荐代码)
- [1.4 代码理解](#1.4 代码理解)
- [1.5 特点](#1.5 特点)
- [2. 希尔排序](#2. 希尔排序)
-
- [2.1 核心思想](#2.1 核心思想)
- [2.2 为什么 gap 要从大到小](#2.2 为什么 gap 要从大到小)
- [2.3 为什么是 `end -= gap`](#2.3 为什么是
end -= gap) - [2.4 推荐代码](#2.4 推荐代码)
- [2.5 代码理解](#2.5 代码理解)
- [2.6 特点](#2.6 特点)
- [3. 选择排序](#3. 选择排序)
-
- [3.1 一次选一个数](#3.1 一次选一个数)
- [3.2 一次选两个数](#3.2 一次选两个数)
- [3.3 `maxIndex == left` 为什么要修正](#3.3
maxIndex == left为什么要修正) - [3.4 特点](#3.4 特点)
- [4. 堆排序](#4. 堆排序)
-
- [4.1 堆的基本概念](#4.1 堆的基本概念)
- [4.2 为什么升序要建大堆](#4.2 为什么升序要建大堆)
- [4.3 向下调整 AdjustDown](#4.3 向下调整 AdjustDown)
- [4.4 建堆](#4.4 建堆)
- [4.5 堆排序完整代码](#4.5 堆排序完整代码)
- [4.6 特点](#4.6 特点)
- [5. 冒泡排序](#5. 冒泡排序)
-
- [5.1 核心思想](#5.1 核心思想)
- [5.2 代码](#5.2 代码)
- [5.3 为什么外层写 `end > 0`](#5.3 为什么外层写
end > 0) - [5.4 特点](#5.4 特点)
- [6. 快速排序](#6. 快速排序)
-
- [6.1 快排整体思想](#6.1 快排整体思想)
- [6.2 Hoare 版本](#6.2 Hoare 版本)
- [6.3 挖坑法](#6.3 挖坑法)
- [6.4 前后指针法](#6.4 前后指针法)
- [6.5 快速排序非递归](#6.5 快速排序非递归)
- [6.6 快速排序优化:三数取中和小区间优化](#6.6 快速排序优化:三数取中和小区间优化)
- [6.7 快排特点](#6.7 快排特点)
- [7. 归并排序](#7. 归并排序)
- [8. 计数排序](#8. 计数排序)
-
- [8.1 核心思想](#8.1 核心思想)
- [8.2 代码](#8.2 代码)
- [8.3 相对映射](#8.3 相对映射)
- [8.4 特点](#8.4 特点)
- [9. 复杂度与稳定性总结](#9. 复杂度与稳定性总结)
- [10. 最终记忆版](#10. 最终记忆版)
0. 排序算法总览
| 排序算法 | 核心思想 | 平均时间复杂度 | 最坏时间复杂度 | 空间复杂度 | 稳定性 |
|---|---|---|---|---|---|
| 直接插入排序 | 将当前元素插入前面的有序区间 | O(N²) | O(N²) | O(1) | 稳定 |
| 希尔排序 | 按 gap 分组做插入排序,逐步缩小 gap | 与 gap 序列有关 | 通常可到 O(N²) | O(1) | 不稳定 |
| 选择排序 | 每趟选择最小值,放到未排序区间开头 | O(N²) | O(N²) | O(1) | 不稳定 |
| 堆排序 | 建大堆,反复把堆顶最大值放到末尾 | O(NlogN) | O(NlogN) | O(1) | 不稳定 |
| 冒泡排序 | 相邻元素比较交换,每趟把最大值冒到末尾 | O(N²) | O(N²) | O(1) | 稳定 |
| 快速排序 | 一趟分区让 key 到最终位置,再处理左右区间 | O(NlogN) | O(N²) | O(logN) | 不稳定 |
| 归并排序 | 先让左右区间有序,再合并两个有序区间 | O(NlogN) | O(NlogN) | O(N) | 稳定 |
| 计数排序 | 统计每个值出现次数,再按次数回填 | O(N + range) | O(N + range) | O(range) | 可稳定 |
说明:希尔排序的复杂度和 gap 序列有关,不能简单固定写成 O(NlogN)。常见
gap /= 2写法实际表现通常优于直接插入排序,但理论复杂度并不稳定。
公共辅助函数
c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
void Swap(int* x, int* y)
{
int tmp = *x;
*x = *y;
*y = tmp;
}
void PrintArray(int* a, int n)
{
for (int i = 0; i < n; i++)
{
printf("%d ", a[i]);
}
printf("\n");
}
1. 直接插入排序
1.1 核心思想
直接插入排序可以类比整理扑克牌:前面一段牌已经排好,后面新拿到一张牌,把它插入到前面合适的位置。
对于数组来说,可以认为:
text
[0, i - 1]:已经有序
[i]:当前要插入的元素
[i + 1, n - 1]:还没有处理
过程图:
text
原数组: 5 2 4 6 1
第 1 趟: [5] 2 4 6 1
tmp = 2,把 5 后移,2 插到前面
结果: [2 5] 4 6 1
第 2 趟: [2 5] 4 6 1
tmp = 4,把 5 后移,4 插入 2 后面
结果: [2 4 5] 6 1
1.2 为什么要用 end
在插入排序中,end 不是多余变量,它表示当前有序区间的最后一个下标 。它从右往左扫描,负责给 tmp 找插入位置。
可以这样理解:
text
tmp 保存当前要插入的元素
end 从有序区间末尾开始往前走
如果 a[end] 比 tmp 大,就把 a[end] 往后挪
直到找到 <= tmp 的位置,或者 end 走到 -1
最后把 tmp 放到 end + 1
为什么最后是 end + 1?因为循环结束时,end 停在"不需要后移"的位置,真正插入点在它后面。
1.3 推荐代码
c
void InsertSort(int* a, int n)
{
for (int i = 1; i < n; i++)
{
int tmp = a[i]; // 待插入元素
int end = i - 1; // 有序区间的最后一个位置
while (end >= 0 && tmp < a[end])
{
a[end + 1] = a[end];
end--;
}
a[end + 1] = tmp;
}
}
1.4 代码理解
这段代码不是相邻元素一直交换,而是采用"挪动"的方式:
text
5 7 9 3
↑ ↑
end tmp
tmp = 3,因为 9 > 3,所以把 9 后移:
text
5 7 9 9
↑
end
继续比较 7 > 3,再后移:
text
5 7 7 9
↑
end
继续比较 5 > 3,再后移:
text
5 5 7 9
end = -1
最后把 tmp 放到 end + 1 = 0:
text
3 5 7 9
1.5 特点
- 时间复杂度:最好 O(N),最坏 O(N²)。
- 空间复杂度:O(1)。
- 稳定性:稳定。因为相等时不往前插,原有相对顺序不变。
- 适用场景:数据量小,或者数据基本有序。
2. 希尔排序
2.1 核心思想
希尔排序可以理解为带 gap 的插入排序。
普通插入排序一次只能挪一个位置,如果一个很小的数在数组末尾,它要一步一步挪到前面,代价很大。希尔排序先用较大的 gap 分组,让元素可以"大步移动",使数组接近有序;最后 gap 变成 1,再做一次普通插入排序。
过程图:
text
原数组: 9 1 2 5 7 4 8 6 3 5
下标: 0 1 2 3 4 5 6 7 8 9
gap = 5 时,同组元素下标相差 5:
组1:a[0], a[5] -> 9, 4
组2:a[1], a[6] -> 1, 8
组3:a[2], a[7] -> 2, 6
组4:a[3], a[8] -> 5, 3
组5:a[4], a[9] -> 7, 5
每一组内部做插入排序。
2.2 为什么 gap 要从大到小
gap 越大,元素移动跨度越大,适合在前期快速调整大致位置;gap 越小,调整越精细。最后 gap = 1 时,整个数组作为一组,完成最终排序。
text
gap 大:快速接近有序
↓
gap 小:局部精细调整
↓
gap = 1:普通插入排序收尾
2.3 为什么是 end -= gap
普通插入排序中,同组相邻元素的下标差是 1,所以向前找位置时是:
c
end--;
希尔排序中,同组相邻元素的下标差是 gap,所以要写:
c
end -= gap;
这也是希尔排序最关键的一点:
把直接插入排序里的"1"换成"gap"。
2.4 推荐代码
c
void ShellSort(int* a, int n)
{
int gap = n;
while (gap > 1)
{
gap /= 2;
// 对所有 gap 分组进行插入排序
for (int i = gap; i < n; i++)
{
int tmp = a[i];
int end = i - gap;
while (end >= 0 && tmp < a[end])
{
a[end + gap] = a[end];
end -= gap;
}
a[end + gap] = tmp;
}
}
}
2.5 代码理解
对于 i 位置的元素,前一个同组元素是:
c
end = i - gap;
如果 tmp < a[end],说明前一个同组元素太大,需要后移到:
c
a[end + gap]
然后继续看同组更前面的元素:
c
end -= gap;
2.6 特点
- 时间复杂度:与 gap 序列有关,常见写法无法严格固定为 O(NlogN)。
- 空间复杂度:O(1)。
- 稳定性:不稳定。因为相同元素可能在不同 gap 分组中跨越移动。
- 本质:预排序 + 最后一趟直接插入排序。
3. 选择排序
3.1 一次选一个数
选择排序的基本思想:每一趟从未排序区间中找最小值,把它放到未排序区间的开头。
过程图:
text
原数组: 6 3 8 2 9
第 1 趟:在 [6 3 8 2 9] 中找最小值 2,和开头 6 交换
结果: [2] 3 8 6 9
第 2 趟:在 [3 8 6 9] 中找最小值 3,位置不变
结果: [2 3] 8 6 9
代码:
c
void SelectSortOne(int* a, int n)
{
for (int i = 0; i < n - 1; i++)
{
int minIndex = i;
for (int j = i + 1; j < n; j++)
{
if (a[j] < a[minIndex])
{
minIndex = j;
}
}
if (minIndex != i)
{
Swap(&a[i], &a[minIndex]);
}
}
}
3.2 一次选两个数
可以在一趟中同时找最小值和最大值:
text
最小值放 left
最大值放 right
然后 left++,right--
代码:
c
void SelectSort(int* a, int n)
{
int left = 0;
int right = n - 1;
while (left < right)
{
int minIndex = left;
int maxIndex = left;
for (int i = left; i <= right; i++)
{
if (a[i] < a[minIndex])
minIndex = i;
if (a[i] > a[maxIndex])
maxIndex = i;
}
Swap(&a[minIndex], &a[left]);
// 如果最大值原来就在 left,那么上一步交换后,最大值被换到了 minIndex
if (maxIndex == left)
{
maxIndex = minIndex;
}
Swap(&a[maxIndex], &a[right]);
left++;
right--;
}
}
3.3 maxIndex == left 为什么要修正
例如:
text
9 1 3 2
当前区间中:
text
最小值 1 的下标 minIndex = 1
最大值 9 的下标 maxIndex = 0,也就是 left
先把最小值换到 left:
text
1 9 3 2
此时最大值 9 已经不在原来的 maxIndex = 0,而是到了 minIndex = 1。
所以必须更新:
c
maxIndex = minIndex;
否则第二次交换会把错误位置的数据换到末尾。
3.4 特点
- 时间复杂度始终 O(N²),即使数组已经有序,也要找最小值。
- 空间复杂度 O(1)。
- 不稳定。因为交换可能改变相同元素的相对顺序。
4. 堆排序
4.1 堆的基本概念
堆是一棵完全二叉树,常用数组存储。
对于下标为 i 的节点:
text
左孩子:2 * i + 1
右孩子:2 * i + 2
父节点:(i - 1) / 2
大堆:每个父节点都大于等于左右孩子,堆顶是最大值。
text
9
/ \
7 8
/ \ / \
3 5 2 6
数组表示:
text
9 7 8 3 5 2 6
0 1 2 3 4 5 6
4.2 为什么升序要建大堆
升序排序需要把最大值放到数组最后。大堆堆顶正好是最大值,所以每次把堆顶和当前堆的最后一个元素交换,最大值就被放到了最终位置。
text
建大堆:最大值在 a[0]
交换: a[0] <-> a[end]
调整: 剩余 [0, end - 1] 继续保持大堆
4.3 向下调整 AdjustDown
向下调整的前提:根节点的左右子树已经是堆,只是根节点可能破坏堆结构。
过程:
text
1. parent 指向根节点
2. child 先指向左孩子
3. 如果右孩子存在且更大,child 改为右孩子
4. 如果 a[child] > a[parent],交换,然后 parent 继续往下走
5. 否则调整结束
代码:
c
void AdjustDown(int* a, int n, int root)
{
int parent = root;
int child = 2 * parent + 1;
while (child < n)
{
// 选出左右孩子中较大的那个
if (child + 1 < n && a[child + 1] > a[child])
{
child++;
}
if (a[child] > a[parent])
{
Swap(&a[child], &a[parent]);
parent = child;
child = 2 * parent + 1;
}
else
{
break;
}
}
}
这里的 n 表示当前堆的有效元素个数 ,不一定是数组总长度。堆排序过程中,数组末尾会逐渐变成有序区间,这部分不再参与堆调整,所以 n 会逐渐变小。
4.4 建堆
建堆时,从最后一个非叶子节点开始,向前依次做向下调整。
最后一个元素下标是 n - 1,它的父节点就是最后一个非叶子节点:
c
(n - 2) / 2
代码:
c
void BuildHeap(int* a, int n)
{
for (int i = (n - 2) / 2; i >= 0; i--)
{
AdjustDown(a, n, i);
}
}
为什么从后往前?因为向下调整要求左右子树已经是堆,从最后一个非叶子节点往前调整,可以保证每次调整时下面的子树已经处理过。
4.5 堆排序完整代码
c
void HeapSort(int* a, int n)
{
// 1. 建大堆
for (int i = (n - 2) / 2; i >= 0; i--)
{
AdjustDown(a, n, i);
}
// 2. 反复把堆顶最大值放到末尾
int end = n - 1;
while (end > 0)
{
Swap(&a[0], &a[end]);
AdjustDown(a, end, 0);
end--;
}
}
4.6 特点
- 建堆复杂度:O(N)。
- 每次删除堆顶后调整:O(logN)。
- 堆排序总复杂度:O(NlogN)。
- 空间复杂度:O(1)。
- 稳定性:不稳定。
5. 冒泡排序
5.1 核心思想
冒泡排序每一趟比较相邻元素,如果前一个比后一个大,就交换。这样一趟下来,当前未排序区间的最大值会被"冒"到末尾。
过程图:
text
原数组: 5 3 8 2
第 1 趟:
5 和 3 比,交换 -> 3 5 8 2
5 和 8 比,不换 -> 3 5 8 2
8 和 2 比,交换 -> 3 5 2 8
↑
最大值到位
5.2 代码
c
void BubbleSort(int* a, int n)
{
for (int end = n - 1; end > 0; end--)
{
int exchange = 0;
for (int i = 0; i < end; i++)
{
if (a[i] > a[i + 1])
{
Swap(&a[i], &a[i + 1]);
exchange = 1;
}
}
if (exchange == 0)
{
break;
}
}
}
5.3 为什么外层写 end > 0
end 表示当前这一趟冒泡的最后一个比较位置。最后只剩两个元素时,还需要比较 a[0] 和 a[1],此时 end = 1。
当 end = 0 时,内层循环不会执行,所以没有必要继续。
5.4 特点
- 时间复杂度:最好 O(N),最坏 O(N²)。
- 空间复杂度:O(1)。
- 稳定性:稳定。相等元素不交换。
exchange是优化点:如果某一趟没有交换,说明数组已经有序。
6. 快速排序
6.1 快排整体思想
快速排序的核心是分区。
一趟排序选一个基准值 key,把小于 key 的放左边,大于 key 的放右边。此时 key 到达最终位置,然后递归处理左右区间。
text
原区间: [ left ............... right ]
一趟分区后: [ 小于 key ] key [ 大于 key ]
↑
key 已经到最终位置
递归框架:
c
void QuickSort(int* a, int left, int right)
{
if (left >= right)
return;
int keyi = PartSort(a, left, right);
QuickSort(a, left, keyi - 1);
QuickSort(a, keyi + 1, right);
}
快排传 begin 和 end,是因为递归时排序的是数组的某个子区间,而不是每次都排序整个数组。
6.2 Hoare 版本
思想
Hoare 版本使用左右指针:
text
key 选最左边时:right 先走,找小;left 后走,找大。
找到后交换,直到 left 和 right 相遇。
最后把 key 和相遇位置交换。
过程图:
text
key = 6
6 1 2 7 9 3 4 5 8
↑ ↑
key right 找小
right 找到 5,left 再找大,left 找到 7,交换:
6 1 2 5 9 3 4 7 8
key 在左,为什么 right 先走
如果 key 选最左边,最后需要把 key 和相遇点交换。right 先走可以保证相遇点更适合放 key:相遇位置上的值通常是小于等于 key 的。若 left 先走,可能让 key 和一个大于 key 的值交换,导致分区错误。
记忆:
text
key 在左,右边先走。
key 在右,左边先走。
单趟代码
c
int PartSortHoare(int* a, int left, int right)
{
int keyi = left;
while (left < right)
{
while (left < right && a[right] >= a[keyi])
{
right--;
}
while (left < right && a[left] <= a[keyi])
{
left++;
}
if (left < right)
{
Swap(&a[left], &a[right]);
}
}
Swap(&a[keyi], &a[left]);
return left;
}
递归代码:
c
void QuickSortHoare(int* a, int begin, int end)
{
if (begin >= end)
return;
int keyi = PartSortHoare(a, begin, end);
QuickSortHoare(a, begin, keyi - 1);
QuickSortHoare(a, keyi + 1, end);
}
6.3 挖坑法
思想
挖坑法不是交换,而是"填坑"。
text
1. 保存 key,key 原来的位置形成坑。
2. right 从右往左找小值,填到左边坑里。
3. left 从左往右找大值,填到右边坑里。
4. 坑不断移动,最后 left 和 right 相遇。
5. 把 key 放到最终坑位。
过程图:
text
key = 6,左边形成坑
[坑] 1 2 7 9 3 4 5 8
right 找到 5,填左坑:
5 1 2 7 9 3 4 [坑] 8
left 找到 7,填右坑:
5 1 2 [坑] 9 3 4 7 8
代码
c
int PartSortHole(int* a, int left, int right)
{
int key = a[left];
while (left < right)
{
while (left < right && a[right] >= key)
{
right--;
}
a[left] = a[right];
while (left < right && a[left] <= key)
{
left++;
}
a[right] = a[left];
}
a[left] = key;
return left;
}
void QuickSortHole(int* a, int begin, int end)
{
if (begin >= end)
return;
int keyi = PartSortHole(a, begin, end);
QuickSortHole(a, begin, keyi - 1);
QuickSortHole(a, keyi + 1, end);
}
细节
挖坑法里不需要额外判断:
c
if (a[right] < key)
因为内层循环结束后,要么找到了小值,要么 left == right,此时赋值相当于自赋值,不影响结果。
6.4 前后指针法
思想
前后指针法使用两个指针:
text
cur:负责从左往右扫描
prev:维护"小于 key 区间"的最后一个位置
数组被分成四部分:
text
[key] [小于 key 的区域] [大于等于 key 的区域] [未扫描区域]
begin+1..prev prev+1..cur-1 cur..right
当 cur 遇到小于 key 的值时:
text
1. prev++,小区间扩大一格
2. 如果 prev != cur,把 a[cur] 换到 a[prev]
3. cur 继续往后扫描
单趟代码
c
int PartSortPrevCur(int* a, int left, int right)
{
int keyi = left;
int prev = left;
int cur = left + 1;
while (cur <= right)
{
if (a[cur] < a[keyi])
{
prev++;
if (prev != cur)
{
Swap(&a[prev], &a[cur]);
}
}
cur++;
}
Swap(&a[keyi], &a[prev]);
return prev;
}
原笔记里常见的压缩写法:
c
if (a[cur] < a[keyi] && ++prev != cur)
{
Swap(&a[prev], &a[cur]);
}
它等价于:
c
if (a[cur] < a[keyi])
{
++prev;
if (prev != cur)
{
Swap(&a[prev], &a[cur]);
}
}
6.5 快速排序非递归
思想
递归快排是系统调用栈帮我们保存"还没有处理的区间"。非递归快排就是自己创建一个栈,把待排序区间 [left, right] 存进去。
text
递归:QuickSort(left, keyi - 1),QuickSort(keyi + 1, right)
非递归:把 [left, keyi - 1] 和 [keyi + 1, right] 压栈
过程图:
text
初始栈: [0, 8]
弹出 [0, 8],单趟排序后 keyi = 5
压入: [0, 4] 和 [6, 8]
之后继续弹出一个区间处理,直到栈为空。
简单栈实现
c
typedef struct Stack
{
int* data;
int top;
int capacity;
} Stack;
void StackInit(Stack* st)
{
st->capacity = 16;
st->top = 0;
st->data = (int*)malloc(sizeof(int) * st->capacity);
if (st->data == NULL)
{
perror("malloc fail");
exit(1);
}
}
void StackPush(Stack* st, int x)
{
if (st->top == st->capacity)
{
st->capacity *= 2;
int* tmp = (int*)realloc(st->data, sizeof(int) * st->capacity);
if (tmp == NULL)
{
perror("realloc fail");
exit(1);
}
st->data = tmp;
}
st->data[st->top++] = x;
}
void StackPop(Stack* st)
{
if (st->top > 0)
st->top--;
}
int StackTop(Stack* st)
{
return st->data[st->top - 1];
}
int StackEmpty(Stack* st)
{
return st->top == 0;
}
void StackDestroy(Stack* st)
{
free(st->data);
st->data = NULL;
st->top = st->capacity = 0;
}
非递归快排代码
c
void QuickSortNonR(int* a, int begin, int end)
{
if (begin >= end)
return;
Stack st;
StackInit(&st);
StackPush(&st, begin);
StackPush(&st, end);
while (!StackEmpty(&st))
{
int right = StackTop(&st);
StackPop(&st);
int left = StackTop(&st);
StackPop(&st);
int keyi = PartSortHoare(a, left, right);
if (left < keyi - 1)
{
StackPush(&st, left);
StackPush(&st, keyi - 1);
}
if (keyi + 1 < right)
{
StackPush(&st, keyi + 1);
StackPush(&st, right);
}
}
StackDestroy(&st);
}
压栈顺序是先左边界再右边界:
c
StackPush(&st, left);
StackPush(&st, right);
出栈时先取到的是 right,再取到的是 left,因为栈是后进先出。
6.6 快速排序优化:三数取中和小区间优化
三数取中
如果每次都选最左边作为 key,遇到有序数组时容易退化成 O(N²)。三数取中是在 left、mid、right 三个位置里选一个大小居中的数作为 key,尽量避免选到极大值或极小值。
text
left mid right
1 5 9
选中间大小的 5 作为 key
代码:
c
int GetMidIndex(int* a, int left, int right)
{
int mid = left + (right - left) / 2;
if (a[left] < a[mid])
{
if (a[mid] < a[right])
return mid;
else if (a[left] < a[right])
return right;
else
return left;
}
else
{
if (a[left] < a[right])
return left;
else if (a[mid] < a[right])
return right;
else
return mid;
}
}
使用时通常把选中的 key 换到 begin 位置,因为后面的分区代码默认 key 在最左边:
c
int midIndex = GetMidIndex(a, begin, end);
Swap(&a[begin], &a[midIndex]);
带三数取中的前后指针法
c
int PartSortPrevCurWithMid(int* a, int left, int right)
{
int midIndex = GetMidIndex(a, left, right);
Swap(&a[left], &a[midIndex]);
int keyi = left;
int prev = left;
int cur = left + 1;
while (cur <= right)
{
if (a[cur] < a[keyi])
{
prev++;
if (prev != cur)
Swap(&a[prev], &a[cur]);
}
cur++;
}
Swap(&a[keyi], &a[prev]);
return prev;
}
小区间优化
快速排序递归到后期,会产生大量很小的区间。小区间继续快排,递归调用成本反而不划算。因此可以在区间长度较小时,改用插入排序或希尔排序。
c
void InsertSortRange(int* a, int left, int right)
{
for (int i = left + 1; i <= right; i++)
{
int tmp = a[i];
int end = i - 1;
while (end >= left && tmp < a[end])
{
a[end + 1] = a[end];
end--;
}
a[end + 1] = tmp;
}
}
void QuickSortOptimized(int* a, int begin, int end)
{
if (begin >= end)
return;
if (end - begin + 1 <= 16)
{
InsertSortRange(a, begin, end);
return;
}
int keyi = PartSortPrevCurWithMid(a, begin, end);
QuickSortOptimized(a, begin, keyi - 1);
QuickSortOptimized(a, keyi + 1, end);
}
6.7 快排特点
- 平均时间复杂度:O(NlogN)。
- 最坏时间复杂度:O(N²)。
- 空间复杂度:平均 O(logN),最坏 O(N)。
- 稳定性:不稳定。
- 常见优化:三数取中、随机选 key、小区间优化、非递归实现。
7. 归并排序
7.1 归并排序思想
归并排序使用分治思想:
text
先拆分,再合并。
但真正完成排序的是"合并"过程,而不是拆分过程。
text
原数组: 10 6 7 1 3 9 4 2
拆分:
10 6 7 1 3 9 4 2
10 6 7 1 3 9 4 2
10 6 7 1 3 9 4 2
合并:
[6 10] [1 7] [3 9] [2 4]
[1 6 7 10] [2 3 4 9]
[1 2 3 4 6 7 9 10]
为什么要先递归再合并?因为归并排序的合并操作要求左右两个区间已经有序。如果左右区间本身无序,就不能通过"比较两个区间开头元素"的方式正确合并。
7.2 合并两个有序区间
例如:
text
左区间:1 6 7 10
右区间:2 3 4 9
用两个指针分别指向两个区间开头,谁小就放入临时数组 tmp。
text
比较 1 和 2,放 1
比较 6 和 2,放 2
比较 6 和 3,放 3
比较 6 和 4,放 4
比较 6 和 9,放 6
...
7.1 递归实现
c
void _MergeSort(int* a, int left, int right, int* tmp)
{
if (left >= right)
return;
int mid = left + (right - left) / 2;
_MergeSort(a, left, mid, tmp);
_MergeSort(a, mid + 1, right, tmp);
int begin1 = left, end1 = mid;
int begin2 = mid + 1, end2 = right;
int index = left;
while (begin1 <= end1 && begin2 <= end2)
{
if (a[begin1] <= a[begin2])
tmp[index++] = a[begin1++];
else
tmp[index++] = a[begin2++];
}
while (begin1 <= end1)
tmp[index++] = a[begin1++];
while (begin2 <= end2)
tmp[index++] = a[begin2++];
for (int i = left; i <= right; i++)
a[i] = tmp[i];
}
void MergeSort(int* a, int n)
{
if (a == NULL || n <= 1)
return;
int* tmp = (int*)malloc(sizeof(int) * n);
if (tmp == NULL)
{
perror("malloc fail");
exit(1);
}
_MergeSort(a, 0, n - 1, tmp);
free(tmp);
}
为什么 tmp 是 int*
c
int* tmp = (int*)malloc(sizeof(int) * n);
malloc 申请的是一整块连续空间,返回这块空间的首地址。因为这块空间要按 int 数组使用,所以用 int* 保存。
text
tmp
↓
[ ][ ][ ][ ][ ][ ]
tmp[0]、tmp[1]、tmp[2] 本质上都是通过指针访问连续空间。
稳定性细节
归并排序要稳定,比较时应写:
c
if (a[begin1] <= a[begin2])
当左右区间元素相等时,优先取左区间元素,可以保持原来的相对顺序。如果写成 <,相等时可能先取右区间元素,稳定性会被破坏。
7.2 非递归实现
非递归归并排序也叫自底向上归并排序。
它不递归拆分,而是直接认为每个单独元素都是有序区间,然后两两合并。
text
gap = 1:一个元素一组,有序
[10] [6] [7] [1] [3] [9] [4] [2]
合并后:
[6 10] [1 7] [3 9] [2 4]
gap = 2:两个元素一组,有序
[6 10] 和 [1 7] 合并
[3 9] 和 [2 4] 合并
合并后:
[1 6 7 10] [2 3 4 9]
gap = 4:四个元素一组,有序
[1 6 7 10] 和 [2 3 4 9] 合并
gap 为什么指数增长
gap 表示当前有序小区间的长度。每一轮都把两个长度为 gap 的有序区间合并成一个长度约为 2 * gap 的有序区间,所以每轮结束后:
c
gap *= 2;
它不是普通下标,不应该 gap++。
非递归代码
c
void Merge(int* a, int* tmp, int left, int mid, int right)
{
int begin1 = left, end1 = mid;
int begin2 = mid + 1, end2 = right;
int index = left;
while (begin1 <= end1 && begin2 <= end2)
{
if (a[begin1] <= a[begin2])
tmp[index++] = a[begin1++];
else
tmp[index++] = a[begin2++];
}
while (begin1 <= end1)
tmp[index++] = a[begin1++];
while (begin2 <= end2)
tmp[index++] = a[begin2++];
for (int i = left; i <= right; i++)
a[i] = tmp[i];
}
void MergeSortNonR(int* a, int n)
{
if (a == NULL || n <= 1)
return;
int* tmp = (int*)malloc(sizeof(int) * n);
if (tmp == NULL)
{
perror("malloc fail");
exit(1);
}
for (int gap = 1; gap < n; gap *= 2)
{
for (int left = 0; left < n; left += 2 * gap)
{
int mid = left + gap - 1;
int right = left + 2 * gap - 1;
// 右区间不存在,不需要合并
if (mid >= n - 1)
break;
// 右区间不完整,修正右边界
if (right >= n)
right = n - 1;
Merge(a, tmp, left, mid, right);
}
}
free(tmp);
}
边界理解
对于每一组,需要合并:
text
[left, left + gap - 1]
[left + gap, left + 2 * gap - 1]
所以:
c
mid = left + gap - 1;
right = left + 2 * gap - 1;
如果 mid >= n - 1,说明右区间不存在,不用合并。
如果 right >= n,说明右区间存在但不完整,需要把 right 修正为 n - 1。
7.3 外排序
当数据量非常大,内存放不下整个数组时,普通内部排序无法一次完成。这时可以使用外排序。
外排序的典型思路是:
text
大文件
↓
切分成多个小文件
↓
每个小文件读入内存排序
↓
得到多个有序小文件
↓
多路归并,合并成一个整体有序的大文件
流程图:
text
big.txt
│
├── part1.txt 排序后 -> part1_sorted.txt
├── part2.txt 排序后 -> part2_sorted.txt
├── part3.txt 排序后 -> part3_sorted.txt
│
└── 多路归并 -> result.txt
下面代码演示两个有序文件的归并。真实大数据场景通常会使用多路归并和缓冲区优化。
c
void MergeTwoSortedFiles(const char* file1, const char* file2, const char* outFile)
{
FILE* f1 = fopen(file1, "r");
FILE* f2 = fopen(file2, "r");
FILE* fout = fopen(outFile, "w");
if (f1 == NULL || f2 == NULL || fout == NULL)
{
perror("open file fail");
if (f1) fclose(f1);
if (f2) fclose(f2);
if (fout) fclose(fout);
exit(1);
}
int x, y;
int ret1 = fscanf(f1, "%d", &x);
int ret2 = fscanf(f2, "%d", &y);
while (ret1 != EOF && ret2 != EOF)
{
if (x <= y)
{
fprintf(fout, "%d\n", x);
ret1 = fscanf(f1, "%d", &x);
}
else
{
fprintf(fout, "%d\n", y);
ret2 = fscanf(f2, "%d", &y);
}
}
while (ret1 != EOF)
{
fprintf(fout, "%d\n", x);
ret1 = fscanf(f1, "%d", &x);
}
while (ret2 != EOF)
{
fprintf(fout, "%d\n", y);
ret2 = fscanf(f2, "%d", &y);
}
fclose(f1);
fclose(f2);
fclose(fout);
}
7.4 归并排序特点
- 时间复杂度:O(NlogN)。
- 空间复杂度:O(N)。
- 稳定性:稳定。
- 适合链表排序,也适合外排序思想。
- 缺点:数组版本需要额外临时空间。
8. 计数排序
8.1 核心思想
计数排序不是基于比较的排序。它适合整数范围比较集中的场景。
思路:
text
1. 找到数组最小值 min 和最大值 max
2. 开一个大小为 range = max - min + 1 的计数数组
3. count[a[i] - min]++
4. 根据 count 回填原数组
例如:
text
原数组: 3 1 2 3 2 1 3
min = 1, max = 3, range = 3
count[0] 记录数字 1 出现次数 -> 2
count[1] 记录数字 2 出现次数 -> 2
count[2] 记录数字 3 出现次数 -> 3
回填:1 1 2 2 3 3 3
8.2 代码
c
void CountSort(int* a, int n)
{
if (a == NULL || n <= 1)
return;
int min = a[0];
int max = a[0];
for (int i = 1; i < n; i++)
{
if (a[i] < min)
min = a[i];
if (a[i] > max)
max = a[i];
}
int range = max - min + 1;
int* count = (int*)calloc(range, sizeof(int));
if (count == NULL)
{
perror("calloc fail");
exit(1);
}
for (int i = 0; i < n; i++)
{
count[a[i] - min]++;
}
int index = 0;
for (int j = 0; j < range; j++)
{
while (count[j] > 0)
{
a[index++] = j + min;
count[j]--;
}
}
free(count);
}
8.3 相对映射
如果数组最小值不是 0,例如:
text
90 95 92 90
没有必要开 0~95 的空间。可以用相对映射:
text
count[a[i] - min]
如果 min = 90,那么:
text
90 -> count[0]
92 -> count[2]
95 -> count[5]
8.4 特点
- 时间复杂度:O(N + range)。
- 空间复杂度:O(range)。
- 适用条件:整数,且数据范围不能太离散。
- 对于
1, 100000000这种范围跨度很大的数据,不适合使用计数排序。 - 稳定性:简单回填整数版本不体现稳定性;如果用于对象排序,需要通过前缀和从后往前放置,才能保证稳定。
9. 复杂度与稳定性总结
| 排序 | 最好 | 平均 | 最坏 | 空间 | 稳定性 | 备注 |
|---|---|---|---|---|---|---|
| 插入排序 | O(N) | O(N²) | O(N²) | O(1) | 稳定 | 越接近有序越快 |
| 希尔排序 | 与 gap 有关 | 与 gap 有关 | 通常可到 O(N²) | O(1) | 不稳定 | 插入排序优化版 |
| 选择排序 | O(N²) | O(N²) | O(N²) | O(1) | 不稳定 | 交换次数少,但比较次数固定 |
| 堆排序 | O(NlogN) | O(NlogN) | O(NlogN) | O(1) | 不稳定 | 升序建大堆 |
| 冒泡排序 | O(N) | O(N²) | O(N²) | O(1) | 稳定 | 可用 exchange 提前结束 |
| 快速排序 | O(NlogN) | O(NlogN) | O(N²) | O(logN) | 不稳定 | 实际常用,需优化 key |
| 归并排序 | O(NlogN) | O(NlogN) | O(NlogN) | O(N) | 稳定 | 适合外排序 |
| 计数排序 | O(N+range) | O(N+range) | O(N+range) | O(range) | 可稳定 | 适合范围集中的整数 |
10. 最终记忆版
插入排序:
前面有序,后面取一个元素,向前找位置插入。
end负责从有序区间末尾往前扫描。
希尔排序:
gap 分组的插入排序。gap 大时快速预排序,gap 小时精细调整,最后 gap = 1 完成排序。
选择排序:
每趟选最小值放前面,也可以同时选最大值放后面。一次选两个数时要注意
maxIndex == left的修正。
堆排序:
升序建大堆。每次把堆顶最大值放到末尾,再对剩余堆做向下调整。
冒泡排序:
相邻比较,大的往后冒。某一趟没有交换,说明已经有序。
快速排序:
一趟分区让 key 到最终位置,再处理左右区间。Hoare、挖坑、前后指针只是分区方式不同。三数取中用于避免 key 太偏。
归并排序:
先让左右子区间分别有序,再合并两个有序区间。真正排序发生在合并阶段。
计数排序:
用计数数组统计每个整数出现次数,再按次数回填。适合整数范围集中的场景。