
先说明白一个事实梳状排序不是多神秘的算法它就是对冒泡排序做了非常小的一次改造而这次改造的关键就是“间隔”和“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::vectorT 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_castint(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_castint做类型转换。如果你用的是旧标准也可以写成(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 10gap 从 10 开始每轮除以 1.310 / 1.3 ≈ 7.69取整 77 / 1.3 ≈ 5.38取整 55 / 1.3 ≈ 3.84取整 33 / 1.3 ≈ 2.30取整 22 / 1.3 ≈ 1.53取整 1到 1 之后不再收缩注意不同语言对浮点数取整的规则可能不同。C 的static_castint会截断小数部分Python 的int()也是截断而不是四舍五入。这没问题因为 1.3 本来就是经验值截断造成的差异对最终排序结果影响很小。真正要注意的是gap 必须最终等于 1。如果取整方式出现异常gap 可能从 2 直接跳到 0那就是死循环或者越界。我在调试时见过这个坑。安全写法是保证 gap 最小为 1if (gap 1) { gap 1; }5.3 用三种典型数据做测试建议至少测三类数据随机数组一般能明显看出梳状排序比冒泡排序快。逆序数组这是冒泡排序最痛苦的场景梳状排序提升最明显。重复元素较多的数组用来观察稳定性也用来检查算法是否会在重复数据前卡住。我通常会写一个小的计数函数比较不同算法的交换次数和遍历轮数。仅仅比较耗时不客观因为机器负载会影响结果。交换次数和比较次数更能反映算法行为差异。在逆序数组上普通冒泡排序需要很多轮才能全部排好而梳状排序在最开始的大间隔阶段就把大量元素送到了大致正确的位置所以后续压力小很多。5.4 排序结果正确性验证不要只看最终排没排好还要加几个辅助判断数组长度是否仍等于原长度。是否有元素丢失或重复。是否严格满足从小到大或从大到小顺序。排序前后元素集合是否完全一致。我一般写一个校验函数对排序后的数组逐个比较arr[i] arr[i1]再复制一份原数组排序前统计元素出现次数排序后重新统计确保没有元素被覆盖。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 变成 0i 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 这个数字为什么值得被单独拿出来说。