数据结构 - 排序(二):快速排序从错误初版到优化版|双指针划分 + 三数取中 + 小区间插入优化

写在前面

上一篇《数据结构 - 排序(一)》中,我们把冒泡排序、直接插入排序、希尔排序、堆排序以及双向循环选择排序统一放进了性能测试工程,通过 10 万个随机整数观察不同排序算法在实际运行中的差异。

这一篇进入排序专题中非常重要的一种算法 ------快速排序(Quick Sort)

快速排序的核心思想并不复杂:

选取一个基准值 key,通过一趟划分让 key 左边的数据都不大于它、右边的数据都不小于它,再递归处理左右两个子区间。

真正值得学习的地方,是它从 "能想到思路" 到 "写出正确代码",再到 "解决性能退化" 的整个过程。

我这次实现快速排序并不是直接写出了最终版本,而是经历了比较完整的演进:

复制代码
错误初版
   ↓
基准值改为基准下标 + 补边界判断
   ↓
加入递归,形成完整快速排序
   ↓
三数取中优化基准选择
   ↓
小区间改用直接插入排序
   ↓
加入 SortComparison 统一性能测试

最终独立版代码已经包含三数取中和小区间插入优化:

GetMidIndex() 负责从 left、mid、right 中选出中间值下标,区间长度不超过 10 时调用 InsertSortRange(),否则继续完成双指针划分和递归。

代码仓库

快速排序独立工程:

数据结构/My_QuickSort · Luminous/Code_2026 - 码云 - 开源中国

排序算法统一性能对比工程:

数据结构/SortComparison · Luminous/Code_2026 - 码云 - 开源中国


一、快速排序核心思想与划分原理

1.1 分治思想概述

假设有数组:

复制代码
5 2 8 1 4 7 3

先选择最左边元素作为基准:

复制代码
key = 5

然后设置左右两个指针:

复制代码
begin → 左边
end   → 右边

我们的目标是通过一趟划分,让基准值落到最终位置:

复制代码
              key
               ↓
小于等于 key | 5 | 大于等于 key

一趟划分结束以后,5 会来到它最终应该处于的位置。之后左区间、右区间分别继续快速排序,这就是典型的分治思想

1.2 双指针划分完整流程

本文实现使用左右双指针交换法,以数组 5 2 8 1 4 7 3 为例,基准值 key = 5

复制代码
5 2 8 1 4 7 3
↑           ↑
begin       end

因为基准放在最左边,所以要先从右向左找。

第一步:右指针找小

从右边开始遍历,找到第一个小于 5 的元素:

复制代码
3 < 5 → 停止

此时状态:

复制代码
5 2 8 1 4 7 3
↑   ↑       ↑
key begin   end
第二步:左指针找大

从左向右遍历,找到第一个大于 5 的元素:

复制代码
5 <= 5
2 <= 5
8 > 5 → 停止

交换 a[begin]a[end]

复制代码
8 ↔ 3

得到:

复制代码
5 2 3 1 4 7 8
第三步:继续移动指针直至相遇

右边继续找比 5 小的:

复制代码
8 >= 5
7 >= 5
4 < 5 → 停止

左边从当前位置继续找比 5 大的:

复制代码
3 <= 5
1 <= 5 → 指针相遇
第四步:基准归位

此时把基准位置的元素与相遇位置的元素交换:

复制代码
5 ↔ 4

得到:

复制代码
4 2 3 1 5 7 8
        ↑
      key最终位置

划分完成:

复制代码
4 2 3 1 | 5 | 7 8

注意:左右两侧此时只是完成了 "相对 key 的划分",内部并没有完全有序,所以还需要递归处理。


二、代码演进:从错误初版到完整递归实现

2.1 初版:思路雏形与致命问题

最开始写出的雏形代码,思路方向正确,但存在核心错误:

cpp 复制代码
void QuickSort(int* a, int left, int right)
{
	int key = a[left];
	int begin = left, end = right;

	while (begin < end)
	{
		// 右边找小
		while (a[end] >= key)
		{
			--end;
		}

		// 左边找大
		while (a[begin] <= key)
		{
			++begin;
		}

		Swap(&a[end], &a[begin]);
	}

	Swap(&key, &a[end]);
}

