☰
快速排序优化:Hoare分区、三数取中与小区间插入排序
2026/10/27 19:40:14 网站建设 项目流程

上周帮同事看一个跑批任务,逻辑不复杂,就是把明细按金额排好序再做分组统计,三十多万行数据,按说不该慢,结果跑了两分多钟。代码里排序那一行是手写的快速排序,基准直接取区间第一个元素,而数据恰好是从库里按主键顺序捞出来的,天然已经有序——等于把快排最怕的场景精准命中。这件事之后我把手头几个项目里的排序实现全部翻了一遍,统一换成了基于Hoare 分区的版本,再加上三数取中选基准和小区间优化两处改动,最坏情况的耗时从"不敢估"变成了可控。

这篇接着上一篇搭好的 Hoare 分区骨架往下走,专门解决两件事:基准怎么选才不会被打回 O(n²),递归什么时候停才不会把时间浪费在小数组上。两块优化加起来代码改动不到三十行,但效果比很多人想象的要明显。适合已经能手写基本快排、但写出来经常在某些数据上表现诡异的朋友,也适合准备面试想搞清楚"为什么这么改"的读者。下面所有代码都是 C 风格实现,换成任何语言逻辑完全一致。

1. 为什么朴素 Hoare 分区一到真实数据就掉链子

1.1 基准选在端点,等于把命运交给输入顺序

Hoare 分区的经典写法是拿区间首元素当基准,然后左右两个指针从两端往中间扫,遇到不合适的就交换。这个写法本身没错,代码短、常数小、原地操作,在随机数据上跑得飞快。问题在于它把分区结果的均匀程度完全押在了输入顺序上——基准恰好是区间中位数附近的值,两半大致对分,递归深度是 log n;基准恰好是区间最小值或最大值,一边是空、一边是 n-1,递归深度退化成 n。

退化成 n 的后果不只是"慢一点"。递归深度 n 意味着 n 层函数调用,每层压栈几十个字节,一百万数据能直接吃掉几十兆栈空间,很多平台的默认栈上限压根扛不住,程序不是算得慢,是直接崩溃。我遇到过的现象很有迷惑性:小数据测试全过,数据量一上去就段错误,还以为是数组越界,查了半天才发现是递归太深。

更麻烦的是"接近有序"这种状态。完全随机的数据不会触发退化,完全倒序也不会(Hoare 分区把首元素当基准,倒序时基准是最大值,确实会退化),真正阴险的是"基本有序、偶尔几个乱序"。线上导出的数据、按时间追加写入的日志、从数据库主键顺序读出来的结果集,全属于这一类。你本地拿随机数测一百遍都是好的,一上真实数据就翻车。

1.2 递归尾巴上的开销:小数组上快排打不过插入排序

第二个问题藏在递归的末端。快排一直分到区间长度是 1 或 2 才停,看起来天经地义,实际算笔账就很亏:一个长度 8 的区间,走快排大概要递归三次、做两次分区,每次分区都有函数调用、指针初始化、循环判断这些固定开销;而同样长度 8 的区间,插入排序最多做 28 次比较和少量移动,循环体紧、没有函数调用、数据全在 L1 缓存里。

我自己测过,长度在 8 到 16 这个区间,插入排序的耗时只有快排递归的三分之一左右。而快排的递归树里,最底下两三层覆盖了绝大多数的区间数量——一棵完全平衡的递归树,叶子节点占了总节点数的一半,也就是说有一半的分区调用是在处理长度 1 到 8 的区间,纯属浪费。

这个问题和基准选择问题是独立的:你把基准选得再准,末端的小区间该浪费还是浪费。所以两块优化要一起上,缺一个效果都打折。

1.3 优化的优先级:先保底,再提速

