先说明白一个事实:梳状排序不是多神秘的算法,它就是对冒泡排序做了非常小的一次改造,而这次改造的关键,就是“间隔”和“1.3”这个数字。冒泡排序最大的问题不是比较次数多,而是小元素向前移动的速度太慢,像乌龟爬。梳状排序通过一个从大到小逐步收缩的间隔,让元素一开始就能跨大步移动,最后再用间隔 1 做一次接近冒泡排序的收尾。整个过程下来,速度往往比普通冒泡排序快很多,而且代码量几乎没有增加。
如果你正在学排序,或者以后要给学生、同事讲算法优化,梳状排序是一个很值得拆开的例子。它不需要引入复杂的递归、分治和额外数组,只需要理解两个点:间隔 gap 怎么变,以及为什么收缩因子选 1.3。下面按实际理解和测试的顺序展开。
1. 冒泡排序的硬伤:“乌龟”只能在数组里一步步爬
很多人第一次学排序,学的就是冒泡排序。写法简单,逻辑直观,但越往后用越觉得慢。要理解梳状排序为什么有效,得先弄清楚冒泡排序到底慢在哪里。
1.1 每次只能比较相邻元素,数据移动靠“交换”
冒泡排序的核心是:从头到尾依次比较相邻元素,如果顺序不对就交换。每一轮结束,至少有一个元素会移动到最终位置。比如从小到大排序时,每一轮会把剩余元素中的最大值推到数组末尾,看起来像大泡泡浮到水面。
这个做法最大的特点是:比较范围永远是相邻的,数据每次最多只能移动一个位置。大元素向后移动还算快,因为可以从中间一路换到后面去。但小元素向前移动就非常痛苦,每轮最多前进一个下标。如果数组里有一个 0 被放在最后面,而数组长度是 10000,那这个 0 要经过差不多 10000 轮才能到最前面。
1.2 小元素向前移动慢,这就是“乌龟问题”
算法领域把这种小元素向头部缓慢移动的现象叫乌龟问题。兔子问题说的是大元素向尾部移动很快,乌龟问题说的是小元素向头部移动很慢。
举个例子,数组是 [8, 7, 6, 5, 1]。从小到大排序时,1 在最后面。普通冒泡排序第一轮会把 8 推到末尾,1 只能前进一位变成 [7, 6, 5, 1, 8]。第二轮 1 再前进一位,变成 [6, 5, 1, 7, 8]。要等 1 到达数组头部,至少需要 4 轮完整遍历。数组越长,这种单向移动的代价越高。
很多人以为冒泡排序慢是因为比较次数太多,其实真正让它在逆序数据上表现糟糕的,是这种“一次只移动一步”的乌龟式推进。比较次数是固定量级,交换和遍历轮数才是真正拖垮性能的根源。
1.3 普通冒泡排序的性能上限
普通冒泡排序的时间复杂度是 O(n²)。最好情况是数组已经有序,加一个“本轮是否交换”的标志位之后,可以提前结束,达到 O(n)。最坏和平均情况都是 O(n²),尤其是对逆序数组,每一轮都要完整跑完,交换次数非常多。
我经常看到有人面试的时候背“冒泡排序平均 O(n²)”,但真正到优化场景,很少有人会去想怎么把冒泡排序改得稍微快一点。大多数人的选择是直接跳到快速排序、归并排序。这当然没错,但梳状排序给了另一个思路:在冒泡排序的框架内,只改一个参数,就能明显缓解乌龟问题。
2. 梳状排序的核心:用 1.3 控制间隔收缩
梳状排序也叫 Comb Sort,它是由 Stephen Lacey 和 Richard Box 在 1991 年提出的。名字里的“梳子”是指它的比较方式像梳子齿一样,先疏后密,把数组从头到尾“梳”几遍。
2.1 先拉开差距:gap 从大到小递减
梳状排序和冒泡排序最大的区别,是不再只比较相邻元素,而是先比较距离较远的两个元素,这个距离叫 gap。
初始 gap 一般取数组长度 n。第一轮比较 arr[0] 和 arr[gap],arr[1] 和 arr[gap + 1],依此类推。如果顺序不对就交换。这一轮下来,小元素有机会一次跨越很远的距离,而不是每次只动一步。
每一轮结束后,gap 会除以一个收缩因子,一般取 1.3。gap 不断变小,比较范围不断收紧,直到 gap = 1。当 gap = 1 时,算法和冒泡排序完全一样,但因为前面的大间隔操作已经让数组基本接近有序,所以最后的冒泡阶段用不了几轮就能完成。
这就是梳状排序的整个思想:先用大间隔让数据快速“趋近有序”,再用小间隔精修。
2.2 为什么偏偏是 1.3
很多人会问,为什么收缩因子是 1.3,不是 2,不是 1.5,也不是 1.2?
这是一个实验倾向很强的结论。提出者在测试中发现,收缩因子取 1.3 左右时,排序表现最好。如果间隔收缩太快,比如直接用 2,那么大间隔阶段太少,乌龟问题没有被充分解决;如果收缩太慢,比如 1.1,虽然理论上更精细,但需要很多轮才能把 gap 从 n 缩到 1,总的比较轮数明显增加,性能反而下降。
1.3 是在“减少乌龟影响”和“控制总轮数”之间找到的一个平衡点。它不是数学上推出来的绝对最优值,而是大量实验情况下表现更好的经验值。所以梳状排序也被一些人称为“冒泡排序 + 1.3”。这个说法虽然简化,但确实点出了核心。
在实际实现里,我一般直接用gap = int(gap / 1.3)。如果你愿意,也可以把 1.3 换成 1.25、1.33 试试,但默认用 1.3 就够了。除非你在做专门的算法对比实验,否则没有必要在这上面过度调参。
2.3 和希尔排序的思路比较
看到这里,熟悉排序的同学可能会想到希尔排序。希尔排序也是先让元素跨大步比较和移动,然后逐步缩小间隔。两者思路确实有相似之处。
区别在于希尔排序是从插入排序改造来的,使用的是插入排序逻辑;梳状排序是从冒泡排序改造来的,使用的是相邻交换逻辑。梳状排序实现起来比希尔排序更直观,控制点也更少,主要就是 gap 的收缩方式。
希尔排序的间隔序列可以设计出多种复杂方案,性能上限更高。梳状排序的性能比不上快速排序、归并排序这些高级算法,但它的价值在于:当你不想引入复杂数据结构,又需要比冒泡排序快很多时,它是一个改动最小的优化方案。
3. C、C++、Java、Python 四个版本怎么落地
梳状排序代码很简短。下面给出四种常见语言的实现,并说明每一处容易写错的地方。
3.1 C 语言版本
C 语言版本直接操作数组,适合用来理解最原始的流程。
#include <stdio.h> void combSort(int arr[], int n) { int gap = n; int swapped = 1; const double shrink = 1.3; while (gap > 1 || swapped) { if (gap > 1) { gap = (int)(gap / shrink); } swapped = 0; for (int i = 0; i + gap < n; i++) { if (arr[i] > arr[i + gap]) { int temp = arr[i]; arr[i] = arr[i + gap]; arr[i + gap] = temp; swapped = 1; } } } }注意两点。第一,gap 不能直接减 1,必须除以收缩因子。第二,当 gap 已经是 1 时,不能再除以 1.3,否则 gap 变成 0,后面的比较会出问题。
循环条件写作gap > 1 || swapped,意思是:只要 gap 还大于 1,就要继续缩小间隔;如果 gap 等于 1,但上一轮仍然发生了交换,说明数组还没有完全有序,还需要继续冒泡一轮。这个条件能保证最后完整有序。
3.2 C++ 模板版本
C++ 可以写成模板,适用不同类型的数据。
#include <vector> #include <algorithm> template <typename T> void combSort(std::vector<T>& arr) { int n = arr.size(); int gap = n; bool swapped = true; const double shrink = 1.3; while (gap > 1 || swapped) { if (gap > 1) { gap = static_cast<int>(gap / shrink); } swapped = false; for (int i = 0; i + gap < n; i++) { if (arr[i] > arr[i + gap]) { std::swap(arr[i], arr[i + gap]); swapped = true; } } } }C++ 版本最需要注意的是std::swap的使用,以及static_cast<int>做类型转换。如果你用的是旧标准,也可以写成(int)(gap / shrink)。
3.3 Java 版本
Java 版本结构完全一致,只是引用类型数组和基本类型数组的写法略有不同。
public static void combSort(int[] arr) { int n = arr.length; int gap = n; boolean swapped = true; final double shrink = 1.3; while (gap > 1 || swapped) { if (gap > 1) { gap = (int) (gap / shrink); } swapped = false; for (int i = 0; i + gap < n; i++) { if (arr[i] > arr[i + gap]) { int temp = arr[i]; arr[i] = arr[i + gap]; arr[i + gap] = temp; swapped = true; } } } }Java 里没有 C++ 的std::swap,所以交换变量时老老实实用临时变量,或者封装成一个私有方法。
3.4 Python 版本
Python 写起来最短,但有一个隐藏的坑:Python 的列表切片赋值如果没写好,会创建额外列表,增加内存开销。这里给出最直接的索引交换版本。
def comb_sort(arr): n = len(arr) gap = n shrink = 1.3 swapped = True while gap > 1 or swapped: if gap > 1: gap = int(gap / shrink) swapped = False for i in range(n - gap): if arr[i] > arr[i + gap]: arr[i], arr[i + gap] = arr[i + gap], arr[i] swapped = True return arr注意range(n - gap)。因为你要访问arr[i + gap],所以i的最大值只能是n - gap - 1。如果写成range(n),一定会越界。
3.5 各语言实现的小差异
对比下来,四种语言核心逻辑完全一样,差别只在语法层:
- C 语言需要手动管理临时变量。
- C++ 可以用模板和
std::swap。 - Java 要注意数组长度和类型转换。
- Python 最简洁,但要注意
range边界和除法取整。
我个人建议先用 Python 跑通逻辑,再用 C/C++ 做性能测试。Python 版本适合学习流程,C/C++ 版本适合观察真实性能差异。
4. 复杂度、稳定性与判断标准
算法不能只看能跑,还要知道它在什么情况下表现如何。下面把复杂度、稳定性和适用场景拆开讲。
4.1 时间复杂度结论
梳状排序的时间复杂度分析比普通冒泡排序麻烦一点,因为 gap 的变化过程会直接影响比较次数。
结论是这样的:
- 最好情况:数组已经有序,gap 收缩到 1 之后,第一轮发现没有交换,提前结束。这里需要 O(n) 级别的比较。
- 平均情况:在常见随机数据下,比普通冒泡排序快很多,但仍达不到快速排序那种 O(n log n) 的稳定效率。
- 最坏情况:仍然可能达到 O(n²)。虽然 1.3 这个收缩因子在大量情况下表现很好,但它不能保证对所有数据分布都避开了最坏情况。
所以在算法课的复杂度表格里,梳状排序通常被列为“平均接近 O(n²/2^p)”,p 表示间隔收缩的轮数相关参数。这个表达式比较抽象,实际理解就一句话:它比冒泡排序的常数小很多,但量级上仍属于平方级算法。
4.2 空间复杂度
梳状排序是原地排序,除了几个临时变量之外,不需要额外数组。
空间复杂度是 O(1)。这一点比归并排序好很多,因为归并排序在合并时需要额外的 O(n) 空间。当然,快速排序虽然原地排序,但递归调用有栈空间开销。梳状排序没有递归,也没有额外存储,这是它作为冒泡排序改进版的一个天然优势。
4.3 稳定性测试方法
梳状排序是不稳定排序。原因在于,当 gap 大于 1 时,两个相等的元素可能因为间隔交换而改变相对顺序。
举例来说,数组是 [2a, 1, 2b],其中 2a 和 2b 是相等的两个元素。初始 gap 较大时,可能发生跨距离交换,把 2a 换到 2b 的后面,而排序完成后它们相对顺序发生变化。这对普通数字排序没有影响,但如果元素是带有其他字段的对象,稳定性就可能很重要。
要测试是否稳定,可以给每个元素加一个序号,排序后检查相同主键元素的序号是否仍然递增。我一般会用一组包含重复值的数据,比如[3(1号), 1, 3(2号), 2],排序后看两个 3 的相对位置有没有改变。如果第二个 3 跑到了第一个 3 前面,说明不稳定。
4.4 什么场景适合用梳状排序
梳状排序不会替代快速排序或归并排序,但它在某些场景下值得考虑:
- 你已经写好了冒泡排序,不想大幅改动逻辑,只想提速。
- 数据量不是特别大,几万到几十万级别,且希望代码简单。
- 内存受限,不能开额外数组。
- 教学场景,用来解释“间隔交换”如何影响排序效率。
- 面试中从冒泡排序延伸优化,展示你能不只背 API。
如果你的数据量达到百万级以上,或者要求稳定的排序结果,更推荐归并排序、Timsort 这类算法。梳状排序更适合作为冒泡排序的“低成本优化版”。
5. 实测与调参:gap 初始值、收缩时机和边界
下面进入真正容易踩坑的部分。我建议动手测试时,不要一上来就测大数据,而是先用小数组把流程走通,再逐步加大规模观察性能。
5.1 初始 gap 用 n 还是 n / 1.3
标准实现里,gap 初始值取数组长度 n。第一轮比较 arr[0] 和 arr[n - 1] 这种跨最大距离的元素对,效果最强。
也有实现把初始 gap 直接取为n / 1.3。这样第一轮的间隔不是最大,但能节省一轮几乎不做交换的“空转”。两种写法都能排序,差异不大。
我一般喜欢用 gap = n,因为逻辑更直观。如果追求一点点效率,可以用gap = n / 1.3,但要注意第一轮要比较的范围就变成i + gap < n,不能越界。
5.2 收缩精度和整数取整
这是比较容易写错的地方。
假设 n = 10,gap 从 10 开始,每轮除以 1.3:
- 10 / 1.3 ≈ 7.69,取整 7
- 7 / 1.3 ≈ 5.38,取整 5
- 5 / 1.3 ≈ 3.84,取整 3
- 3 / 1.3 ≈ 2.30,取整 2
- 2 / 1.3 ≈ 1.53,取整 1
- 到 1 之后不再收缩
注意,不同语言对浮点数取整的规则可能不同。C++ 的static_cast<int>会截断小数部分,Python 的int()也是截断,而不是四舍五入。这没问题,因为 1.3 本来就是经验值,截断造成的差异对最终排序结果影响很小。
真正要注意的是:gap 必须最终等于 1。如果取整方式出现异常,gap 可能从 2 直接跳到 0,那就是死循环或者越界。我在调试时见过这个坑。安全写法是保证 gap 最小为 1:
if (gap < 1) { gap = 1; }5.3 用三种典型数据做测试
建议至少测三类数据:
- 随机数组:一般能明显看出梳状排序比冒泡排序快。
- 逆序数组:这是冒泡排序最痛苦的场景,梳状排序提升最明显。
- 重复元素较多的数组:用来观察稳定性,也用来检查算法是否会在重复数据前卡住。
我通常会写一个小的计数函数,比较不同算法的交换次数和遍历轮数。仅仅比较耗时不客观,因为机器负载会影响结果。交换次数和比较次数更能反映算法行为差异。
在逆序数组上,普通冒泡排序需要很多轮才能全部排好,而梳状排序在最开始的大间隔阶段就把大量元素送到了大致正确的位置,所以后续压力小很多。
5.4 排序结果正确性验证
不要只看最终排没排好,还要加几个辅助判断:
- 数组长度是否仍等于原长度。
- 是否有元素丢失或重复。
- 是否严格满足从小到大或从大到小顺序。
- 排序前后元素集合是否完全一致。
我一般写一个校验函数,对排序后的数组逐个比较arr[i] <= arr[i+1],再复制一份原数组,排序前统计元素出现次数,排序后重新统计,确保没有元素被覆盖。
6. 避坑指南和排查链路
最后整理几个实际调试中容易遇到的问题。很多人写梳状排序,第一次跑不通,问题往往不是算法思想不对,而是实现细节出错。
6.1 死循环从哪里来
如果程序一直跑不完,优先看 gap 的收缩规则。
常见错误一:gap 每次除以 1.3,但 gap 变成 0 之后还在循环。解决办法是在 while 循环里先判断if (gap > 1) gap = (int)(gap / 1.3);,保证 gap 最小为 1。
常见错误二:循环条件是while (gap > 1 || swapped),但漏掉了swapped,导致 gap 变成 1 后循环直接退出,排序还没完成。因为 gap = 1 时如果仍然有元素需要交换,必须继续冒泡直到没有交换。
常见错误三:gap = 1 之后还在循环里继续除以 1.3,结果 gap 变成 0,i + gap永远是 i,比较没有意义,甚至越界。
排查顺序:先打印每一轮的 gap 值,确认 gap 是从大到小、最终停在 1,且没有变成 0。再打印每轮是否发生交换,确认最后一次循环没有交换。
6.2 结果不正确,先看哪里
如果排序结果不对,比如数组没有完全有序,优先检查内层循环的边界。
内层循环应该满足i + gap < n。如果你写成i < n,在 i 接近末尾时访问arr[i + gap],可能会越界,或者读到未初始化数据。在 C/C++ 中越界可能造成隐蔽错误,在 Java/Python 中会直接抛异常。
另一个检查点是交换条件。从小到大排序时,条件是arr[i] > arr[i + gap]。如果你不小心写成了arr[i] < arr[i + gap],那不是排序,而是把数组部分反序,结果乱七八糟。
结果不正确时的排查顺序:
- 先打印原始数组和排序后数组,肉眼对比。
- 再用一个简单的有序性校验函数检查。
- 再检查 gap 每一轮的值是否合理。
- 最后检查内层循环边界和交换方向。
6.3 性能没有明显提升,怎么排查
有人测试后会觉得“也没比冒泡排序快多少”。这种情况常见原因有三个。
第一,数据量太小。比如只有几十个元素,任何排序算法的耗时差异都很难体现,还可能被函数调用开销掩盖。梳状排序的优势要在几百上千个元素以上才比较明显。
第二,数组已经接近有序。如果数据本来就有序,或者只有少量逆序对,冒泡排序加标志位后也能很快退出,梳状排序不会有太大优势。
第三,gap 收缩策略退化。比如没有等 gap 到 1 就退出,或者收缩太快导致几乎没有大间隔阶段。此时算法退化成普通冒泡排序。
排查方法很简单:分别统计冒泡排序和梳状排序在同一个随机逆序数组上的比较次数或交换次数。如果两者接近,大概率是 gap 收缩策略没有生效;如果梳状排序的交换次数明显更少,说明大间隔阶段确实起了作用。
6.4 什么时候不要用梳状排序
最后说点边界建议。
- 如果你需要稳定排序,梳状排序不满足要求。
- 如果你要处理 100 万以上的大数据量,还是用快速排序、归并排序或者标准库的排序函数更可靠。
- 如果你已经用了标准库
sort、Collections.sort,没有必要自己手写梳状排序替换,除非是学习或特殊限制场景。 - 如果你追求最坏情况可控,梳状排序的 O(n²) 最坏复杂度需要谨慎。
梳状排序真正适合的定位,是理解“冒泡排序为什么慢”和“一个参数怎么带来明显改善”。它的代码量小,学习成本低,实验很容易复现。这比死记硬背冒泡排序的优化技巧要有用得多。
我个人建议,学习时可以按这个顺序测试:先跑普通冒泡排序,统计交换次数;再把 1.3 引入,改成梳状排序,重新统计。你会看到,同一个逆序数组,交换次数和遍历轮数都会明显下降。这个实验比单纯看算法复杂度公式更直观,也更容易记住 1.3 这个数字为什么值得被单独拿出来说。