写在前面
上一篇 《数据结构-排序(二):快速排序从错误初版到优化版》 中,我从最开始的错误实现一路修改,完成了快速排序的基础递归版本,并进一步加入了两个优化:
- 三数取中 :从
left、mid、right三个位置中选择更合适的基准; - 小区间插入排序:当待排序区间较小时,不再继续递归 QuickSort,而改用直接插入排序收尾。
继续学习之后会发现,快速排序并不是一段固定不变的代码。它真正可以拆成三个相对独立的问题:
如何选基准?
↓
如何完成一次 Partition?
↓
如何继续处理左右子区间?
围绕这三个问题,这次又继续扩展了三套实现:
cpp
QuickSort_Hole // 挖坑法
QuickSort_PointerSwap // 双指针交换法
QuickSort_Stack // 手动栈非递归版
其中:
- 挖坑法改变的是"一趟划分时元素怎么移动";
- 双指针交换法把上一篇的 Partition 独立封装出来;
- 栈迭代版则进一步改变"左右子区间怎么继续处理",用我们自己维护的栈代替函数递归。
这次同步更新了两个工程:
QuickSort 快速排序专项工程
用于集中学习、测试快速排序的不同实现:
cpp
QuickSort.h
QuickSort.c
test.c
代码仓库:
数据结构/My_QuickSort · Luminous/Code_2026 - 码云 - 开源中国
SortComparison 排序算法综合工程
原本已经包含冒泡、插入、希尔、堆、双向选择以及快速排序,本次又把三种新快排同步加入统一性能测试。
代码仓库:
数据结构/SortComparison · Luminous/Code_2026 - 码云 - 开源中国
两个工程出现相同算法并不是重复:一个负责专项学习,一个负责汇总进完整排序算法体系。
一、快速排序的三层核心架构
快速排序的核心仍然是分治。假设当前待排序区间:
6 1 7 3 9 2 8 5 4
选取基准 key = 6,一趟划分结束以后,希望形成:
1 4 5 3 2 | 6 | 8 9 7
↑
keyi
此时并不要求左右两部分已经完全有序,只要求:
key 左边 <= key
key 右边 >= key
而基准 6 已经来到了最终排序结果中应该处于的位置。原来的 [left, right] 便被拆成:
[left, keyi - 1]
[keyi]
[keyi + 1, right]
接下来继续处理左右两个子区间即可。
因此 QuickSort 可以进一步拆成三层独立的逻辑:
基准选择策略
+
Partition 划分策略
+
子区间调度策略
上一篇优化了第一部分;这一篇主要研究后两部分。
二、三种版本共用的两项基础优化
在介绍新版本以前,先回顾两个所有版本都复用的公共优化。
2.1 三数取中:避免有序数组退化
如果始终取最左端作为基准:
keyi = left;
遇到已经有序的数据 1 2 3 4 5 6 7 8 9,第一次取 1,只能划分为:
空 | 1 | 2 3 4 5 6 7 8 9
第二次又取 2,再次得到空左区间......递归树会逐渐退化成单链结构,时间复杂度从平均 O(Nlog N) 退化到 O(N^2),递归深度也可能由 O(log N) 退化到 O(N)。
所以之前加入了三数取中函数:
cpp
int GetMidIndex(int* a, int left, int right);
从 a[left]、a[mid]、a[right] 中选出数值处于中间的一个作为基准,然后统一交换到最左边,再复用后续 Partition 逻辑。
三数取中不能从数学上彻底消除 QuickSort 的最坏情况,但可以明显降低常见数据中连续选到极端基准的概率。
2.2 小区间插入排序:降低递归常数开销
另一项公共优化:
cpp
if (right - left + 1 <= 10)
{
InsertSortRange(a, left, right);
return;
}
当区间已经非常小时,不再继续「选择基准 → Partition → 函数递归 → 再次 Partition」,而是直接使用插入排序。
原因并不是插入排序的理论复杂度比快排更好,而是小规模数据下常数项同样重要。插入排序实现简单、函数和控制逻辑开销少、连续访问数组局部性较好,且经过前面多次 QuickSort 划分之后,小区间往往已经具有一定局部有序性,插入排序对这类数据效率很高。
所以这里属于一种典型的工程优化:大区间用 QuickSort,小区间用 InsertSort 。阈值 10 不是固定标准,后续可以通过性能测试继续调整。
三、Partition 实现一:挖坑法 QuickSort_Hole
上一篇的 QuickSort 主要通过「右边找小、左边找大、找到后交换」完成 Partition。而挖坑法换了一种思路:
先把基准值单独保存,让它原来的位置成为一个"坑",然后从左右两侧不断找合适的数据填坑,坑的位置随之移动。
3.1 从例子理解"坑"的移动
仍然使用数组 6 1 7 3 9 2 8 5 4,保存基准值 int key = 6;,那么逻辑上可以暂时理解为:
[坑] 1 7 3 9 2 8 5 4
现在左边出现一个坑。因为最终希望「小值 | key | 大值」,所以这个左侧坑应该由小于 key 的值来填。
第一步:右边找小,填入左边的坑
从最右侧开始,找到 4 < 6,于是把 4 填进原来的坑:
4 1 7 3 9 2 8 5 [坑]
对应代码:
a[hole] = a[right];
hole = right;
数值 4 原来的位置,现在又变成了新的坑,"坑"从左边跑到了右边。
第二步:左边找大,再填右边的坑
此时右边的坑应该放大于 key 的元素。从左向右遍历,找到 7 > 6,于是:
4 1 [坑] 3 9 2 8 5 7
对应代码:
cpp
a[hole] = a[left];
hole = left;
新的坑又回到了左边。
之后不断重复「右边找小 → 填坑 → 坑移到右边 → 左边找大 → 填坑 → 坑移到左边」,一直到 left == right,最后把一开始保存的 key 放入最后一个坑:
cpp
a[hole] = key;
一趟 Partition 完成。
3.2 挖坑法完整划分实现
cpp
int PartSort_Hole(int* a, int left, int right)
{
int midi = GetMidIndex(a, left, right);
Swap(&a[left], &a[midi]);
int hole = left;
int key = a[hole];
while (left < right)
{
// 右边找小,填到坑中
while (left < right && a[right] >= key)
{
right--;
}
a[hole] = a[right];
hole = right;
// 左边找大,填到坑中
while (left < right && a[left] <= key)
{
left++;
}
a[hole] = a[left];
hole = left;
}
a[hole] = key;
return hole;
}
这里有一个非常值得和双指针版本对比的地方:挖坑法必须把基准值本身 保存起来。原因是后面会反复执行 a[hole] = ...,原来存放基准的位置会被其他元素覆盖,如果不提前保存 key,基准值就真的丢了。
3.3 挖坑法递归主体
有了 PartSort_Hole() 以后,QuickSort 主体反而非常简单:
cpp
void QuickSort_Hole(int* a, int left, int right)
{
if (right - left + 1 <= 10)
{
InsertSortRange(a, left, right);
return;
}
int holei = PartSort_Hole(a, left, right);
QuickSort_Hole(a, left, holei - 1);
QuickSort_Hole(a, holei + 1, right);
}
也就是说:挖坑法真正改变的是 Partition,而不是 QuickSort 的分治框架。
四、Partition 实现二:双指针交换法 QuickSort_PointerSwap
第二种并不是新的排序思想。它就是上一篇已经完成的双指针版本,这次主要做的是:
把一趟区间划分从 QuickSort 主体中抽离出来。
原来 QuickSort 一个函数同时负责选基准、双指针扫描、交换、基准归位、递归左右区间;现在拆成:
PartSort_Swap:负责一趟划分QuickSort_PointerSwap:负责组织整个递归过程
这样不仅职责更加清晰,后面的非递归 QuickSort 也能直接复用同一个 PartSort_Swap()。
4.1 双指针交换法执行过程
还是以 6 1 7 3 9 2 8 5 4 为例,基准 key = 6,设置双指针:
begin → 从左向右
end → 从右向左
end 找小于 key 的元素,begin 找大于 key 的元素。比如找到 7 > 6、4 < 6:
6 1 7 3 9 2 8 5 4
↑ ↑
begin end
执行 Swap(&a[end], &a[begin]); 得到:
6 1 4 3 9 2 8 5 7
继续扫描,最终 begin == end,再执行 Swap(&a[keyi], &a[end]); 让基准归位。
4.2 为什么这个版本保存 keyi,而挖坑法保存 key?
这是两种方法一个很典型的区别:
- 挖坑法 :保存的是基准值。因为原基准位置马上会被其他元素覆盖。
- 双指针交换法 :保存的是基准下标 。因为在最终归位以前,基准仍然一直待在
a[keyi],扫描过程中可以直接用a[keyi]做比较。
两种算法最终做的是同一件事,但内部维护状态完全不同:
挖坑法:key 单独保存 → 数据不断填坑
双指针交换:key 留在数组中 → 找到两个不合适位置直接 Swap
4.3 双指针划分函数
cpp
int PartSort_Swap(int* a, int left, int right)
{
int midi = GetMidIndex(a, left, right);
Swap(&a[left], &a[midi]);
int keyi = left;
int begin = left;
int end = right;
while (begin < end)
{
// 右边找小
while (begin < end && a[end] >= a[keyi])
{
end--;
}
// 左边找大
while (begin < end && a[begin] <= a[keyi])
{
begin++;
}
Swap(&a[end], &a[begin]);
}
Swap(&a[keyi], &a[end]);
return begin;
}
注意当前写法:必须右边先走。因为基准目前放在最左端,这种划分逻辑需要保证最后左右指针相遇的位置,可以正确和左端基准完成交换。
4.4 QuickSort 主体
cpp
void QuickSort_PointerSwap(int* a, int left, int right)
{
if (right - left + 1 <= 10)
{
InsertSortRange(a, left, right);
return;
}
int keyi = PartSort_Swap(a, left, right);
QuickSort_PointerSwap(a, left, keyi - 1);
QuickSort_PointerSwap(a, keyi + 1, right);
}
这一拆分以后,可以非常清楚地看到:PartSort_Swap 负责当前区间怎么划分,QuickSort_PointerSwap 负责划分完成后子区间怎么继续处理。这为下一步取消递归做好了准备。
五、子区间调度:手动栈非递归实现 QuickSort_Stack
前面两个版本虽然 Partition 不同,但最终都有递归调用。那么问题来了:快速排序一定必须递归吗?
答案是不需要。快速排序真正需要保存的其实只是:还有哪些 [left,right] 区间没有处理。递归函数帮我们隐式保存了这些任务,既然如此,就可以自己建立一个栈,把这些区间显式保存起来。
5.1 递归背后实际上保存了什么?
假设 [left, right] 划分以后得到 [left, keyi - 1] 和 [keyi + 1, right],递归直接写:
cpp
QuickSort(a, left, keyi - 1);
QuickSort(a, keyi + 1, right);
看起来只是"函数自己调用自己"。但从底层角度,每进入一层函数,都需要在函数调用栈中保存返回地址、局部变量、当前函数状态、待恢复的执行位置......
如果划分不断极端失衡,递归深度可能达到 (O(N)),最终可能耗尽线程调用栈,发生 Stack Overflow。这正好也和之前学习的函数栈帧知识对应起来。
5.2 手动栈只需要保存区间边界
一个尚未完成的 QuickSort 任务可以完整表示为 [left, right],因此我们不需要模拟整个函数栈帧,只需要保存两个整数。
例如初始区间 [0, 99] 压栈:
cpp
st[top++] = left;
st[top++] = right;
出栈时先得到 right,再得到 left:
cpp
int r = st[--top];
int l = st[--top];
因为栈遵循 LIFO(后进先出) 的规则。
5.3 非递归快速排序完整实现
cpp
void QuickSort_Stack(int* a, int left, int right)
{
// 简易动态栈,成对保存 [left, right]
int* st = (int*)malloc(sizeof(int) * (right - left + 2));
int top = 0;
st[top++] = left;
st[top++] = right;
while (top > 0)
{
int r = st[--top];
int l = st[--top];
// 小区间直接插入排序
if (r - l + 1 <= 10)
{
InsertSortRange(a, l, r);
continue;
}
// 继续复用双指针 Partition
int keyi = PartSort_Swap(a, l, r);
// 先压右区间
if (keyi + 1 < r)
{
st[top++] = keyi + 1;
st[top++] = r;
}
// 再压左区间
if (l < keyi - 1)
{
st[top++] = l;
st[top++] = keyi - 1;
}
}
free(st);
}
当前实现确实是用动态数组作为显式栈,成对保存区间边界,并在算法结束后释放内存。
5.4 为什么先压右区间,再压左区间?
代码中先压右区间、后压左区间。由于栈是后进先出,所以左区间会先被取出来处理,这样执行顺序比较接近原递归版本。
不过这里只是执行顺序上的对应。对于排序正确性而言,先处理左边还是先处理右边并不会改变最终排序结果,真正重要的是:所有尚未完成的子区间最终都能被处理。
5.5 while 循环到底替代了什么?
递归版本的流程:
处理当前区间
↓
Partition
↓
递归左区间
↓
递归右区间
非递归版本的流程:
初始区间入栈
↓
while(栈非空)
{
弹出一个区间
↓
Partition
↓
新产生的子区间重新入栈
}
所以两者本质可以理解为:递归版用函数调用栈隐式管理任务,非递归版用自己的栈显式管理任务。这也是把递归算法转换为迭代算法时非常典型的一种方法。
六、三种实现横向对比
6.1 核心维度对比
| 实现 | Partition 方式 | 子区间调度 | 三数取中 | 小区间插入 |
|---|---|---|---|---|
QuickSort_Hole |
挖坑法 | 函数递归 | √ | √ |
QuickSort_PointerSwap |
双指针交换 | 函数递归 | √ | √ |
QuickSort_Stack |
双指针交换 | 手动栈 + while | √ | √ |
进一步概括:
QuickSort_Hole:研究 Partition 还能怎么实现------保存 key → 挖坑 → 右找小填坑 → 左找大填坑 → key归位QuickSort_PointerSwap:研究怎样把 Partition 单独抽离出来,实现划分逻辑和调度逻辑的职责分离QuickSort_Stack:研究不用函数递归还能不能完成 QuickSort,用区间入栈、弹出划分、子区间重新入栈的循环完成任务
6.2 时间复杂度:三种版本数量级没有改变
无论挖坑还是双指针,最终都属于 QuickSort。如果划分比较均匀,递归树高度大约 log N,每一层所有 Partition 加起来大约处理 N 个元素,所以平均时间复杂度: 
如果不断划分成极端不平衡的结构,总操作量为
,最坏时间复杂度:
三数取中主要用于减少这种情况在常见数据中的出现概率,但并没有从理论上消除 QuickSort 的最坏复杂度。
6.3 空间复杂度:递归栈与显式栈
- 递归版本:划分比较均匀时递归深度 O(log N),极端情况下可达 O(N),消耗的是函数调用栈空间。
- 手动栈版本 :取消了 QuickSort 的函数递归,但需要自己保存
[left,right]区间,消耗的是堆上申请的内存。
因此应该理解成:递归版本用隐式栈,非递归版本用显式栈 ,而不是"递归有空间、非递归无空间"。这样做的核心价值是:降低 QuickSort 对线程函数调用栈深度的依赖,避免大数据下栈溢出。
6.4 为什么原来的优化还能保留?
这里很容易发现快速排序模块化的价值:
- 三数取中针对的是「基准怎么选」
PartSort_Swap针对的是「一个区间怎么划分」- 手动栈针对的是「子区间怎么继续处理」
三者属于不同层次,所以栈版本仍然可以调用 PartSort_Swap(),其内部仍然继续用三数取中,小区间插入排序也继续保留。
因此本次三个版本在比较时仍然共享三数取中和小区间插入排序,不会出现一个版本经过优化、另一个版本完全裸跑的明显不公平情况。
七、工程测试与验证
7.1 两个工程的定位
QuickSort 专项工程
专项项目目前用于测试原优化版 QuickSort、QuickSort_Hole、QuickSort_PointerSwap、QuickSort_Stack,让几种快排可以单独放在一起观察。
SortComparison 综合工程
综合排序工程原本已经有 BubbleSort、InsertSort、HeapSort、ShellSort、CircleSelectSort、QuickSort,这次又同步增加三种新快排,成为新的第 10、11、12 组性能测试。
7.2 测试的公平性原则
测试规模仍然为十万随机整数:
cpp
const int N = 100000;
首先生成一次 origin 作为原始随机数组。之后每个算法执行前都执行:
cpp
memcpy(tmp, origin, sizeof(int) * N);
原因很简单:如果直接让第二个算法接着第一个算法的结果运行,第一个算法运行完以后数组已经有序,第二个算法面对的输入就和第一个完全不同,这样比较运行时间没有意义。
所以统一用 origin 保存原始数据,tmp 作为当前算法的工作数组,每次重新复制,才能保证所有算法面对完全相同的输入。
7.3 正确性校验
性能测试不能只看运行时间。因为算法如果排序错误,再快也没有意义。因此排序结束后都调用:
cpp
Check(tmp, N)
逐个判断 a[i] <= a[i + 1],如果出现逆序立即返回失败。
最终测试结果同时包含运行时间和 ok / err 状态,只有速度和正确性一起成立,性能测试才有意义。
7.4 三组新增测试代码
cpp
//10.快速排序:挖坑法
memcpy(tmp, origin, sizeof(int) * N);
clock_t s10 = clock();
QuickSort_Hole(tmp, 0, N - 1);
clock_t e10 = clock();
printf("QuickSort_Hole 快排(挖坑): %.3fs, %s\n",
(double)(e10 - s10) / CLOCKS_PER_SEC,
Check(tmp, N) ? "ok" : "err");
//11.快速排序:双指针交换递归版
memcpy(tmp, origin, sizeof(int) * N);
clock_t s11 = clock();
QuickSort_PointerSwap(tmp, 0, N - 1);
clock_t e11 = clock();
printf("QuickSort_Swap 快排(双指针): %.3fs, %s\n",
(double)(e11 - s11) / CLOCKS_PER_SEC,
Check(tmp, N) ? "ok" : "err");
//12.快速排序:栈迭代非递归版
memcpy(tmp, origin, sizeof(int) * N);
clock_t s12 = clock();
QuickSort_Stack(tmp, 0, N - 1);
clock_t e12 = clock();
printf("QuickSort_Stack 快排(栈迭代): %.3fs, %s\n",
(double)(e12 - s12) / CLOCKS_PER_SEC,
Check(tmp, N) ? "ok" : "err");
实际运行时不要提前假定挖坑一定最快、非递归一定最快。不同编译器、优化等级、处理器、随机数据都会影响结果。这一轮测试更值得观察的是:三个版本是否全部 ok,以及同一机器、同一数据条件下三种实现的实际差异。
八、代码结构与算法思想的思考
8.1 比多写三个函数更重要的:模块化思想
这一轮真正重要的一点,是开始把算法整体框架 和算法中的具体步骤拆开。
例如 PartSort_Swap(...) 只负责完成当前 [left,right] 的一次划分,而 QuickSort_PointerSwap(...) 负责不断组织新的子区间。到了 QuickSort_Stack(...) 以后,"组织子区间"的方式甚至从函数递归变成了显式栈 + while,但是 PartSort_Swap(...) 仍然可以原封不动地复用。
这实际上就是比较基础但非常重要的模块化思想:把稳定、独立的能力抽成单独函数,让不同实现去复用,而不是每写一个版本就复制整套代码。
8.2 重新理解快速排序的层次
到这里,快速排序已经不应该只理解成两行递归调用,而应该拆成三个层次:
- 基准怎么选? 目前用三数取中
- Partition 怎么完成? 目前有双指针交换法、挖坑法
- 左右子区间怎么继续处理? 可以用函数递归,也可以用手动栈 + while
因此快速排序更像一个可以组合的算法框架:
Pivot Selection
+
Partition
+
Subproblem Scheduling
理解到这里以后,再继续学习前后指针法、三路划分、尾递归优化甚至 Introsort 时,思路都会更加清楚。
写在最后
上一篇学习快速排序的时候,我主要关注的是:怎样从一个有错误的实现,一步步修改成正确、性能更好的递归 QuickSort。
这一篇继续向下拆以后,会发现快速排序其实比一份固定代码更灵活。同一个 Partition,可以使用双指针交换,也可以挖坑填值;同一个分治过程,可以由函数递归自动维护待处理任务,也可以由手动栈显式维护区间。
这次真正值得记录的,不只是又多了三个函数,而是开始从"这一行代码怎么写",逐渐转向"这个函数到底负责什么""这一步能不能单独抽离""不同实现之间哪些能力能够复用""递归背后实际上保存了什么"。这也是这次代码更新相比单纯增加算法数量更有价值的地方。
后面还可以继续学习前后指针划分、三路快速排序以及大量重复数据下的优化,进一步观察 Partition 策略对快速排序性能的影响。