两处优化的收益性质完全不同。三数取中买的是保底——它不保证让平均情况更快,甚至因为多了几次比较和交换,在随机数据上会略微变慢,但它把最坏情况从 O(n²) 拉回到接近 O(n log n) 的概率极高。小区间优化买的是提速——它在所有数据形态上都能拿到收益,随机的、有序的、重复的都一样。

所以顺序很清楚:先解决保底,再谈提速。只做小区间优化不做基准优化,在有序数据上照样崩;只做基准优化不做小区间优化,性能能看但不够看。两个都上,才算一个能拿出去用的实现。

2. 三数取中:用三次比较买一份保险

2.1 原理:拿样本估计中位数,把最坏情况挡在门外

三数取中的思路朴素得不像个"算法":既然怕基准选到极值,那就别只看一个元素,从区间里挑三个位置——左端、中点、右端——取它们的中位数当基准。有序数据下,左端是最小值、右端是最大值、中点是中间值,三者中位数就是中点值,正好把区间对半分。逆序数据同理。

这个做法把"基准是极值"的概率从必然降到了极低。严格来说,如果输入是被精心构造的"反三数取中"序列,仍然可以逼出 O(n²),这就是所谓的中位数选择攻击。但实际工程里没人会这么针对你,而且真要防,得上随机化或者内省排序,那是另一个话题。

我更看重的是它的确定性。加了三数取中之后,同一份数据跑一百次结果和耗时完全一致,排查性能问题的时候不用再怀疑"这次怎么又慢了"。随机化基准虽然理论上更安全,但引入了不确定性,调试成本高,我个人不太喜欢在业务代码里用。

2.2 三种落地写法的取舍

把中位数找出来之后,它放在哪个位置,决定了后面分区函数怎么改。我踩过的写法有三种:

写法 A:中值交换到 lo,原封不动用原始 Hoare 扫描。先用三次比较把a[lo]、a[mid]、a[hi]排成升序,此时a[mid]就是中值,把它和a[lo]交换,基准就落在区间头部,接着用i = lo - 1, j = hi + 1的经典 do-while 扫描。改动最小,原来的分区骨架一行不用动。

写法 B:中值交换到 hi,用 Sedgewick 式扫描,最后把基准归位。基准放在尾部,i从左往右扫、j从右往左扫,循环结束后把a[i]和a[hi]交换,返回i。好处是基准最终停在它该在的位置上,便于后续做三路分区;坏处是多了一次交换,而且归位之后的切分方式对重复元素不友好。

写法 C:什么都不动,只把中值算出来存成变量。基准直接取a[mid]的值,数组保持原样。这个写法理论上安全——Hoare 分区只要求基准值是数组里的某个元素值,不要求它在原位——但调试时非常难受,因为断点打下去看不到基准在哪,边界条件也很难推。我试过一次,排查一个偶发的越界花了一个多小时,后来老老实实换回写法 A。

我的建议是:如果你已经有了一段能跑通的 Hoare 分区,用写法 A,改动最小、风险最低;如果后续要接三路分区,用写法 B,结构更顺。

2.3 在 Hoare 分区里,中值到底该放到哪个位置

这里有个很容易被忽略的细节:写法 A 要求基准放在 lo,而且必须配合i = lo - 1的初始化。原因在于 Hoare 分区的核心约定——左指针i从左往右扫,停在第一个"不小于基准"的位置;右指针j从右往左扫,停在第一个"不大于基准"的位置。i从lo - 1起步,第一次++i就落在lo上,而a[lo]就是基准值本身,它不满足a[i] < pivot,所以第一步必然停下。这个"必然停下"就是防越界的第一道保险。

如果你把基准放到别的位置,却仍然用i = lo - 1起步,那么i第一步可能越过lo继续往右跑——只要a[lo]小于基准,它就不会停,一路扫到区间外。这是我见过最多的越界来源,而且症状是"偶尔崩一次",特别难查。

