如果你和我一样,曾经为了搞懂排序算法,在纸上把快排、归并、堆排的递归树画过无数遍,八成会对一件事产生执念:分治排序,好像只有二分这一条路。二分确实漂亮,log₂ n 的递归深度,每一层合并代价 O(n),整个算法 O(n log n)。但我一直有个疑问:分治的关键,真的在于“切成两半”吗?如果我把问题切成三段、四段,甚至完全按照值域去切,而不是机械地按数组位置切,合并的时候是不是能省掉很多逐项比较?
带着这个疑问,我折腾了一个原创排序算法,取名叫 SplitMergeSort。它不是传统意义上的二分分治,也不是简单地把归并排序改成三分归并。核心思路是:把待排序数组按两个分界值切成三块,让三块的值域天然有序,递归排序后直接拼接,从而把传统归并排序中最耗时的“逐项比较合并”压缩成近乎零成本的操作。这篇文章我会讲清楚它的设计动机、完整原理、工程实现、复杂度分析,以及我实测和踩坑的过程。适合对排序算法原理感兴趣、想跳出教科书二分框架的读者,也适合当成一个“分治排序还能怎么玩”的思路参考。
1. 从二分的惯性里跳出来:SplitMergeSort 的设计动机
1.1 传统分治排序的二分惯性
我们在教科书里学的分治排序,几乎全在二分框架里打转。
归并排序把数组从中间劈开,递归处理左右半边,最后两路归并。快速排序也是选一个枢轴,把数组分成小于枢轴和大于枢轴两部分,再分别递归。主定理告诉我们,T(n) = 2T(n/2) + O(n) 的解是 O(n log n),而且是渐进意义下的最优。所以大家都默认,二分是最合理、最漂亮的分治方式。
但这个“最优”是有前提的:它假设每个子问题的规模差不多,且合并或分区代价是线性的。它没有考虑数据的实际分布。换句话说,二分只是把“位置”切开了,对“值”本身视而不见。
我并不是说二分不对。我只是好奇,当数据本身具有明显分布特征时,能不能通过改变分治的切法,让某些步骤的成本直接降为零。
1.2 二分不一定是最优的分治策略
二分在逻辑上简洁,但在某些场景下很浪费。
先看归并排序。即使左半段的最大值已经小于右半段的最小值——也就是说两个子段整体上已经有序了——标准两路归并依然要逐项比较左右两边的元素,把每个元素都搬到辅助数组里。这种“明知有序还要比较一遍”的浪费,普遍存在。
再看快速排序。如果数组里存在大量重复元素,普通快排的递归树会非常深,哪怕你用了三向切分,也需要多写一堆边界判断。重复元素在二分视角下是“障碍”,但在值域视角下却是“可以整体跳过的块”。
这让我意识到,分治不一定非要“按数量对半切”,完全可以“按值域切成多块”。只要每个块之间的值域不重叠,那么块与块之间的全局顺序就已经确定了,剩下的工作只是把块内部排好序,然后把块按顺序拼起来。这其实是一种把分治和桶排序思想混合的路径。
1.3 我想达到的三个目标
在动手写代码前,我给自己定了三个目标:
- 目标一:分叉数不是 2。 我想做一个三路分治,甚至以后可以推广到 k 路,验证“非二分”的分治在逻辑上是否成立。
- 目标二:合并阶段尽量摆脱逐项比较。 如果块与块之间值域天然有序,那合并就不该再调用 while 循环去两个数组里挑最小元素,而应该直接拼接。
- 目标三:保持稳定性。 教科书里的快排不稳定,而归并排序稳定。我希望新的算法也能稳定,这样它在某些场景下才有实际替换价值。
这三个目标最终收敛成了 SplitMergeSort 的雏形:一次递归,把数组切成三块,块间零比较合并。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心思路:如何用三路切分实现“零比较合并”
2.1 把值域切三刀,而不是把索引切一刀
SplitMergeSort 的 Split 阶段不是取 mid = (left + right) // 2,而是选两个分界值 p1 和 p2,把数组扫描一遍,按照元素值的大小分成三个子段:
- L 段:所有小于
p1的元素; - M 段:所有大于等于
p1且小于等于p2的元素; - R 段:所有大于
p2的元素。
注意,这里的“切”并不是在数组中间切一刀,而是用两个阈值把整个值域切成三个区间。每个元素根据自己的值进入对应区间,所以最终三段之间的数值范围是严格分离的:
code复制max(L) < p1 ≤ min(M)
max(M) ≤ p2 < min(R)
用大白话说:L 段里的所有元素都比 M 段小,M 段里的所有元素都比 R 段小。这段关系在分区完成的那一刻就已经成立,不需要等到递归排序结束再比较。
2.2 为什么三路切分之后合并阶段不需要逐项比较
传统归并排序的合并阶段之所以要逐项比较,是因为左半段和右半段只是“位置”上分开,值域完全可能交叉。比如左半段最后一个元素是 99,右半段第一个元素是 1,不比较根本不知道谁先谁后。
但 SplitMergeSort 的三个子段在值域上已经被隔离了。假设我们递归把 L、M、R 三块内部都排好序,那么最终结果可以直接写成:
code复制sorted(A) = sorted(L) + sorted(M) + sorted(R)
这里 + 就是数组拼接,也就是“零比较合并”。不需要三路归并,不需要从三段头部依次找最小值。因为 L 段整体小于 M 段,M 段整体小于 R 段,顺序是天然确定的。
这也是它叫 SplitMergeSort 的原因:Split 阶段负责把值域切开,Merge 阶段只做拼接式的合并,而不是传统归并里的两两比较合并。
2.3 完整示例:一次递归的演进过程
用一个简单例子走一遍流程。假设待排序数组是:
code复制[7, 1, 5, 3, 9, 4, 8, 2, 6]
第一步,选择两个分界值。假设选出的 p1 = 3,p2 = 7。
第二步,扫描数组,按规则分成三段:
x < 3:1, 2 → L 段3 ≤ x ≤ 7:7, 5, 3, 4, 6 → M 段x > 7:9, 8 → R 段
注意扫描时保持每个元素在段内的原始相对顺序,所以分区结果是:
code复制L = [1, 2]
M = [7, 5, 3, 4, 6]
R = [9, 8]
第三步,递归排序三段。L 和 R 长度很小,排序后是 [1, 2] 和 [8, 9]。M 递归后变成 [3, 4, 5, 6, 7]。
第四步,