这段代码存在三个核心问题:

  1. 基准只存值不存位置int key = a[left] 只是把值复制到局部变量,最后 Swap(&key, &a[end]) 只交换了局部变量,数组里的基准元素根本没动,划分完全失效。
  2. 无边界保护 :内层 while 没有 begin < end 约束,极端情况指针会越界访问数组。
  3. 无递归无出口:只有单趟划分,不是完整的排序算法。

2.2 第一次修复:基准下标化 + 边界保护

针对上述问题做两处关键修复:

  • 基准从 "存值" 改为存下标 keyi = left,始终操作数组内的真实元素;
  • 内层查找全部加上 begin < end 边界判断,防止越界。

修复后代码:

cpp 复制代码
void QuickSort(int* a, int left, int right)
{
	int keyi = left;
	int begin = left, 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]);
	keyi = begin;
}
修复点说明
  1. 真正交换数组内的基准Swap(&a[keyi], &a[end]) 交换双方都是数组元素,基准真正归位。
  2. 增加边界保护 :左右指针相遇时立即停止查找,保证整个划分过程始终限制在 [left, right] 有效区间内。

2.3 为什么必须右指针先移动?

当前代码的顺序是:右边先找小,左边再找大

原因在于基准初始位于 left,最终 Swap(&a[keyi], &a[end]) 需要保证相遇位置上的值小于等于基准值。右指针先移动,能够保证最终相遇点的元素满足条件,适合作为基准归位位置。这是双指针交换法非常重要的细节。

2.4 补全递归:形成完整快速排序

此时只完成了一趟划分,只能保证基准元素位于最终位置,左右子区间仍然无序。因此需要加入递归,分别处理左右子区间。

完整基础版代码:

cpp 复制代码
void QuickSort(int* a, int left, int right)
{
	if (left >= right)
	{
		return;
	}

	int keyi = left;
	int begin = left, 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]);
	keyi = begin;

	QuickSort(a, left, keyi - 1);
	QuickSort(a, keyi + 1, right);
}

新增 if (left >= right) return;

作为递归终止条件:区间只有 0 个或 1 个元素时,天然有序,直接返回。

2.5 递归过程拆解

假设一次划分以后:

复制代码
4 2 3 1 | 5 | 7 8
          ↑
        keyi
  • QuickSort(a, left, keyi - 1) 负责排序左区间 4 2 3 1
  • QuickSort(a, keyi + 1, right) 负责排序右区间 7 8

每一层递归都重复「选基准 → 双指针划分 → 基准归位 → 递归左右区间」的流程,直到区间长度 ≤ 1。


三、性能优化 1:三数取中解决有序数组退化

3.1 基础版的痛点:固定左基准的退化问题

基础版每次固定选区间最左元素作为基准,对随机数组通常问题不大。但如果数组本身已经有序或接近有序:

复制代码
1 2 3 4 5 6 7 8 9

第一次选 1 作为基准,划分后左区间为空、右区间为 2 3 4 5 6 7 8 9;第二次选 2 作为基准,再次得到空左区间...... 递归结构从理想的均匀二叉树退化成了一条斜链。

3.2 为什么有序数组会退化为 O(N^2)

此时递推关系变成:

总操作量为:

最终时间复杂度退化为:

同时递归深度也从 增加到 ,数据量较大时可能耗尽栈空间,引发栈溢出

3.3 三数取中的优化思路

不再固定选最左元素作为基准,而是在 leftmidright 三个位置中,选择数值处于中间的元素 作为基准,这就是三数取中(Median of Three)

对于有序数组,三数取中会选出中间位置的元素作为基准,划分后左右区间长度接近相等,立刻避免了极端不平衡的情况。

说明:三数取中不能从理论上完全消除最坏情况,人为构造的特殊数据仍可能让快速排序退化,但它能显著降低常见场景下选到极端值的概率,工程效果非常好。