还有一个配套细节:i和j的暂停条件必须用严格不等号。while (a[i] < pivot)和while (a[j] > pivot),不能写成<=和>=。写成非严格的话,遇到和基准相等的元素直接跨过去,指针很容易冲过对方甚至冲出边界;而用严格不等号,遇到相等就停下来交换,虽然看起来多做了几次交换,但交换之后两个相等元素各归一边,切分反而更均匀。这一条是 Hoare 分区和 Lomuto 分区最容易混淆的地方。

2.4 代价核算:随机数据上会慢多少

说句实话,三数取中在纯随机数据上是负收益。多两次比较、最多两次交换,基准的均匀程度提升微乎其微(随机数据下首元素本来就是随机分布的),实测会慢 3% 到 5%。

但这个代价换来的是有序数据上从"几十秒"到"几十毫秒"的转变,这笔账怎么算都值。我做过一组对照,100 万个 32 位整数,g++ -O2,某台开发机:

数据形态朴素 Hoare加三数取中
完全随机76 ms79 ms
完全升序超过 60 s,未跑完77 ms
完全降序超过 60 s,未跑完76 ms
全相同元素80 ms82 ms
近乎有序(1% 随机扰动)约 1.9 s78 ms

绝对耗时随机器和编译器浮动很大,读倍数关系就行。可以看到,除了随机数据上那 4% 左右的损失,其他形态全是数量级的改善。

顺带说一个反直觉的现象:全相同元素这个场景,朴素 Hoare 反而没问题。因为i和j都严格不等号判断,遇到相等就交换,两个指针每轮各走一步,最后在正中间相遇,切分是对半的。这个性质比 Lomuto 分区好得多,Lomuto 遇到全相同元素会退化成 O(n²)。但注意,这不是"Hoare 对重复元素免疫"——如果数据里 90% 是同一个值、10% 是另一个值,切分仍然会倾斜,那种情况得靠三路分区,后面再聊。

3. 小区间优化:让递归在最底层收手

3.1 阈值不是玄学,但也不是常数

小区间优化的做法是:递归下去之后,如果当前区间长度小于某个阈值,就不再继续分区,直接调用插入排序排完这段,然后返回。这个阈值取多少,是唯一需要调参数的地方。

我给一组实测数据,还是 100 万个随机整数,其余条件不变,只改阈值:

阈值总耗时说明
466 ms递归层数仍然偏多
863 ms已接近最优
1262 ms稳定区间
1662 ms稳定区间
2463 ms小段插入排序开始变长
3265 ms插入排序的平方项抬头
4868 ms明显变差

曲线很平,8 到 16 之间几乎没有区别。这就是为什么网上关于阈值的说法从 8 到 32 都有——它就真的是个不敏感参数。取 16 是个比较稳的选择:在随机数据、近乎有序数据、带重复数据上都落在最优点附近,不用来回折腾。

但要说一句,阈值的最优值和几个因素相关:内存速度越快(数据更容易进 L1)、插入排序实现得越紧凑,阈值就该越大;编译器对递归函数内联做得越激进,快排的固定开销越小,阈值该越小。所以别迷信某个具体数字,自己跑一遍,从 8 试到 32,取最快的那个。

3.2 为什么偏偏是插入排序

候选有好几个,冒泡、选择、插入,为什么选插入?

插入排序的核心优势是对近乎有序的序列接近线性。而快排递归到最底层时,小区间里的元素已经经过了上面几层分区的粗排,虽然不保证有序,但整体上是"局部集中、轻微乱序"的状态,插入排序处理这种数据特别顺手。冒泡排序不管数据什么状态都要做满 O(k²) 次比较,选择排序同理,而且两者的交换次数远高于插入排序。

另一个原因是常数小、无额外空间。插入排序的内层循环就是一次比较加一次赋值,编译出来接近十几条指令,全部在寄存器里跑。相比之下归并排序在小数组上也很优秀(很多标准库对小区间用插入排序,对整体用归并),但它需要额外缓冲区,用在快排的递归内部会引入内存分配或者共享缓冲的麻烦。

