☰
快速排序在数组上的实现细节:分区策略、递归边界与工程化优化
2026/10/11 14:53:06 网站建设 项目流程

最近帮同事复查一段数据处理流程,最耗时间的不是数据库,也不是网络请求,而是一段看起来人畜无害的数组排序——数据源是个基本有序的数组,排序用的又是最朴素的快速排序写法:固定取区间左端当基准。结果就是,排序耗时直接比平时的随机数据涨了快两个数量级。这个案例让我决定把快速排序在数组上的实现细节完整捋一遍,因为它太容易被写坏,又太容易在数据分布变化时踩雷。

数组排序这个需求,几乎每个写代码的人都避不开。真正拉开差距的,不是谁会调用现成排序函数,而是当数据规模上来、数据分布变了之后,你手里的排序代码还能不能稳住。这篇文章想讲清楚:为什么快速排序在数组上能成为主力实现,分区策略该怎么选,递归边界为什么总是出错的根源,以及真实工程环境里你要怎么改它才不会崩。

1. 快排在数组场景能占据主力,靠的不是“名气”,是硬指标

很多教材把快速排序放在排序章节的靠前位置,大家觉得它是“考试重点”,所以都用它。但如果深入看,快排之所以在数组场景中成为默认选项之一,背后有几个非常硬的基础条件。

1.1 数组的随机访问特性,和分区策略是天生一对

快速排序的核心动作是“分区”:选一个基准元素,把小于它的放到左边,大于它的放到右边。听起来简单,但这个动作对数据结构有一个潜在要求——你得能快速拿到任意下标的元素,并且能在O(1)时间内完成交换。数组刚好满足:arr[i]直接通过下标定位,交换只需要一个临时变量,整个过程在原数组上就能完成。

如果把同样的逻辑搬到链表上,情况就尴尬了。链表的locate(i)需要从头往后走,复杂度是O(n);快速排序每层分区都要做很多次“按位置比较”,在链表上用快排,哪怕递归层数正确,实际常数也会被拉得非常大。这也是为什么在很多链式结构的标准库里,默认选用归并排序而不是快排——不是快排不能写,而是它和数组的随机访问特性绑定太深了。

这个特性在面试题里经常被隐式考察:当题干明确给出int[]或ArrayList时,快排是自然的思路;当题干给的是LinkedList或者单链表节点时,继续死磕快排就会变成一场灾难。

1.2 空间复杂度与缓存局部性:为什么O(n log n)在数组上真实可信

快速排序是原地排序,额外空间主要来自递归调用栈。理想情况下递归深度是log n量级,所以空间复杂度是O(log n);大多数情况下,这是一笔非常便宜的成本。对比归并排序,虽然同样是O(n log n)时间,但每次merge都需要一个长度为n的辅助数组,或者至少一个拷贝缓冲区。当数据量到百万级,数组内存占用可能是8MB甚至更多,归并对内存的压力是实打实的。

快排在数据访问模式上也更友好。分区过程是“从数组的一端扫向另一端”,这恰好符合CPU缓存的顺序访问偏好。相比在一些树形结构上反复跳跃访问,数组上的快排能让缓存命中率高出一截。这也是为什么在很多基准测试里,快排速度分明,实际跑起来却比同样复杂度的归并更快的原因之一。

这样说,并不是说快排天下无敌。它也有两个明显的软肋:最坏时间复杂度是O(n²),而且不稳定。前者让它在“某种特定输入”下性能崩盘,后者让它在“需要保留原始相对顺序”的业务里直接被淘汰。这两个软肋,会在后面的部分展开聊,因为它们正是工程实现里大量坑的源头。

2. 分区方案决定快排骨架:Lomuto 与 Hoare 的适用边界

看快速排序的代码,很多人会误以为核心是递归,其实递归结构非常简单。真正决定快排性能和Bug率的,是分区函数怎么写。主流分区方法主要有两种:Lomuto分区和Hoare分区。两者都叫分区,但行为差异很大。

2.1 Lomuto:教学首选,代码最短,代价是交换多

