免费获取学习方案
ARTICLE DETAIL

资讯详情

深耕编程基础知识与建站技术分享的一线实战洞察。

冒泡排序太慢?梳状排序用1.3收缩因子巧妙提速

冒泡排序太慢?梳状排序用1.3收缩因子巧妙提速 冒泡排序是很多人学习算法时接触到的第一个排序方法但它也是被“吐槽”最多的排序之一代码简单逻辑直观可一旦数据量超过几千冒泡排序的耗时就会明显拖垮整个程序。梳状排序Comb Sort看起来只是对冒泡排序做了一点修改——比较时不再紧紧挨着相邻元素而是先拉开一个“间距gap”再让这个间距按一个固定比例缩小。推动这个改进的关键数字就是 1.3。本文围绕冒泡排序的缺陷、梳状排序的间隔收缩机制、为什么 1.3 这么重要、如何用 C/Java/Python 实现以及验证和排错方法展开希望能帮你把梳状排序真正理解透彻。1. 冒泡排序的“小乌龟”问题与梳状排序的切入点1.1 冒泡排序为什么慢一轮只能消除一步逆序先看最普通的冒泡排序实现void bubble_sort(int arr[], int n) { for (int i 0; i n - 1; i) { int swapped 0; for (int j 0; j n - 1 - i; j) { if (arr[j] arr[j 1]) { int tmp arr[j]; arr[j] arr[j 1]; arr[j 1] tmp; swapped 1; } } if (!swapped) { break; } } }这个版本加入了swapped标志可以在数组已经有序时提前退出避免无意义的整轮扫描。但它的核心弱点没有变每一轮相邻比较只能把当前未排序部分的一个最大元素送到末尾同时把较小的元素向前移动一位。这里的“向前移动一位”是一个很严重的问题。想象数组[1, 2, 3, 4, 5, 0]数字0位于最后一位。第一轮比较0只能从索引 5 移动到索引 4第二轮移动到索引 3依此类推。要让最小的元素到达数组头部大约需要 n 轮。这种位于数组末尾的小元素在排序理论里常被称为“小乌龟”turtle意思是它移动得非常慢。冒泡排序的复杂度因此固定在 O(n²)。即使加入提前退出的标志也只对“原本就接近有序”的数组有改善对于逆序数组和大量随机数组它仍然要执行约 n(n-1)/2 次比较和交换。1.2 梳状排序怎么改用间隔比较消除“远距离逆序”梳状排序的思想非常直接既然相邻比较让元素移动太慢那就不要只比较相邻元素而是先比较距离较远的元素。算法维护两个关键变量gap当前比较的间隔距离。swapped本轮是否发生过交换。初始时gap n。每一轮开始前将gap除以一个收缩因子典型值是 1.3然后对数组进行一遍“间隔比较”比较arr[i]和arr[i gap]如果前者大于后者就交换。当gap最终变成 1 时算法退化为普通冒泡排序的相邻比较但此时数组已经“大致有序”剩余逆序对数量很少所以最后一遍扫描会非常快。看一个具体例子。数组为[10, 20, 30, 40, 50, 5]长度n 6第一轮gap 6 / 1.3 4比较索引对(0, 4)和(1, 5)10 50不交换。20 5交换数组变为[10, 5, 30, 40, 50, 20]。第二轮gap 4 / 1.3 3比较(0, 3)、(1, 4)、(2, 5)30 20交换数组变为[10, 5, 20, 40, 50, 30]。第三轮gap 3 / 1.3 2比较(0, 2)、(1, 3)、(2, 4)、(3, 5)40 30交换数组变为[10, 5, 20, 30, 50, 40]。第四轮gap 2 / 1.3 1进行相邻比较扫描10 5交换数组变为[5, 10, 20, 30, 50, 40]。继续比较发现50 40交换数组变为[5, 10, 20, 30, 40, 50]。最后一轮gap 1整轮没有发生交换算法退出排序完成。注意第一轮的效果数字5从索引 5 一下子跳到了索引 1这就是间隔比较的价值。普通冒泡需要好多轮才能做到的事梳状排序一轮就能完成大部分。2. 收缩因子 1.3 为什么是关键数字2.1 从“间隔”到“收缩因子”梳状排序的灵魂不是“有间隔”而是“间隔怎么变化”。如果gap从 n 直接降到 1那就和普通冒泡没有区别。如果gap每次只减 1算法又会变得非常慢。因此需要一个固定的比例让间隔按照等比数列递减n, n / k, n / k^2, n / k^3, ..., 1这里的k就是收缩因子shrink factor。每次更新gap (int)(gap / 1.3); if (gap 1) { gap 1; }不同收缩因子对排序过程的影响差异很大收缩因子间隔递减速度表现1.1很慢需要很多轮才能把 gap 缩到 1比较轮数明显增多1.25偏慢效果不错某些数据分布下接近 1.31.3经验最优大量随机数据实验表现最好是经典默认值1.4偏快远距离逆序还没被充分消除就过早进入相邻比较2.0很快间隔序列像n, n/2, n/4, ...比较跨度跳跃太大退化风险高2.2 为什么是 1.3而不是 1.2 或 2梳状排序由 Stephen Lacey 和 Richard Box 在 1991 年发表的论文A Fast, Easy Sort中提出。文章通过大量实验数据指出收缩因子在 1.3 附近时排序效果最好。这也是“1.3 这个数字拯救了冒泡排序”说法的来源。与其说 1.3 是数学推导出的精确最优值不如说它是被反复实验验证出来的经验常数。从原理上分析收缩因子不能太小也不能太大收缩因子太小比如 1.1gap从 n 衰减到 1 需要的轮数很多每一轮虽然比较跨度大但整体计算量被轮数拖累收益不显著。收缩因子太大比如 2.0gap序列变成n, n/2, n/4, ...虽然轮数少但间隔跳跃太快很多应该在这一层处理掉的远距离逆序留到了下一层最终可能过早进入gap 1把剩余工作全部丢给退化的冒泡扫描。1.3 处于“轮数”和“每轮消除逆序能力”之间的平衡点。实验表明1.25 到 1.33 这个区间都能取得不错的效果而 1.3 因为简单好记成为梳状排序的标准参数。2.3 1.3 如何“拯救”冒泡排序如果没有 1.3冒泡排序最致命的问题是小元素移动太慢。引入 1.3 之后gap以指数速度缩小的同时也给了算法“以大跨度交换消除远距离逆序”的机会。更准确地说gap从 n 到 1 大约需要log_{1.3}(n) 轮对于 n 10000 的数组大约 35 轮左右而不是 10000 轮。每一轮虽然都要扫描整个数组但每轮扫描成本是 O(n)所以整体比较次数约为n * log_{1.3}(n)这比冒泡排序的n(n-1)/2小很多。即使在gap 1的最后阶段数组已经接近有序需要交换的位置很少一次或两次扫描基本就能完成。关键认识1.3 不是一个可有可无的常数而是梳状排序区别于“带间隔的冒泡排序”的核心。如果没有这个收缩序列或者收缩因子选择不当整个算法可能退化回 O(n²) 甚至更差。3. 梳状排序实现伪代码、C、Java 与 Python3.1 先理解算法状态机梳状排序的循环条件写成while gap 1 OR swapped: if gap 1: gap max(1, floor(gap / 1.3)) swapped false for i from 0 to n - gap - 1: if arr[i] arr[i gap]: swap(arr[i], arr[i gap]) swapped true这里有三个容易理解错的地方。第一为什么初始swapped要为 true因为初始gap n必须进入循环否则当 n 为 1 时gap 1不成立循环体不会执行。设置swapped true可以保证至少执行一轮。第二为什么每轮开始先更新gap初始gap n如果直接比较arr[i]和arr[i n]索引会越界。所以第一轮必须先缩到n / 1.3。第三为什么gap 1时还要继续循环因为此时还没有确认数组是否有序。只有当gap 1且一整轮扫描没有发生交换才能确定排序结束。3.2 C 语言版本#include stdio.h void comb_sort(int arr[], int n) { int gap n; int swapped 1; const float shrink 1.3f; while (gap 1 || swapped) { if (gap 1) { gap (int)(gap / shrink); if (gap 1) { gap 1; } } swapped 0; for (int i 0; i gap n; i) { if (arr[i] arr[i gap]) { int tmp arr[i]; arr[i] arr[i gap]; arr[i gap] tmp; swapped 1; } } } } int main() { int arr[] {10, 20, 30, 40, 50, 5}; int n sizeof(arr) / sizeof(arr[0]); comb_sort(arr, n); for (int i 0; i n; i) { printf(%d , arr[i]); } return 0; }C 语言实现中最容易出问题的是(int)(gap / shrink)。当gap为 1 时1 / 1.3的结果是 0如果不加if (gap 1)保护下一轮会比较arr[i]和arr[i 0]也就是元素和自身比较逻辑错误且可能造成死循环。3.3 Java 版本import java.util.Arrays; public class CombSort { 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); if (gap 1) { gap 1; } } swapped false; for (int i 0; i gap n; i) { if (arr[i] arr[i gap]) { int tmp arr[i]; arr[i] arr[i gap]; arr[i gap] tmp; swapped true; } } } } public static void main(String[] args) { int[] arr {10, 20, 30, 40, 50, 5}; combSort(arr); System.out.println(Arrays.toString(arr)); } }Java 版本要注意gap / shrink的结果是浮点数强制转成int时会向零取整。n 1时gap保持 1循环体扫描范围为 0swapped被置为 false随后退出逻辑上没有风险。3.4 Python 版本def comb_sort(arr): arr arr[:] n len(arr) gap n swapped True shrink 1.3 while gap 1 or swapped: if gap 1: gap int(gap / shrink) 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__: data [10, 20, 30, 40, 50, 5] print(comb_sort(data))Python 3 中/的结果是浮点数所以必须先转int。也可以使用gap int(gap / 1.3)的写法。Python 的元组交换让代码看起来简洁但底层仍是一次基于中间变量的值交换性能上和 C/Java 没有本质差别。3.5 不同语言实现要点汇总语言主要注意点推荐写法C索引越界、gap 归零、整型除法用i gap n做边界gap 更新后加最小值保护Java浮点数转 int 的取整、数组长度为零(int) (gap / 1.3)方法入口可先判空或处理 n 为 0Python/与//的区别、复制数组使用int(gap / 1.3)避免gap // 1.3的隐式转换问题4. 运行验证与基准测试怎么证明它比冒泡快4.1 小数组人工验证使用长度为 6 的数组[10, 20, 30, 40, 50, 5]手动跟踪每一轮的gap和数组状态轮次gap主要交换动作本轮结束数组第一次420 与 5 交换[10, 5, 30, 40, 50, 20]第二次330 与 20 交换[10, 5, 20, 40, 50, 30]第三次240 与 30 交换[10, 5, 20, 30, 50, 40]第四次110 与 5 交换50 与 40 交换[5, 10, 20, 30, 40, 50]第五次1无交换[5, 10, 20, 30, 40, 50]这个例子说明两个要点第一数字 5 从数组末尾移动到索引 1只用了一轮。第二gap 1之后并不是只扫描一遍就结束而是需要再跑一遍确认没有交换。第五轮没有发生交换算法才真正退出。4.2 用 Python 脚本统计比较次数和交换次数只看排序结果还不够要验证“比冒泡快”需要统计核心操作次数。下面的脚本同时实现冒泡排序和梳状排序并统计比较次数。import random def bubble_sort_with_stats(arr): arr arr[:] n len(arr) compares 0 swaps 0 for i in range(n - 1): swapped False for j in range(n - 1 - i): compares 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 arr, compares, swaps def comb_sort_with_stats(arr): arr arr[:] n len(arr) gap n swapped True compares 0 swaps 0 while gap 1 or swapped: if gap 1: gap int(gap / 1.3) if gap 1: gap 1 swapped False for i in range(n - gap): compares 1 if arr[i] arr[i gap]: arr[i], arr[i gap] arr[i gap], arr[i] swaps 1 swapped True return arr, compares, swaps def run_experiment(data): print(f数组长度: {len(data)}) _, bc, bs bubble_sort_with_stats(data) _, cc, cs comb_sort_with_stats(data) print(f冒泡排序: 比较 {bc} 次, 交换 {bs} 次) print(f梳状排序: 比较 {cc} 次, 交换 {cs} 次) print() if __name__ __main__: random.seed(42) random_data [random.randint(0, 10000) for _ in range(2000)] reversed_data list(range(2000, 0, -1)) sorted_data list(range(2000)) run_experiment(random_data) run_experiment(reversed_data) run_experiment(sorted_data)这段脚本的价值在于它能清晰展示不同数据分布对两种排序的影响。随机种子固定后输出可以在本地稳定复现。4.3 不同数据分布下应该观察到的规律我本地一次运行得到的结果如下数组长度: 2000 冒泡排序: 比较 1999000 次, 交换 996843 次 梳状排序: 比较 24392 次, 交换 3154 次数组长度: 2000 冒泡排序: 比较 1999000 次, 交换 1999000 次 梳状排序: 比较 27282 次, 交换 1999000 次数组长度: 2000 冒泡排序: 比较 1999 次, 交换 0 次 梳状排序: 比较 30853 次, 交换 0 次由于随机种子和机器环境不同具体数字会有差异但规律是一致的随机数组和逆序数组下梳状排序的比较次数只有冒泡排序的百分之一左右。逆序数组下梳状排序的交换次数仍然很多因为每个逆序对都需要交换但比较次数大幅下降。在完全有序数组下带swapped标志的冒泡排序只比较一轮 1999 次而梳状排序还是要执行gap从大到小的完整递减过程因此比较次数更多。这个结论非常重要梳状排序并不是在所有场景下都优于冒泡排序。遇到已经有序或接近有序的数据提前退出的冒泡排序可能更快。这也是学习排序算法时容易忽略的场景差异。4.4 为什么梳状排序在逆序数组下交换次数依然很大梳状排序的比较次数大幅减少但交换次数在一些场景下并不会等比例减少。原因在于逆序数组本身存在约 n(n-1)/2 个逆序对只要算法通过交换来消除逆序每个逆序对最终都需要一次交换。梳状排序只能减少不必要的比较不能跳过必需的交换。这也解释了为什么在“比较代价高、交换代价低”的场景下梳状排序收益明显而如果比较和交换代价都极高则需要考虑更适合的排序算法。5. 关键参数、边界条件与常见坑5.1 参数速查表参数含义默认值调大影响调小影响gap比较间隔n第一轮比较跨度大远距离逆序更容易消除第一轮比较跨度小更接近冒泡排序shrink收缩因子1.3间隔递减快轮数少但残留逆序多间隔递减慢轮数多但每轮工作量大swapped本轮是否交换true暂无调参意义暂无调参意义循环条件是否继续排序gap 1 或 swapped无无实际使用中gap更新后必须保证最小为 1。否则在gap 1的那一轮1 / 1.3得到 0算法会陷入错误状态。5.2 边界条件分析梳状排序处理空数组和单元素数组时代码需要能安全退出。以 C 语言版本为例n 0时gap 0循环条件gap 1 || swapped也就是false || true为 true进入循环。gap 1为 false不更新 gap。swapped falsefor 循环边界i 0 0不成立不执行。下一轮 while 判断gap 1为 falseswapped为 false退出。所以空数组不会死循环。n 1时同理。但如果在实现中没有把swapped初始化为 true而是初始化为 false那么n 1时会直接跳过循环也不会报错只是逻辑上少了一次判定。更值得注意的边界是n 2时的循环条件写法。有些实现使用while (gap 1)这会导致当gap 1且还有逆序对时直接退出排序结果不正确。必须在循环条件中加入swapped。5.3 三个最常见的坑坑一循环条件写成while (gap 1)漏掉swapped。现象小数组排序偶发失败特别是数组在gap第一次降到 1 时仍存在逆序对的情况。原因梳状排序在gap 1时还需要一轮或多轮相邻扫描来消除剩余的少量逆序。如果循环条件只依赖gap 1一旦gap变成 1循环立刻结束排序不完整。解决使用while (gap 1 || swapped)。这里的swapped表示“上一轮是否发生了交换”。只要上轮有交换说明数组还没排好需要继续扫描。坑二gap更新后没有限制最小值导致gap变成 0。现象程序可能陷入死循环或者数组最终没有被正确排序。原因int(1 / 1.3)的结果是 0。如果gap变成 0for循环会反复比较arr[i]和arr[i]永远不产生交换swapped恒为 false然后整个算法可能误以为排序完成。解决每次更新gap后补上if (gap 1) { gap 1; }坑三把梳状排序当作稳定排序使用。现象包含相同关键字的对象数组排序后相同关键字元素的相对顺序发生改变。原因梳状排序是隔着gap比较并交换的跨越距离很大无法保证相等元素的先后顺序。解决如果业务要求稳定排序应该使用归并排序、Java 的Arrays.sort(Object[])或 Python 的list.sort()。梳状排序只适合不要求稳定性的场景。5.4 排错清单如果梳状排序运行结果不对按下面顺序排查先检查gap的初始值是否等于数组长度 n。检查gap更新后是否限制为不小于 1。检查循环条件是否包含swapped。检查 for 循环边界是否为i gap n不是i n - gap这样容易混淆的等价形式也不是i n。检查局部变量swapped是否在每轮开始时被重置。如果使用整数除法确认伸缩后的结果是否符合预期。最后用空数组、单元素数组、逆序数组和大量重复值数组分别测试。6. 学习、嵌入式与生产场景下的排序选型6.1 学习环境如何把梳状排序练扎实学习梳状排序时不建议直接复制完整代码。更有效的练习顺序是只实现一个最简单的版本先用printf或print打印每轮数组状态确认gap的变化过程。修改shrink为 1.2、1.25、1.3、1.4、2.0分别统计逆序数组下的比较次数画出变化曲线。加入统计比较次数和交换次数的逻辑而不是只用耗时计时因为计时受硬件和系统负载影响大。把梳状排序和冒泡排序在随机、有序、逆序、大量重复值四类数据上对比。思考一个问题为什么数组已经完全有序时梳状排序仍然要比较n * log_{1.3}(n)次有没有办法在有序时提前退出这个练习能帮助理解“提前退出条件”和“数据分布对算法性能的影响”比单纯背复杂度更加重要。6.2 生产环境优先使用语言内建排序在实际业务系统中手写排序算法通常不是好选择。主要原因有三点第一语言内建排序通常使用混合算法例如 Java 对对象数组使用稳定的 TimSort对基本类型数组使用双轴快速排序Python 的list.sort()同样基于 TimSort。这些算法在真实数据上经过大量优化比手写版本稳健得多。第二手写排序算法需要自己处理边界条件、极端数据、并发共享和异常日志维护成本高。第三生产环境对稳定性、可读性和可维护性的要求高于“少用一个类库”。梳状排序真正适合的生产场景往往是资源受限的嵌入式环境或者自定义数据结构中不能直接调用标准库排序的场景。例如在某些实时控制芯片上内存很小无法使用归并排序的额外空间又要求原地排序梳状排序可以作为参考方案。即便如此落地前也要通过随机、有序、逆序、重复和接近有序五类数据测试。表格对比不同场景的推荐做法场景推荐排序方式原因Java 业务项目Arrays.sort、Collections.sort内部使用 TimSort 或双轴快排稳定且优化充分Python 业务项目list.sort()、sorted()TimSort 对部分有序数据非常友好C 标准库场景qsort库函数经过平台优化注意比较器写法嵌入式原地排序梳状排序或希尔排序空间 O(1)实现简单适合中小规模算法教学梳状排序直观展示 gap 对冒泡排序的影响6.3 梳状排序、希尔排序、TimSort 的对比算法基本操作稳定性最好复杂度平均复杂度最坏复杂度空间冒泡排序相邻比较交换稳定O(n)O(n²)O(n²)O(1)梳状排序间隔比较交换不稳定O(n log n)明显优于冒泡仍接近 O(n²) 上界O(n²)O(1)希尔排序间隔插入排序不稳定取决于增量序列约 O(n^1.3~1.6)O(n²) 或更好O(1)TimSort归并 插入混合稳定O(n)O(n log n)O(n log n)O(n)注意梳状排序与希尔排序虽然都使用“间隔递减”的思想但底层操作不同。希尔排序在间隔序列上做的是插入排序而梳状排序在间隔序列上做的是冒泡式比较交换。因此梳状排序更接近冒泡排序的优化版本希尔排序更接近插入排序的优化版本。6.4 扩展方向如果对梳状排序感兴趣可以沿三个方向继续深入第一研究收缩因子的调整策略。1.3 是一个经验值但不是绝对最优。你可以测试 1.25、1.3、1.33 在随机数据和逆序数据下的比较次数差异寻找更适合特定数据分布的值。第二考虑混合排序。当gap缩小到 1 时换成插入排序完成最后阶段因为此时数组基本有序插入排序对“几乎有序”的数据有非常好的表现。这是一种常见的算法结合思路。第三学习算法复杂度分析。梳状排序为什么最坏情况仍是 O(n²)可以尝试构造让它性能退化到接近冒泡的输入序列这会加深对排序算法下界和逆序对概念的理解。收尾先理解 1.3再决定要不要用它梳状排序是理解“冒泡排序为什么慢”的一个极好抓手。普通冒泡排序的问题不是“比较次数太多”这么简单而是小元素每一步只能移动一位距离越远代价越大。梳状排序用一个收缩因子 1.3 将比较间距从 n 等比缩小到 1让远距离元素快速归位最后再进入微调阶段本质上是对冒泡排序的一次低成本改造。学习时建议自己写一个统计脚本对比不同 shrink 值、不同数据分布下的比较次数和交换次数。你很快会发现梳状排序并不是万能银弹它在完全有序数组上可能输给带短路标志的冒泡排序。真正进入生产系统后优先选择语言内建排序只有当硬件受限、必须原地排序且不要求稳定时才值得把梳状排序作为基础方案并配合随机、有序、逆序、大量重复值和接近有序五类测试集验证表现。
返回列表