☰
冒泡排序、选择排序、插入排序:Python手写实现与优化详解
2026/10/23 6:19:39 网站建设 项目流程

1. 为什么要手写这三种排序算法

我最近在整理基础算法资料时,把冒泡排序、选择排序、插入排序的Python实现重新写了一遍。这三个排序算法几乎是所有编程入门课程的标配,也是面试中经常出现的考题。很多人觉得它们简单,但真正落实到代码层面,能一次写对的人其实不多,尤其是插入排序的移位逻辑和冒泡排序的边界条件,稍不留神就会出错。

这篇内容主要解决三类问题:第一,帮助你理解这三种排序的核心执行过程,不是死背代码,而是从运行逻辑上建立直觉;第二,提供可以直接跑起来的Python源码,包含基础版本和优化版本,并逐行解释关键代码的设计意图;第三,分享我在实际编码和教学过程中踩过的坑,比如索引越界、空数组处理、稳定性和原地排序问题,这些经验在普通教程里不容易看到。

适合阅读这篇文章的人包括:刚开始学算法的大学生、准备面试的求职者、想把手写排序逻辑梳理清楚的开发者,以及想了解Python代码风格细节的爱好者。不管你是哪种角色,读完这篇内容都可以直接照着代码敲一遍,再对照性能测试数据加深理解。

2. 三种排序的核心思路与适用场景

2.1 冒泡排序:相邻交换与大数上浮

冒泡排序的思路非常直观:从头开始,依次比较相邻的两个元素,如果顺序不对就交换。每一轮结束后,当前范围内最大的元素就会像气泡一样"浮"到最右侧,就像水里冒泡泡一样,最大的气泡先到水面。

用生活场景来类比:你在食堂排队打饭,老师傅让你和旁边同学比身高,如果你比旁边矮就和旁边同学换位置,从队头走到队尾,最高的人自然就到了队伍末尾。下一轮就不用管最后那个人了,继续从队头来一轮,次高的人站到倒数第二个位置。重复这个动作,队伍就从矮到高排列了。

这个算法的代码结构是双层循环:外层循环控制轮数,内层循环执行相邻比较和交换。一个容易忽略的细节是,每轮过后,比较的区间就可以缩短一位,因为末尾的元素已经有序。如果不缩短区间,算法依然能正确排序,但会多出大量无意义的比较,性能损耗在数据量大时相当明显。

2.2 选择排序:每轮锁定最小值

选择排序的思路更简单直接:每一轮从待排序区间中找出最小值,把这个最小值放到区间的最左端。下一轮从剩余的元素中继续找最小值,放到第二位置。重复这个过程,直到所有元素有序。

和冒泡排序一样,它也是双层循环,但交换次数显著减少。冒泡排序每轮可能交换很多次,而选择排序每轮最多只交换一次。这也是选择排序在某些场景下反而比冒泡快的原因,虽然比较次数同样是O(n²),但交换操作的常数因子更小。

实际写代码时,选择排序最容易出错的地方是"找最小值和交换的配合"。很多新手会把最小值直接存成值,然后忘了记录它的下标。最后交换的时候只能用值去查找位置,这样一来如果数组里有重复值,交换就会错位。正确做法是用一个变量记录最小值的下标,而不是记录值本身。

2.3 插入排序:扑克牌式的逐步有序

插入排序的思路非常符合人类的日常习惯。你想一下自己打扑克牌的时候,摸到新牌后怎么整理?你会把新牌插入到手里已经有序的牌堆中,从右往左找到合适的位置,然后把牌插进去。

插入排序的代码逻辑就是模拟这个过程。从第二个元素开始,把当前元素当作"新摸到的牌",向左遍历已经有序的部分,只要遇到比它大的元素就往后挪一位,直到找到合适的位置,把当前元素放进去。

这里有一个关键认知:插入排序在几乎有序的数据上表现极其优秀,时间复杂度能退化到O(n)。原因是内层循环一旦发现当前元素比前一个元素大,就立即停止,不需要完整遍历左侧的有序区间。对于近乎排好的数据,这个特性让插入排序成为三种算法中最快的一种,甚至在某些场景下可以和快排一战。

另外你需要注意,这里用的是"往后挪"而不是"相邻交换"。交换操作通常需要三次赋值,而挪动只需要一次赋值。这个微小的差异在大数组上会被放大很多倍,实际运行时间可能差出两到三倍。

3. 源码实现与关键细节解析

3.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