3.4 三数取中函数实现

cpp 复制代码
// 三数取中:返回left、mid、right中值为中位数的下标
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[right] < a[mid])
		{
			return mid;
		}
		else if (a[right] < a[left])
		{
			return right;
		}
		else
		{
			return left;
		}
	}
}

通过两层条件判断覆盖三个元素的 6 种大小关系,最终返回中位数所在下标。

中间下标写成 left + (right-left)/2,避免了 (left+right)/2 潜在的整数加法溢出问题。

3.5 为什么返回下标而非直接返回值?

因为原有划分逻辑是基于「基准位于 left 位置」设计的。拿到中位数下标后,只需要把该元素交换到 left 位置,后面的划分代码完全不需要修改。

这是典型的工程设计思路:只优化基准选择,不改动已经稳定的划分主体


四、性能优化 2:小区间插入排序降低递归开销

4.1 递归末端的常数开销问题

递归到最后阶段,会产生大量长度很小的子区间(比如长度 8、5、3、2)。对于这些小数组,继续执行「函数调用、基准选择、双指针扫描、再次递归」的管理成本已经很高。

此时算法复杂度的阶数不再是决定真实速度的唯一因素,常数项开销的影响非常明显。因此加入优化:当区间长度不超过阈值时,不再递归快排,改用直接插入排序

当前代码阈值设为 10:

cpp 复制代码
if (right - left + 1 <= 10)
{
	InsertSortRange(a, left, right);
	return;
}

4.2 为什么选择直接插入排序

  1. 小数据下 O(N^2) 并不可怕:比如 N=8 时,最坏也只有几十次操作,开销极小。
  2. 插入排序常数项低:代码逻辑简单,没有递归、没有复杂辅助结构。
  3. 适配接近有序的场景:快速排序递归到末端的小区间,通常已经具有一定局部顺序,而插入排序对接近有序的数据性能极佳。

因此插入排序非常适合作为快速排序的 "小区间收尾算法"。

4.3 区间版插入排序实现

原有插入排序默认处理 [0, n-1],而快速排序需要处理任意 [left, right] 区间,因此封装区间版本:

cpp 复制代码
void InsertSortRange(int* a, int left, int right)
{
	for (int i = left + 1; i <= right; i++)
	{
		int tmp = a[i];
		int j = i - 1;

		while (j >= left && a[j] > tmp)
		{
			a[j + 1] = a[j];
			j--;
		}

		a[j + 1] = tmp;
	}
}

逻辑与普通插入排序完全一致,只是左边界从 0 改为 left。


五、最终优化版完整实现

5.1 完整代码

cpp 复制代码
void QuickSort(int* a, int left, int right)
{
	// 小区间改用插入排序,阈值10可调整
	if (right - left + 1 <= 10)
	{
		InsertSortRange(a, left, right);
		return;
	}

	// 三数取中,把中位数交换到left位置作为基准
	int midi = GetMidIndex(a, left, right);
	Swap(&a[left], &a[midi]);

	int keyi = left;
	int begin = left, 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]);
	keyi = begin;

	// 左右区间递归
	QuickSort(a, left, keyi - 1);
	QuickSort(a, keyi + 1, right);
}

5.2 为什么不用单独写 left >= right 递归出口?

小区间优化本身已经覆盖了递归出口:

  • left == right 时,区间长度为 1,进入 InsertSortRange() 后循环不执行,直接返回;
  • left > right 时,区间长度 ≤ 0,同样进入小区间分支,插入排序主体不会执行。

因此代码是安全的。如果追求教学可读性,也可以额外保留 if (left >= right) return;,不影响功能。


六、快速排序复杂度与特性分析

6.1 时间复杂度

  • 平均 / 理想情况:划分均匀,递归深度约 O(log N),每层总工作量约 O(N),整体时间复杂度 O(N logN)。
  • 最坏情况:每次划分极端失衡,时间复杂度退化为 O(N^2)。三数取中可以大幅降低最坏情况出现的概率,但无法从理论上完全消除。

6.2 空间复杂度