还有一点常被忽略:插入排序是稳定的。快排整体不稳定,但如果你的业务在后面对"相等元素保持原始顺序"有要求,小区间用插入排序至少不会在小范围内打乱。当然这只在小范围内管用,全局别指望,真要稳定排序还是老老实实用归并。

3.3 两种实现路径:递归内截断 vs 整体补一刀

实现上有两种思路,效果和副作用不一样,我两种都用过。

路径一:递归内截断。每个递归调用进来先判断,区间长度小于阈值就直接插入排序然后return,不再往下分区。好处是逻辑内聚,一个函数管到底,不需要额外的扫尾阶段。坏处是递归树里多了判断分支,而且每个小段都单独调一次插入排序函数(虽然能内联掉,但函数入口的固定开销还是有的)。

路径二:快排只负责粗排,最后整体补一次插入排序。快排函数里,区间长度小于阈值就直接return,什么都不做;等整个快排返回之后,数组处于"每段长度小于阈值、段内微微乱"的状态,再对整个数组跑一遍插入排序。这个做法听起来很美——插入排序对近乎有序的数组是 O(n) 级别的——但严格算下来并不是白拿的:整体插入排序外层循环仍然是 n 次,虽然内层移动距离短,可那 n 次外层迭代加上每次的判断,代价并不比路径一低。

我实测的结论是:路径一在大多数据形态上略快,路径二在数据量特别大(比如千万级)时偶尔反超,但差距都在 5% 以内。考虑到路径一的代码更好写、更好调试、不会出现"忘记补那一刀导致数据没排全"的低级错误,我推荐路径一。

注意:如果走路径二,快排函数的出口必须保证所有长度小于阈值的区间都原样返回,最后统一补排序。一旦代码里有分支提前return却跳过了补排序,结果就是"大部分有序、少数几个数字错位",这种 bug 用眼睛看很难发现,必须靠完整的测试用例兜住。

3.4 阈值和切分质量的联动

阈值和基准选择之间有个不太被提起的联动效应:阈值越大,三数取中被调用的次数越少。

递归到区间长度小于阈值时就直接插入排序了,根本不会走三数取中那几次比较和交换。在阈值等于 16 的情况下,递归树底部的区间不再需要算中位数,整体调用次数比阈值为 1 时少了一大截。而三数取中每次调用至少带来三次比较和最多三次交换,累计起来不是小数。所以在阈值优化单独看是 12% 左右的收益,和三数取中叠加之后,总收益往往比两者简单相加还要高一点——因为小区间优化顺便把三数取中的开销也砍掉了。

这提醒我一件事:优化项之间不是独立的,A 的收益会改变 B 的代价。只做单项基准测试然后简单相加,很容易高估或低估组合效果。真要评估,必须以组合后的完整实现为准。

4. 把两块优化拼进完整实现

4.1 代码骨架

先给完整的骨架,几个关键函数分开写,方便单独测试。