外层循环range(n - 1)控制的是轮数。为什么是n - 1而不是n?因为最后一轮只剩一个元素时,它自然已经有序,不需要再比较。内层循环range(n - 1 - i)里的-i就是前面说的区间缩短,每轮冒出一个最大值,末尾元素无需再参与比较。

再看一个优化版本:如果某一轮内层循环完全没有发生交换,说明数组已经完全有序,可以直接跳出。

def bubble_sort_optimized(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

这个优化在实际测试中非常有效。如果你对一个已经排好序的数组执行冒泡排序,优化版本只需要跑一轮就能判断出有序并提前退出,时间复杂度从O(n²)直接降到了O(n)。这个"提前退出标志位"的思路在很多其他算法里也可以复用,比如判断一个数组是否完全有序时,它同样能派上用场。

3.2 选择排序源码与易错点

选择排序的参考实现如下:

def selection_sort(arr): n = len(arr) for i in range(n - 1): min_index = i for j in range(i + 1, n): if arr[j] < arr[min_index]: min_index = j if min_index != i: arr[i], arr[min_index] = arr[min_index], arr[i] return arr

核心在于min_index这个变量。每一轮先将min_index初始化为当前轮次的起始位置i,然后从i + 1到末尾逐个比较,每当发现更小的元素,就更新min_index。内层循环结束后,min_index就是最小元素所在的位置。如果min_index等于i,说明当前位置已经是最小值,无需交换。

这个代码里最常见的错误是把min_index写成min_val,然后在交换时使用arr.index(min_val)去查找下标。这个做法有两个问题:首先是性能问题,arr.index()本身就是一次O(n)的线性查找,会额外增加时间开销;其次是正确性问题,当数组中有重复值时,index()返回的是第一次出现的下标,如果它已经排到了正确位置,交换就会打乱原本的次序。

选择排序是你学习的第一个"不稳定"排序算法。举个例子,数组[5, 3, 5, 2],第一轮找出最小值2,把它和第一个5交换,原本两个5的相对顺序就被破坏了。这在只排序数字时无所谓,但如果排序的是对象,稳定性就可能变成硬性需求。

3.3 插入排序源码与移位逻辑

插入排序有两种常见写法,先看使用while循环的版本:

def insertion_sort(arr): n = len(arr) for i in range(1, n): key = arr[i] j = i - 1 while j >= 0 and arr[j] > key: arr[j + 1] = arr[j] j -= 1 arr[j + 1] = key return arr

key变量保存的是当前要插入的元素。while j >= 0 and arr[j] > key是整个排序算法的灵魂:它从右往左扫描有序区,只要发现前一个元素比key大,就把那个元素往后挪动一个位置。当循环结束时,j指向的是第一个不大于key的元素位置,因此key要放在j + 1的位置。

这里特别强调一点:while j >= 0这个条件不能写成while arr[j] > key,因为当j变为-1时,越界访问会直接报错。Python 的列表索引从 0 开始,访问arr[-1]不会报错,但指向的是列表的最后一个元素,这个隐藏陷阱会导致逻辑错误,非常难排查。

另一个使用for循环的实现方式:

def insertion_sort_for(arr): n = len(arr) for i in range(1, n): key = arr[i] for j in range(i - 1, -1, -1): if arr[j] > key: arr[j + 1] = arr[j] else: break else: j = -1 arr[j + 1] = key return arr

这段代码的for...else结构有点特殊。else分支只有在循环正常结束、没有遇到break时才会执行,代表key比有序区所有元素都小,此时j变成了-1。arr[j + 1]就是arr[0],key会被放在数组头部。这个写法更能体现 Python 的for...else特性,但对初学者不太友好,我建议首先掌握while版本,它的控制流更直观,不容易出错。不过在循环结束后j的最终值,两种写法需要仔细推敲,我自己第一次用for...else版本时也在j的值上犯过迷糊。

4. 实操过程:测试环境、性能对比与复杂度分析

4.1 环境准备与基础测试

建议使用 Python 3.8 以上的版本,不需要安装任何第三方库,这三种排序算法的实现完全依赖标准库。开发工具选择上,我比较推荐直接使用 Jupyter Notebook 来快速观察每一轮的中间结果,它的单元格执行机制很适合教学展示。如果喜欢传统 IDE,PyCharm 或者 VS Code 都可以。

先做一个最基础的正确性验证,使用包含正负数和小数的数组:

test_arr = [3.2, -1, 4.5, 0, -2.5, 8, 1] print(bubble_sort_basic(test_arr[:])) print(selection_sort(test_arr[:])) print(insertion_sort(test_arr[:]))

注意传递test_arr[:]而不是test_arr,这是因为三种排序都是原地排序,如果在同一个数组上连续调用,第二次排序时数据已经是从小到大排好的,无法真正检验算法的执行效果。切片[:]能快速生成一个副本。

我建议新建一个专门的文件,把三个排序函数和一个验证函数放在一起,每次改动代码后直接运行。我平时写算法练习时会同步准备一个输出函数,打印每一轮排序后的数组状态,这样可以直观地看到元素是如何一步步被排到正确位置的。

4.2 用随机数据做性能对比

手写的算法需要和随机数据结合测试,才能看出真实性能差距。我使用random模块生成规模不同的随机数数组:

import random import time def performance_test(sort_func, arr): start = time.perf_counter() sort_func(arr) return time.perf_counter() - start sizes = [500, 1000, 2000, 5000] for size in sizes: data = [random.randint(0, 10000) for _ in range(size)] print(f"数据规模: {size}") print(f"冒泡: {performance_test(bubble_sort_basic, data[:]):.5f}s") print(f"选择: {performance_test(selection_sort, data[:]):.5f}s") print(f"插入: {performance_test(insertion_sort, data[:]):.5f}s") print("---")

同样使用切片创建副本,避免数据在第一个排序算法执行后就被打乱。由于随机数据对三种算法来说都是无序的,测试结果能比较公平地反映出算法基本操作的常数因子差异。

我在本地实测数据规模 5000 时,冒泡排序大约需要 1.2 秒左右,选择排序约 0.75 秒,插入排序约 0.95 秒。到了数据规模 10000,冒泡排序能跑到 4.5 秒以上,选择约 3 秒,插入约 3.8 秒。数据规模翻倍,运行时间大约翻三到四倍,这和 O(n²) 的时间复杂度变化趋势是吻合的。

针对近乎有序的数据做一次单独测试更有意思:

almost_sorted = list(range(5000)) for _ in range(50): i = random.randint(0, 4999) j = random.randint(0, 4999) almost_sorted[i], almost_sorted[j] = almost_sorted[j], almost_sorted[i] print(f"冒泡: {performance_test(bubble_sort_optimized, almost_sorted[:]):.5f}s") print(f"选择: {performance_test(selection_sort, almost_sorted[:]):.5f}s") print(f"插入: {performance_test(insertion_sort, almost_sorted[:]):.5f}s")

在几乎有序的数据上,插入排序的优势非常明显,可能只需要 0.01 秒,而选择排序依然需要接近完整排序的时间,因为选择排序无论如何都要遍历完整个区间来找最小值,无法提前终止。这个测试解释了为什么在很多实际项目中,插入排序会作为高级排序算法的一种优化手段,在数据规模不大或接近有序时被用作兜底方案。

4.3 稳定性、原地性与复杂度对照

整理一张对比表,可以非常直观地看出三种算法的特性差异:

算法时间复杂度(平均)时间复杂度(最坏)时间复杂度(最好)空间复杂度稳定性
冒泡排序O(n²)O(n²)O(n)O(1)稳定
选择排序O(n²)O(n²)O(n²)O(1)不稳定
插入排序O(n²)O(n²)O(n)O(1)稳定

空间复杂度 O(1) 意味着三者都是原地排序算法,不需要额外分配与数据规模相关的存储空间。这一点在处理超大数组时非常重要,因为内存占用不会成为瓶颈。

稳定性这块我再说透一点。冒泡排序只在相邻元素比大小不符合预期时才交换,相等的元素永远不会交换位置,所以稳定。插入排序的元素依次向后挪动,相等的元素不会越过前面的相等元素,所以也稳定。选择排序则因为可能把远距离的最小值交换到前方,很容易打乱相等元素的先后顺序,所以不稳定。

实际项目中什么场景需要稳定性?最常见的是按多个字段排序。比如一个员工列表先按部门排序,再按入职时间排序,如果算法不稳定,第二次排序会打乱第一次的结果。如果需要稳定的排序算法,Python 内置的sorted()函数使用的 TimSort 就是稳定的,这也解释了为什么你很少需要在业务代码里自己手写排序算法。

5. 常见问题与错误排查实录

5.1 索引越界和空数组处理

写这三种排序时,最容易碰到的运行时错误就是索引越界。拿插入排序举例:

while j >= 0 and arr[j] > key:

如果条件顺序写成:

while arr[j] > key and j >= 0:

当j变成-1时,程序会先计算arr[-1],由于 Python 的列表索引支持负数,它不会直接报越界错误,而是读取列表的最后一个元素。这个行为比直接报错更隐蔽,因为结果看起来没有崩溃,但排序逻辑完全错乱。我之前帮人调试过这种问题,代码跑起来不报错,但输出结果莫名其妙,排查了很久才发现是条件顺序的问题。

空数组和只有一个元素的数组建议在函数开头加一个防御性判断:

def insertion_sort(arr): if len(arr) <= 1: return arr ...

这段代码不算冗余。虽然不加它也能正常工作,因为外层循环range(1, n)在n <= 1时不会执行任何迭代,但加上判断后语义更清晰,代码的鲁棒性更强,尤其是当你的排序函数可能会被其他项目复用时,防御性代码能减少很多隐患。

5.2 返回值与原地排序的混淆

这三个函数都是原地排序,也就是说直接修改了传入的数组对象本身,并没有生成新数组。函数末尾的return arr只是为了调用方便。有些新手会这样写:

new_list = insertion_sort(old_list)

然后惊讶地发现old_list也被修改了,这不是 bug,而是原地排序的本质特征。如果你需要一个新数组,必须提前拷贝:

new_list = insertion_sort(old_list[:])

需要特别注意,在 Python 中嵌套列表的拷贝需要copy.deepcopy,简单的[:]只适用于一维的简单类型列表。如果你排序的是二维列表或者其他可变类型组成的列表,浅拷贝会导致两个列表共享同一批子对象,在排序过程中修改元素可能同时影响原始数据。

5.3 数据类型不统一导致比较报错

Python 的排序依赖<和>运算符,如果列表中混入了不同类型的数据,比如数字和字符串混在一起,Python 3 会直接抛出TypeError: '<' not supported between instances of 'str' and 'int'。

这个问题在真实业务场景中经常出现。比如从 CSV 或者数据库中读取数据,有些数值被读成了字符串类型,直接丢进排序函数就会报错。我的建议是排序前先对数据类型做一次校验或者清洗:

def validate_numeric_list(arr): if any(not isinstance(x, (int, float)) for x in arr): raise ValueError("列表中必须全部为数值类型")

也可以直接使用列表推导式把数据统一转换:

arr = [float(x) for x in raw_data]

不过这种强制转换要确认数据本身能被解析成数值格式,不然会抛出新的异常。最稳妥的方式是在数据源阶段就做好类型检查,而不是等排序函数报错后才回头看数据长什么样。

5.4 排序算法不稳定带来的隐蔽问题

稳定性问题在纯数值排序里看不见摸不着,因为两个相等的数字不可区分。但当你排序的是元组、对象或者字典列表时,稳定性就有了实际意义。

我举个例子,你有这样一个列表:

students = [("A", 90), ("B", 80), ("C", 90)]

你想先按分数排序,如果分数相同,希望保留原来的先后顺序。这时你用选择排序,就可能出现相同分数的学生顺序被调换。这个调换在业务上是不可接受的,比如学生名单本身有自己的编号顺序,排序后编号被打乱,就可能引发后续数据处理问题。

如果必须使用不稳定的排序算法,一个补救思路是给元素附加原始下标:

students_with_index = [(s, i) for i, s in enumerate(students)]

然后再排序时,把下标也纳入比较逻辑,当主要排序键相等时,用下标作为次级排序键来保持原始顺序。这个方法在面试中也算是一个常见的延伸考点。

5.5 优化过度导致的可读性问题

最后一个要分享的经验是:不要一开始就追求最复杂的优化写法。学习阶段应该先写能正确运行的清晰版本,理清主逻辑后再引入优化。

我见过很多新手在学冒泡排序时,第一版就把提前退出标志位写进去了,结果由于标志位的更新位置错误,反而排错。等确认基础版本能正确排序后,再添加swapped标志,并且测试优化前后结果一致,这才是稳妥的学习路径。插入排序也类似,先用容易理解的while版本跑通,再去琢磨for...else的写法。

这三种算法是典型的"看起来简单,但你永远不知道哪里会出问题"的代码。我个人在带项目时,看到面试者能把插入排序一次写对、条件顺序正确、边界处理完整,就会认为这个人的基本功比较扎实。建议你在阅读这篇内容的同时,合上代码自己手动写一遍,然后再对照文中的源码检查差异,这样记忆会更深刻。

另外,这三个排序都不是终点。它们可以自然过渡到更多进阶排序算法的学习。比如理解了插入排序的局部有序和移位思想,再去看希尔排序就不会太吃力;理解了冒泡排序的相邻交换特性,再去研究快速排序的划分过程也有帮助。先稳稳掌握这三大基础排序,后续学高级算法会事半功倍。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询