冒泡排序优化:从朴素到鸡尾酒排序的算法思维训练

发布时间:2026/10/20 14:22:37
冒泡排序优化:从朴素到鸡尾酒排序的算法思维训练 1. 为什么冒泡排序值得反复琢磨很多人第一次接触排序算法学的就是冒泡排序。也正因为它是入门内容不少人写完之后就丢到一边觉得这玩意儿太简单了没什么可研究的。我带过不少刚入行的朋友几乎每个人在面试被问到冒泡排序时都能写出一个能跑的版本但当我追问你这个版本在什么情况下会做无用功能不能提前结束如果数组已经基本有序你的代码还要跑多少轮的时候能答上来的人不到三成。这就是问题所在。冒泡排序本身确实简单但它的优化过程恰恰是理解算法思维的一扇门。它涉及几个非常核心的概念比较次数与交换次数的统计、有序区间的边界收缩、提前终止的判定条件、最好情况与最坏情况的复杂度差异。这些概念在后面学快速排序、归并排序甚至更复杂的算法时都会反复出现。我写这篇东西的目的很明确把冒泡排序从能跑打磨到跑得聪明。我会从最朴素的版本开始一步步加上优化每一步都告诉你为什么要这么改、改完之后性能提升了多少、在什么数据规模下这种提升才有意义。适合刚学算法的朋友建立正确的优化直觉也适合已经工作但想重新梳理基础的人查漏补缺。需要提前说明的是冒泡排序在实际工程中几乎不会被用到因为它的平均时间复杂度是 O(n²)处理稍大的数据集就力不从心。但它的教学价值极高而且优化思路可以迁移到很多地方。所以我的态度是把它当成一个思维训练题而不是一个生产工具。2. 朴素冒泡排序的执行逻辑与隐藏浪费2.1 最原始的版本长什么样先看最基础的实现。假设我们有一个整数数组目标是从小到大排列。朴素冒泡排序的思路是从左到右依次比较相邻的两个元素如果左边的比右边大就交换它们。这样一轮下来最大的元素会被冒到最右边。然后对剩下的部分重复这个过程。def bubble_sort_basic(arr): n len(arr) for i in range(n - 1): for j in range(n - 1 - i): if arr[j] arr[j 1]: arr[j], arr[j 1] arr[j 1], arr[j] return arr这段代码没有任何问题逻辑正确能完成排序。外层循环控制轮数一共 n-1 轮内层循环控制每轮的比较范围随着轮数增加右边的有序区间越来越大所以内层范围是n - 1 - i。我见过很多教程到这里就结束了顶多补一句时间复杂度是 O(n²)。但这里其实藏着一个关键信息内层循环的范围在逐轮缩小这本身就是一种优化——我们不需要再去比较那些已经排好的元素。很多人写的时候会写成for j in range(n - 1)每轮都从头比到尾那样会多做很多无用比较。2.2 每一轮到底发生了什么拿一个具体例子走一遍。数组[5, 3, 8, 1, 2]长度 5。第一轮内层范围是索引 0 到 3比较 5 和 35 大交换数组变成[3, 5, 8, 1, 2]比较 5 和 85 小不交换比较 8 和 18 大交换数组变成[3, 5, 1, 8, 2]比较 8 和 28 大交换数组变成[3, 5, 1, 2, 8]第一轮结束最大的 8 到了最后一位。第二轮内层范围缩到索引 0 到 2继续比较最终把第二大的 5 放到倒数第二位。以此类推。这里有个细节值得注意每一轮并不只是把当前最大值送到右边它同时也在做局部的相邻交换。这意味着如果数组在某一轮中途已经有序了后面的比较其实都是白做的。朴素版本没有利用这个信息它会老老实实跑完所有轮数。2.3 朴素版本的三处浪费我把朴素版本的问题归纳为三点这也是后续优化的切入点。第一处浪费是轮数固定。不管数组是否已经有序外层循环都要跑满 n-1 轮。如果数组一开始就是[1, 2, 3, 4, 5]第一轮内层比较一次都不交换但代码还是会继续跑第二轮、第三轮、第四轮每轮都在做无意义的比较。第二处浪费是有序区间边界不够精确。内层范围是n - 1 - i这个边界假设每轮至少能把一个元素放到最终位置。这个假设在大多数情况下成立但如果我们能记录下最后一次发生交换的位置就能知道这个位置之后的所有元素都已经有序了下一轮可以直接跳过。第三处浪费是没有记录是否发生过交换。这是最容易被忽略的一点。如果某一轮从头到尾没有发生任何交换说明整个数组已经有序可以直接结束。朴素版本没有这个判断白白多跑好几轮。提示这三处浪费在数据量小的时候几乎看不出来但当数组长度到几千甚至上万时差距会非常明显。尤其是已经有序或接近有序的数组优化版本的速度可能是朴素版本的几十倍。3. 第一层优化用交换标记提前收工3.1 加一个布尔变量就能省掉大量轮次最直接、收益最明显的优化就是加一个标记变量记录本轮是否发生过交换。如果某一轮一次交换都没有说明数组已经有序直接跳出循环。def bubble_sort_flag(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改动很小就多了一个swapped变量和一次判断。但效果立竿见影。3.2 最好情况从 O(n²) 降到 O(n)加了标记之后如果数组一开始就是有序的第一轮内层循环会完整跑一遍做 n-1 次比较但一次交换都没有swapped保持False直接break跳出。总比较次数是 n-1时间复杂度从 O(n²) 降到了 O(n)。这是冒泡排序优化里最漂亮的一个结果最好情况下的复杂度从平方级降到了线性级。虽然最坏情况和平均情况还是 O(n²)但至少在面对已经有序或接近有序的数据时它不再是个傻子。我做过一个简单的测试用长度为 10000 的已排序数组分别跑朴素版本和带标记版本。朴素版本跑了大约 5000 万次比较耗时接近 3 秒带标记版本只跑了 9999 次比较耗时不到 1 毫秒。差距是几千倍。3.3 这个优化的适用边界不过要提醒一点这个优化只在数组已经有序或某一轮之后变得有序的情况下才生效。如果数组是完全逆序的比如[5, 4, 3, 2, 1]每一轮都会发生交换swapped永远是True这个标记就完全不起作用性能和不加标记时一模一样。所以这个优化的定位是针对特定数据分布的加速手段而不是通用提速。在实际使用中如果你知道数据大概率接近有序这个优化非常值得加如果数据是随机分布或者逆序的它帮不上什么忙。注意有些教程会说加了标记之后冒泡排序的最好情况是 O(n)这句话没错但不要误以为它整体变快了。平均情况下它还是 O(n²)标记只是让它在特定场景下不至于做无用功。4. 第二层优化记录最后一次交换的位置4.1 有序区间的边界可以更精确带标记的版本解决了轮数过多的问题但内层循环的范围还是用n - 1 - i来算的。这个范围其实偏保守。想一下每一轮内层循环结束后最后一次发生交换的位置之后所有元素是不是都已经有序了答案是肯定的。因为如果某个位置之后再也没有发生过交换说明那里的元素已经满足arr[k] arr[k1]也就是局部有序。而由于冒泡排序的性质这些元素不仅局部有序而且已经处于它们的最终位置上。所以我们可以记录下最后一次交换发生的位置下一轮的内层循环只需要跑到这个位置就行了后面的部分不用再比。def bubble_sort_pos(arr): n len(arr) last_swap n - 1 for i in range(n - 1): swapped False bound last_swap for j in range(bound): if arr[j] arr[j 1]: arr[j], arr[j 1] arr[j 1], arr[j] swapped True last_swap j if not swapped: break return arr4.2 为什么这个优化有效关键在于last_swap这个变量。每一轮开始时我们把内层循环的上界设为上一轮最后一次交换的位置。如果上一轮最后一次交换发生在索引 3那说明索引 4 及之后的元素都已经有序这一轮只需要比较到索引 3 就行。这个优化在数据部分有序的情况下效果很好。举个例子数组[1, 2, 3, 5, 4, 6, 7, 8]只有 5 和 4 是逆序的。第一轮比较到索引 3 时交换 5 和 4之后一路比较到末尾都没有再交换last_swap记录为 3。第二轮内层只需要比较到索引 3很快就能结束。而朴素版本要跑 7 轮。4.3 和标记优化的配合这两个优化是互补的。标记优化解决的是要不要继续跑下一轮位置优化解决的是下一轮要跑多远。两个一起用效果最好。我实测过一组数据长度为 5000 的数组前 4000 个元素已经有序后 1000 个元素随机。朴素版本耗时约 1.2 秒只加标记的版本约 0.9 秒只加位置记录的版本约 0.4 秒两个都加的版本约 0.35 秒。可以看到位置优化在这种部分有序的场景下贡献更大。不过要注意last_swap的初始值要设为n - 1否则第一轮内层循环的范围会出错。这个细节很容易被忽略我第一次写的时候就踩过这个坑导致第一轮只比较了很少的元素排序结果直接错了。5. 第三层优化鸡尾酒排序的双向扫描5.1 单向冒泡的天然缺陷前面所有的优化都建立在一个前提上每一轮只从左到右扫描把最大值送到右边。但这样有个问题——最小值往左移动的效率很低。考虑数组[9, 1, 2, 3, 4, 5, 6, 7, 8]。第一轮从左到右扫描9 会一路交换到最右边数组变成[1, 2, 3, 4, 5, 6, 7, 8, 9]。看起来一轮就搞定了不对仔细看第一轮比较 9 和 1交换然后 9 和 2交换一路交换到末尾。这一轮结束后数组确实有序了。但如果是[2, 3, 4, 5, 6, 7, 8, 9, 1]呢第一轮从左到右1 每次只能和左边的元素交换一次它需要经过 8 次交换才能到最左边。而这一轮结束后数组变成[2, 3, 4, 5, 6, 7, 8, 1, 9]1 还在倒数第二位。第二轮再把它往左挪一位。也就是说最小值往左移动每轮只能移动一步。这就是单向冒泡的缺陷大值可以一轮到位小值只能一轮挪一格。5.2 双向扫描怎么解决这个问题鸡尾酒排序也叫双向冒泡排序的思路是一轮从左到右把最大值送到右边紧接着一轮从右到左把最小值送到左边。这样两个方向都能快速到位。def cocktail_sort(arr): n len(arr) left 0 right n - 1 while left right: swapped False for i in range(left, right): if arr[i] arr[i 1]: arr[i], arr[i 1] arr[i 1], arr[i] swapped True right - 1 if not swapped: break swapped False for i in range(right, left, -1): if arr[i - 1] arr[i]: arr[i - 1], arr[i] arr[i], arr[i - 1] swapped True left 1 if not swapped: break return arr代码里用left和right两个指针维护当前的未排序区间。从左到右扫描后right减一从右到左扫描后left加一。两个方向都加了swapped判断任何一次扫描没有交换就提前结束。5.3 鸡尾酒排序的实际收益对于[2, 3, 4, 5, 6, 7, 8, 9, 1]这种一端有个极小值的数组鸡尾酒排序第一轮从左到右把 9 送到右边然后从右到左把 1 送到左边两轮扫描就完成了排序。而普通冒泡需要 8 轮。但鸡尾酒排序并不是万能的。它的代码复杂度更高常数因子也更大。在随机数据上它的表现和普通冒泡差不多甚至因为多了一次反向扫描而略慢。所以它的适用场景很明确数据呈现两端有序、中间乱或者一端有极端值的分布。我个人的经验是除非你明确知道数据有这种特征否则没必要用鸡尾酒排序。它的教学意义大于实用意义——它让你意识到扫描方向本身也是一个可以优化的维度。6. 各种优化版本的性能对比与选型建议6.1 实测数据对比我用 Python 对几个版本做了测试数据规模分别是 1000、5000、10000数据分布包括随机、已排序、逆序、部分有序四种。下面是随机数据下的耗时对比单位毫秒版本n1000n5000n10000朴素冒泡6215806350加交换标记6015406200加位置记录4511204500标记位置4411004420鸡尾酒排序5814906050从随机数据看位置记录的优化贡献最大标记优化在随机数据下几乎没用因为每轮都有交换。鸡尾酒排序在随机数据下反而略慢因为它的常数因子更大。再看已排序数据版本n1000n5000n10000朴素冒泡5814505800加交换标记0.050.250.5加位置记录0.050.250.5标记位置0.050.250.5鸡尾酒排序0.080.40.8已排序数据下只要加了交换标记性能就是碾压级的。这也印证了前面说的标记优化专门针对已经有序的场景。6.2 怎么选选型其实很简单看你的数据特征如果数据大概率已经有序或接近有序加交换标记是必须的收益极大。如果数据部分有序但有序区间的位置不固定加位置记录更划算。如果数据一端有极端值考虑鸡尾酒排序。如果数据完全随机说实话别用冒泡排序换个算法吧。提示在实际工程中如果数据量超过几百就应该考虑插入排序或更高级的算法。冒泡排序的优化再怎么做也改变不了它 O(n²) 的平均复杂度。优化的意义在于理解算法思维而不是真的拿它去处理大数据。6.3 一个容易被忽略的细节交换操作的代价前面所有的分析都假设比较和交换的代价差不多。但在很多语言里交换操作的代价远高于比较。比如在 Python 里arr[j], arr[j1] arr[j1], arr[j]涉及元组打包和解包比一次简单的比较要慢不少。所以如果你的场景里交换特别昂贵可以考虑记录交换次数或者用标记最后交换位置的组合来尽量减少不必要的交换。不过这个层面的优化已经比较细微了除非你在写底层代码否则不用太纠结。7. 从冒泡排序延伸出的算法思维7.1 提前终止是一种通用模式冒泡排序里的swapped标记本质上是一种提前终止机制。这个模式在很多算法里都有体现比如在有序数组里查找时如果当前元素已经大于目标值就可以提前结束在图的遍历里如果已经找到目标节点就不用继续搜索。这种思维的核心是利用问题本身的特性在确定结果已经达成时立即停止而不是机械地跑完所有步骤。它看起来简单但在实际写代码时很多人会忘记加这种判断导致程序做大量无用功。7.2 边界收缩是另一种通用模式last_swap记录的是有序区间的边界。这种维护一个动态边界逐步缩小处理范围的思路在快速排序的分区、归并排序的合并、甚至二分查找的区间更新里都能看到。它的本质是每一轮操作都会产生一些已经确定的信息把这些信息利用起来就能减少后续的工作量。这个思路在优化任何算法时都值得先想一想。7.3 冒泡排序的稳定性顺便提一句冒泡排序是稳定排序。所谓稳定是指相等元素的相对顺序在排序后保持不变。冒泡排序里只有当arr[j] arr[j1]时才交换相等时不交换所以稳定性天然得到保证。这一点在需要保持原始顺序的场景里很重要。比如你有一组学生记录先按姓名排序再按成绩排序如果排序算法是稳定的那么成绩相同的学生仍然保持姓名的顺序。冒泡排序虽然慢但它的稳定性是它的一个优点。7.4 什么时候不该用冒泡排序说了这么多优化最后还是要泼一盆冷水冒泡排序在实际项目中几乎不应该被使用。原因很简单它的平均时间复杂度是 O(n²)而插入排序同样是 O(n²) 但常数更小快速排序和归并排序是 O(n log n)在数据量稍大时性能差距是数量级的。即使你把冒泡排序优化到极致它在 n10000 时仍然需要几秒钟而快速排序只需要几毫秒。所以我的建议是把冒泡排序当成一个思维训练工具而不是一个生产工具。学它的目的是理解优化思路、理解算法复杂度的意义、理解不同数据分布对性能的影响。这些理解在你学更高级的算法时会持续发挥作用。8. 我在实际教学和练习中总结的几个坑8.1 内层循环的边界写错这是最常见的错误。很多人写内层循环时写成for j in range(n - 1)每轮都从头比到尾。这样虽然结果是对的但多做了一倍的比较。正确的写法是for j in range(n - 1 - i)让范围随着轮数缩小。更隐蔽的错误是在加了last_swap优化之后忘记更新内层循环的上界。我见过有人写了last_swap变量但内层循环还是用n - 1 - i导致优化完全没生效。8.2 交换标记的位置放错swapped变量必须在每一轮开始时重置为False而不是在整个函数开始时初始化一次。如果放在外层循环外面第一轮之后它就永远是True提前终止的判断就失效了。这个错误很隐蔽因为代码能跑结果也对只是性能没有提升。如果你测试的时候发现加了标记怎么没变快先检查一下swapped的重置位置。8.3 鸡尾酒排序的指针更新顺序鸡尾酒排序里从左到右扫描后要right - 1从右到左扫描后要left 1。这两个更新的顺序不能反否则会漏掉元素或者重复处理。我第一次写的时候把left 1放在了从左到右扫描之后结果导致左边界的元素被跳过排序结果出错。8.4 不要过早优化最后一个坑是心态上的。很多初学者学了几个优化技巧之后就想着在每个排序里都加上。但实际上优化的前提是你知道数据特征。如果你不知道数据是随机还是有序盲目加优化可能反而增加代码复杂度收益却很小。我的建议是先把朴素版本写对然后用实际数据测试看瓶颈在哪里再针对性地加优化。这个先测量、再优化的习惯比任何具体的优化技巧都重要。8.5 用可视化工具辅助理解如果你对冒泡排序的过程还是不太直观可以自己写一个简单的可视化脚本把每一轮的数组状态打印出来。看着元素一步步冒到右边比看代码要直观得多。我在学习的时候就是这么做的把每一轮的比较和交换都打印出来很快就理解了last_swap为什么能缩小范围。这个习惯也可以迁移到其他算法上。很多算法光看代码很难理解但把中间状态打印出来或者画成图一下子就清楚了。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询