#include <cstdio> #include <cstdlib> static const int INSERTION_THRESHOLD = 16; static inline void swapInt(int &a, int &b) { int t = a; a = b; b = t; } // 把 lo / mid / hi 三个位置排成升序,中值交换到 lo,返回基准值 static inline int medianOfThreeToFront(int *a, int lo, int hi) { int mid = lo + ((hi - lo) >> 1); if (a[mid] < a[lo]) swapInt(a[mid], a[lo]); if (a[hi] < a[lo]) swapInt(a[hi], a[lo]); if (a[hi] < a[mid]) swapInt(a[hi], a[mid]); swapInt(a[lo], a[mid]); // 此时 a[mid] 是中值,挪到 lo return a[lo]; } // 小区间插入排序,区间闭合 [lo, hi] static inline void insertionSort(int *a, int lo, int hi) { for (int i = lo + 1; i <= hi; ++i) { int key = a[i]; int j = i - 1; while (j >= lo && a[j] > key) { a[j + 1] = a[j]; --j; } a[j + 1] = key; } } // Hoare 分区,返回右半段的起点减一 static int hoarePartition(int *a, int lo, int hi) { int pivot = medianOfThreeToFront(a, lo, hi); int i = lo - 1; int j = hi + 1; for (;;) { do { ++i; } while (a[i] < pivot); do { --j; } while (a[j] > pivot); if (i >= j) return j; swapInt(a[i], a[j]); } } void quickSort(int *a, int lo, int hi) { while (lo < hi) { if (hi - lo < INSERTION_THRESHOLD) { insertionSort(a, lo, hi); return; } int p = hoarePartition(a, lo, hi); // 小区间先递归,大区间继续循环,控制栈深度 if (p - lo < hi - p) { quickSort(a, lo, p); lo = p + 1; } else { quickSort(a, p + 1, hi); hi = p; } } }

这段代码有两个额外的小设计,值得单独说。第一处是quickSort用while循环代替了对两个子区间的双递归,每次只对较短的一边递归、较长的一边用循环迭代,栈深度的上界从 n 降到 log n。这个改动的收益在随机数据上不明显,但在有大量重复值或者刻意构造的数据上,是防栈溢出的最后一道保险。第二处是hi - lo < INSERTION_THRESHOLD用的是严格小于,意味着长度恰好等于 16 的区间还是要分区的,这个边界对性能影响不大,但最好在注释里写清楚,免得后面的人改代码时来回纠结。

4.2 分区函数逐行过一遍

medianOfThreeToFront里那三次比较是标准的"三数排序"网络,三次比较加最多三次交换能把三个位置排成升序。排完之后a[lo]最小、a[mid]中值、a[hi]最大,此时把a[mid]和a[lo]一换,中值就到了头部。这里有个容易想岔的地方:交换之后a[mid]变成了最小值,原本的"三点已排序"状态被破坏了。这没关系,因为后面分区完全不依赖那三个位置的有序性,我们只需要基准值正确、并且它位于lo。

hoarePartition里i = lo - 1, j = hi + 1这个初始化看着别扭,其实是为了配合do-while的先自增语义。如果改成while循环,初始化就应该是i = lo, j = hi,然后先判断再自增,代码会多几行、而且容易写错顺序。do-while是 Hoare 原版的做法,虽然现代编码规范里有"少用 do-while"的说法,但这个场景下它确实更清晰。

if (i >= j) return j;这一句是整个过程的核心。返回的是j而不是i,因为j是"右半段的最后一个元素"或者"左半段的一个安全分界点"。配合外层quickSort的[lo, p]和[p + 1, hi]切法,两边都不为空、都不等于原区间,递归必然收敛。这里如果用 Lomuto 的切法[lo, p-1]和[p, hi],配 Hoare 的返回值,会出现p等于hi的情况,直接无限递归。

4.3 测试用例与实测数据

排序算法写完必须过的一批用例,我整理成了清单,每次改动之后全跑一遍:

用例目的期望
空数组、单元素边界不崩溃、原样返回
两个元素升序 / 降序最小分区单元正确排序、无死循环
全相同元素(100 万)重复值压力正确、耗时在 n log n 量级
完全升序(100 万)最坏情况正确、耗时不退化
完全降序(100 万)最坏情况正确、耗时不退化
只有两个值交替切分倾斜正确,观察耗时是否异常
已排序 + 末尾一个乱序近乎有序正确、不退化
随机 100 万平均情况正确、对比基准耗时
随机 + 大量重复(10% 唯一值)重复值倾斜正确,记录耗时
含负数、含 INT_MIN比较边界正确

那个"含 INT_MIN"的用例别嫌多余。我见过一次实现,为了省一次比较,把基准值写成"比任何元素都小的哨兵",结果测试数据里真有INT_MIN,判断直接失效,分区切出空区间,死循环。这种问题只会在大批量真实数据里偶发,测试用例里主动塞进去最省事。

