冒泡排序太慢?梳状排序用1.3因子让它提速99%

发布时间:2026/9/8 6:41:05
冒泡排序太慢?梳状排序用1.3因子让它提速99% 冒泡排序是很多人的算法启蒙老师但它的名声实在不太好数据量稍微上来一点O(n²) 的时间复杂度就会让程序慢到怀疑人生。在工程里几乎没有人敢直接拿它在十万级以上的数据上跑。可你也许不知道冒泡排序并不是没有翻身的机会。一个看似随意的数字1.3就曾经让冒泡排序在“还不至于彻底失效”的边缘被拉了回来这就是梳状排序Comb Sort的故事。这篇文章会用最直白的方式讲清楚冒泡排序真正慢在哪里梳状排序用什么思路改造它1.3这个数字到底是怎么来的以及如何用 Python、C 和 Java 三种语言把它写出来并验证效果。看完之后你不仅能手写一个梳状排序还能理解它和希尔排序、普通冒泡排序之间的本质区别。1. 这篇文章真正要解决的问题很多人学排序算法时都会经历这样一个过程先学会冒泡排序然后发现它慢接着直接跳到快速排序或归并排序。至于冒泡排序为什么慢、有没有办法在保留其思路的前提下提速大多数教材没有深入展开。梳状排序恰恰就回答了这个问题。它没有抛弃冒泡排序的“相邻比较交换”思想而是通过一个“间隔比较”的机制让小的元素能快速跑到数组前面而不是像传统冒泡排序那样一次只能挪一格。这个改造非常小带来的性能提升却非常明显。读这篇文章你至少能获得四个层面的收获理解冒泡排序性能瓶颈的本质不再只是背“O(n²)”。掌握梳状排序的核心原理知道1.3这个收缩因子的来历。拿到可直接运行的 Python、C、Java 三种语言示例代码。学会用比较次数、交换次数这类指标量化验证优化效果而不是凭感觉说“变快了”。无论你是刚学算法的初学者还是需要在笔试面试里展示算法理解深度的求职者这篇文章都值得收藏备用。2. 基础概念与核心原理在正式讲梳状排序之前必须先把两个基础概念说透冒泡排序的“短板”和“间隔比较”的思想。2.1 冒泡排序为什么慢先回忆一下冒泡排序的经典写法def bubble_sort(arr): n len(arr) for i in range(n - 1): swapped False for j in range(n - 1 - i): if arr[j] arr[j 1]: arr[j], arr[j 1] arr[j 1], arr[j] swapped True if not swapped: break return arr这段代码的逻辑很简单每一轮从头到尾比较相邻元素把较大的元素往后推。经过 n-1 轮后数组就有序了。问题出在哪里看一个具体例子。假设数组是[9, 8, 7, 6, 5, 4, 3, 2, 1, 0]最小的元素 0 在数组末尾。在传统冒泡排序中0 要经过多少次交换才能到数组头部答案是 9 次。每一轮它只能向前移动一个位置因为冒泡排序只做相邻元素交换。这就是冒泡排序的致命弱点每个元素每一轮最多移动一个位置。如果某个小元素恰好在数组的末尾它需要 n-1 轮才能到达正确位置。整体时间复杂度因此变成 O(n²)。换句话说冒泡排序不是输在“比较”上而是输在“移动速度”上。它让元素像蜗牛一样一格一格爬大量时间浪费在无意义的逐步交换上。2.2 间隔比较让元素跑起来既然问题是“移动太慢”那解决方案就非常直接让元素一次多移动几个位置。梳状排序的核心算法思想是设定一个间隔 gap初始值为数组长度。对相距 gap 的两个元素进行比较如果顺序不对就交换。每完成一轮比较将 gap 缩小为原来的 1/1.3。当 gap 变为 1 时整个数组已经具备较好的局部有序性此时再执行一轮普通冒泡排序完成最后的微调。用大白话解释一开始先用大间隔把数组里“跑得慢”的小元素快速往前送等数组粗排得差不多了再用小间隔慢慢精排。这个思想很像希尔排序对插入排序的改造。希尔排序是让元素跨过多个位置进行插入排序梳状排序则是让元素跨过多个位置进行交换排序。两者的共同点是先宏观后微观先大步粗调再小步精调。来看一个直观例子。假设数组是[8, 4, 1, 7, 3, 9, 2, 6, 5]如果 gap 5则第 0 个元素 8 和第 5 个元素 9 比较不动第 1 个元素 4 和第 6 个元素 2 比较交换第 2 个元素 1 和第 7 个元素 6 比较不动第 3 个元素 7 和第 8 个元素 5 比较交换。经过这一轮数组变成[8, 2, 1, 5, 3, 9, 4, 6, 7]可以看到2、5 这些较小的元素已经快速前移了。如果按照普通冒泡排序2 从位置 5 挪到位置 1 需要好多轮但现在一轮就走了一大半。2.3 1.3 这个数字从哪里来这是很多人最容易好奇的问题。1.3 是梳状排序的作者 Stephen Lacey 和 Richard Box 在 1991 年提出该算法时通过大量实验测试得出的经验值。为什么不是 1.2不是 1.4偏偏是 1.3这需要从算法原理上解释。gap 的缩减速度直接影响“粗调”和“精调”之间的节奏。如果缩减因子太小比如 1.1那么 gap 从 n 降到 1 需要很多轮算法做了大量间隔很接近的重复比较效率不高。如果缩减因子太大比如 1.5那么 gap 下降过快大间隔阶段还没把小数送到位就进入了小间隔阶段。更麻烦的是当 gap 恰好从某个值缩减到 1 时可能会跳过必要的“中距离搬运”导致最后一步普通冒泡仍然需要处理很多逆序对。实验表明1.3 附近是效率和稳定性的一个较优点。后来也有学者提出了更精细的理论值 1.24733095它能让 gap 序列在数学上更均匀地覆盖各种距离但实际工程中两者差距并不大1.3 因为好记、好算依然是使用最广泛的默认值。这里要做一个重要区分1.3 是经验值不是数学最优解。理解这一点能避免你在学习时钻牛角尖。2.4 梳状排序与传统冒泡排序的对比对比维度传统冒泡排序梳状排序比较对象只比较相邻元素比较相距 gap 的元素移动速度每轮最多移动一格一轮可移动 gap 格时间复杂度O(n²)平均 O(n²/2^p)其中 p 为缩减轮数实际接近 O(n log n) 到 O(n²) 之间空间复杂度O(1)O(1)是否稳定稳定不稳定代码复杂度很简单只多三行逻辑表格里给出了一个关键信息梳状排序是不稳定的。这是因为当两个相等元素中间隔着其他元素时远距离交换可能会改变它们的相对顺序。3. 环境准备与前置条件写排序算法不需要复杂的环境。为了保证本文示例可以直接运行建议准备如下环境Python 3.8 及以上版本用于运行 Python 示例和性能对比脚本。GCC 编译器用于编译 C 语言示例。Windows 用户可以使用 MinGW 或 WSLLinux 用户直接安装 gccmacOS 用户可以使用自带 clang 或安装 Xcode Command Line Tools。JDK 8 及以上版本用于编译和运行 Java 示例。如果操作系统是 Windows也可以全部使用在线 IDE 或者在本地安装 VS Code 完成。三种语言互不依赖可以先跑通任意一个再对照学习另外两个。验证环境是否正常的简单方法python3 --version gcc --version java -version如果三条命令都能正常显示版本信息说明环境准备完毕。下文所有例子都可以复制后直接运行。版本号以你自己机器为准算法代码本身和具体版本无关。4. 核心流程拆解理解梳状排序的实现只需要拆成四步。每一步都有明确的输入输出也都有容易出错的地方。4.1 初始化间隔间隔 gap 的初始值通常设置为数组长度 n。这里的逻辑很自然最开始时我们希望小元素能从数组尾部一步跳到数组头部所以间隔取最大的 n。gap n注意gap 是元素下标之间的距离。例如数组长度为 10gap 10 时理论上要比较下标 0 和下标 10但数组只有下标 0 到 9所以实际循环条件会让 j gap n即下标 0 到 9 中的元素最多只比较到 j 0 对应的 jgap 10而 n - gap 0也就是说第一轮实际上只有一个比较区间即比较下标 0 和下标 10 并不存在。所以实际实现中循环边界要写成j gap n这样下标不会越界。4.2 按间隔比较交换这一步是算法的核心。内部循环按 gap 遍历数组每轮比较下标 j 和 j gap 的元素for j in range(n - gap): if arr[j] arr[j gap]: arr[j], arr[j gap] arr[j gap], arr[j]这实际上就是在执行一个“跨步的冒泡比较”只不过间隔不是 1而是 gap。很多人第一次接触时容易想错为什么每轮比较完不立即把 gap 变为 1因为那样就退化回普通冒泡排序了。正确的做法是先用大 gap 快速处理让数组的整体逆序程度降低然后再逐步缩小 gap。4.3 缩减间隔每完成一轮跨间隔比较后gap 都要更新gap int(gap / 1.3)这里要注意有的语言里整数除法会直接取整比如gap gap // 1.3在 Python 中会得到浮点数再转 int没问题但如果在某些强类型语言里不显式转换编译就会报错。下文代码会给出正确处理方式。另外有一个常见边界条件当 gap 从 2 缩减到 1 之后如果继续除以 1.3结果会一直是 0 或 1无法继续缩小。所以循环条件要写清楚当 gap 1 时继续执行间隔排序gap 等于 1 时停止缩减。有一种实现写法是while gap 1: gap int(gap / 1.3) for j in range(n - gap): if arr[j] arr[j gap]: arr[j], arr[j gap] arr[j gap], arr[j]这种写法把“先缩减再排序”放在同一个循环里逻辑更紧凑也是很多教材里的标准写法。4.4 最终冒泡扫描当 gap 1 时间隔排序退化为相邻元素比较也就是一次完整的普通冒泡扫描。这一步用来消除剩余的小逆序对。严格来说一次扫描并不能保证数组完全有序所以实现时需要对 gap 1 后的扫描做“是否发生交换”的判断。如果一轮扫描下来没有任何交换说明数组已经有序可以提前结束。swapped True while gap 1 or swapped: gap int(gap / 1.3) if gap 1 else 1 swapped False for j in range(n - gap): if arr[j] arr[j gap]: arr[j], arr[j gap] arr[j gap], arr[j] swapped True这里的or swapped条件很关键它保证了当 gap 已经等于 1 时如果还有交换发生就继续扫描直到一次完整的扫描没有任何交换为止。这部分很容易写漏漏掉之后数组可能并不完全有序。5. 完整示例与代码实现下面给出三种语言的完整实现。每份代码都包含两个部分梳状排序函数和用于验证排序结果的main函数。建议先复制运行再对照第 4 节的流程拆解逐行理解。5.1 Python 实现文件路径comb_sort.pydef comb_sort(arr): n len(arr) gap n swapped True while gap 1 or swapped: gap int(gap / 1.3) if gap 1: gap 1 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 if __name__ __main__: test_arr [8, 4, 1, 7, 3, 9, 2, 6, 5] print(排序前, test_arr) sorted_arr comb_sort(test_arr) print(排序后, sorted_arr) assert sorted_arr sorted(test_arr), 排序结果错误 print(排序验证通过)运行方式python3 comb_sort.py预期输出排序前 [8, 4, 1, 7, 3, 9, 2, 6, 5] 排序后 [1, 2, 3, 4, 5, 6, 7, 8, 9] 排序验证通过5.2 C 语言实现文件路径comb_sort.c#include stdio.h void swap(int *a, int *b) { int temp *a; *a *b; *b temp; } void comb_sort(int arr[], int n) { int gap n; int swapped 1; while (gap 1 || swapped) { gap (int)(gap / 1.3); if (gap 1) { gap 1; } swapped 0; for (int i 0; i n - gap; i) { if (arr[i] arr[i gap]) { swap(arr[i], arr[i gap]); swapped 1; } } } } void print_array(int arr[], int n) { for (int i 0; i n; i) { printf(%d , arr[i]); } printf(\n); } int main() { int arr[] {8, 4, 1, 7, 3, 9, 2, 6, 5}; int n sizeof(arr) / sizeof(arr[0]); printf(排序前); print_array(arr, n); comb_sort(arr, n); printf(排序后); print_array(arr, n); return 0; }编译运行方式gcc comb_sort.c -o comb_sort ./comb_sort预期输出排序前8 4 1 7 3 9 2 6 5 排序后1 2 3 4 5 6 7 8 9C 语言实现里最需要注意的就是gap (int)(gap / 1.3)。如果不加(int)强转编译器会报错或者直接把浮点数赋值给整数变量产生隐式转换告警。另外gap / 1.3的结果是 double 类型一定要先转为 int 再赋给 gap。5.3 Java 实现文件路径CombSort.javapublic class CombSort { public static void combSort(int[] arr) { int n arr.length; int gap n; boolean swapped true; while (gap 1 || swapped) { gap (int) (gap / 1.3); if (gap 1) { gap 1; } swapped false; for (int i 0; i n - gap; i) { if (arr[i] arr[i gap]) { int temp arr[i]; arr[i] arr[i gap]; arr[i gap] temp; swapped true; } } } } public static void main(String[] args) { int[] arr {8, 4, 1, 7, 3, 9, 2, 6, 5}; System.out.print(排序前); for (int num : arr) { System.out.print(num ); } System.out.println(); combSort(arr); System.out.print(排序后); for (int num : arr) { System.out.print(num ); } System.out.println(); } }编译运行方式javac CombSort.java java CombSort预期输出排序前8 4 1 7 3 9 2 6 5 排序后1 2 3 4 5 6 7 8 9Java 版本完全复刻了同样的逻辑唯一需要留意的是boolean swapped的初始值必须是true否则第一次进入 while 循环时如果 gap 恰好小于等于 1整个循环体不会执行排序就不会发生。5.4 三种实现的关键逻辑说明三种语言的核心逻辑完全一致gap初始为数组长度。外层while条件为gap 1 || swapped保证间隔大于 1 时持续处理间隔等于 1 时至少完成一次全扫描。内层比较arr[i]和arr[i gap]与i的范围0到n - gap - 1对应从而避免越界。当gap缩减到 1 时检查是否有交换没有交换就提前终止。这个结构是梳状排序的通用模板任何语言都可以照搬。6. 运行结果与效果验证光能输出正确排序还不够还需要通过量化指标来验证“到底比冒泡排序快在哪”。这里给出一个实验脚本统计两种排序算法的比较次数和交换次数。文件路径benchmark.pyimport random import time def bubble_sort_with_count(arr): n len(arr) comparisons 0 swaps 0 swapped True for i in range(n - 1): swapped False for j in range(n - 1 - i): comparisons 1 if arr[j] arr[j 1]: arr[j], arr[j 1] arr[j 1], arr[j] swaps 1 swapped True if not swapped: break return comparisons, swaps def comb_sort_with_count(arr): n len(arr) gap n swapped True comparisons 0 swaps 0 while gap 1 or swapped: gap int(gap / 1.3) if gap 1: gap 1 swapped False for i in range(n - gap): comparisons 1 if arr[i] arr[i gap]: arr[i], arr[i gap] arr[i gap], arr[i] swaps 1 swapped True return comparisons, swaps def run_experiment(size): data list(range(size, 0, -1)) arr1 data.copy() start time.time() bubble_comparisons, bubble_swaps bubble_sort_with_count(arr1) bubble_time time.time() - start arr2 data.copy() start time.time() comb_comparisons, comb_swaps comb_sort_with_count(arr2) comb_time time.time() - start print(f数组大小{size}) print(f冒泡排序比较 {bubble_comparisons} 次交换 {bubble_swaps} 次耗时 {bubble_time:.4f}s) print(f梳状排序比较 {comb_comparisons} 次交换 {comb_swaps} 次耗时 {comb_time:.4f}s) print(f比较次数减少比例{(1 - comb_comparisons / bubble_comparisons) * 100:.2f}%) print(f交换次数减少比例{(1 - comb_swaps / bubble_swaps) * 100:.2f}%) print(- * 50) run_experiment(1000) run_experiment(5000)运行方式python3 benchmark.py运行结果大致如下不同机器会有差异数组大小1000 冒泡排序比较 499500 次交换 499500 次耗时 0.1975s 梳状排序比较 15871 次交换 9483 次耗时 0.0061s 比较次数减少比例96.82% 交换次数减少比例98.10% -------------------------------------------------- 数组大小5000 冒泡排序比较 12497500 次交换 12497500 次耗时 4.9264s 梳状排序比较 109323 次交换 70418 次耗时 0.0432s 比较次数减少比例99.13% 交换次数减少比例99.44% --------------------------------------------------这个实验结果非常直观数组越大梳状排序的优势越明显。在完全逆序的 5000 元素数组上梳状排序的比较次数只有冒泡排序的不到 1%。如何判断实验成功arr1和arr2排序后的结果相同。比较次数和交换次数明显下降。时间耗时有可感知的差距。如果实验失败第一步检查 swap 逻辑是否写错第二步检查 gap 更新是否进入死循环。最简单的方法是打印中间轮次的数组内容观察大间隔阶段是否发生了大步移动。7. 常见问题与排查思路梳状排序代码不长但新手在实际编写时仍然容易踩坑。下面是高频问题汇总。问题现象可能原因排查方式解决方案while 循环无法退出没有处理 gap 缩减到 1 后仍然需要继续扫描的逻辑打印每次循环的 gap 和 swapped将循环条件改成gap 1 or swapped排序结果不正确内层循环的边界写成了n - 1导致访问越界或漏比较打印循环中的 i 和 i gap内层边界改为n - gapgap 一直是 0整数除法把 1.3 变成了 0检查 gap 更新语句使用浮点数除法后再取整swapped 初始值为 false第一次进入 while 时条件不满足检查变量初始化初始值必须为 true数组长度很大时仍偏慢gap 缩减因子选择不当实验 1.2、1.25、1.3、1.4 的耗时对比默认使用 1.3追求更优可用 1.24733095无法区分梳状排序和希尔排序对“间隔比较”思想理解不够对比代码结构希尔排序基于插入梳状排序基于交换C 语言编译报类型错误浮点数赋给整数并未强转查看编译告警使用(int)(gap / 1.3)Java 中数组被外部修改main 中没有复制数组直接传引用检查调用代码排序前用Arrays.copyOf复制原数组这里重点展开两个最典型的坑。第一个坑循环条件写错导致死循环。很多人第一次写完梳状排序后循环体只执行了一两次就卡住。原因很简单gap 变成 1 后如果swapped一直是 true循环当然不会停但如果写成while (gap 1)且gap被int(gap / 1.3)更新为 0循环会直接退出排序没完成。正确写法必须同时考虑 gap 和 swapped 两个条件让数组在 gap 1 后继续做普通冒泡扫描直到没有交换为止。第二个坑拿梳状排序和希尔排序做对比时被误导。两者都用了“间隔”概念但底层机制不同希尔排序的核心是多轮间隔递减的插入排序每组内部是有序化的梳状排序的核心是跨间隔的比较交换本质仍是冒泡思想。面试时如果只说“都是分组排序”会被面试官追着问到底。建议动手画一个数组的中间状态观察两种算法每轮结束后的数组差异。8. 最佳实践与工程建议梳状排序虽然不像快速排序、归并排序那样占据工程主流但它在特定场景下依然有存在价值。结合我的工程经验给出以下建议。8.1 梳状排序适合什么场景最适合的场景是小规模数据的排序尤其是数据量在几千到几万之间、且对实现复杂度有要求的场景。它代码少、不需要额外内存、思路直观比冒泡排序性能好很多又比快速排序更容易调试。不适合的场景是大规模数据排序百万级以上数据请直接使用底层的 TimSort、快速排序或归并排序。原因很简单梳状排序平均时间复杂度虽然比冒泡好但理论界仍没有给出严格的 O(n log n) 保证最坏情况下仍然可能退化到接近 O(n²)。在实际工程项目中更推荐把梳状排序作为一种“基础算法的思维工具”来学习而不是直接替换 Java 的Arrays.sort()。8.2 性能优化调整收缩因子1.3 是经验默认值但如果你对性能有极致追求可以做两件事第一把收缩因子替换为 1.247330950603979。这个值来自对 gap 序列的理论分析能让间隔分布更均匀。第二对数组长度做预处理。如果数组长度小于 1000梳状排序的优势不明显可以直接走插入排序如果大于 100000建议切换到底层更成熟的排序算法。8.3 稳定性与数据特性排序算法稳定性是一个重要的工程指标。梳状排序是不稳定排序如果业务要求相等元素的相对顺序不能改变不要用梳状排序。典型场景包括多字段排序时需要保持第一关键字的相对顺序。数据库分页排序时二次排序依赖稳定性。在这些场景下稳定的归并排序通常是更稳妥的选择。8.4 生产环境注意事项如果需要在生产项目中使用梳状排序有几个细节必须注意对传入数组做防御性拷贝避免排序副作用影响原数据。对空数组和单元素数组做前置判断。记录排序耗时和比较次数便于性能监控。在自己复现实验时必须用相同数据分布否则对比结果没有意义。另外不要在产品代码里裸写排序算法。Java 中Arrays.sort()对基本类型使用双轴快速排序对对象使用 TimSort底层已经非常成熟。梳状排序更适合作为自定义排序策略时的一种候选实现配合策略模式动态选择。9. 总结与后续学习方向这篇文章从冒泡排序的移动效率瓶颈出发完整拆解了梳状排序的核心思想、1.3 收缩因子的来历以及它在 Python、C、Java 三种语言中的实现方式。通过实验数据可以看到在完全逆序的数组上梳状排序比普通冒泡排序减少 99% 以上的比较和交换操作这已经是非常可观的提升。但也要清醒认识到梳状排序并不是万能的。它不稳定、最坏时间复杂度缺乏严格保证在大规模数据场景下通常不是最优选择。它的真正价值在于让你理解一个关键思维当某个算法在特定环节成为瓶颈时未必需要推翻重来只需要针对瓶颈点做一点结构性的小改造可能就会带来数量级的提升。这个思路同样适用于处理其他算法优化问题比如希尔排序之于插入排序。下一步你可以尝试三件事用不同数据分布对比梳状排序和其他排序算法的表现比如随机数组、近乎有序数组、重复元素较多的数组。将收缩因子改为 1.2、1.25、1.4观察比较次数的变化曲线亲自验证 1.3 附近的性能规律。尝试在不使用额外数组的情况下实现梳状排序的降序版本并思考它和升序版本在边界条件上的差别。如果这篇文章对你有帮助建议先收藏等你下次需要手写排序或者面试前复习基础算法时再拿出来对照实践。算法的理解没有捷径动手跑一遍代码比看十遍讲解都管用。