数据结构-排序(三):快速排序进阶|挖坑法、双指针交换与手动栈非递归实现

写在前面

上一篇 《数据结构-排序(二):快速排序从错误初版到优化版》 中,我从最开始的错误实现一路修改,完成了快速排序的基础递归版本,并进一步加入了两个优化:

  • 三数取中 :从 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 > 64 < 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 重新理解快速排序的层次

到这里,快速排序已经不应该只理解成两行递归调用,而应该拆成三个层次:

  1. 基准怎么选? 目前用三数取中
  2. Partition 怎么完成? 目前有双指针交换法、挖坑法
  3. 左右子区间怎么继续处理? 可以用函数递归,也可以用手动栈 + while

因此快速排序更像一个可以组合的算法框架:

复制代码
Pivot Selection
        +
Partition
        +
Subproblem Scheduling

理解到这里以后,再继续学习前后指针法、三路划分、尾递归优化甚至 Introsort 时,思路都会更加清楚。


写在最后

上一篇学习快速排序的时候,我主要关注的是:怎样从一个有错误的实现,一步步修改成正确、性能更好的递归 QuickSort。

这一篇继续向下拆以后,会发现快速排序其实比一份固定代码更灵活。同一个 Partition,可以使用双指针交换,也可以挖坑填值;同一个分治过程,可以由函数递归自动维护待处理任务,也可以由手动栈显式维护区间。

这次真正值得记录的,不只是又多了三个函数,而是开始从"这一行代码怎么写",逐渐转向"这个函数到底负责什么""这一步能不能单独抽离""不同实现之间哪些能力能够复用""递归背后实际上保存了什么"。这也是这次代码更新相比单纯增加算法数量更有价值的地方。

后面还可以继续学习前后指针划分、三路快速排序以及大量重复数据下的优化,进一步观察 Partition 策略对快速排序性能的影响。

相关推荐
chushiyunen1 小时前
mockito笔记
java·开发语言·笔记
Doubbbbbbble云1 小时前
基于空间局部性的排序算法性能重构思路4
java·重构·排序算法
linx2951 小时前
单元三 · 那 C 的底层知识怎么办
c语言·开发语言·数据结构·c++·嵌入式硬件
数据知道2 小时前
国密算法实战——SM2/SM3/SM4 在国产系统中的应用
网络·算法·安全·网络安全·密码学·哈希算法
zander2582 小时前
LeetCode 128. 最长连续序列
数据结构·算法
linx2952 小时前
单元四 · 对称认知·上:内存与指针
c语言·开发语言·数据结构·嵌入式硬件·算法
-dzk-3 小时前
【二叉树】LC 236.二叉树的最近公共祖先
数据结构·二叉树
午彦琳3 小时前
2026.9.11
数据结构·python·算法