排序算法是数据结构课程里绕不开的重点,但真正到了工程环境还愿意手写快排的人,多半是被性能或者面试逼出来的。我最早在项目里用快速排序处理一批用户行为日志数据,自以为写了个标准的递归分区版本,结果在某个特殊数据集上差点跑到O(n²)的复杂度,从那之后我才开始认真研究快排的各种优化思路。这篇就系统梳理一下快速排序的实现、复杂度陷阱和工程优化手段。内容包括分区写法选择、三数取中、三路快排、小区间插入排序、尾递归消除和非递归实现,适合正在啃数据结构的同学、准备面试的开发者,以及想在项目中真的把排序写快的朋友。
1. 快速排序的核心思想与第一版实现
1.1 分治模型的真正关键:分区操作
快速排序用的是分治策略,做的事情可以概括成三步:选定一个基准元素、把数组按基准拆成左右两部分、再对左右子数组递归执行同样的过程。但是很多人学快排的时候忽略了一个重要细节,分治本身并不能保证排序正确,真正决定数据移动量的是分区(partition)这一步。
分区操作做了一件很巧妙的事:它把小于基准的元素全部挪到左侧,大于基准的元素全部挪到右侧。这时候基准元素一定已经处于它最终该待的位置,因为它的左边全是比它小的,右边全是比它大的,它不需要再参与任何后续移动。这个性质是快速排序正确性的核心,也是后面各种优化都围绕“怎么更好分区”展开的原因。
用生活里的例子理解的话,可以把分区想象成整理一摞扑克牌:你随手抽出一张基准牌,然后让所有比它小的牌跑到它左边、比它大的跑到右边。这张基准牌的位置当场就固定了,接下来只需要对左右两堆牌分别重复这个动作,最终整副牌就排好了。
1.2 两种主流分区写法对比:Lomuto与Hoare
分区算法的写法有很多种,最常见的是Lomuto分区和Hoare分区。我的建议是两种都写一遍,因为底层逻辑不同,踩过的坑也不一样。
Lomuto分区写法直观,代码量少,非常适合教学和快速验证思路。它的思路是以最右元素为基准,维护一个指针i指向“小于基准区间的末尾位置”,再让另一个指针j从头扫到尾。每遇到一个小于基准的元素,就把i往后移一格,然后交换i和j位置的元素。循环结束后,i+1就是基准元素应该放的位置,交换过去再返回这个索引即可。
int partitionLomuto(int arr[], int low, int high) { int pivot = arr[high]; int i = low - 1; for (int j = low; j < high; j++) { if (arr[j] < pivot) { i++; std::swap(arr[i], arr[j]); } } std::swap(arr[i + 1], arr[high]); return i + 1; }Hoare分区则从数组两端向中间扫描。左边找一个不小于基准的元素,右边找一个不大于基准的元素,找到后让它们交换位置,直到左右两个指针相遇。这种写法交换次数通常比Lomuto少,实际表现也更快,但代码理解难度更高,而且返回的j并不是基准元素的位置,只是“分界点”。递归时需要处理[low, j]和[j+1, high],不能直接套用[low, j-1]的逻辑。
int partitionHoare(int arr[], int low, int high) { int pivot = arr[low + (high - low) / 2]; int i = low - 1, j = high + 1; while (true) { do { i++; } while (arr[i] < pivot); do { j--; } while (arr[j] > pivot); if (i >= j) return j; std::swap(arr[i], arr[j]); } }两种分区方式放到工程场景里,我倾向于直接用Hoare分区。Lomuto胜在逻辑简单,遇到重复元素多的数组时却会做大量无意义交换,性能容易崩掉。Hoare分区虽然边界处理繁琐,但整体交换次数少,缓存也更友好。面试时可以先用Lomuto把思路讲清楚,再主动提一句Hoare的优势,印象分会好很多。
1.3 第一版代码与三个边界问题
把递归加上去,一个教科书版快速排序就出来了。这个版本能跑,但只能算是“实验室玩具”。
void quickSort(int arr[], int low, int high) { if (low >= high) return; int pi = partitionLomuto(arr, low, high); quickSort(arr, low, pi - 1); quickSort(arr, pi + 1, high); }代码短不代表安全。我随便写个在线评测系统测试,最常冒出来的问题有三个:第一,递归终止条件写错或漏写,导致无限递归或越界访问;第二,分区返回值在极端情况下越界,比如所有元素都小于基准时返回high;第三,对已经有序的数组,固定选择第一个或最后一个元素为基准会退化成冒泡排序级别的性能。
我踩过最典型的一个坑:用自己的排序代码去处理一份接近有序的业务数据,直接把线上接口耗到超时。查了半天才意识到问题不在代码逻辑,而在基准选择策略。这也是下一篇会重点展开讲的内容,后面第三部分会集中说优化手段。
2. 复杂度分析:快排为何快,又为何会退化
2.1 三种情形下的复杂度推导
很多人知道快排的平均复杂度是O(n log n),但推导过程值得再走一遍,因为里面的逻辑能解释清楚“快排为什么叫快排”。
最好情况:每次分区都恰好把数组对半分。这时候递归树是一棵高度为log₂n的平衡树,每一层处理的所有元素加起来是n个,整体复杂度就是n log n。平均情况也接近这个水平,因为随机数据下每次分区即使不是严格对半分,只要分到固定比例,递归树高度仍然是对数级别。
最坏情况:每次分区都选到了全局最小或最大元素做基准。比如递归处理时基准总落在数组边缘,一个子数组为空,另一个子数组是n-1个元素。这时候递归树退化成一条n层深的链表,每层还要扫描n个元素,总复杂度直接变成O(n²),一点脾气没有。
这里面有个值得注意的点:快排和归并排序都是分治排序,但归并排序无论数据分布如何都是严格的n log n,快排却有O(n²)的尾巴。原因在于归并排序的合并阶段无论如何都需要线性扫描,而快排的分区质量完全取决于基准选得好不好。这也是后面所有优化都围绕基准选择、分区策略来做的根本原因。
2.2 最容易让快排“翻车”的几类数据
总结我实际遇到过的快排退化场景,主要有三类,而且都很有规律。
第一类是已经有序或接近有序的数组。固定取第一个元素做基准,对一个升序数组处理时,每次分区后右侧子数组长度永远只减1,快排变成赤裸裸的选择排序。数据量一大,跑起来能急死人。
第二类是大量重复元素的数组。比如几千万元素只取值0到9,用Lomuto分区时,等于基准的元素会不断交换,分区后左右严重不平衡。比较次数和交换次数都会被放大,性能直线下滑。
第三类是小数组。子数组长度降到十几甚至几个元素时,递归调用的函数开销和栈操作成本占比越来越高,此时快排的log n优势被常数因子拖累。这也是为什么工程级快排基本都会加一个“数组足够小时改用插入排序”的策略。
2.3 递归深度:看不见的栈空间成本
快排的递归深度在最好情况下是log₂n,最坏情况下是n。这个差异在数据量只有几千时还不明显,一旦跑到百万级,最坏情况下的递归深度就是百万量级。绝大多数编译环境的默认栈空间根本扛不住,直接栈溢出崩溃。
即使在最好情况下,递归带来的隐性开销也不小。每一层递归都要保存当前函数局部变量和返回地址,比迭代实现多出一部分栈操作。在嵌入式设备或者对内存敏感的场景里,这个问题会被放大。工程上一般通过尾递归消除、手动模拟栈循环两种方式来解决,后面到了第三部分详细展开。
3. 快速排序的优化手段:从基准到递归结构
3.1 基准选择优化:三数取中与随机化
既然最坏情况来自“总选到最小或最大元素”,那核心解法自然就是让基准尽量靠近数组的中位数。这里有两个思路,一个是从概率上规避,一个是从工程上取近似中位数。
随机化基准的思路最直接:在每次分区前,随机选一个位置和固定基准位置交换,再执行正常分区。由于最坏情况的数组分布是固定的,随机抽样后每次都踩中极端值的概率低到可以忽略不计。这个方案适合面试时展示思路,简洁、有效、一行代码就能实现。
三数取中(median-of-three)是我最常用的方案。做法是取数组low、mid、high三个位置的元素,找出数值排在中间的那个作为基准。代码实现上需要注意一点,三点之间要做三次比较和交换让它有序,然后返回mid位置的索引作为基准位置。
int medianOfThree(int arr[], int low, int high) { int mid = low + (high - low) / 2; if (arr[low] > arr[mid]) std::swap(arr[low], arr[mid]); if (arr[low] > arr[high]) std::swap(arr[low], arr[high]); if (arr[mid] > arr[high]) std::swap(arr[mid], arr[high]); return mid; }为什么三数取中比随机化更常用?因为它不仅避免了最坏情况,还把基准值约束在数组已有值范围内,对整体有序的数据几乎呈现出“每次都选到中位数”的理想表现。随机化只能降低概率,三数取中有确定的对抗有序数据的逻辑支撑。实际组合使用效果更好,比如先随机选三个点,再做三数取中,兼顾概率与确定性。
这里还要提醒一个细节:计算mid索引时,用low + (high - low) / 2而不是(low + high) / 2,虽然写起来繁琐一点,但能有效避免整数溢出。工程量大的时候,这个习惯能省去很多查bug的时间。
3.2 小数组切换:插入排序的正确时机
快排在处理大数组时无可匹敌,但递归调用直到子数组只剩一个元素并不明智。当数组规模缩小到十几二十个元素时,递归层层调用的开销、函数栈的分配成本已经超过了排序本身的计算成本,插入排序靠着极小的常数和出色的局部性反而更快。
工程上最常用的经验阈值是10到20之间,STL里的sort就长期使用16作为切换阈值。我个人的习惯是取16,因为现代CPU的缓存行是64字节,16个4字节整数正好接近一次缓存预取能覆盖的范畴,实际性能很稳定。
具体实现方式是在快排递归入口加一个判断,如果当前区间长度小于阈值,直接对这一小段执行插入排序然后返回。插入排序本身对接近有序的小数组非常友好,耗时几乎等于数组长度,可以把快排最底层的几层递归彻底砍掉。
void insertionSort(int arr[], int low, int high) { for (int i = low + 1; i <= high; i++) { int key = arr[i]; int j = i - 1; while (j >= low && arr[j] > key) { arr[j + 1] = arr[j]; j--; } arr[j + 1] = key; } } void quickSortOptimized(int arr[], int low, int high) { while (low < high) { if (high - low < 16) { insertionSort(arr, low, high); return; } int pi = partition(arr, low, high); if (pi - low < high - pi) { quickSortOptimized(arr, low, pi - 1); low = pi + 1; } else { quickSortOptimized(arr, pi + 1, high); high = pi - 1; } } }阈值太小,递归层数减少有限;阈值太大,插入排序本身的O(n²)特性又会开始产生负面影响。我做过简单的数据对比,16到32之间性能差距不大,但超过64之后部分场景反而变慢。拿不准的时候,直接取16基本不会出错。
3.3 三路快排:一次性解决重复元素
前面提到大量重复元素会让普通快排退化,根源在于经典分区只分了两个区域,等于基准的元素被反复交换。三路快排(3-way partition)把这个病灶直接摘除:把数组拆成小于基准、等于基准、大于基准三个区域,等于基准的元素原地不动,不参与后续任何递归。
这个思路源自“荷兰国旗问题”的解法。实现时维护三个指针:lt指向等于基准区的末尾,gt指向等于基准区之后的位置,i是当前扫描指针。初始时基准取区间最左侧元素,lt和i都指向low,gt指向high。扫描过程中发现小于基准的元素就交换到lt位置,同时lt和i一起前进;大于基准的交换到gt位置,gt后退;等于基准的留在中间,i直接前进。递归时只需要处理[low, lt-1]和[gt+1, high]两个区间。
void quickSort3Way(int arr[], int low, int high) { if (low >= high) return; int lt = low, i = low + 1, gt = high; int pivot = arr[low]; while (i <= gt) { if (arr[i] < pivot) { std::swap(arr[lt++], arr[i++]); } else if (arr[i] > pivot) { std::swap(arr[i], arr[gt--]); } else { i++; } } quickSort3Way(arr, low, lt - 1); quickSort3Way(arr, gt + 1, high); }三路快排最有意思的地方在于,当数组中几乎所有元素都相等时,分区一次就把全部数据放进了等于基准的中间区,两个递归调用全部为空,实际复杂度直接降为O(n)。这种极端场景下,其他优化方案全都不如它。
不过三路快排也有自己的缺点,它在随机分布的大数据集上会比普通快排多一点指针移动和比较开销。我通常只在确定数据中存在大量重复值,或者写通用排序时配合其他优化一起用。比如C语言的qsort、Java的DualPivotQuicksort都是双基准三路划分的工程改进版本,兼顾重复元素和随机数据两端的表现。
3.4 递归结构优化:尾递归消除与非递归实现
递归是快排最自然的表达方式,系统栈却未必能一直兜住递归深度。尾递归消除的原理非常直观:如果递归调用发生在函数尾部,编译器可以将当前的栈帧复用给下一次调用,而不必新压一层栈。具体到快排,就是对分区后的两个子区间先递归处理较短的那一侧,然后让循环处理较长的那一侧。这种方式可以把递归深度强制约束在log n量级,因为较长的区间总是通过循环接手,递归只有处理短区间时才会发生。
void quickSortTail(int arr[], int low, int high) { while (low < high) { int pi = partition(arr, low, high); if (pi - low < high - pi) { if (pi - 1 > low) quickSortTail(arr, low, pi - 1); low = pi + 1; } else { if (pi + 1 < high) quickSortTail(arr, pi + 1, high); high = pi - 1; } } }如果你使用的编程语言对递归优化不友好,或者数据规模大到系统栈已经岌岌可危,更稳妥的办法是用显式栈模拟递归过程。手动栈没有任何语言层面的递归深度限制,只要堆内存够用,处理千万级数组也毫无压力。
void quickSortIterative(int arr[], int n) { std::stack<std::pair<int, int>> st; st.push({0, n - 1}); while (!st.empty()) { auto [l, r] = st.top(); st.pop(); if (l >= r) continue; int pi = partition(arr, l, r); st.push({l, pi - 1}); st.push({pi + 1, r}); } }如果编译环境不支持C++17的结构化绑定,把auto [l, r]换成auto range = st.top(); int l = range.first; int r = range.second;即可。手动栈的代价是代码可读性下降,但换来的稳定性在真实项目里永远值得。
4. 优化效果实测与工程避坑指南
4.1 对比实测:不同优化在不同数据下的表现
我在一台普通开发机上对不同版本做过一轮粗略对比,数据趋势稳定,值得参考。测试数据集选了四种典型情况:完全随机的整型数组、已经有序的数组、大量重复元素数组(取值范围仅0到9)、以及长度15左右的小数组。
随机数组上,朴素快排、三数取中版、三路快排三者的差距不大,优化版比朴素版快大概20%到30%。这符合预期,因为随机数据本身很少触发最坏情况,优化主要省在交换次数和递归开销上。
有序数组是最直观的分水岭。朴素快排固定取末尾元素为基准,处理10万元素的有序数组就会出现明显卡顿,优化版几乎还是线性速度,两者在量级上的差距可能达到几十倍。三数取中在有序数据上的提升最为显著。
大量重复元素的场景则是三路快排的主场。普通快排在几千个只含5种取值的数组上就已经出现性能毛刺,而三路快排直接跳过中间区域,在百万级重复数据上依然平稳。如果业务场景明确存在大量重复主键,三路快排基本是必须的。
小数组测试验证了插入排序切换阈值的价值。不加插入排序优化时,递归层数在小区间上依然反复进行,耗时明显偏高;加上16阈值后,小数组排序开销降低了一截。这个优化单独看不显眼,但放到整体排序中能稳定减少底层递归调用。
4.2 边界条件与编码细节排查
优化版本越来越多,边界条件也埋得越来越深。排查这些问题的时候我总结过一套自己的检查顺序,已经帮我省下很多时间。
第一,检查分区返回索引的使用边界。Lomuto分区能用[low, pi-1]和[pi+1, high],Hoare分区则要用[low, j]和[j+1, high],用错一个区间就直接丢失元素或者无限递归。
第二,检查三路快排中lt和gt的移动顺序。扫描时arr[i] < pivot分支必须先交换、再同时移动lt和i;arr[i] > pivot分支交换后只移动gt,i不能动,因为换过来的右侧元素还没有比较过。这个细节写错,结果会完全错乱。
第三,检查三数取中索引与值的关系。交换的是arr[low]、arr[mid]、arr[high]三个位置的值,最后返回的mid是索引,分区时要用arr[mid]作为基准值。经常有人因为索引和值混用,导致基准取值完全错误。
第四,手动栈实现时注意压栈顺序。先从栈中弹出区间再处理,处理完后左右两个子区间都可以压入,顺序不影响正确性。但最好优先处理较短的区间,把长区间留在栈里,避免栈规模膨胀。
4.3 快速排序常见问题速查表
| 现象 | 原因 | 解决方式 |
|---|---|---|
| 有序数组上性能急剧下降 | 固定基准触发最坏情况 | 使用三数取中或随机基准 |
| 大量重复元素时排序卡顿 | 经典分区反复交换相等元素 | 改用三路快排 |
| 数据量小时递归开销大 | 递归层级过深 | 小区间切换插入排序 |
| 递归调用导致栈溢出 | 最坏递归深度接近数组长度 | 尾递归消除或显式栈实现 |
| 分区后左右数据未正确划分 | 边界区间传错 | 确认分区方式对应的递归边界 |
| Hoare分区返回j后丢失元素 | 误用[low, j-1]区间 | 正确改为[low, j]和[j+1, high] |
| 三数取中后基准值不对 | 混淆索引和值 | 明确基准使用arr[mid] |
这张表算是我个人调试快排的速查手册,每次排查问题都按这个顺序过一遍。尤其是边界区间传错和递归深度超限这两个问题,在真实项目中出现的频率远高于想象。
快速排序是所有排序算法里最值得精读的一个,它把“分治”思想和高性能演算浓缩在一百行代码内。我个人的经验是,学快排不要只背模板,亲手实现一遍朴素版,再走一遍从三数取中、三路快排到尾递归消除的优化链路,才算真正吃透。这个过程中你会自然理解为什么某些优化是必要的,为什么某些坚持在某些场景下要退让。下次再遇到一个奇怪的排序性能问题,你不会只想到换一种排序算法,而是能直接判断出问题出在基准、分区还是递归深度上。这就是手写一遍快排带给你最大的价值。