4.4 参数微调记录

最后把两处优化叠起来,做一组综合对比,数据规模 100 万,同一台机器:

实现版本随机升序降序全相同近乎有序
朴素 Hoare76 ms未跑完未跑完80 ms约 1.9 s
仅三数取中79 ms77 ms76 ms82 ms78 ms
仅小区间(阈值 16)63 ms未跑完未跑完66 ms约 1.6 s
两者叠加62 ms62 ms62 ms65 ms63 ms

这张表最值得看的是最后一行:所有数据形态的耗时都在 62 到 65 毫秒之间。这个"平"本身就是优化成功的标志——排序算法在真实场景里最怕的不是平均慢,是某些数据上突然爆炸,导致监控告警、超时重试、雪崩。把方差压下去,比把均值压下去重要得多。

5. 踩过的坑和排查手册

5.1 死循环:区间没收敛的三种典型写法

第一个坑是把 Hoare 的返回值和 Lomuto 的切法混用。Hoare 分区返回j,切法是[lo, j]和[j+1, hi];Lomuto 分区返回p(基准归位后的下标),切法是[lo, p-1]和[p, hi]。两个混着写,只要j恰好等于hi,右边区间就是[hi+1, hi]空区间、左边是[lo, hi]原区间,递归直接原地打转。判断方法很简单:跑一个两元素数组,如果卡住不动,八成是这个问题。

第二个坑是把if (i >= j)写成if (i > j)。两个相邻元素的情况下,i和j会交错到i = j + 1,也可能恰好i == j。写成大于号,相等的情况就不返回,继续进循环,指针继续互穿,最后越界或者死循环。写成>=是唯一安全的选择。

第三个坑是基准比较用了非严格不等号。while (a[i] <= pivot) ++i;这种写法,遇到和基准相等的元素不停,一路扫到底,直接冲出右边界。改成严格不等号,同时在交换之后保证两个指针各自前进,才能保证收敛。

5.2 三数取中写错,分区结果静默出错

三数取中最阴险的 bug 不是崩溃,是结果看起来对、但切分不均匀。我遇到过一次,mid算成了(lo + hi) / 2,数据量小的时候完全正常,数据量到几百万、lo + hi超过int上限时溢出成负数,mid变成一个非法下标,读到了数组外的垃圾值。这个垃圾值被当成基准,分区照样能跑完,只是切得稀烂,耗时从 60 毫秒涨到十几秒——不崩溃,只是慢,最难查。

改成lo + ((hi - lo) >> 1)就没事了。这个写法还有个额外好处:(hi - lo) >> 1在编译器优化下比除法少几条指令,虽然微不足道,但省一行是一行。

另一个静默错误是三次比较的顺序写反。标准的比较网络必须按(mid, lo)、(hi, lo)、(hi, mid)这个顺序来,顺序错了不能保证三个位置排好序,中值可能落在错误的位置上,基准取到的可能是最小值或者最大值。这种 bug 在有序数据上会立刻暴露成性能爆炸,在随机数据上则完全看不出来,属于"测试环境永远正常、线上偶发变慢"的经典形态。

5.3 小区间优化把数据截断了

前面提过,路径二(整体补一刀)最容易出这个问题。还有一种变体:在递归函数里判断区间长度小于阈值就return,但忘了在那里调用插入排序,写成路径一的骨架却漏了排序动作。结果就是数组被快排粗排成了若干长度小于 16 的小段,段间有序、段内乱序,整体看起来"大致有序",跑一个小数据集测试甚至能过(因为数据集本身就小于阈值,压根没进递归)。

防这个的方法很直接:测试用例里必须有一个长度明显大于阈值的数据集(比如 10 万),并且用严格的全量比对验证(和标准库std::sort的结果逐元素对比),不能只看"看起来有序"。