快速排序本身是原地排序,不需要额外的长度为 N 的辅助数组,但递归调用需要栈空间。

  • 划分均匀时递归深度 O(log N),栈空间约 O(log N);
  • 极端退化时递归深度可达 O(N),栈空间也达到 O(N)。

6.3 稳定性

快速排序属于不稳定排序。划分过程中的跨区间交换,可能改变相同元素原来的相对顺序。

6.4 为什么实际中快速排序通常更快

从大 O 量级看,快速排序和堆排序都是 O(Nlog N),但实际运行中快速排序通常更快,主要原因:

  1. 分区扫描是顺序访问连续数组,CPU 缓存局部性更好;
  2. 内层操作简单,常数因子更小;
  3. 加入三数取中、小区间插入等优化后,实际表现进一步提升。

注意:这不是绝对结论,真实结果受数据分布、编译器优化、硬件缓存等多种因素影响。


七、工程实现与统一测试

7.1 独立工程:My_QuickSort

独立工程中除了排序本身,还包含随机数据生成和有序性检查模块:

  • GenArray() 使用 rand() 生成随机数组;
  • Check() 逐个检查相邻元素,验证升序正确性;
  • 测试规模 100000,使用当前时间初始化随机种子;
  • 使用 clock() 统计运行时间,同时输出耗时与 ok/err 状态。

7.2 接入 SortComparison 横向对比

完成独立版本后,将最终优化版同步加入 SortComparison 统一工程。 sort.h 新增声明:

cpp 复制代码
int GetMidIndex(int* a, int left, int right);
void InsertSortRange(int* a, int left, int right);
void QuickSort(int* a, int left, int right);

sort.c 中对应加入三数取中、区间插入排序与最终 QuickSort 实现,和其他排序算法共用同一套测试框架。

7.3 测试代码与公平性原则

当前测试项目共 9 组排序算法,快速排序为第 9 组:

cpp 复制代码
//9.快速排序
memcpy(tmp, origin, sizeof(int) * N);

clock_t s9 = clock();
QuickSort(tmp, 0, N - 1);
clock_t e9 = clock();

printf("QuickSort   快速排序:  %.3fs, %s\n",
	(double)(e9 - s9) / CLOCKS_PER_SEC,
	Check(tmp, N) ? "ok" : "err");

所有算法测试前都执行 memcpy(tmp, origin, sizeof(int) * N) 恢复原始随机数组,保证每一种算法面对的输入完全一致,对比公平。

7.4 为什么必须做有序性校验

排序算法优化过程中,最危险的不是运行慢,而是运行很快但结果错误。比如初版中错误交换局部变量的问题,程序不会崩溃,但排序结果完全错误。

因此每个算法执行后都通过 Check() 做升序验证,只有「时间合理 + Check == ok」的测试结果才有意义。


八、演进总结与易错点梳理

8.1 四阶段演进总表

阶段 主要问题 核心改进
初始版 基准只存局部值,归位错误;无边界保护可能越界 改用 keyi 存下标,增加 begin < end 边界判断
基础正确版 只有单趟划分,不是完整排序 加入递归终止条件,递归处理左右子区间
第一次性能优化 固定左基准,有序数组下严重退化 三数取中选基准,改善划分均匀性
第二次性能优化 小区间递归管理成本偏高 长度 ≤ 10 的小区间改用插入排序,降低常数开销

整个演进逻辑可以浓缩为:

复制代码
先保证正确
    ↓
再保证边界安全
    ↓
完成递归框架
    ↓
处理最坏输入
    ↓
优化实际运行常数

