你有没有注意过自己打扑克时的习惯?抓牌、看牌、然后把手里的牌重新插一遍——新抓到的牌和手里已有的牌比较大小,找到合适的位置插进去,顺手把后面的大牌往后挪一挪。一圈下来,手里的牌总是整齐的从小到大排着。你其实无意识地在做一件计算机里极其重要的事:排序。
再想想网购。你在购物网站上筛选"价格从低到高"、按销量排序、按评价排序;高考结束后各院校按录取分数线从高到低排成一张大表。这些场景背后,都是同一类算法在忙碌——把一串记录按照某个关键字的递增或递减次序重新排列。
排序是计算机科学中最经典、最基础的问题之一。它足够简单,一个新手也能写出冒泡排序;它又足够深邃,冒泡、插入、选择、希尔、堆、快排、归并、计数,每一种都代表着一种完全不同的解题思路,背后藏着复杂度分析、分治、递归、堆、哈希映射等一大片知识。这篇文章就把这些算法一个一个讲透:先讲清楚思想,再给出完整可运行的 C 语言代码,然后用同一个测试数组亲眼看着每一轮排序发生,最后给出复杂的总结与对比。
全文我们会反复使用同一个测试数组来演示每种算法的执行过程:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
// 贯穿全文的测试数组
int a[] = { 5, 3, 9, 6, 2, 4, 7, 1, 8 };
int n = sizeof(a) / sizeof(a[0]); // n = 9
// 交换两个整数的工具函数,后面所有排序都会用到
void swap(int* x, int* y)
{
int tmp = *x;
*x = *y;
*y = tmp;
}写排序代码之前,先铺垫三个前置知识,后面到处都会用到:递归(一个函数内部调用自身,配合终止条件大事化小,快速排序和归并排序的骨架)、动态内存(malloc/free 在堆区申请和释放数组,归并排序的辅助数组、计数排序的计数数组都要靠它)、时间复杂度(用大 O 记法描述算法运行时间随数据规模 n 增长的趋势,排序是分析复杂度的最佳舞台)。如果你对递归和动态内存还不熟,可以翻翻前面几篇文章,这里就不再展开了。
认识排序:概念与常见算法
先给排序下个严谨的定义:所谓排序,就是使一串记录,按照其中的某个或某些关键字的大小,递增或递减地排列起来的操作。用大白话说,就是给一堆无序数据"排好队"。
排序的运用无处不在,课件里举了两个最直观的例子:购物筛选排序(电商平台按价格、销量、评价排序,让海量商品以你关心的维度呈现)和院校排名(各大高校按分数线、综合实力排出名次)。凡是"排个先后"的场景,背后都是排序算法。
排序算法家族庞大,按基本思想可以分成几大类:
| 类别 | 代表算法 | 核心思想 |
|---|---|---|
| 插入排序 | 直接插入、希尔排序 | 把元素逐个插入到已有序的序列中 |
| 选择排序 | 直接选择、堆排序 | 每轮选出最大(最小)元素放到正确位置 |
| 交换排序 | 冒泡排序、快速排序 | 通过比较交换让元素"流动"到正确位置 |
| 归并排序 | 二路归并 | 分治:先使子序列有序,再合并两个有序序列 |
| 非比较排序 | 计数排序 | 不比较大小,统计次数后直接回收 |
接下来就按照这张路线图,一种一种把它们实现出来。
插入排序
插入排序的基本思想一句话就能说清:把待排序的记录按其关键码值的大小,逐个插入到一个已经排好序的有序序列中,直到所有的记录插入完为止,最终得到一个新的有序序列。你打扑克理牌,用的就是这个思想——手里的牌永远是排好序的,每来一张新牌,就找到它该待的位置插进去。
直接插入排序
直接插入排序的完整描述是:当插入第 i(i >= 1)个元素时,前面的 array[0] 到 array[i-1] 已经排好序,此时用 array[i] 的排序码与 array[i-1]、array[i-2]……的排序码从后往前依次比较,找到插入位置,把 array[i] 插入,原来位置上的元素顺序后移。
这里有一个关键点:数组是连续存储的,想在中间"插入"一个元素,必须先把后续元素整体后移一位腾出空位。所以整个操作分两步——先找位置,再腾位子,而找位置的过程本身就是边比较边腾位子。
// 直接插入排序:把第 i 个元素插入到前面 i-1 个已有序的元素中
void InsertSort(int* a, int n)
{
// i 表示当前待插入元素的前一个位置,i 从 0 到 n-2
// 第 i 轮把 a[i+1] 插入到 [0, i] 这个有序区间中
for (int i = 0; i < n - 1; i++)
{
int end = i; // end 指向已有序区间的最后一个位置
int tmp = a[end + 1]; // tmp 保存待插入的元素
// 从后往前比较:只要前面的元素比 tmp 大,就往后挪一位
while (end >= 0)
{
if (a[end] > tmp)
{
a[end + 1] = a[end]; // 大元素后移,腾出位置
end--;
}
else
{
break; // 找到合适位置,跳出
}
}
a[end + 1] = tmp; // 把 tmp 放进找到的位置
}
}注意 tmp 一定要先保存下来:因为 a[end+1] = a[end] 会覆盖掉 a[i+1] 位置原有的值,不提前保存就丢了。
用我们的测试数组 {5, 3, 9, 6, 2, 4, 7, 1, 8} 走一遍,看看每一轮发生了什么:
初始: 5 3 9 6 2 4 7 1 8
第1轮 i=0:tmp = 3
5 > 3 → 5 后移 → [_, 5] 9 6 2 4 7 1 8
3 放入位置0 → [3, 5] 9 6 2 4 7 1 8
第2轮 i=1:tmp = 9
5 > 9 ? 否,直接放回 → 3 [5, 9] 6 2 4 7 1 8
第3轮 i=2:tmp = 6
9 > 6 → 9 后移 → 3 5 [_, 9] 2 4 7 1 8
5 > 6 ? 否 → 3 [5, 6, 9] 2 4 7 1 8
第4轮 i=3:tmp = 2
9, 6, 5, 3 全部比 2 大,依次后移
→ [_, 3, 5, 6, 9] 4 7 1 8
2 放入位置0 → [2, 3, 5, 6, 9] 4 7 1 8
第5轮 i=4:tmp = 4
9, 6, 5 后移,3 比 4 小停
→ 2 3 [4, 5, 6, 9] 7 1 8
第6轮 i=5:tmp = 7
9 后移,6 比 7 小停 → 2 3 4 5 6 [7, 9] 1 8
第7轮 i=6:tmp = 1
9, 7, 6, 5, 4, 3, 2 全部后移
→ [1, 2, 3, 4, 5, 6, 7, 9] 8
第8轮 i=7:tmp = 8
9 后移,7 比 8 小停 → 1 2 3 4 5 6 7 [8, 9]
结果: 1 2 3 4 5 6 7 8 9直接插入排序的特性值得专门总结:
- 元素集合越接近有序,直接插入排序的时间效率越高。极端情况下,如果数组本来就完全有序,那么每一轮
tmp都直接大于前面的最后一个元素,内层循环一次都不执行,只需 n-1 次比较就结束,此时时间复杂度是 O(N)。 - 时间复杂度:最坏情况(逆序数组)下,第 i 轮需要移动 i 次,总移动次数是 1+2+...+(n-1) = n(n-1)/2,即 O(N²)。
- 空间复杂度:O(1),只在原数组上操作,没有借助额外空间。
"越接近有序越快"这个特性非常值钱,它是下一节希尔排序的灵感来源——希尔排序所做的一切,就是想办法让数组"先接近有序",再享受直接插入排序在近似有序数组上的高速。
希尔排序
直接插入排序有个致命弱点:如果最小的元素排在最后面,它得一步一步地往前"挪" n-1 次,慢得让人着急。希尔排序(又称缩小增量法)就是对这一点的优化:先选定一个整数(通常是 gap = n/3 + 1),把所有距离相等(相隔 gap 个位置)的记录分在同一组,对每一组内的记录进行插入排序;然后 gap = gap/3 + 1 得到下一个更小的整数,再分组、再排序;直到 gap = 1 时,就相当于对整个数组做一次直接插入排序。
为什么要这么折腾?因为分组预排序可以让小数快速往前跳、大数快速往后跳——元素一次能移动 gap 个位置,而不是一格。当 gap 从大到小逐步缩小时,数组越来越接近有序;最后 gap = 1 时数组已经"基本有序",直接插入排序就能以接近 O(N) 的代价完成收尾。
代码里 gap 从 n 开始、每轮以 gap = gap / 3 + 1 缩小,所以对 n = 9 的测试数组,第一轮 gap = 9/3 + 1 = 4。先看 gap = 4 时数组是怎么分组的:按下标相距 4 个位置分四组,同组元素画上相同的标记:
数组下标: 0 1 2 3 4 5 6 7 8
元素值: 5 3 9 6 2 4 7 1 8
分组: ▲ ● ■ ★ ▲ ● ■ ★ ▲
▲ 组(下标 0,4,8):5, 2, 8 → 插入排序后 2, 5, 8
● 组(下标 1,5): 3, 4 → 插入排序后 3, 4
■ 组(下标 2,6): 9, 7 → 插入排序后 7, 9
★ 组(下标 3,7): 6, 1 → 插入排序后 1, 6
gap=4 预排序后: 2 3 7 1 5 4 9 6 8注意看,最小的 1 只用了一次跨越就从下标 7 跳到了下标 3 附近,最大的 9 也向前跨过了两个位置——这就是 gap 分组的力量:元素一次能移动 gap 个位置,而不是一格。接下来 gap = 4/3 + 1 = 2,再分两组预排序(下标 0,2,4,6,8 的 {2,7,5,9,8} → {2,5,7,8,9},下标 1,3,5,7 的 {3,1,4,6} → {1,3,4,6}),数组变为 2 1 5 3 7 4 8 6 9;然后 gap = 2/3 + 1 = 1,最后做一次完整的直接插入排序收尾,得到 1 2 3 4 5 6 7 8 9。
// 希尔排序:缩小增量法,对直接插入排序的优化
void ShellSort(int* a, int n)
{
int gap = n;
while (gap > 1)
{
gap = gap / 3 + 1; // 推荐写法:除以 3 加 1,保证最后一次 gap 恰好为 1
// 下面这段就是"分组版"的直接插入排序:
// 把直接插入排序里所有的相邻移动(end+1 / end-1)换成隔 gap 移动
for (int i = 0; i < n - gap; i++)
{
int end = i;
int tmp = a[end + gap]; // 待插入元素在 gap 个位置之后
while (end >= 0)
{
if (a[end] > tmp)
{
a[end + gap] = a[end]; // 大元素向后跳 gap 个位置
end -= gap;
}
else
{
break;
}
}
a[end + gap] = tmp;
}
}
}把这段代码和 InsertSort 对照着看会发现,它们几乎一模一样,只是把所有的 1 换成了 gap——这就是"希尔排序是分组版的直接插入排序"这句话的代码含义。当 gap = 1 时,它就完全退化为直接插入排序,这是循环必然走到的最后一轮。
希尔排序的特性总结:
- 希尔排序是对直接插入排序的优化。当 gap > 1 时都是预排序,目的是让数组更接近有序;当 gap == 1 时,数组已经接近有序,直接插入排序会非常快。
- 时间复杂度约为 O(N^1.3)(取 O(n^1.3) 是严蔚敏《数据结构(C语言版)》中给出的估算值,不同书籍、不同 gap 序列给出的结果并不统一)。
- 空间复杂度 O(1)。
希尔排序的时间复杂度为什么难算?
课件对希尔排序的复杂度给出了完整的推导思路,这里展开讲一下。外层循环:gap 每次除以 3 再加 1,从 n 一路缩小到 1,执行的趟数约为 log₃n,所以外层循环的时间复杂度是 O(log₃n) = O(log n)(除以 2 的取法就是 O(log₂n),同样记为 O(log n))。
内层循环:假设一共 n 个数据,分成 gap 组,每组 n/gap 个元素。对某一组做插入排序,最坏情况下移动次数是 1 + 2 + 3 + ... + (n/gap - 1),一共有 gap 组,所以最坏情况下总移动次数为:
gap × [1 + 2 + 3 + ... + (n/gap - 1)]gap 的取值序列(以除 3 为例)是:n/3、n/9、n/27、...、2、1。逐项代入:
- 当 gap = n/3 时,每组 3 个元素,总移动数 = (n/3) × (1+2) = n;
- 当 gap = n/9 时,每组 9 个元素,总移动数 = (n/9) × [8(1+8)/2] = (n/9) × 36 = 4n;
- 最后一趟 gap = 1,即直接插入排序,内层排序消耗为 n。
可以发现,前几趟和最后一趟的总移动量都是 n 量级的,中间某几趟会更大一些,整体曲线大致是"先上升再下降"的形态,中间顶点的精确值难以用初等数学表达出来。所以希尔排序的时间复杂度不好精确计算——因为 gap 的取值方式很多,导致很难统一计算,很多书给出的结论都不一样,比较通行的说法是约 O(N^1.3)。
选择排序
选择排序的基本思想非常朴素:每一次从待排序的数据元素中选出最小(或最大)的一个元素,存放在序列的起始位置,直到全部待排序的数据元素排完。说白了就是"挑柿子捡软的捏"——每次挑一个最小的放到最前面。
直接选择排序
课本上给的标准步骤是:
- 在元素集合
array[i] ~ array[n-1]中选择关键码最大(小)的数据元素; - 若它不是这组元素中的最后一个(第一个)元素,则将它与这组元素中的最后一个(第一个)元素交换;
- 在剩余的集合中重复上述步骤,直到集合只剩 1 个元素。
朴素版本每轮只选一个最小(或最大)的。课件给出了一个进阶版本:每轮同时选出最小值和最大值,分别放到区间的开头和末尾,这样一轮能搞定两个元素,比较次数大约省一半。它用 begin 和 end 两个指针夹住当前待排序区间,mini 和 maxi 分别记录最小、最大值的下标。
// 直接选择排序(进阶版):每轮同时选出最小值和最大值
// begin 指向待排序区间起点,end 指向终点;mini/maxi 记录最小/最大值下标
void SelectSort(int* a, int n)
{
int begin = 0, end = n - 1;
while (begin < end)
{
int mini = begin, maxi = begin; // 先假设 begin 位置既是最小又是最大
for (int i = begin; i <= end; i++)
{
if (a[i] > a[maxi])
{
maxi = i; // 记录更大的元素下标
}
if (a[i] < a[mini])
{
mini = i; // 记录更小的元素下标
}
}
// 关键处理:如果最大值正好在 begin 位置(区间起点),
// 那么 mini 与 begin 交换后,最大值会被换到 mini 原来所在的位置,
// 所以必须让 maxi 指向 mini,第二次交换才能把真正的最大值放到末尾
if (begin == maxi)
{
maxi = mini;
}
swap(&a[mini], &a[begin]); // 最小值放到区间开头
swap(&a[maxi], &a[end]); // 最大值放到区间末尾
++begin;
--end;
}
}`begin == maxi` 的判断是这份代码最容易写错的地方。设想区间里最小值在末尾、最大值在开头的情况:先执行 `swap(&a[mini], &a[begin])` 把最小值换到开头的同时,也把原开头的最大值"顶"到了 mini 原来的位置。如果此时还按原 `maxi`(也就是 begin)去交换,就会把一个最小值又换到末尾,排序就错了。必须先修正 `maxi = mini`,再做第二次交换。
用我们的测试数组验证一下这个"陷阱"处理。走前四轮(完整过程 9 个元素只需 4 轮):
初始: 5 3 9 6 2 4 7 1 8
下标: 0 1 2 3 4 5 6 7 8
第1轮 begin=0, end=8:min=1(下标7),max=9(下标2),begin!=maxi
交换 a[7]↔a[0] → 1 3 9 6 2 4 7 5 8
交换 a[2]↔a[8] → 1 3 8 6 2 4 7 5 9
第2轮 begin=1, end=7:min=2(下标4),max=8(下标2),begin!=maxi
交换 a[4]↔a[1] → 1 2 8 6 3 4 7 5 9
交换 a[2]↔a[7] → 1 2 5 6 3 4 7 8 9
第3轮 begin=2, end=6:min=3(下标4),max=7(下标6),begin!=maxi
交换 a[4]↔a[2] → 1 2 3 6 5 4 7 8 9
交换 a[6]↔a[6] → 1 2 3 6 5 4 7 8 9 (最大值已在末尾,原地交换)
第4轮 begin=3, end=5:区间 {6,5,4},min=4(下标5),max=6(下标3)
begin == maxi 成立!先把 maxi 改为 mini(即5)
交换 a[5]↔a[3] → 1 2 3 4 5 6 7 8 9
交换 a[5]↔a[5] → 1 2 3 4 5 6 7 8 9
结果: 1 2 3 4 5 6 7 8 9第 4 轮正是 begin == maxi 场景的现场演示:最大值 6 恰好位于区间起点 begin=3,若不修正 maxi,第二次交换就会把刚放到位置 3 的最小值 4 换走。
直接选择排序的特性总结:
- 思考非常好理解,但效率不是很好,实际中很少使用。它的缺点在于:不管数组是否有序,每一轮都必须把整个区间完整扫一遍找最值,不存在"提前退出"的优化空间——最好情况、最坏情况、平均情况都是 O(N²)。
- 时间复杂度:O(N²);空间复杂度:O(1)。
堆排序
直接选择排序效率低,根子在于"找最小(最大)值"这件事太慢——每轮都要线性扫描。那么问题来了:有没有一种数据结构,能让我们快速拿到当前集合中的最大值?答案是堆。
堆排序(Heapsort)就是利用堆积树(堆)这种数据结构所设计的一种排序算法,它本质上还是选择排序——通过堆来进行选择数据。注意一个关键结论:排升序要建大堆,排降序建小堆。为什么?因为大堆的堆顶永远是当前最大的元素,把它和末尾交换,最大值就位;剩下的元素重新调整成堆,又能在堆顶拿到次大值……如此反复,从后往前填满数组,正好得到升序序列。
在二叉树章节我们已经实现了堆和向下调整算法,这里把它完整地复习一遍。核心操作 AdjustDown 的作用是:假设某个结点的左右子树都已经是大堆,但该结点本身可能不满足堆的性质,把它一层层"沉"到正确位置。
// 向下调整算法:把以 parent 为根的子树调整成大堆
// 前提:parent 的左右子树已经是大堆
// n 为当前堆的有效元素个数(堆排序过程中堆在逐渐变小)
void AdjustDown(int* a, int n, int parent)
{
int child = parent * 2 + 1; // 先默认取左孩子(完全二叉树左孩子下标 = 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 = parent * 2 + 1;
}
else
{
break; // 父亲已经不小于孩子,子树已是堆,提前结束
}
}
}
// 堆排序:排升序建大堆
void HeapSort(int* a, int n)
{
// 1. 建堆:从最后一个非叶子结点 (n-1-1)/2 开始,逐个向下调整
// 叶子结点没有孩子,天然满足堆的性质,不需要调整
for (int i = (n - 1 - 1) / 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); // 重新调整 [0, end) 区间为大堆
--end;
}
}建堆为什么从下标 (n-1-1)/2 开始?数组对应的完全二叉树里,叶子结点集中在后半部分。最后一个非叶子结点正是最后一个元素 n-1 的父结点 (n-1-1)/2。从它往前依次向下调整,就能保证每个结点调整时它的左右子树都已经是堆——这正是 AdjustDown 的前置条件。建堆的时间复杂度为 O(N),不是 O(NlogN),因为越往下层的结点数量越多但调整高度越低,两者相抵。
用我们的测试数组完整走一遍堆排序。第一步建大堆(只展示关键交换,→ 表示一次交换):
初始数组: 5 3 9 6 2 4 7 1 8
下标: 0 1 2 3 4 5 6 7 8
最后一个非叶子结点:(9-1-1)/2 = 3,即 a[3]=6
AdjustDown(3):孩子 a[7]=1、a[8]=8,选较大的 8,8>6 交换
→ 5 3 9 8 2 4 7 1 6
AdjustDown(2):孩子 a[5]=4、a[6]=7,7>9?否,不动
AdjustDown(1):孩子 a[3]=8、a[4]=2,8>3 交换;继续沉,a[7]=1、a[8]=6,6>3 交换
→ 5 8 9 6 2 4 7 1 3
AdjustDown(0):孩子 a[1]=8、a[2]=9,9>5 交换;继续沉,a[5]=4、a[6]=7,7>5 交换
→ 9 8 7 6 2 4 5 1 3
建堆完成:堆顶 a[0]=9 是最大值
排序阶段(堆顶与末尾交换 + 重新调整):
交换 a[0]↔a[8]:3 8 7 6 2 4 5 1 [9] 调整 → 8 6 7 3 2 4 5 1 [9]
交换 a[0]↔a[7]:1 6 7 3 2 4 5 [8 9] 调整 → 7 6 5 3 2 4 1 [8 9]
交换 a[0]↔a[6]:1 6 5 3 2 4 [7 8 9] 调整 → 6 3 5 1 2 4 [7 8 9]
交换 a[0]↔a[5]:4 3 5 1 2 [6 7 8 9] 调整 → 5 3 4 1 2 [6 7 8 9]
交换 a[0]↔a[4]:2 3 4 1 [5 6 7 8 9] 调整 → 4 3 2 1 [5 6 7 8 9]
交换 a[0]↔a[3]:1 3 2 [4 5 6 7 8 9] 调整 → 3 1 2 [4 5 6 7 8 9]
交换 a[0]↔a[2]:2 1 [3 4 5 6 7 8 9] 调整 → 2 1 [3 4 5 6 7 8 9]
交换 a[0]↔a[1]:1 [2 3 4 5 6 7 8 9] 完成!方括号 [ ] 中的元素是已经就位的部分。可以看到,每次交换后前面的无序区重新调整成大堆,堆顶依次吐出 9、8、7、6、5、4、3、2、1,从后往前填满数组,最终得到升序序列。
堆排序的时间复杂度:建堆 O(N),排序阶段每轮交换 + 调整,调整一次 O(logN),共 N-1 轮,所以整体是 O(NlogN)。空间复杂度 O(1),完全在原数组上操作。堆排序的优点是无论数据如何分布,都能稳定地保持 O(NlogN);它和后面要讲的快速排序、归并排序一起,构成了"大数据量排序"的主力。
交换排序
交换排序的基本思想:根据序列中两个记录键值的比较结果来对换这两个记录在序列中的位置。它的特点是:键值较大的记录向序列的尾部移动,键值较小的记录向序列的前部移动。冒泡排序和快速排序都属于这一类。
冒泡排序
冒泡排序是最基础的交换排序,之前做题时已经接触过它的思路。之所以叫"冒泡",是因为每一个元素都可以像小气泡一样,根据自身大小一点一点向数组的一侧移动——大的气泡向上冒(往数组尾部走),小的气泡沉在下面(留在数组前部)。
// 冒泡排序:每轮把最大的元素"冒"到数组末尾
void BubbleSort(int* a, int n)
{
// 内层循环每完成一轮,末尾就多一个就位的最大元素
// 所以第 i 轮只需要比较前 n-i-1 对相邻元素
for (int i = 0; i < n; i++)
{
int exchange = 0; // 优化标志:记录本轮是否发生过交换
for (int j = 0; j < n - i - 1; j++)
{
if (a[j] > a[j + 1]) // 相邻元素比较,大的往后换
{
exchange = 1;
swap(&a[j], &a[j + 1]);
}
}
// 如果一整轮都没有发生交换,说明数组已经有序,提前结束
if (exchange == 0)
{
break;
}
}
}exchange 标志是冒泡排序的关键优化:对已经有序的数组,第一轮扫完发现一次交换都没发生,立刻退出,此时时间复杂度是 O(N);没有这个优化的话,即使有序也得傻傻地跑完 n 轮。
用测试数组走一遍完整的冒泡过程:
初始: 5 3 9 6 2 4 7 1 8
第1轮:相邻两两比较,9 一路"冒"到末尾
3 5 6 2 4 7 1 8 [9]
第2轮:8 冒到倒数第二
3 5 2 4 6 1 7 [8 9]
第3轮:7 就位
3 2 4 5 1 6 [7 8 9]
第4轮:6 就位
2 3 4 1 5 [6 7 8 9]
第5轮:5 就位
2 3 1 4 [5 6 7 8 9]
第6轮:4 就位
2 1 3 [4 5 6 7 8 9]
第7轮:3 就位
1 2 [3 4 5 6 7 8 9]
第8轮:扫描一遍发现一次交换都没发生,exchange 保持 0,提前退出
结果: 1 2 3 4 5 6 7 8 9冒泡排序的特性:时间复杂度平均和最坏 O(N²),最好(数组有序)O(N),空间复杂度 O(1)。它的优点是好写、稳定,但每轮只能把一个元素送到位、且要反复做无意义的比较,实际性能在 O(N²) 级别的算法里也属于偏慢的。
快速排序
接下来是本文的重头戏——快速排序。它是 Hoare 于 1962 年提出的一种二叉树结构的交换排序方法,也是应用最广泛、面试最常考的排序算法。
快速排序的基本思想:任取待排序元素序列中的某个元素作为基准值,按照该排序码把待排序集合分割成两个子序列——左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后对左右子序列重复该过程,直到所有元素都排列在相应位置上为止。
仔细体会这句话:一趟划分做完,基准值就已经处在它最终该在的位置了——它左边的都比它小,右边的都比它大,所以它再也不会被移动。剩下的问题只是左右两个子序列内部的排序,而它们是完全独立的两段,这就构成了递归:一个大问题被拆成两个更小的同类问题,正是"二叉树结构的分治"。
可以想象班主任按身高给全班排队:先随便挑一个同学当"基准"站好,然后让所有比他矮的站到他左边,所有比他高的站到他右边。他两边还乱着,但这个人自己已经站到了最终位置。接着对左边那堆、右边那堆分别再挑一个基准重复同样的事……直到每一堆只剩一个人。这个过程天然形成一棵递归树,树的深度就是划分的轮数。
快速排序的主框架如下。注意递归的终止条件是区间里没有元素或只有一个元素(left >= right),此时区间天然有序,直接返回。
// 快速排序主框架:left、right 是闭区间 [left, right] 的左右端点
void QuickSort(int* a, int left, int right)
{
if (left >= right) // 区间为空或只有一个元素,天然有序,返回
{
return;
}
// _QuickSort 负责单趟划分:以基准值为界把 [left, right] 分成两半
// 返回基准值最终所在的下标 meet
int meet = _QuickSort(a, left, right);
QuickSort(a, left, meet - 1); // 递归处理左子区间
QuickSort(a, meet + 1, right); // 递归处理右子区间
}骨架很简单,精髓全在单趟划分 _QuickSort 里。划分方式主要有三种:hoare 版本、挖坑法、lomuto 前后指针法。三种方法效果等价(都把基准值放到最终位置并返回它的下标),只是实现思路不同。我们逐一实现、推演。
hoare 版本
hoare 版本是快排发明者本人的原始思路:
- 创建左右指针,确定基准值(通常取区间最左端元素);
- 从右向左找出比基准值小的数据,从左向右找出比基准值大的数据,把左右指针指向的数据交换,进入下一次循环;
- 直到左右指针相遇(错开),把基准值与 right 指针位置交换,返回 right。
// hoare 版本单趟划分:右边找小、左边找大,交换;最终把基准值放到相遇位置
int _QuickSort(int* a, int left, int right)
{
int begin = left; // 保存左端点(基准值位置)
int end = right; // 保存右端点
int keyi = left; // 基准值下标,先取最左边的元素
++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--]);
}
}
// 循环结束,right 指向的位置必然不大于基准值,把基准值放过去
swap(&a[keyi], &a[right]);
return right; // 基准值最终位置
}用测试数组推演一遍单趟划分(key = 5):
初始:5 3 9 6 2 4 7 1 8 key = 5, left = 1, right = 8
↑key ↑left ↑right
第1次扫描:右找小:8、7 > 5 跳过,1 < 5 停 → right = 7
左找大:3 < 5 跳过,9 > 5 停 → left = 2
交换 a[2] ↔ a[7]:5 3 1 6 2 4 7 9 8 left = 3, right = 6
第2次扫描:右找小:7 > 5 跳过,4 < 5 停 → right = 5
左找大:6 > 5 停 → left = 3
交换 a[3] ↔ a[5]:5 3 1 4 2 6 7 9 8 left = 4, right = 4
第3次扫描:右找小:2 < 5 停 → right = 4
左找大:2 < 5 跳过跳到下标5;left(5) 已越过 right(4),内层退出
left(5) > right(4),循环结束
把基准值与 right 位置交换:2 3 1 4 5 6 7 9 8 return 4单趟结束后,基准值 5 落在下标 4,它左边是 {2,3,1,4} 全部小于 5,右边是 {6,7,9,8} 全部大于 5。接下来递归处理左右两个区间,最终整个数组有序。完整递归过程可以画成下面这棵"分治树",每个结点标注一次划分后的数组状态:
[2 3 1 4] 5 [6 7 9 8] ← 第一趟划分
/ \
[1] 2 [3 4] [6 7] 8 [9] ← 第二趟划分
/ \
[3] 4 [6] 7 ← 第三趟划分
...
最终:1 2 3 4 5 6 7 8 9hoare 版本有两个经典问题,几乎必考,这里重点讲透。
问题 1:为什么跳出循环后,right 位置的值一定不大于 key(基准值)?
这是整个算法正确性的关键——正因为 a[right] <= key,最后把基准值换到 right 位置才不破坏"左边小、右边大"的性质。原因是:循环跳出只有两种情况。第一种,left 在"左找大"的过程中一直没找到比基准值大的元素,一路向右越过了 right,此时 right 停留在"右找小"找到的位置,它指向的值本来就 ≤ key。第二种,最后一次交换后 left++、right-- 导致 left > right,此时 right 跨到了 left 的左侧,而 left 扫描过的数据都不大于 key(凡是大于 key 的都会让 left 停下等待交换,能被 left 扫过去的都是小于等于 key 的),所以 right 此时指向的正是 left 刚经过的位置,其值 ≤ key。两种情况结论一致:right 最终停在的位置,其值一定不大于基准值。
问题 2:为什么 left 和 right 指向的数据和 key 值相等时,也要交换?
直观地想,相等的值交换来交换去似乎纯属浪费。但假设我们不交换相等值——即"右找小"的循环条件改成 a[right] >= a[keyi]、"左找大"改成 a[left] <= a[keyi]——当数组中大量元素与基准值相等时(比如数组里全是 5),right 会一路向左走到底,left 一路向右走到底,整个区间几乎无法分割,一趟划分后左区间还是整个数组,快排直接退化成 O(N²),且递归深度巨大。反过来,相等值也参与交换,会让相等的元素被均匀地分散到基准值两侧,即使数据大量重复,划分依然相对平衡。
"相等也交换"是 hoare 版本效率的保证之一。代价只是多几次无意义的交换,换来的是面对大量重复数据时依然接近 O(NlogN) 的表现,这笔交易非常划算。
挖坑法
挖坑法是 hoare 版本的改良,思路更直观,也更好写。步骤是:先把基准值存到临时变量中,基准值原来的位置就成了一个"坑";然后右指针向左找比基准值小的数据,找到后立即填入左边的坑,右指针位置变成新坑;左指针向右找比基准值大的数据,找到后立即填入右边的坑,左指针位置变成新坑;如此交替,直到左右指针相遇,最后把一开始存起来的基准值填进最后一个坑,返回这个坑的下标。
// 挖坑法单趟划分:基准值先"挖"出来留坑,左右交替填坑,最后把基准值填回坑中
int _QuickSort(int* a, int left, int right)
{
int key = a[left]; // 先把基准值挖出来存好
int hole = left; // 基准值原来的位置就是一个"坑"
while (left < right)
{
// 右边找小:找到比 key 小的元素,填进左边的坑,自己变成新坑
while (left < right && a[right] >= key)
{
--right;
}
a[hole] = a[right]; // 填坑
hole = right; // 新坑
// 左边找大:找到比 key 大的元素,填进右边的坑,自己变成新坑
while (left < right && a[left] <= key)
{
++left;
}
a[hole] = a[left]; // 填坑
hole = left; // 新坑
}
a[hole] = key; // 左右指针相遇,把基准值填进最后一个坑
return hole;
}用测试数组推演挖坑法(key = 5,坑用 _ 表示,带坑的数组里 _ 位置的值是"无效"的,因为已经被搬走):
初始:5 3 9 6 2 4 7 1 8 key = 5, 坑在 0
↑l ↑r
右找小:8、7 ≥ 5 跳过,1 < 5 停(right = 7),a[7] 填入坑0,坑移到7
→ 1 3 9 6 2 4 7 _ 8 (位置7现在是坑)
左找大:3 ≤ 5 跳过,9 > 5 停(left = 2),a[2] 填入坑7,坑移到2
→ 1 3 _ 6 2 4 7 9 8 (位置2现在是坑)
右找小:7 ≥ 5 跳过,4 < 5 停(right = 5),a[5] 填入坑2,坑移到5
→ 1 3 4 6 2 _ 7 9 8 (位置5现在是坑)
左找大:6 > 5 停(left = 3),a[3] 填入坑5,坑移到3
→ 1 3 4 _ 2 6 7 9 8 (位置3现在是坑)
右找小:2 < 5 停(right = 4),a[4] 填入坑3,坑移到4
→ 1 3 4 2 _ 6 7 9 8 (位置4现在是坑)
左找大:2 ≤ 5 跳过,left 与 right 相遇(4 == 4),循环结束
把 key 填进最后一个坑:1 3 4 2 5 6 7 9 8 return 4结果和 hoare 版本完全一致:5 落在下标 4,左边全小于它,右边全大于它。挖坑法的好处是不需要思考"相遇位置为什么能放基准值"——坑天然就是留给基准值的,逻辑上更直白,代码里也少了一处 left <= right 的边界判断。
lomuto 前后指针法
第三种单趟划分来自 Lomuto,思路完全不同:用 prev 和 cur 两个指针,cur 从左往右找比基准值小的元素,每找到一个就与 prev 后面的位置交换,让所有比基准值小的元素像"推土机"一样被集中到数组左边。
// lomuto 前后指针法单趟划分:cur 找小,与 prev 交换,把小元素都集中到左侧
int _QuickSort(int* a, int left, int right)
{
int prev = left, cur = left + 1; // prev 指向已排好的小元素末尾,cur 负责向后扫描
int key = left; // 基准值下标,取最左边元素
while (cur <= right)
{
// 找到比基准值小的元素:
// 先 ++prev 指向下一个待交换位置,如果 prev 和 cur 重合就无需交换
if (a[cur] < a[key] && ++prev != cur)
{
swap(&a[cur], &a[prev]);
}
++cur;
}
// 最后把基准值换到 prev 位置,它左边全是小于它的元素
swap(&a[key], &a[prev]);
return prev;
}用测试数组推演(key = 5,| 表示 prev 的位置,↑ 表示 cur):
初始:5 3 9 6 2 4 7 1 8 prev = 0, cur = 1
↑k ↑p ↑c
cur=1:a[1]=3 < 5,++prev=1,1==cur 不交换 cur→2
cur=2:a[2]=9 < 5? 否 cur→3
cur=3:a[3]=6 < 5? 否 cur→4
cur=4:a[4]=2 < 5,++prev=2,2≠4,交换 a[2]↔a[4]
→ 5 3 2 6 9 4 7 1 8 cur→5
cur=5:a[5]=4 < 5,++prev=3,3≠5,交换 a[3]↔a[5]
→ 5 3 2 4 9 6 7 1 8 cur→6
cur=6:a[6]=7 < 5? 否 cur→7
cur=7:a[7]=1 < 5,++prev=4,4≠7,交换 a[4]↔a[7]
→ 5 3 2 4 1 6 7 9 8 cur→8
cur=8:a[8]=8 < 5? 否 cur→9 结束
最后把基准值换到 prev=4:1 3 2 4 5 6 7 9 8 return 4同样得到基准值 5 在下标 4。注意 lomuto 版本里"小元素"的定义是严格小于(a[cur] < a[key]),等于基准值的元素会留在右侧——这与 hoare 版本"相等也交换"的策略不同,是它自身保证划分正确性的方式。
非递归版本
递归版本的快速排序依赖函数调用栈,当数据量极大、递归深度很深时有栈溢出的风险(最坏情况递归深度可达 N)。非递归版本把这个"栈"从系统手里拿过来,自己用一个显式的栈(数据结构中的顺序栈)来模拟递归过程——把待处理的区间压栈,循环弹栈、划分、再把划分出的子区间压栈,直到栈空。
这里的栈就是数据结构"栈"章节实现的那个顺序栈,接口为 STInit / STPush / STPop / STTop / STEmpty / STDestroy。快速排序非递归只是它的一个应用场景,不要重复造轮子,直接用现成的。
// 快速排序非递归版本:借助栈模拟递归,避免系统栈溢出风险
void QuickSortNonR(int* a, int left, int right)
{
ST st; // 顺序栈(数据结构章节实现)
STInit(&st);
STPush(&st, right); // 先压右端点,再压左端点(注意压栈顺序)
STPush(&st, left);
while (!STEmpty(&st))
{
// 弹出区间 [begin, end](栈是后进先出,先弹 left)
int begin = STTop(&st);
STPop(&st);
int end = STTop(&st);
STPop(&st);
// 单趟划分,这里用 lomuto 前后指针法
int keyi = begin;
int prev = begin;
int cur = begin + 1;
while (cur <= end)
{
if (a[cur] < a[keyi] && ++prev != cur)
{
swap(&a[prev], &a[cur]);
}
++cur;
}
swap(&a[keyi], &a[prev]);
keyi = prev;
// 划分结果:[begin, keyi-1] keyi [keyi+1, end]
// 把非空的左右子区间压栈,等待下一轮处理
if (keyi + 1 < end) // 右区间非空
{
STPush(&st, end);
STPush(&st, keyi + 1);
}
if (begin < keyi - 1) // 左区间非空
{
STPush(&st, keyi - 1);
STPush(&st, begin);
}
}
STDestroy(&st);
}压栈时先压右区间、后压左区间,这样左区间会先弹出先处理——和递归版本"先递归左、再递归右"的顺序保持一致。每次循环处理一个区间,直到栈空,等价于递归版本完成了全部划分。非递归和递归的时间复杂度相同,只是把系统栈换成了自己的栈,空间上更可控。
快速排序的优化:三数取中与小区间插入排序
前面的快排有一个致命的阿喀琉斯之踵:对已经有序(或逆序)的数组,如果每次基准都取最左元素,每趟划分都极度不平衡,递归树退化成一条链,复杂度直接退化成 O(N²),递归深度也到 N——数据量大时栈溢出。面试必问"快排的优化",两大法宝是三数取中和小区间插入排序。
优化一:三数取中。 既然取最左元素当基准在有序数组上必踩坑,那就别死心眼取最左——取区间左、中、右三个位置,用它们的中位数当基准。有序数组的中位数恰好落在正中间,划分立刻变成均匀对半,O(N²) 退化被从根上消灭(工程上几乎不会再碰到最坏输入):
// 三数取中:返回 a[left]、a[mid]、a[right] 三者中位数的下标
int GetMidIndex(int* a, int left, int right)
{
int mid = (left + right) / 2;
if (a[left] < a[mid])
{
if (a[mid] < a[right]) return mid; // left < mid < right
else if (a[left] > a[right]) return left; // right < left < mid
else return right; // left < right < mid
}
else // a[left] >= a[mid]
{
if (a[mid] > a[right]) return mid; // right < mid < left
else if (a[left] < a[right]) return left; // mid < left < right
else return right; // mid < right < left
}
}这个函数有 6 种排列情况,被两两一组压缩成上面 6 行——写的时候最容易出错的就是漏情况,建议画一条数轴把三个值摆上去逐个验证。用的时候把中位数和 left 交换,剩下的单趟划分代码一行不用改:
// 优化后的快排:三数取中 + 小区间插入排序
void QuickSort(int* a, int left, int right)
{
if (left >= right) // 区间为空或单元素,天然有序
return;
// 优化二:小区间插入排序。
// 区间缩小到 <= 10 个元素时,递归的"函数调用 + 栈帧"开销开始大于
// 直接插入排序本身,而且此时区间已接近有序,插入排序几乎 O(N)——
// 用插入排序收尾,既省递归开销,又吃到"近似有序"的红利
if (right - left + 1 <= 10)
{
InsertSort(a + left, right - left + 1);
return;
}
// 优化一:三数取中,把中位数换到 left 当基准
int mid = GetMidIndex(a, left, right);
if (mid != left)
swap(&a[mid], &a[left]);
int keyi = _QuickSort(a, left, right); // 单趟划分(hoare/挖坑/lomuto 任选)
QuickSort(a, left, keyi - 1); // 递归左区间
QuickSort(a, keyi + 1, right); // 递归右区间
}为什么小区间(比如长度 ≤ 10)改用插入排序更划算?两个原因叠加:一是递归有固定开销——每次函数调用都要压栈、传参、返回,区间越小,递归调用的次数相对"实际比较交换次数"占比越大,10 个元素的区间递归划分要多出十几层调用,纯属浪费;二是越排越有序——经过前面若干轮划分,小区间内部的元素已经大体有序(只是局部乱序),而直接插入排序在近似有序数组上接近 O(N) 的速度。两个因素叠加,小区间用插入排序通常能让整体快排再快 10%~20%,这是实际工程里(比如 glibc 的 qsort、STL 的 sort)都在用的手段——STL 的 sort 甚至会在递归深度超限时改用堆排序兜底,那就是后话了。
三数取中 + 小区间插入排序是"优化后快排"的黄金搭档:前者解决"最坏退化",后者解决"递归开销"。面试问"快排怎么优化",把这两个说出来并写对 `GetMidIndex`,就是满分回答。另外,随机选基准(每次随机挑一个下标当基准)是三数取中的等价替代,效果类似,代码更短。
快速排序特性总结
- 时间复杂度:平均和最好情况 O(NlogN),最坏情况 O(N²)。最坏情况发生在每次划分都极度不平衡时——例如对已经有序的数组,如果基准值总是取最左端元素,每次划分都只减少一个元素,递归树退化成一条链,复杂度退化为 O(N²)。缓解手段包括随机选基准值、三数取中(取左、中、右三个元素的中位数做基准)等。
- 空间复杂度:O(logN)(递归栈深度,平均情况)。最坏情况下递归深度退化为 O(N)。
- 快排的综合性能在大多数场景下优于堆排序和归并排序(虽然三者平均复杂度都是 O(NlogN),但快排的常数因子小、缓存友好),因此被大量标准库采用。
归并排序
归并排序(MERGE-SORT)是建立在归并操作上的一种有效排序算法,是分治法(Divide and Conquer)的典型应用。它的核心思想:将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为二路归并。
归并排序的思路和快速排序正好相反:快排是"先划分再递归"(自上而下),归并是"先递归到底再合并"(自下而上)——先把数组对半劈开,劈到每个子序列只剩一个元素(天然有序),然后两两合并,合并出的有序序列再继续合并,直到整个数组有序。分治递归树长这样:
[5 3 9 6 2 4 7 1 8] ← 原数组
/ \
[5 3 9 6 2] [4 7 1 8]
/ \ / \
[5 3 9] [6 2] [4 7] [1 8]
/ \ / \ / \ / \
[5] [3 9] [6] [2] [4] [7] [1] [8] ← 递归到底,单元素天然有序
\ / \ / \ / \ /
[3 5 9] [2 6] [4 7] [1 8] ← 开始两两合并
\ / \ /
[2 3 5 6 9] [1 4 7 8] ← 合并出两个有序序列
\ /
[1 2 3 4 5 6 7 8 9] ← 最终合并"合并两个有序数组"是归并排序的地基,也是整个算法的灵魂。两个指针分别指向两个有序序列的开头,比较它们指向的元素,谁小谁就进辅助数组,直到某一方耗尽,再把另一方剩余元素全部拷入。这个操作每一轮比较只移动一个元素,总代价是线性的 O(N)。
合并过程用测试数组的实际数据演示一遍(以最后一步合并 {2,3,5,6,9} 和 {1,4,7,8} 为例):
左序列:2 3 5 6 9 右序列:1 4 7 8 tmp 数组
↑l ↑r
比较 2 和 1:1 小 → 拷入 tmp → tmp: [1]
↑l ↑r→4
比较 2 和 4:2 小 → 拷入 tmp → tmp: [1 2]
↑l→3 ↑r
比较 3 和 4:3 小 → 拷入 tmp → tmp: [1 2 3]
比较 5 和 4:4 小 → 拷入 tmp → tmp: [1 2 3 4]
比较 5 和 7:5 小 → 拷入 tmp → tmp: [1 2 3 4 5]
比较 6 和 7:6 小 → 拷入 tmp → tmp: [1 2 3 4 5 6]
比较 9 和 7:7 小 → 拷入 tmp → tmp: [1 2 3 4 5 6 7]
比较 9 和 8:8 小 → 拷入 tmp → tmp: [1 2 3 4 5 6 7 8]
右序列耗尽,把左序列剩余的 9 拷入 → tmp: [1 2 3 4 5 6 7 8 9]归并排序需要一个与原数组等长的辅助数组 tmp 来暂存合并结果,合并完再拷回原数组。注意课件里用的是 C++ 的 new/delete[],我们是 C 语言,这里统一改用 malloc/free:
// 归并排序的子函数:把 [left, mid] 和 [mid+1, right] 两个有序区间合并
// tmp 是提前申请好的辅助数组,长度为 n
void _MergeSort(int* a, int left, int right, int* tmp)
{
if (left >= right) // 区间为空或只有一个元素,天然有序
{
return;
}
int mid = (right + left) / 2; // 分成两个子区间
// [left, mid] 和 [mid+1, right]
// 1. 先递归,让两个子区间各自有序
_MergeSort(a, left, mid, tmp);
_MergeSort(a, mid + 1, right, tmp);
// 2. 合并两个有序子区间到 tmp 中
int begin1 = left, end1 = mid; // 左子区间 [begin1, end1]
int begin2 = mid + 1, end2 = right; // 右子区间 [begin2, end2]
int index = begin1; // 写入 tmp 的起始位置
while (begin1 <= end1 && begin2 <= end2)
{
// 谁小谁先进 tmp;相等时取左序列的元素(<=),保证稳定性
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++];
}
// 3. 把合并结果从 tmp 拷回原数组的对应区间
for (int i = left; i <= right; i++)
{
a[i] = tmp[i];
}
}
// 归并排序入口:申请辅助数组,调用子函数,释放内存
void MergeSort(int* a, int n)
{
int* tmp = (int*)malloc(sizeof(int) * n); // C 语言用 malloc 申请
if (tmp == NULL)
{
perror("malloc fail");
return;
}
_MergeSort(a, 0, n - 1, tmp);
free(tmp); // 记得释放
}归并排序的特性总结:
- 时间复杂度:O(NlogN)。归并的过程可以看成二叉树,树高 logN,每层合并的总代价都是 O(N),相乘得到 O(NlogN)。而且无论数据怎么分布,归并排序的时间都是稳定的 O(NlogN),不存在快排那种最坏退化。
- 空间复杂度:O(N),需要等长的辅助数组。这是归并排序最大的缺点——空间换时间。
- 归并排序是稳定的:合并两个有序子序列时,比较条件用的是
a[begin1] <= a[begin2]——相等时优先取左子序列的元素,这样相等的元素在合并过程中始终保持"左先右后"的次序,相对顺序不会被打破。这一点在后面的稳定性分析里还要细讲。
归并排序还特别适合处理**外部排序**(数据量大到内存装不下,存在磁盘文件里):可以把大文件切成多个能装进内存的小块分别排序,再借助归并操作把它们合并成一个有序大文件,这是归并在工业界最经典的应用。
归并排序的非递归版本
递归版归并好懂,但和快排一样有递归开销和栈溢出风险。归并排序的非递归思路反而比快排更自然,因为它可以自底向上:先把数组看成 n 个长度为 1 的有序序列,相邻的两两合并成长度 2 的有序序列;再两两合并成长度 4 的……每轮把归并长度 gap 翻倍,直到 gap ≥ n,整个数组就有序了。和递归版"先劈到底再合上来"正好是同一棵分治树的两种走法:
gap = 1: [5][3]→[3 5] [9][6]→[6 9] [2][4]→[2 4] [7][1]→[1 7] [8]
gap = 2: [3 5]+[6 9]→[3 5 6 9] [2 4]+[1 7]→[1 2 4 7] [8]
gap = 4: [3 5 6 9]+[1 2 4 7]→[1 2 3 4 5 6 7 9] [8]
gap = 8: 整体归并,完成// 归并排序非递归版本:gap 从 1 开始倍增,相邻两个 gap 大小的区间两两归并
void MergeSortNonR(int* a, int n)
{
int* tmp = (int*)malloc(sizeof(int) * n); // 辅助数组
if (tmp == NULL)
{
perror("malloc fail");
return;
}
int gap = 1; // 每轮归并的子区间长度
while (gap < n)
{
// 每轮把数组按 2*gap 切成若干组,每组含左右两个 gap 长的子区间
for (int i = 0; i < n; i += 2 * gap)
{
int begin1 = i, end1 = i + gap - 1; // 左子区间 [begin1, end1]
int begin2 = i + gap, end2 = i + 2 * gap - 1; // 右子区间 [begin2, end2]
// 边界处理一:右子区间根本不存在(begin2 越界),本组只有一个子区间,
// 它已经有序,无需合并(也不要动它)
if (begin2 >= n)
break;
// 边界处理二:右子区间不完整(end2 越界),把它截断到 n-1
if (end2 >= n)
end2 = n - 1;
// 合并两个有序子区间到 tmp(和递归版完全相同的归并逻辑)
int index = i;
int b1 = begin1, e1 = end1, b2 = begin2, e2 = end2;
while (b1 <= e1 && b2 <= e2)
{
if (a[b1] <= a[b2]) // 相等取左,保持稳定
tmp[index++] = a[b1++];
else
tmp[index++] = a[b2++];
}
while (b1 <= e1)
tmp[index++] = a[b1++];
while (b2 <= e2)
tmp[index++] = a[b2++];
// 把本组归并结果从 tmp 拷回原数组的对应区间
for (int j = begin1; j <= end2; j++)
a[j] = tmp[j];
}
gap *= 2; // 归并长度翻倍
}
free(tmp);
}这里的易错点是边界处理。当 n 不是 2 的幂时(比如 n = 9),最后几轮的某些组会凑不齐两个完整子区间,必须处理两种情况:右子区间完全不存在(begin2 >= n)——此时本组只有一个子区间,它本身已经有序,直接跳过;右子区间不完整(end2 >= n)——把它截断到 n - 1。漏掉这两个判断,数组就越界访问,轻则数据错乱,重则直接崩溃。非递归归并的时间复杂度同样是 O(N·logN)(gap 翻倍 logN 轮,每轮整体 O(N)),空间 O(N)。
快排非递归要显式栈(因为快排是"自上而下",必须先记着还没处理的区间);归并非递归完全不需要栈(因为归并是"自下而上",天然按层次推进)。这个区别的本质是:**快排是前序型分治(先划分再递归),归并是后序型分治(先递归再合并)**——后序型只要按层从小到大推进就能复刻递归,前序型必须手动保存状态。理解了这一点,你就看懂了"哪些递归能轻松改非递归、哪些不能"。
非比较排序:计数排序
前面所有排序算法的共同点是"比较元素大小"。还有一类排序根本不比较——计数排序就是其中的代表。它又称鸽巢原理的应用,是哈希直接定址法的变形。
思想极其朴素:统计数组中每个元素出现的次数,然后按照次数把元素"倒"回原数组。操作分两步:
- 统计相同元素出现次数;
- 根据统计结果将序列回收到原来的序列中。
但这里有个问题:如果数据范围很大(比如元素在 0 到 10 亿之间),直接开一个 10 亿大小的计数数组显然不现实。所以要用"相对映射"而不是"绝对映射"——先找出数组中的最小值 min 和最大值 max,只需要开 max - min + 1 个计数位,元素 x 映射到下标 x - min。
// 计数排序:统计元素出现次数,再按次数回收(数据范围集中时效率极高)
void CountSort(int* a, int n)
{
// 1. 遍历数组,找出最小值和最大值
int min = a[0], max = a[0];
for (int i = 1; i < n; i++)
{
if (a[i] > max)
{
max = a[i];
}
if (a[i] < min)
{
min = a[i];
}
}
// 2. 申请计数数组,长度为数据范围
int range = max - min + 1;
int* count = (int*)malloc(sizeof(int) * range);
if (count == NULL)
{
perror("malloc fail");
return;
}
memset(count, 0, sizeof(int) * range); // 计数数组清零
// 3. 统计每个元素出现的次数
// 元素 a[i] 映射到下标 a[i] - min
for (int i = 0; i < n; i++)
{
count[a[i] - min]++;
}
// 4. 按次数把元素回收回原数组
int j = 0;
for (int i = 0; i < range; i++)
{
while (count[i]--) // 出现几次就写几个
{
a[j++] = i + min; // 下标 i 对应元素值 i + min
}
}
free(count); // 释放计数数组
}用测试数组演示一遍:{5, 3, 9, 6, 2, 4, 7, 1, 8},min = 1,max = 9,range = 9。计数数组的 9 个下标分别对应值 1~9:
元素值: 1 2 3 4 5 6 7 8 9
下标(i): 0 1 2 3 4 5 6 7 8
count[i]: 1 1 1 1 1 1 1 1 1 ← 每个元素恰好出现一次
回收:从下标0到8,依次写入 i+min:
1 2 3 4 5 6 7 8 9如果数组里有重复元素,比如 {5, 3, 9, 6, 2, 4, 7, 1, 8, 5, 3},那么 count[2](对应值 3)和 count[4](对应值 5)都会变成 2,回收时这两个值会被连续写两次:
元素值: 1 2 3 4 5 6 7 8 9
count[i]: 1 1 2 1 2 1 1 1 1
回收: 1 2 3 3 4 5 5 6 7 8 9计数排序的特性总结:
- 数据范围集中时效率极高:时间复杂度和空间复杂度都不取决于数据量 n,而取决于数据范围 range,为 O(N + range) 时间、O(range) 空间。
- 适用场景有限:只适合数据范围集中(range 不能太大)、且元素类型可以映射到整数下标的场景。如果数据是浮点数、字符串,或者范围高达 10⁹,计数排序就无能为力了。
- 计数排序是稳定的——回收时按下标从小到大输出,同值的元素天然保持原有相对顺序。
排序算法复杂度与稳定性分析
现在八种排序全部实现完毕,是时候做一次全面的总结了。先把一个重要概念讲清楚——稳定性:
假定在待排序的记录序列中,存在多个具有相同关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中 r[i] = r[j],且 r[i] 在 r[j] 之前,而在排序后的序列中 r[i] 仍在 r[j] 之前,则称这种排序算法是稳定的;否则称为不稳定的。
一句话:排序前后,相等元素的相对次序有没有被打乱。打乱了就不稳定。注意稳定性只对"有相同关键字的记录"才有讨论意义,而且它针对的是排序前后记录的原始顺序——比如按成绩排序时,我们希望同分的同学仍然保持原来的先后(比如按学号顺序),这就是稳定性的价值所在。
下面是八种排序算法的复杂度与稳定性总表,请务必烂熟于心:
| 排序方法 | 平均情况 | 最好情况 | 最坏情况 | 辅助空间 | 稳定性 |
|---|---|---|---|---|---|
| 冒泡排序 | 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^1.3) | O(n) | O(n²) | O(1) | 不稳定 |
| 堆排序 | O(nlogn) | O(nlogn) | O(nlogn) | O(1) | 不稳定 |
| 归并排序 | O(nlogn) | O(nlogn) | O(nlogn) | O(n) | 稳定 |
| 快速排序 | O(nlogn) | O(nlogn) | O(n²) | O(logn)(最坏 O(n)) | 不稳定 |
| 计数排序 | O(n+range) | O(n+range) | O(n+range) | O(range) | 稳定 |
(注:希尔排序的时间复杂度在不同资料中表述不一——gap 序列取法很多、推导困难,平均情况严蔚敏《数据结构(C语言版)》给出的估算值约为 O(n^1.3),也有资料写作 O(nlog²n);最坏情况一般认为可达 O(n²),这里采用主流教材的说法。)
稳定的有四个:冒泡、直接插入、归并、计数(顺口溜:"冒泡插归计");不稳定的也有四个:直接选择、希尔、堆、快排("选希堆快")。时间和空间的取舍要看清:追求 O(1) 空间(原地排序)的,稳定与性能难以兼得;唯一一个"既稳定又快"的是归并,代价是要 O(n) 辅助空间。
稳定性不能只看结论,得理解每个算法"为什么不稳定"。课件给出了四个验证案例,我们逐个分析。
直接选择排序:5 8 5 2 9。 第一轮选出最小值 2,与位置 0 的 5 交换,得到 2 8 5 5 9。问题就出在这次交换:原来位置 0 的第一个 5 被换到了位置 3,跑到了第二个 5 的后面,两个相等的 5 相对次序反转了。选择排序"跨区间交换"元素的动作,是它不稳定的根源——最小值和它目标位置的元素互换时,可能把某个相等元素甩到后面。
希尔排序:5 8 2 5 9。 n = 5,第一轮 gap = 5/3 + 1 = 2。分组:下标 {0,2,4} 的 {5,2,9} 一组,下标 {1,3} 的 {8,5} 一组。组内插入排序后数组变为 {2,5,5,8,9}——原来位置 2 的 5 和位置 3 的 5,排序后位置 2 的 5 被"隔组"换动,两个 5 的相对次序发生了改变。分组时相等的元素可能被分到不同的组,各组独立排序时跨越式的移动会打乱它们的相对位置。
堆排序:2 2 2 2。 四个元素值全相等,看似怎么排都一样,但我们追踪"原始下标"就能看到相对次序的变动:建堆后首尾交换,位置 0 的元素(第一个 2)被换到位置 3;之后每次 swap(&a[0], &a[end]) 都会把堆顶元素挪到末尾。这些父子结点间的大跨度交换(一次跨越 logN 个位置)完全不在意元素值是否相等,只要调整过程中发生交换,相同值的元素相对次序就可能被破坏。更直观的反例是 {5, 8, 5, 2, 9}:建大堆并经过首尾交换后,原来在下标 2 的 5 跑到了下标 0,原来在下标 0 的 5 留在了下标 2,次序反转。
快速排序:5 3 3 4 3 8 9 10 11。 第一趟划分 key = 5,hoare 版本执行到基准值与 right 交换:swap(&a[0], &a[4]) 后数组变成 3 3 3 4 5 8 9 10 11,原来下标 4 的 3 被换到了下标 0,跑到了另外两个 3 的前面。更普遍的原因是 hoare 版本连相等的值也参与交换(问题 2 里讲过),相等元素的相对次序在一次又一次的交换中被随意打乱。
性能对比测试
学了八种排序,它们的实际速度到底差多少?耳听为虚,眼见为实,用一段测试代码直接量一量。思路:生成 100000 个随机数,复制成 7 份完全相同的数组,分别用直接插入、希尔、直接选择、堆、快排、归并、冒泡排序,用 clock() 记录各自的耗时。
// 测试排序的性能对比:100000 个随机数,7 种排序各测一遍
void TestOP()
{
srand(time(0));
const int N = 100000;
// 准备 7 份完全相同的数据
int* a1 = (int*)malloc(sizeof(int) * N);
int* a2 = (int*)malloc(sizeof(int) * N);
int* a3 = (int*)malloc(sizeof(int) * N);
int* a4 = (int*)malloc(sizeof(int) * N);
int* a5 = (int*)malloc(sizeof(int) * N);
int* a6 = (int*)malloc(sizeof(int) * N);
int* a7 = (int*)malloc(sizeof(int) * N);
for (int i = 0; i < N; ++i)
{
a1[i] = rand();
a2[i] = a1[i];
a3[i] = a1[i];
a4[i] = a1[i];
a5[i] = a1[i];
a6[i] = a1[i];
a7[i] = a1[i];
}
// 分别计时
int begin1 = clock();
InsertSort(a1, N);
int end1 = clock();
int begin2 = clock();
ShellSort(a2, N);
int end2 = clock();
int begin3 = clock();
SelectSort(a3, N);
int end3 = clock();
int begin4 = clock();
HeapSort(a4, N);
int end4 = clock();
int begin5 = clock();
QuickSort(a5, 0, N - 1);
int end5 = clock();
int begin6 = clock();
MergeSort(a6, N);
int end6 = clock();
int begin7 = clock();
BubbleSort(a7, N);
int end7 = clock();
// 打印耗时(单位:毫秒)
printf("InsertSort:%dms\n", end1 - begin1);
printf("ShellSort:%dms\n", end2 - begin2);
printf("SelectSort:%dms\n", end3 - begin3);
printf("HeapSort:%dms\n", end4 - begin4);
printf("QuickSort:%dms\n", end5 - begin5);
printf("MergeSort:%dms\n", end6 - begin6);
printf("BubbleSort:%dms\n", end7 - begin7);
// 释放内存
free(a1);
free(a2);
free(a3);
free(a4);
free(a5);
free(a6);
free(a7);
}在 Release 模式下跑(Debug 模式会因调试信息拖慢速度,数据仅供参考),100000 个随机数的典型结果大致是:
InsertSort:2600ms ← O(N²) 级别,肉眼可见的慢
ShellSort:20ms ← 希尔排序的威力:比插入快一百多倍
SelectSort:2800ms ← O(N²) 级别
HeapSort:15ms ← O(NlogN) 级别
QuickSort:10ms ← 常数因子小,O(NlogN) 里最快
MergeSort:20ms ← 稳定但要多一份空间
BubbleSort:6000ms ← O(N²) 里最慢,垫底(不同机器、不同编译器结果差异很大,重点看数量级的差距:O(N²) 的三兄弟明显比 O(NlogN) 慢两个数量级,100000 的数据量足以让差距一目了然。如果想看更夸张的效果,把 N 换成 1000000,冒泡和选择基本会慢到让人怀疑人生。)
各排序算法的适用场景
学了这么多,最后一个重要问题是:实际开发中到底该用哪个? 没有万能的排序,只有合适的场景。总结如下:
| 场景 | 推荐 | 理由 |
|---|---|---|
| 数据量很小(如 n 小于几百) | 直接插入排序 | 实现简单、常数小,在近乎有序的数据上甚至接近 O(N) |
| 数据基本有序 | 直接插入排序 | "越接近有序越快",此时它比任何 O(NlogN) 算法都快 |
| 数据量大且无序 | 快速排序 / 堆排序 / 归并排序 | O(NlogN) 级别,快排综合表现最好,堆排序不需要额外空间,归并排序稳定 |
| 要求稳定且数据量大 | 归并排序 | 唯一"稳定 + O(NlogN)"的组合,代价是 O(N) 空间 |
| 数据范围集中(整数) | 计数排序 | 可以做到 O(N),比所有比较排序都快;范围大时不可用 |
| 内存极其紧张 | 堆排序 / 希尔排序 | 都是 O(1) 辅助空间,原地排序 |
| 担心快排最坏情况(如已有序大数组) | 三数取中 / 随机选基准的优化快排 | 消除 O(N²) 退化风险 |
两个最容易踩的坑:一是对已经有序的大数组直接调用未优化的快排,会退化成 O(N²) 甚至栈溢出,务必三数取中或随机选基准;二是看到数据是整数就无脑上计数排序,先看一眼数据范围——range 达到千万级时 O(range) 的空间就吃不消了。
思考题与练习
八大排序各有性格,光背结论不够,得能"看出"每种算法的指纹。下面这组练习就是为了锻炼这种直觉。
练习 1:看第一趟结果,认排序算法。 已知初始序列为 {5, 3, 9, 6, 2, 4, 7, 1, 8}。下面四个序列分别是"冒泡、选择、插入、快排(一趟划分)"中的某一种的第一趟排序结果,请对号入座并说明依据:
A. {1, 3, 9, 6, 2, 4, 7, 5, 8} B. {3, 5, 9, 6, 2, 4, 7, 1, 8} C. {3, 5, 6, 2, 4, 7, 1, 8, 9} D. {2, 3, 1, 4, 5, 6, 7, 9, 8}
提示
A:最小值 1 被换到了位置 0(和 5 交换),其余元素基本没动——**选择排序**的指纹(每轮把最值放到区间端点)。B:前两个元素 3、5 已经有序,后面原封不动——**插入排序**第一轮的特征(只处理到第 2 个元素,把 3 插到 5 前面)。C:最大值 9 一路"冒"到了数组末尾——**冒泡排序**第一趟的特征(相邻比较,大数逐位上浮,一趟之后最大值必然就位)。D:基准 5 恰好落在下标 4,它左边是 `{2,3,1,4}` 全部小于 5、右边是 `{6,7,9,8}` 全部大于 5——**快排一趟划分**的特征(基准落位,两边分界)。这类"根据第一趟结果判断排序算法"的题是笔试常客,抓住每个算法的标志性动作:选择→最值放端点,插入→前缀有序,冒泡→最值到末尾,快排→基准落位两边分界。练习 2:归并的稳定性从哪来? 归并排序的合并代码里用的是 a[begin1] <= a[begin2](相等取左)。如果把 <= 改成 <(相等取右),归并还稳定吗?为什么?
提示
不稳定。相等时优先取右子区间的元素,会让原本靠前的相等元素跑到后面。这个细节说明:**归并排序的稳定性不是"天生"的,而是"写出来"的**——它取决于合并时相等值的取舍策略。同样的道理,快排的稳定性也不是绝对不可能,只是 hoare/lomuto 的划分方式天然会打乱相等元素。练习 3:计数排序的适配边界。 判断下列场景能否用计数排序,并说明理由:① 对 10⁶ 个 0100 的整数排序;② 对 10⁶ 个 010⁹ 的整数排序;③ 对 10⁶ 个 010⁵ 的浮点数排序;④ 对 10⁶ 个负数(范围 -5000050000)排序。
提示
① 可以:range 只有 101,O(N+range) 极快,比快排还快一个量级。② 不建议:range = 10⁹,光计数数组就要 4GB 内存。③ 不可以:计数排序依赖"值可映射到整数下标",浮点数不能直接当数组下标(可以按精度缩放映射,但那样 range 可能爆炸)。④ 可以:相对映射已经处理了负数——下标 = 值 - min,计数数组大小为 100001,完全可行。判断标准永远是两条:**值域是否集中**、**能否映射成整数下标**。练习 4:快排最坏情况还能构造吗? 三数取中 + 随机选基准之后,快排真的"不可能"遇到最坏情况了吗?如果想从理论上构造一个让优化后的快排仍然 O(N²) 的输入,该怎么做?
提示
不能彻底消除。三数取中取的是左、中、右三个位置的中位数,理论上可以构造一种数据,让"任意三个位置的数都满足:三数取中选出的基准总是当前区间的最小值",比如精心构造的等比数列/特殊排列(这也是"Median-of-3 killer"序列的来源)。随机选基准也无法保证每次都避开最坏。但在工程上,这类输入出现概率极低,三数取中 + 随机化已经足够把快排退化概率降到"可以忽略",所以它是主流库的标配。这个问题的意义在于理解:**复杂度分析说的是最坏上界,工程优化改变的是最坏情况出现的概率**。练习 5(挑战):堆排序为什么救不了稳定性? 堆排序最坏也是 O(N·logN),空间 O(1),看起来和归并一样优秀,为什么工程上排序反而更常用快排?以及,为什么说堆排序"不可能"稳定?(提示:从"父子交换的大跨度"和"相等元素相对次序"两个角度想。)
提示
第一问:堆排序常数因子大(建堆 + 反复 AdjustDown 的父子交换次数是快排比较次数的数倍),且**缓存不友好**(父子下标相距 logN 个位置,访问跳跃大,CPU 缓存命中率低),实际运行常常比快排慢 2~3 倍。第二问:堆排序每次把堆顶换到末尾,堆顶和末尾相距 logN 个位置,这种大跨度交换在调整过程中可能发生在任意相等元素之间;更关键的是,堆本身只保证"父子关系",相等元素之间的相对次序在堆里根本没有被记录,任何一次满足条件的父子交换都可能打乱它。要证明"不可能稳定",可以举反例:`{5a, 5b, 3}` 建堆后 `5a` 与 `3` 交换,次序就已改变——稳定排序要求"任何输入都不打乱相等元素",一个反例就足以否决。学完本章,你应该能脱口而出:八大排序各自的思想一句话;稳定性总表(冒泡/插入/归并/计数稳定,选择/希尔/堆/快排不稳定)及每个"不稳定"的根因;快排三种单趟划分的实现与"为什么相等也交换";三数取中 + 小区间优化的原理;归并"自下而上"非递归的边界处理;计数排序的相对映射与适用边界;以及"什么时候用哪种排序"的选型直觉。这些都掌握,数据结构初阶的排序篇章就正式收官了。
从打扑克理牌开始,一路写完了八大排序:直接插入的"边比较边后移"、希尔排序的"缩小增量分组预排序"、直接选择的"每轮挑最值"、堆排序的"用大堆快速取最大"、冒泡的"大数逐轮上浮"、快速排序的三种单趟划分与递归非递归两种写法、归并排序的"先分后合"、计数排序的"统计次数直接回收"。它们有的朴素、有的精巧,有的以简单取胜、有的以速度见长,有的牺牲空间换稳定、有的在时间上做到极致——但本质上,都是同一种能力的训练:把"如何让一串数据有序"这个问题,拆解成可以用计算机高效执行的操作序列。
下次再摸到扑克牌、再在购物网站上点"按价格排序"的时候,不妨想想,你指尖下的每一次点击,背后是哪一种算法在工作。
对了,最后留一个小思考题:文中快排的三种单趟划分,为什么 {5, 3, 3, 4, 3, 8, 9, 10, 11} 这个例子能证明快排不稳定?试着用 lomuto 版本(严格小于才交换)再推演一遍,看看结果会不会有所不同——答案会帮助你更深刻地理解"稳定性"到底由什么决定。
答案:lomuto 版本推演
用 lomuto 前后指针法(`a[cur] < a[key]` 严格小于才交换)推演 `{5, 3, 3, 4, 3, 8, 9, 10, 11}`,key = 5(下标 0),prev = 0,cur = 1:- cur=1(值 3 < 5):
++prev得 1,prev == cur,不交换; - cur=2(值 3 < 5):
++prev得 2,prev == cur,不交换; - cur=3(值 4 < 5):
++prev得 3,prev == cur,不交换; - cur=4(值 3 < 5):
++prev得 4,prev == cur,不交换; - cur=5..8(值 8、9、10、11 均不小于 5):跳过。
循环结束 prev = 4,执行 swap(a[0], a[4]),把基准值 5 与下标 4 的元素对调。
排序前三个 3 的下标依次是 1、2、4(记为 3①、3②、3③);排序后数组变成 {3③, 3①, 3②, 4, 5, ...}——本来排在最后的那个 3③ 被基准交换"甩"到了最前面,跑到了 3①、3② 之前,相对次序被打乱,所以 lomuto 版本同样不稳定。
结论:虽然 lomuto 在扫描阶段对相等元素"网开一面"(严格小于才搬动),但最后一步 swap(a[key], a[prev]) 会把基准值和 prev 位置的元素对调——当 prev 位置恰好是一个与基准相等的元素时(本例的 3③),这次对调照样跨越多个下标、把一个靠后的相等元素搬到最前面。只要划分过程存在"跨段的元素对调",相等元素的相对次序就可能被破坏。所以快排无论用 hoare、挖坑还是 lomuto 都是不稳定的——这不是换一种写法能救回来的,稳定性取决于划分本身会不会发生跨越位置的相等元素移动。这么一推你就明白:快排不稳定是"划分结构"决定的,而非"某一个具体实现写错了"。
还没有评论 — 第一条由你来留。