5.4 问题速查表

把上面这些整理成一张表,出问题的时候按症状对号入座:

症状最可能原因排查动作
两元素数组卡死i >= j写成了i > j,或切法与返回值不匹配打印i、j、p的变化序列
大数据量崩溃、小数据正常递归过深导致栈溢出改成单边递归 + 循环,观察栈深度
结果大体有序、局部乱小区间优化漏了插入排序检查所有return分支
有序数据耗时爆炸、随机正常三数取中没生效或写错顺序在medianOfThreeToFront打断点看返回值
百万级以上性能骤降mid计算溢出检查是否用了(lo + hi) / 2
偶发越界崩溃基准没放在lo却用了i = lo - 1检查基准位置和指针初始化是否匹配
全相同元素耗时正常但两值交替异常切分倾斜,需要三路分区统计分区后两半的长度比
输出结果和标准库不一致边界区间闭合性搞错(<和<=混用)用长度 2 到 20 的全排列穷举验证

最后一行那个穷举验证特别值得做。长度 2 到 10 的全排列一共三百多万种,跑一遍也就几秒钟,能把所有边界条件全覆盖到。我现在的习惯是:任何排序实现落地前,先跑一遍长度 1 到 10 的全排列比对,跑过了再去测性能。这个习惯帮我省掉了至少三次线上事故。

6. 后续还能从哪几个方向继续压

6.1 尾递归消除其实已经做了

前面代码里quickSort用while循环处理较长的一边、只对较短的一边递归,这个改动已经等价于尾递归消除,栈深度上界从最坏 n 降到 log n。很多人以为这一步需要手动展开成显式栈,其实不用——选中较短的子区间递归,天然就保证了每层递归规模至少减半,深度必然是对数级。这个技巧配合小区间优化,栈空间占用能压到几十 KB 级别,在嵌入式或者递归受限的环境里非常关键。

6.2 重复元素多了,该上三路分区

Hoare 分区对"全相同元素"表现很好,但对"少数几个值反复出现"就不行了。比如数据只有 0、1、2 三个值,每种各占三分之一,基准取到 1 的时候,左边的 0 和右边的 2 分得很好,但等于 1 的那三分之一会被随机分到两边,下一轮递归还要再处理一遍。数据里唯一值越少,这个浪费越严重。

三路分区的做法是把区间切成"小于基准 / 等于基准 / 大于基准"三段,中间那段直接跳过递归。实现上需要在一次扫描里维护三个指针,代码量比两路分区多一倍,但遇到重复值多的场景收益巨大。我的经验是:如果业务数据里唯一值数量远小于总数(比如订单状态、省市编码这种枚举字段),直接上三路;如果是 ID、金额这种几乎唯一的字段,两路加三数取中就够。

6.3 内省排序是个稳妥的兜底

三数取中把最坏情况挡住了绝大多数,但理论上仍然存在被构造数据打崩的可能。内省排序的思路是给递归深度设一个上限(通常是 2 倍 log n),超过上限就切换成堆排序——堆排序的最坏情况是 O(n log n),虽然常数大、平时慢,但不会爆炸。这样整个算法的上界被硬性锁死在 O(n log n),无论输入长什么样。

代价是实现复杂度上一个台阶,而且需要额外维护递归深度计数。对于业务代码里调用频次极高的排序,我会考虑加上;对于一次性的数据清洗、离线分析,三数取中加小区间优化已经完全够用了。

我个人在实际项目里的取舍很简单:排序逻辑写在业务代码里、又跑在不能出事的路径上,就用标准库的排序(人家内部早就把这些全做了);只有在需要定制比较逻辑、定制内存布局、或者要嵌进一个不能拉依赖的环境里,才手写这一套。手写的时候,三数取中加小区间优化这两块是底线配置,再往下省就是在给自己埋雷。

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

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

立即咨询