Lomuto分区的思路很直观,用最后一个元素当基准,维护一个“已处理的小于基准的区间右边界”,从左往右扫描,碰到小于基准的值就和边界后面的位置交换。代码非常短:

int partitionLomuto(int[] arr, int low, int high) { int pivot = arr[high]; // 基准取区间的最后一个元素 int i = low - 1; // i 指向“小于基准的区间”的右边界 for (int j = low; j < high; j++) { if (arr[j] < pivot) { i++; swap(arr, i, j); } } swap(arr, i + 1, high); // 把基准放回中间位置 return i + 1; // 基准最终下标 }

以数组[5, 2, 8, 1, 9, 3, 7]为例,取最后一个元素7作基准。扫描过程中遇到5、2、1、3时都会往前交换,最后把7放到下标5,结果变成[5, 2, 3, 1, 7, 9, 8],基准7左侧是4个元素,右侧是2个元素。返回下标5。

从代码和示例可以看出,Lomuto的特点是:结构简单,不容易写错,但是交换次数偏多。每次发现“当前元素小于基准”,都要做一次交换;就算当前元素本来就在正确位置,也会多交换一次。这个常数问题在数据量不大的时候无所谓,在百万级数组上就会变成肉眼可见的差距。

2.2 Hoare:交换更少、效率更高,但边界坑深

Hoare分区是另一个思路:左右两个指针从两端向中间靠拢,左指针找到“大于等于基准”的停住,右指针找到“小于等于基准”的停住,两者交换,直到指针相遇。它比Lomuto更“对称”,平均交换次数也少一些:

int partitionHoare(int[] arr, int low, int high) { int pivot = arr[low + (high - low) / 2]; // 基准取中间位置 int i = low - 1; int j = high + 1; while (true) { do { i++; } while (arr[i] < pivot); do { j--; } while (arr[j] > pivot); if (i >= j) return j; swap(arr, i, j); } }

这里有个非常关键的细节:Hoare分区返回的j,并不保证是基准元素的最终位置。它返回的是“左半区间的右端点”。进入左右递归时,区间必须写[low, j]和[j + 1, high],而不是像Lomuto那样[low, pivotIdx-1]和[pivotIdx+1, high]。很多人第一次写Hoare版快排,就是在这里把区间写错,导致死循环或元素丢失。

而且,Hoare的基准值取自中间位置元素,这只是一种常见选法,并不是必须如此。它的真正好处是:当元素与基准相等时,左右指针都会停下来并交换,因此对于“大量重复元素”的输入,Hoare不会像Lomuto那样立刻退化,表现要平稳得多。

2.3 选型结论:教材选Lomuto,工程选Hoare或三路

如果主要目的是理解算法、写出能跑的代码,Lomuto没有任何问题。它简单,正确性容易验证,代码审计也方便。但如果是生产环境的数据量,我会优先考虑Hoare或者后面要说的三路分区。原因很简单:少做一次交换,在大数组上就是实实在在的耗时差异;即使某段代码扫描逻辑多几个判断,整体性能依然是Hoare占优。

我见过一个真实项目,排序一段十几万大小的业务数组,从Lomuto换到Hoare,总耗时下降了大约三成。这还是在单次调用场景下;如果排序在循环里频繁执行,差距会被放大到不可忽视的程度。

3. 最容易写错的不是分区,是递归出口与区间划分

快速排序的递归非常简单,正因为简单,所以很多人忽视了它的边界设计,结果一出问题就抓瞎。表面上看起来是“分区返回的结果不对”,实际上往往是递归区间写错了。

3.1 递归调用的区间,必须和分区返回值严格对应

先看最标准的Lomuto版快速排序框架:

void quickSortLomuto(int[] arr, int low, int high) { if (low >= high) return; // 没有元素或只有一个元素 int pivotIndex = partitionLomuto(arr, low, high); quickSortLomuto(arr, low, pivotIndex - 1); // 基准左侧区间 quickSortLomuto(arr, pivotIndex + 1, high); // 基准右侧区间 }

这个框架有一个核心约定:partitionLomuto返回的是基准元素的最终下标,因此基准已经在该位置,左右递归都必须排除这个下标。low >= high作为出口,同时处理了“空区间”和“单元素区间”两种情况。

看起来无懈可击对吧?但如果有人把递归改成quickSortLomuto(arr, low, pivotIndex),就完蛋了——基准左侧区间包含了基准本身,下一次递归会把同一个元素反复作为基准,导致无限递归,最终栈溢出。这种错误非常隐蔽,因为小数组上可能碰巧不会崩,等数据量到几万的时候,程序会直接报栈溢出错误。

3.2 Lomuto 与 Hoare 在递归区间上的差异导致“死循环体验”

Lomuto版递归区间明确:基准在中间,排除后左右递归。到了Hoare版,情况就不同了:

void quickSortHoare(int[] arr, int low, int high) { if (low >= high) return; int split = partitionHoare(arr, low, high); quickSortHoare(arr, low, split); // 注意:包括 split quickSortHoare(arr, split + 1, high); }

这里的split对应的是“右半区间的起点”,它可能落在基准的左边,也可能就是基准本身,但整个左半区间是[low, split],右半区间是[split + 1, high]。如果照搬Lomuto的写法写成[low, split-1]和[split+1, high],会漏掉元素或者导致某些元素永远不被排序,表现出来就是排序结果中总有那么一两个数位置不对。

我在代码评审里见过一个更险的写法:Hoare分区返回i而不是j。左递归用[low, i],右递归用[i+1, high]。在大多数随机数据下这两种返回看起来都对,但一旦出现连续重复元素,i和j会落在不同位置,排序结果就可能出错。所以,Hoare版的返回值含义必须和分区实现保持一致,文档里写清楚返回的是分界点的“左边界”还是“右边界”,少一个词,后续维护的人就会多踩一个坑。

3.3 真实案例:数组明明不大,递归深度却爆了

另一个容易被忽视的边界问题是递归深度本身。理想情况下,每次分区都接近对半,递归深度是log2 n。比如一百万数据,递归深度大概20层,毫无压力。但如果每次分区都极度不均衡,递归深度会退化到n。

当n是十万时,20层变成十万层,每次调用还占用栈空间,最终把栈打爆。这个场景不需要特别构造:对一个已经排好序的数组,如果用固定取左端元素当pivot的Lomuto分区,每次分区都只能砍掉一个元素,递归深度直接等于n。很多同学是在数组排序测试时突然遇到StackOverflow,第一反应是“递归边界写错了”,其实边界没错,是数据分布让递归深度失控了。

这种情况的修复方式,通常不是改递归边界,而是要解决“为什么数据总是给最坏情况”。这就进入下一步——数据分布才是快排真正的命门。

4. 数据分布击穿:顺序、逆序、重复元素成倍放大快排的死穴

上一节最后提到的现象,本质上是快排在特定数据分布下会性能崩溃。这几个“特定分布”并不是多罕见,日常生活里随便一抓一大把。

4.1 单边pivot遇上整体有序数组:退化成冒泡

如果每次都取区间左端元素当pivot,遇到一个已经从小到大排好的数组,会发生什么?只要自己手推一次就知道:第一次分区,pivot是最小元素,左边没有元素,右边是剩下的n-1个元素;第二次递归,pivot又是剩下的最小值,结果又只砍掉一个。整个排序变成一层层剥洋葱,每次都处理n-1、n-2、n-3……个元素,总复杂度退化成O(n²)。

这还只是平均最坏场景。逆序数组也是一样,只是分区方向反过来,效率同样崩盘。所以,看似“从小到大”和“从大到小”两种完全相反的输入,在固定取左端pivot的快排面前是同一种灾难。

真实生产环境里,一个数组的初始状态经常是“几乎有序”。比如数据库导出的记录按主键排列,日志数据按时间递增,配置列表按字母排序后缓存在内存里。你很难保证输入永远随机,所以“固定一端取pivot”的写法,等于把系统性能押在数据分布的运气上。

4.2 Lomuto 版在全部相同元素上的退化

另一个不容易被察觉的输入是“大量重复元素”。比如一个数组里几万个元素全都等于同一个值。Lomuto分区取末尾元素为pivot,扫描过程中arr[j] < pivot永远为false,i始终停留在low-1。分区结束时,基准被换到low位置,左侧为空,右侧是全部剩余元素。也就是说,一次分区只处理了一个元素,效率同样退化成O(n²)。

也许有人觉得“业务数据很少全部相同”。但要考虑另一种情况:数组里大部分元素相同,只夹杂少量不同值,比如一个“状态字段”排序,大部分值是0,少数是1和2。Lomuto分区虽然不会完全退化成O(n²),但每次分区都会把大量相等的元素扫来扫去,交换次数和比较次数都远高于随机数据的表现。这类“大量重复”输入,在业务数据中反而非常常见。

我在开头提到的那个案例,就是同样的病根。数据基本有序,还带着大量重复值,朴素的固定边界pivot加Lomuto分区,把排序变成了双重灾难。换成分区策略更优的实现之后,同样的数据规模,耗时回归到正常区间,整个过程和单次调用时看不出任何区别。

4.3 检测到数据分布问题的几个信号

如果代码里已经出了类似的诡异性能问题,可以从几个信号快速怀疑到快排上:

  • 相同数据规模下,排序耗时忽高忽低,尤其输入接近有序或重复时明显劣化;
  • 数据量大到一定阈值后直接栈溢出;
  • 排序结果偶尔出错,而且只在特定输入分布下复现。

前两个信号指向pivot选取和递归深度问题,第三个更多是分区返回值与递归区间不匹配。只要有一次崩溃经历,就会深刻理解为什么标准库的排序实现要花那么多心思去处理“特殊输入”。

5. 工程化改造,拯救生产环境的快排

如果要让快排在真实数据上表现稳定,就不能用算法书上的“裸版”。工程实现基本围绕三个方向做增强:pivot选取更聪明、重复数据处理更彻底、小数组切换更高效的插入排序。

5.1 三取中 pivot 消除最坏输入

最常用的pivot改进是“三取中”:从区间的左端、中间、右端各取一个元素,选出中间值作为pivot。这样做的好处是,即使数组整体有序,取到的pivot也不会是最小或最大值,而是接近中间的值,分区会接近均衡。

int medianOfThree(int[] arr, int low, int high) { int mid = low + (high - low) / 2; int a = arr[low], b = arr[mid], c = arr[high]; if (a > b) { int t = a; a = b; b = t; } if (b > c) { int t = b; b = c; c = t; } if (a > b) { int t = a; a = b; b = t; } return b; // 返回三个值中的中间值 }

注意,这里选出来的是一段“值”,不是下标。使用时可以先把基准值换到区间末尾,后面分区逻辑就不用改太多。三取中并不能完全消灭最坏情况,但它能让那些“恰好构造出来”的恶意输入变得不那么容易触发。

5.2 三路分区:重复元素不再是负担,反而变成优势

针对大量重复元素,最有效的增强是“三路分区”,也叫Dijkstra三路划分。它的核心是维护三个区间:小于基准、等于基准、大于基准。扫描一趟,所有等于基准的元素一次性归位,后续递归完全不用再碰它们。

void quickSortThreeWay(int[] arr, int low, int high) { if (low >= high) return; int pivot = arr[low + (high - low) / 2]; int lt = low; int i = low; int gt = high; while (i <= gt) { if (arr[i] < pivot) { swap(arr, i, lt); i++; lt++; } else if (arr[i] > pivot) { swap(arr, i, gt); gt--; } else { i++; } } quickSortThreeWay(arr, low, lt - 1); // 小于基准的左区间 quickSortThreeWay(arr, gt + 1, high); // 大于基准的右区间 }

这段代码的运行过程,可以想象成一根扫描指针i穿梭在区间中,左边界lt向右推,右边界gt向左推,等于pivot的元素被留在中间。全部元素相同时,一趟分区就把整个数组处理完,不会产生任何左右递归,复杂度直接是O(n)。这正是处理重复数据想要的效果。

三路分区最著名的应用,就是不少现代语言标准库的排序实现中针对大量重复整数的处理策略。它不是某种“高级技巧”,而是实际会用到的主流方案。

5.3 小数组切换到插入排序能获得明显提升

最后一个增强是“阈值切换”。当递归区间小到一定程度,就放弃继续分区,改用插入排序。原因在于,快排在小区间上的递归调用开销、函数栈帧、重复比较,成本已经高于插入排序本身的常数了。插入排序在数据量小的时候不仅快,而且实现简单。

通常经验阈值在10到20之间。我习惯用16:

void quickSortHybrid(int[] arr, int low, int high) { if (high - low + 1 <= 16) { insertionSort(arr, low, high); return; } // 选pivot、分区、递归... }

插入排序对“几乎有序”的片段尤其友好,而经过多轮分区后,数组局部往往已经非常接近有序。这个组合就像先快排负责“大框架”,插入排序负责“收尾”,比单纯递归所有分区要快不少。实测下来,在中等规模数组上通常能带来10%到20%的提升,区间越小收益越明显。

6. 实测数据与选型建议:快排并不是所有排序问题的最优解

讲了这么多原理,最后还是用数据说话。我在本地一台普通开发机上,对一百万整数的数组做过粗略对比,这里给出量级供参考。测试场景分别是随机数据、有序数据和大量重复数据,排序实现分别是Lomuto裸版、Hoare裸版、三路分区快排、以及语言内置排序。

测试场景Lomuto裸版Hoare裸版三路分区快排内置排序
一百万随机数大约180ms大约110ms大约95ms大约45ms
一百万有序数直接栈溢出大约600ms大约20ms大约2ms
一百万重复数超过3s大约300ms大约15ms大约2ms

这个结果背后的信息量很大。第一,内置排序之所以遥遥领先,是因为它综合了多种策略:适当选择pivot、阈值切换、处理重复元素、甚至自动在快排和归并之间切换以保留稳定性和最坏情况保证。第二,就算是最普通的Hoare裸版,在面对有序和重复数据时依然比Lomuto扛得多;三路分区又在这两者之上进一步拉开差距。

我负责任地提醒一句:不同机器、不同JVM版本、不同内置排序实现,具体数值会差不少,但“Lomuto裸版最脆弱、三路分区最稳、内置排序全面胜出”这个排序是稳定成立的。

顺带讨论选型。不是所有场景都应该亲手写快排。如果你的需求只是对数组排个序,首选永远是语言内置排序,它经过充分测试,安全性和性能都靠得住。手写快排的场景通常是:算法学习与面试、需要在嵌入式或受限环境里自行控制排序行为、或者追求比通用实现更极致性能的特化场景。

还要想清楚两个关键问题。第一,稳定性重不重要。快排是不稳定排序,如果业务要求“同值元素保持原有顺序”,那就不能用快排,稳定排序请走归并方向。第二,数据规模和数据分布是否可控。如果你确认输入永远是随机分布,裸版快排也没问题;只要输入可能有序、逆序或大量重复,就必须上增强版。

判断“该不该用快排”的三个标准,我总结是:是否需要原地排序、能否接受不稳定、以及能否处理最坏输入。三条都满足,快排就是你手里最顺手的刀;有一条不满足,就得换个工具。

个人实际体验是,大部分踩坑问题不是“快排这个算法不行”,而是“实现版本和输入不匹配”。写算法题时随手写的Lomuto,和跑生产数据时扛住极端分布的快排,看起来同名,骨子里完全不是一回事。把这些边界想明白,再回头去看那些奇怪的排序耗时、栈溢出和偶发性排序错误,你就有清晰的排查方向了。最后可以分享一个小习惯:我写完排序代码之后,一定会拿三组输入做回归测试——完全不排序的数据、已经排好序的数据、全部相同的数据。三组都通过,才敢放心把它交出去。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询