先问一个可能让很多人犹豫的问题:既然 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(key=lambda 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(key=lambda x: x[2]) # 按第三个字段排序 items.sort(key=lambda x: x[2], reverse=True) # 按第三个字段降序内置方法在 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]你能直观看到每一轮都把当前最大值推向末尾。这样的观察胜过十遍概念讲解。跑完后,你可以再试试给列表加入重复元素、空列表、字符串列表,以及把>改成<看降序效果。最终你会发现:原来排序算法不是只能背代码,它是一组可以通过小实验完全掌控的逻辑组合。
编程学习里的很多困惑,比如循环边界、列表修改、比较规则,其实不需要更高级的理论来解释。它们需要用一次能看见过程的最小实验来击穿。冒泡排序恰好提供了这样一个实验。