8.2 核心易错点汇总

  1. 基准要存下标,不要只存局部值 错误写法:int key = a[left]; Swap(&key, &a[end]); 正确写法:int keyi = left; Swap(&a[keyi], &a[end]);

  2. 内层查找必须加边界条件 必须带 begin < end 约束,否则极端情况会数组越界。

  3. 当前写法要右指针先移动 基准初始在左侧,右指针先找小才能保证相遇位置的值 ≤ 基准,适合基准归位。

  4. 一趟划分不代表数组有序 只保证基准左侧 ≤ 基准、右侧 ≥ 基准,左右子区间仍需递归。

  5. 固定左基准在有序数据上会退化 时间复杂度从 O(Nlog N) 退化为 O(N^2),递归深度从 O(log N) 增加到 O(N),严重时栈溢出。

  6. 三数取中改善常见场景,非理论绝对保证 主要降低选到极端基准的概率,无法彻底消除最坏情况。

  7. 小区间插入是工程优化,不改变复杂度阶数 整体快速排序平均复杂度仍然是 O(Nlog N),优化的是实际运行中的常数项和递归开销。


九、排序算法横向对比

算法 平均时间 最坏时间 额外空间 稳定性
冒泡排序 O(N^2) O(N^2) O(1) 稳定
直接插入 O(N^2) O(N^2) O(1) 稳定
双向选择 O(N^2) O(N^2) O(1) 不稳定
希尔排序 与增量有关 与增量有关 O(1) 不稳定
堆排序 O(Nlog N) O(Nlog N) O(1) 不稳定
快速排序 O(Nlog N) O(N^2) 平均 O(log N) 递归栈 不稳定

堆排序的优势是最坏情况仍稳定保持 O(Nlog N);快速排序的优势是平均性能优秀,连续数组访问对缓存友好,实际运行速度通常很有竞争力。两者各有特点,并非完全替代关系。


写在最后

这一篇快速排序最值得记录的,并不是最后那几十行代码,而是它的整个演进过程。

最开始已经知道大致思路:右边找小、左边找大、交换。但第一版因为只保存了基准值,最后交换局部变量,并没有真正让数组里的基准归位。改成下标后,又进一步发现必须给双指针增加边界判断;一趟划分能正常运行以后,再加入左右递归,才真正得到一份完整的 QuickSort。

而 "代码正确" 之后,问题还没有结束。固定把最左元素作为 key,在有序数据中会产生极度不平衡的递归树,让平均很优秀的快速排序退化到 O(N^2),甚至因为递归层数过大出现栈溢出风险。所以进一步加入三数取中改善基准选择。

最后,针对递归末端大量小区间,引入直接插入排序减少函数调用和划分开销,最终形成「三数取中 + 双指针划分 + 递归分治 + 小区间插入排序」的版本。

并且这次不再只单独运行 QuickSort,而是把它正式加入 SortComparison,与之前的冒泡、插入、希尔、堆排序、双向选择排序统一使用 10 万随机数据 + 相同原始数组 + 时间统计 + Check 有序性验证进行横向测试。

从这一过程里能很明显看到一个规律:

算法学习通常不是 "把代码背下来",而是先写出来,再用边界数据暴露问题,最后针对问题一点点优化。

快速排序尤其适合这样学习,因为它同时涵盖了双指针、分治、递归、时间复杂度、递归栈以及工程优化等多个非常重要的知识点。

相关推荐
hansang_IR1 小时前
【题解】P9753 [CSP-S 2023] 消消乐
数据结构·c++·算法
竞赛考级题库1 小时前
202606 青少年等级考试C/C++真题一级 建议答题时长:60min
java·c语言·c++
迷茫、Peanut1 小时前
C语言混合链接
c语言
shehuiyuelaiyuehao1 小时前
算法40,模拟运算,替换所有的问号
数据结构·算法·leetcode
用户204937554951 小时前
从“能识别”到“稳定识别”:离线ASR在真实会议场景中的问题与工程优化实践
算法
YSL0701242 小时前
OpenCode保姆级安装教程
数据结构
鹿角片ljp2 小时前
LeetCode 148:排序链表|归并排序、快慢指针找左中点与链表断开
算法
LabVIEW开发2 小时前
LabVIEW字符串特殊字符检测兼容
算法·labview·labview知识·labview功能·labview程序
wdfk_prog2 小时前
RT-Thread Kconfig 配置明明是 y,为什么 rtconfig.h 就是不生成?一次 `_PATH` 后缀踩坑复盘
c语言