
先问一个可能让很多人犹豫的问题既然 Python 里已经有了list.sort()和sorted()为什么还要学冒泡排序这种“又慢又基础”的写法我在带新人写代码的时候经常遇到这个疑问。有人觉得它是过时的教学玩具有人觉得面试前背一背就行实际项目里根本用不上。我的判断是冒泡排序真正的价值不在性能也不在“会不会写”而在于它是理解排序过程、循环控制、列表操作和边界条件的最佳入门模型。你把它手写一遍很多 Python 的隐藏细节会跟着暴露出来变量交换、循环范围、列表原地修改、是否提前退出、空列表如何处理、相同元素会不会乱序。这篇文章不打算讲高深算法而是把列表升序排列这件事拆开讲清楚“为什么算法是这样设计的”“什么时候它真的值得用”“什么时候你应该果断放弃它”同时给出一套可以直接运行的代码以及一个能用在其他排序场景里的排查思路。1. 先搞清楚“升序排列”这件事到底难在哪如果只看结果“升序排列”听起来特别简单给定一个[5, 2, 9, 1]输出[1, 2, 5, 9]就行。但这里有一个很容易被忽略的问题你是想修改原列表还是生成一个新列表你是要对数字排序还是顺便要处理字符串、元组、对象如果列表里有重复值它们的相对顺序要不要保留这些问题决定了你该用哪种方案。冒泡排序最核心的使用场景是对一个可修改的列表做“原地升序排列”也就是说不新建列表直接改变原列表中元素的顺序。这一点需要在一开始就说清楚因为它和sorted()的默认行为不一样。1.1 从一次简单的输出需求说起假设你拿到一个成绩列表scores [78, 92, 63, 85, 71]你想把它从低到高排好。最直接的做法是scores.sort() print(scores)这是 Python 内置方法速度快写法简单90% 的业务场景到这里就够了。但如果有人问你这个sort()内部到底做了什么它怎么知道哪个元素该排在前面如果你不用内置方法能不能自己实现冒泡排序回答的就是这个问题。它用一种非常直观的策略来排序从头到尾依次比较相邻的两个元素如果前一个比后一个大就交换它们的位置。这样一轮结束后最大的数会像气泡一样“浮”到列表末尾。然后继续处理剩下的部分直到整个列表有序。这种策略不聪明但它非常容易验证。你可以手动模拟一遍变量在每一步的值都很清楚。对初学者来说这比一下子面对快速排序的分治递归要好接受得多。1.2 排序算法的第一课比较和交换所有排序算法底层都离不开两个操作比较和交换。比较是判断两个元素谁大谁小交换是让它们跑到正确的位置上去。冒泡排序把这两个操作变成了一个重复执行的模式if 前一个元素 后一个元素: 交换前一个元素和后一个元素看起来简单但“交换”在 Python 里有一个非常容易踩坑的地方。很多从 C 语言转过来的程序员会习惯性写三行代码temp arr[j] arr[j] arr[j 1] arr[j 1] temp这在 Python 里完全可以运行。但 Python 提供了一种更简洁的写法arr[j], arr[j 1] arr[j 1], arr[j]这行代码的本质是先把右边的两个值取出来再按顺序赋给左边的两个变量。它比临时变量写法更清晰也更难写错。如果你在代码里看到别人这样写过它不是奇技淫巧而是 Python 元组赋值特性在列表交换上的自然应用。到这里可以得出第一个结论冒泡排序的难点不在于“比较”这个动作而在于把“比较 交换 轮次控制 边界控制”组合成一个正确的循环结构。下面我们一步步把它写出来。2. 用手写代码把升序排列跑通直接开始写代码之前先约定一个通俗但不失严谨的描述输入一个 Python 列表元素支持比较运算比如整数、浮点数、字符串。输出原列表变成升序排列也就是从小到大。额外要求不借助内置排序函数不使用额外列表来存储全部元素。这里说的“额外要求”是为了让你把注意力放在排序逻辑本身。如果你准备放进生产项目我更建议你使用内置sort()但在这里请先容忍这种“不高效”的写法。2.1 环境准备与最小示例Python 3.8 以上版本即可不需要安装第三方库。你可以直接在命令行里进入 Python 交互环境也可以创建一个bubble_sort.py文件运行。下面是最小示例def bubble_sort(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 if __name__ __main__: scores [78, 92, 63, 85, 71] bubble_sort(scores) print(scores)运行结果[63, 71, 78, 85, 92]这段代码已经完成了列表升序排列。注意这里返回了arr也修改了原来的列表对象。调用函数之后你打印原来的scores看到的是排序后的结果。如果你希望保留原始顺序就必须在调用前复制一份scores_copy scores[:] bubble_sort(scores_copy)切片在这里起到复制列表的作用这是 Python 列表操作中非常常用的技巧。很多新手在排序后想要保留原列表却直接写成new_scores scores结果原列表也被改了这是因为两个变量引用同一个列表对象。关于这一点后面的踩坑部分会详细展开。2.2 每一轮排序到底发生了什么手动模拟一遍会很有帮助。假设列表是[5, 2, 9, 1]。第 1 轮i 0内层循环j从 0 到 2比较5和2交换列表变成[2, 5, 9, 1]。比较5和9不交换列表保持[2, 5, 9, 1]。比较9和1交换列表变成[2, 5, 1, 9]。这一轮结束后的效果列表最大值9被移动到了最后一位。第 2 轮i 1内层循环j从 0 到 1比较2和5不交换。比较5和1交换列表变成[2, 1, 5, 9]。这一轮结束后5移动到了倒数第二位。第 3 轮i 2内层循环j从 0 到 0比较2和1交换列表变成[1, 2, 5, 9]。从模拟过程可以看到两个关键设计外循环range(n - 1)是因为 n 个元素最多需要 n - 1 轮处理。当只有最后一个元素还没归位时它和前一个元素比较一次就能确定位置所以不需要第 n 轮。内循环range(n - 1 - i)是因为每一轮结束后列表末尾都会多一个已经排好的最大值这些元素不需要再参与比较。如果不写- i程序也能排对只是会做很多无效比较。随着待排序数据量增大这种无效开销会被放大。这就是这段代码里最值得讲清楚的两个边界轮次边界和每轮比较范围边界。忽略它们往往不是报错而是白跑很多轮或者在某种输入下索引越界。2.3 加上“提前结束”优化理解什么时候该停上面的写法能完成任务但对一个已经有序的列表它仍然会傻乎乎地跑完全部轮次。比如输入[1, 2, 3, 4, 5]它还是会比较很多次。既然排好序了不该再浪费时间。改进方案是增加一个标记变量记录当前这一轮是否发生过交换。如果某一轮从头到尾都没有交换过说明列表里所有相邻元素已经满足前一个不大于后一个也就是已经有序可以提前退出。def bubble_sort_early_stop(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这个优化不影响排序结果但能体现一个很重要的编程习惯循环不是只能从头跑到尾你应该想清楚什么时候可以停下来。对基本有序的列表这个改进能节省大量无意义的比较对完全乱序的列表它仍然要跑完大部分轮次性能提升不明显。但理解这个优化是理解“算法复杂度不是只看最坏情况”的起点。3. 不只是整数列表更复杂的升序场景怎么处理冒泡排序的代码一旦写好排序规则就会受到if arr[j] arr[j 1]这行比较逻辑的限制。这个限制可以拆成三层元素必须支持比较。如果元素是自定义对象需要先定义“大于”的规则。如果想把严格递增改成稳定排序或不希望改变相等元素的相对顺序要考虑比较方向。输入材料里看到的热搜词包括字符串排序、列表切片、两个列表转字典这些都是 Python 列表的高频操作。虽说不必在一篇文章里讲完但我们可以至少把冒泡排序从整数列表扩展到字符串列表顺便理解列表切片的典型作用。3.1 字符串列表的升序排列字典序是什么字符串列表的升序默认按字典序排列也就是逐个字符比较它们在 Unicode 编码中的大小。说人话就是apple会排在banana前面Apple在默认比较规则下会排在apple前面因为大写A的编码小于小写a。words [banana, apple, Cherry, date] bubble_sort(words) print(words)运行结果会依据字符串默认比较规则[Cherry, apple, banana, date]这是很多初学者觉得“怪”的地方为什么大写的排在前面因为在 Python 里字符比较走的是 Unicode 码点值C的码点小于a所以它排在apple前面。如果你希望按照英文字母的常规大小写无关顺序来排就需要统一转成小写再比较if arr[j].lower() arr[j 1].lower(): arr[j], arr[j 1] arr[j 1], arr[j]这个例子说明排序的规则不在于算法本身而在于那行“比较条件”里定义了什么。冒泡排序只是把“满足比较规则的元素移到前面”这个过程重复执行至于这个规则是什么由你的输入和比较表达式共同决定。3.2 元组或字典列表怎么按某个字段升序如果列表里的元素是元组比如[(1, a), (3, c), (2, b)]直接比较元组时Python 会先比较第一个元素相同再比较第二个元素。如果你只想按第二个字段升序排列就不能直接在if里写arr[j] arr[j 1]而应该写出具体的字段比较逻辑。def bubble_sort_by_second(arr): n len(arr) for i in range(n - 1): for j in range(n - 1 - i): if arr[j][1] arr[j 1][1]: arr[j], arr[j 1] arr[j 1], arr[j] return arr pairs [(1, banana), (2, apple), (3, cherry)] bubble_sort_by_second(pairs) print(pairs)字典列表也可以采用类似方式比如按item[price]排序if arr[j][price] arr[j 1][price]: arr[j], arr[j 1] arr[j 1], arr[j]看到这里你会发现冒泡排序的“算法骨架”始终没变变化的是“比较规则”这一层。这也是为什么我建议你在学习时不要把代码当成死记硬背的模板而要把if 条件看作需要根据业务替换的接口。将来你学sorted(keylambda x: x[1])的时候会发现同一个思路只不过内置函数把 key 提取函数作为参数暴露给了你。3.3 升序排列的稳定性相等元素怎么处理什么是稳定排序简单说就是当两个元素相等时排序后它们的相对位置保持不变。冒泡排序在实现为“只有才交换”时是稳定的。如果你把条件写成稳定的性质就破坏了相等元素会被交换位置。在整数列表里稳定不稳定看不出影响。但当时元素是包含多个字段的对象时稳定性就变得重要。比如先按姓名排序再按成绩排序第二次排序时如果排序算法不稳定就可能打乱第一次相同成绩内部的姓名顺序。# 如果写成 稳定结构就会被破坏 if arr[j] arr[j 1]: arr[j], arr[j 1] arr[j 1], arr[j]所以在实现“升序排列”时请使用不要用。这个小细节看起来无关紧要实际会影响排序结果的稳定性也会直接影响后续依赖顺序的业务逻辑。4. 从跑通到能用观察复杂度、复制列表、处理特殊输入当你把基本版本跑通之后问题从“怎么排”变成了“能不能放心用”。这是进入生产环境意识的关键一步。学校里学排序往往到“代码能出正确结果”就停止了但在真实项目管理中还需要考虑数据量、原列表是否该被修改、空列表会不会报错、程序卡住时怎么排查。接下来把这几个问题逐个拆开。4.1 为什么说它慢时间复杂度背后的直觉冒泡排序的时间复杂度最坏情况下是 O(n^2)最好情况下是 O(n)平均是 O(n^2)。其中 n 是列表长度。初学者可能对 O(n^2) 缺乏体感这里用一个直观例子说明排序 10 个元素大约需要几十次比较这没什么感觉。排序 1000 个元素大约需要 50 万次比较略微有感觉。排序 1 万个元素大约需要 5000 万次比较程序开始卡顿。排序 100 万个元素就会到几乎不可用的程度。为什么嵌套循环会带来这样的开销因为外层循环控制轮数内层循环控制每轮比较次数。轮数乘以每轮次数整体操作量和 n 的平方成正比。这不是冒泡排序独有的问题所有使用“两层循环暴力处理”的算法都会面临同样的规模困境。如果只是学习或者处理几十个元素的小列表不需要担心性能。但如果你在真实项目中要对几千个以上元素频繁排序每次排序都调用自己写的冒泡排序函数就必须考虑性能了。实际上到那个阶段你会更倾向于使用内置sort()因为它的底层是 TimSort 算法平均复杂度远优于 O(n^2)而且经过高度优化。4.2 原地修改会造成什么问题一个很常见的真实场景是你有一份原始成绩列表需要一份从低到高的排序版但后面又希望看到原始录入顺序。如果直接调用你写的冒泡排序原始列表会被修改后面就无法恢复原始顺序。解决方案是在排序前复制列表。三种常见方案# 切片复制 new_lst old_lst[:] # list() 构造 new_lst list(old_lst) # copy 模块适用于嵌套列表场景 import copy new_lst copy.deepcopy(old_lst)如果列表里的元素是整数、字符串这些不可变对象前两种方式足够。处理嵌套列表时需要根据实际需求判断是浅复制还是深复制。排序操作通常只需要对最外层列表进行修改浅复制通常已够用。但如果列表里的元素也是列表或其他可变对象而且排序规则依赖内部元素深复制可能更安全。注意如果你写的排序函数内部有arr arr[:]这样的复制函数内部修改就不会影响外部传入的列表。如果你希望函数“排序原列表并返回它”就不要在内部复制如果你希望“原列表保持不变返回新列表”就必须先复制再排序。4.3 空列表、单元素列表、所有元素相等边界输入是写算法不能回避的部分。以下三种情况会直接检验你的排序函数是否健壮空列表[]len(arr)为 0外循环range(-1)不会执行函数返回[]不会报错。单元素列表[42]外循环range(0)不执行函数返回[42]不会报错。所有元素相等的列表[1, 1, 1]内循环里没有任何元素满足arr[j] arr[j 1]如果没有 early stop它会跑完 n - 1 轮加上 early stop 后第一轮就发现没有交换提前退出。从工程角度看空列表和单元素列表天然不需要排序。它们能正常运行不报错已经代表函数的基本健壮性没问题。但“没报错”不代表“效率上没问题”。所有元素相等时如果列表很长没有 early stop 的版本会白白做很多比较这是真实处理同值数据时容易忽略的细节。4.4 如果排序结果不对按什么顺序排查我先给一个通用排查链路它也适用于绝大多数排序算法实现问题先确认输入列表本身是否符合预期。打印排序前的列表看元素类型、大小、嵌套结构排除输入脏数据导致的问题。再确认比较条件。升序用降序用字符串按字段时要明确比较哪个字段。检查外循环范围。如果外循环写成range(n)功能上通常没错但会多跑一轮如果写成range(n - 2)最后一个元素的位置可能不会被正确处理这就是边界错误。检查内循环范围。如果写成range(n - 1)会导致每一轮都把已经排好的末尾元素再比较一遍结果仍然正确但明显低效如果写成range(n)当j 1超过最大索引时会发生 IndexError。检查是否存在不必要的原地修改。如果调用方不希望原列表被改动就需要在入口处用切片或list()复制。最后检查列表长度。特别长的列表需要考虑性能。如果跑很久都没有结果先终止程序减少列表长度逐步测试。这里有一个很容易被忽视的层面对比arr[j]和arr[j 1]时如果输入里混入None或不同类型元素Python 会在比较时报TypeError: not supported between instances of NoneType and int。这时你需要回到第一层先清理输入数据。排序算法本身不负责清洗数据这是使用者需要保证的前置条件。5. 时机判断什么时候该自己写冒泡排序经过前面的实现和边界分析需要做一次冷静的决策。我可以给出一个相对清晰的判断框架什么情况下自己写冒泡排序是有意义的什么情况下应该选择其他方案。5.1 适合自己写的场景学习阶段目标是理解排序过程。训练循环、嵌套、边界条件和调试能力。面试前准备基础算法面试题。你明确知道自己要处理的数据量非常小比如几十个元素而且希望不依赖内置函数实现一个可读性很强的排序函数作为教学示例。在这些场景里冒泡排序值得手写。它能让思维变得更具体你不再只调用sort()而是能解释出“排序至少要经过多轮比较和交换才能完成”。5.2 不适合自己写的场景生产环境处理几十万条数据。对运行时间有明确要求的接口或服务。需要稳定、可靠、经过充分测试的排序能力。你需要用key提取字段、反向排序、对复杂对象排序。这些场景应该直接用内置方法。Python 的内置排序 API 本身已经足够清晰new_scores sorted(scores) # 返回新列表升序 scores.sort() # 原地排序升序 items.sort(keylambda x: x[2]) # 按第三个字段排序 items.sort(keylambda x: x[2], reverseTrue) # 按第三个字段降序内置方法在 C 层面完成了大量优化工作版本升级后还在持续演进。自己实现的 Python 纯代码排序在生产环境里很难在性能和稳定性上超过它不应该为了“自己写算法”而牺牲真实业务质量。5.3 学习之外的延续路径如果你已经理解了冒泡排序下一步可以按这个顺序学习第一步学会对它加 early stop理解最坏情况和最好情况的差异。 第二步尝试实现简单选择排序或插入排序对比它们和冒泡排序的循环结构与交换次数。 第三步使用计时工具比较不同排序函数在不同数据量下的表现。 第四步再回到sort()和sorted()的源码说明理解 Python 内置 TimSort 为什么适合真实世界的大量部分有序数据。 第五步尝试把“比较规则”抽象成参数比如传入一个自定义compare函数摸索函数式编程思想。这条路不需要走得很快。重点是每次你写一个排序函数都要搞清楚它的循环边界、比较规则、原地/复制语义、输入类型约束、极端情况处理以及它为什么可以提前停止。把这些细节都理顺了比背诵十个排序算法更有效。6. 排序之外的认知收获流程固化比单次排序更值得关注如果只是要一个把列表升序排列的代码前面五段已经解决了。但回到博客开头那个问题为什么值得学一个看起来不实用的算法这里需要补充一个更底层的观察排序问题本质上是把“一堆无序输入”转化为“满足明确规则的有序输出”的重复流程。你的价值不止要实现一次而是要能保证这个流程稳定地工作并知道它在什么条件下失效。这个道理可以迁移到很多非算法领域。比如数据处理流水线可以看作一排排序任务先清理空值再按时间戳排列日志再按用户 id 去重分组。单个任务是不是最快的并不重要重要的是每一段流程的输入输出格式、边界条件和失败处理是什么。如果你还没有建立这种思维排序算法是最好的开始实验田因为它步骤短、状态可观察、错误容易复现。6.1 手写排序对你理解 Python 列表意味着什么列表是 Python 日常开发中使用率极高的数据结构。切片、修改、遍历、成员判断这些操作在排序代码里反复出现。当你手写一次冒泡排序你会经历一次“对列表做深层操作”的过程通过索引访问元素、按位置交换元素、通过len(arr)控制循环范围。哪怕是一个很难察觉的range(n)和range(n - 1)差异都会在排序中暴露出不一样的结果。这种经验没法靠背诵 API 获得只能通过亲手写代码、调试、看变量变化来积累。6.2 从一个排序函数到一套可复用判断框架我给读者提供一个简单的四步法用于判断任何排序相关需求该怎么处理看数据量。看是否允许修改原数据。看需要按什么字段、什么方向排序。先把最小用例写出来运行一遍再决定是否引入内置方法或自定义函数。实际落地时这四步通常已经足够帮初学者避免最常见的翻车对大数据量盲目写纯 Python 循环、原地排序导致业务数据丢失、排序方向写反、没有验证空列表等边界情况。7. 收尾但不说教建议现在动手跑一遍写到这其实已经把冒泡排序实现列表升序排列的关键点都覆盖了。我建议你做的第一件事不是直接复制最终代码而是打开 Python 环境手动输入那个最基础的版本逐步打印每一轮排序后的列表状态。你可以用一段临时调试代码来观察过程def bubble_sort_debug(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 print(f第 {i 1} 轮后: {arr}) if not swapped: break bubble_sort_debug([5, 2, 9, 1, 7])输出会长这样第 1 轮后: [2, 5, 1, 7, 9] 第 2 轮后: [2, 1, 5, 7, 9] 第 3 轮后: [1, 2, 5, 7, 9] 第 4 轮后: [1, 2, 5, 7, 9]你能直观看到每一轮都把当前最大值推向末尾。这样的观察胜过十遍概念讲解。跑完后你可以再试试给列表加入重复元素、空列表、字符串列表以及把改成看降序效果。最终你会发现原来排序算法不是只能背代码它是一组可以通过小实验完全掌控的逻辑组合。编程学习里的很多困惑比如循环边界、列表修改、比较规则其实不需要更高级的理论来解释。它们需要用一次能看见过程的最小实验来击穿。冒泡排序恰好提供了这样一个实验。