☰
冒泡、选择、插入排序详解:O(n²)算法与稳定性分析
2026/10/19 3:11:36 网站建设 项目流程

做技术这些年,我至少被问过上百次“排序算法该从哪个开始学?”我的答案一直很固定:先啃时间复杂度为O(n²)的那几个。原因不是它们实用,而是因为它们能把循环嵌套、边界条件、稳定性、原地排序这些基本功给你练得明明白白。今天这第(一)篇,我就把冒泡排序、选择排序、插入排序全部摊开讲,讲思路、推过程、写代码、踩坑、优化,一套走完。内容适合正在准备笔试、刚开始系统学算法、或者已经在用高级排序但对底层原理有点懵的同学。我尽量用大白话把每个细节都说透,保证你能照着写完就能跑。

1. 为什么非要先学O(n²)的排序算法

1.1 平方级复杂度不是废物,它是理解复杂度的起点

很多人一看到O(n²)就皱眉头,觉得这算法“太慢”,这其实是被大O符号唬住了。大O描述的是数据规模n增大时的增长趋势,但它忽略了一个极其重要的因素:常数因子。你想想,如果数据规模只有10个,冒泡排序要比较45次,插入排序最坏也就45次,这点操作在CPU面前连零头都算不上。但如果你一上来就学快速排序,尽管平均O(n log n),可它的常系数、递归调用开销、分区操作细节都不小,在小数据集上未必比插排快。

我常说一句话:O(n²)算法是理解复杂度的“显微镜”,而不是“垃圾堆”。你把这三兄弟吃透,才能明白为什么归并排序需要额外空间,为什么快排的partition写不好会退化,为什么工程排序库最后总要加一个“如果区间长度小于某个阈值,改用插入排序”。没有对平方级算法的实测感受,你对高级排序的敬畏和理解都是悬空的。

1.2 三种平方级算法各有“性格”,选型差异巨大

冒泡、选择、插入虽然都是O(n²),但只是笼统地说,真实行为差别很大。我习惯用三个维度去区分它们:比较次数、交换次数、对数据有序度的敏感度。

冒泡排序比较次数固定是n(n-1)/2,交换次数在最坏情况下也是n(n-1)/2,也就是说最坏时比较和交换一样多。选择排序的比较次数同样是n(n-1)/2,但交换次数最多只有n-1次,这是它最突出的优点。插入排序则完全不同,它的比较和移动次数取决于数据初始有序程度:最好时只有n-1次比较,最坏时才到n(n-1)/2次。这个“自适应”特性,让插入排序落在实际应用里真正能打的象限。理解了这些差异,你才知道为什么后面讲快排优化时,大家都在用插入排序收尾,而没人说用冒泡收尾。

2. 冒泡排序:最容易理解,也最容易写错

2.1 一趟冒泡的完整推演:数字是怎么“浮”上去的

冒泡排序的思路一句话能说清:从头到尾相邻比较,如果前一个比后一个大,就交换,一趟下来最大的数就会“冒”到末尾;下一趟继续从头开始,但已经归位的末尾元素就不再参与。这个比喻特别像气泡从水底往上浮,越大的泡越早到水面。

我给你推演一个具体例子,数组是[5, 1, 4, 2, 8]。第一趟开始,先比较5和1,5大于1,交换,数组变成[1, 5, 4, 2, 8];然后比较5和4,交换,变成[1, 4, 5, 2, 8];接着比较5和2,交换,变成[1, 4, 2, 5, 8];最后比较5和8,不需要交换,第一趟结束,最大值8已经在正确位置。第二趟只需要比较前4个元素,会得到[1, 2, 4, 5, 8],但注意程序并不知道已经有序,它还会继续跑第三趟,直到外层循环结束。这就是为什么基础版冒泡效率很低。

代码如下:

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]

这里有一个最关键的边界:内层循环是range(n - 1 - i),不是range(n - 1),更不是range(n)。因为每一趟结束后,末尾i个元素已经归位,再去比较它们就是纯浪费时间。我第一次写的时候就是没加这个-i,数组是能排对的,但多了差不多i次无意义的比较,n一大差距就出来了。后来我养成了一个习惯:先写出循环不变量的注释,再写代码,很多边界错误当场就能发现。

2.2 两个值得一试的优化:提前终止与双向冒泡

基础版冒泡写完后,紧接着可以加一个小优化:如果某一趟从头到尾一次交换都没发生,说明数组已经有序,立刻终止。实现方式特别简单,加个swapped标记:

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

这个优化在数据基本有序时能显著减少趟数。比如数组[1, 2, 3, 4, 5],第一趟从头比到尾,一次交换都没有,直接break,总共只做了4次比较。这从最坏O(n²)直接变成最好O(n),性能瓶颈的关键在于数据有序程度。

还有一个进阶版本叫鸡尾酒排序,也叫双向冒泡。它每一趟先从左往右把最大值送到末尾,再从右往左把最小值送到开头,一次循环能同时归位两个元素。对[2, 3, 4, 5, 1]这种最小值在末尾的数组,普通冒泡第一趟只把5归位,第二趟才把4归位,第三趟3,第四趟2,最后才轮到1往前移,至少要4趟;双向冒泡第一趟从左往右把5送到末尾,接着从右往左把1送到开头,第二趟再处理中间,效率直接翻倍。不过注意,鸡尾酒排序的代码不算短,实际工程里不会有人写它,更多是锻炼你“打破惯性思维”的能力。

2.3 稳定性与代码细节里的隐藏坑

冒泡排序是稳定排序,这一点很多人容易忽略。稳定性的意思是:如果两个元素值相等,排序后它们的相对顺序不变。冒泡为什么能保证稳定?因为交换条件是arr[j] > arr[j+1],只有当左边严格大于右边才交换;如果两个值相等,>不成立,它们就永远不会交换,相对顺序自然保住。如果你手一滑写成>=,相等元素就会交换,排序结果依然是升序,但稳定性被破坏。笔试和面试里,一小撮人就栽在这上面。

还有一个隐藏坑是Python的交换写法。arr[j], arr[j+1] = arr[j+1], arr[j]这个语法糖没问题,但有些初学同学会写成临时变量那套,然后在赋值顺序上翻车,比如先arr[j] = arr[j+1],后面忘了保存原值,导致数据丢失。我的建议是能用元组交换就用元组交换,逻辑清晰还省一行。另外,冒泡排序是原地排序,空间复杂度O(1),它不会开辟额外数组空间,这是它的优点之一。

3. 选择排序:比较次数固定,交换次数最少

3.1 核心思想:每轮找最值,直接放到位置

选择排序的思路比冒泡更“干脆”:第一轮在整个数组里找出最小的元素,放到下标0的位置;第二轮从剩下的元素里找出最小的,放到下标1;以此类推。它不像冒泡那样频繁交换,每轮只做一次交换,这就是它交互次数极少的来源。

代码异常简洁:

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

内层循环在i+1到n-1之间找出最小值下标min_idx,然后和位置i交换。这里有个关键点:把min_idx初始化为i,而不是某个极端值或None。这样如果后面所有元素都比它大,就不需要交换,原地待着就好。如果你把min_idx初始化为-1,代码就会又多一堆判断,不划算。

我最喜欢用选择排序给学生讲“循环不变式”:每一轮外层循环结束后,位置0到i的元素已经是全局最小的i+1个元素的有序排列。这个性质让选择排序的行为变得非常可预测,无论数据是什么排列,比较次数永远是n(n-1)/2。换句话说,它没有“好运气”一说,也没有“倒霉”一说,稳定性一样差,但也一样稳定地慢。

3.2 稳定性陷阱:为什么说选择排序不稳定

很多人学到选择排序时都很困惑:为什么它会不稳定?明明只是找最小值然后交换,感觉和冒泡差不多啊。问题就出在这个“交换”上。

我拿一个典型的例子来说:数组是[5, 8, 5, 2, 9],三个数字里有两个5,为了区分它们,我标注为5a和5b,原数组是[5a, 8, 5b, 2, 9]。第一轮找出最小值2,下标3,然后和下标0的5a交换,数组变成[2, 8, 5b, 5a, 9]。你发现没有,原本在5b前面的5a,现在跑到了5b后面,两个相等元素的相对顺序变了。这就是选择排序不稳定的原因:既有的相对顺序会被一次“远距离交换”打乱。

如果你非要用选择排序并且保持稳定性,也是有办法的:不交换,而是把i到min_idx之间的元素整体往后挪一位,再把找到的最小值填到位置i。这种做法的本质是把选择排序的“交换”改成“插入式移动”,但移动次数变成O(n²),代码也变得麻烦。我个人的看法是:工程上完全没有必要。你要是需要稳定性,直接用插入排序或归并排序,效果更好、代码更自然。把选择排序改成稳定版本,更多是算法题里锻炼思维用的。

3.3 什么时候才该用选择排序

既然选择排序慢,不稳定性又是个麻烦,为什么还要学它?我告诉你三个应用场景。第一个场景是“写操作比读操作昂贵得多”的环境,比如某些嵌入式存储设备,写一次有寿命损耗或者开销极大,这时候选择排序的“最多n-1次交换”优势就体现出来了。第二个场景是数组元素是很大的结构体,交换成本远大于比较成本,选择排序能尽可能减少元素搬动。第三个场景是理解“极值定位+交换”这个思想模式,它是后面堆排序的雏形——堆排序本质上就是“每次从剩余元素中选出极值”,只不过它用堆这种数据结构把“选出极值”的操作从O(n)优化到O(log n)。从这个角度看,选择排序,是通往堆排序的必经之路。

不过在日常业务代码里,我几乎不会主动用选择排序。原因很简单:它的比较次数固定,即使数据已经全部有序,它依然要老老实实比较n(n-1)/2次,没有利用任何有序信息。这种“不智能”的特性和插入排序一对比,劣势特别明显。

4. 插入排序:小数组和近乎有序数据里的实战王者

4.1 打牌思维:你整理手牌时用的就是它

插入排序是我最想推荐给初学者反复琢磨的O(n²)算法,没有之一。它的逻辑和整理扑克牌一模一样:你抓了一张新牌,会把它和手里已有的牌从右往左比较,找到它该放的位置,然后插进去。手里的牌始终保持有序,新牌不断加入。

算法过程是:从数组的第二个元素开始,把当前元素当作“新牌”,它前面所有元素已经是有序序列。我们从右往左比较,凡是比“新牌”大的元素都往后挪一位,直到找到“新牌”的正确位置,然后放进去。代码有七行,但每一行都值得反复品味:

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

我来推演一次,数组[5, 2, 4, 6, 1, 3]。第一轮,i=1,key=2,j=0,arr[0]=5大于2,把5往后挪成[5, 5, 4, 6, 1, 3],j变成-1,循环结束,把key放到j+1也就是位置0,数组变成[2, 5, 4, 6, 1, 3]。此时前2个元素有序。第二轮,key=4,从右往左比较,5大于4,挪位,2不大于4,停止,把4放到位置1,得到[2, 4, 5, 6, 1, 3]。后面继续,最终完成排序。

注意while循环里j >= 0这个条件,非常关键。很多人写成j > 0,当key是当前前缀的最小值时,j会一直减到-1,然后arr[-1]在Python里能访问到最后一个元素,就会做奇怪的操作,得到错误结果。我在带新人时看过好多次这个错误,排查起来极其隐蔽。

4.2 近乎有序数据下的“超常发挥”

插入排序最迷人的地方是自适应。它不像选择排序那样无视数据状态,而是会利用已有的有序性:如果整个数组已经有序,那么每一轮while循环里的arr[j] > key都不成立,一次挪位都不发生,总共只需要n-1次比较,时间复杂度降到了O(n)。这在所有O(n²)排序里是独一份的。

它的最坏情况出现在完全逆序的数据:每一轮key都要和前面所有元素比较并且挪位,总比较和移动次数都是n(n-1)/2。平均来说也是O(n²),但常数比冒泡小不少。我做过简单对比测试,n为1000的随机整数数组,插入排序花费时间通常只有冒泡的三分之一到二分之一。这不是玄学,是因为插入排序每一轮虽然挪位多,但操作比冒泡的“交换三元组”更简单,而且局部性好,缓存命中率高。

基于这个特性,插入排序在实际工程中有一个很经典的用法:作为快排的“收尾工具”。你可以想象,快排递归到某个区间长度小于比如16时,数据已经“大体有序”,这时直接改用插入排序,比继续递归快排快得多。因为小规模数据递归调用的函数栈开销、partition的复杂逻辑,常常会掩盖O(n²)和O(n log n)之间的复杂度差距。很多编程语言内置排序的底层实现里都藏着这样的优化,这也是O(n²)排序算法最体面的归宿。

4.3 二分插入排序:减少比较却是“虚假的胜利”

有时我会看到一个优化版本叫二分插入排序:用二分查找在“前面有序部分”里找到key该插入的位置,再执行移动。这样比较次数从O(n²)降到O(n log n),但移动次数仍然是O(n²)。代码像是这样:

import bisect def insertion_sort_binary(arr): n = len(arr) for i in range(1, n): key = arr[i] pos = bisect.bisect_right(arr, key, 0, i) for j in range(i, pos, -1): arr[j] = arr[j - 1] arr[pos] = key

这种优化是不是“虚假的胜利”?不是完全虚假,但实际收益很有限。因为对一段已经有序的前缀做二分查找,虽然比较次数减少了,后面的元素整体移动仍然要一个一个搬。在很多机器上,比较整数和移动整数的成本差异并不大,所以这个优化经常跑不过朴素的插入排序。更关键的是,它破坏了插入排序的稳定性吗?没有,因为使用的是bisect_right,会插到相等元素后面。但代码复杂度提升了不少。我的建议是:初学阶段先别碰这种优化,等理解了希尔排序怎么跳跃式移动之后,再回来看会更有收获。

5. 三种算法横向对比与工程选型建议

5.1 复杂度、稳定性、特点对照表

我整理了一张表,这几乎是面试前必背的东西:

算法平均时间复杂度最坏时间复杂度最好时间复杂度空间复杂度稳定性交换/移动次数特点
冒泡排序O(n²)O(n²)O(n²),优化后可O(n)O(1)稳定最坏交换n(n-1)/2次
选择排序O(n²)O(n²)O(n²)O(1)不稳定交换最多n-1次,比较固定
插入排序O(n²)O(n²)O(n)O(1)稳定移动次数依赖逆序对数量

这个表你有空最好自己推导一遍,不要死记。比如冒泡排序的最好时间复杂度,基础版本就是O(n²),因为外层循环不管数据是否有序都要跑n-1趟;加了提前终止标记后,才变成O(n)。选择排序最好和最坏一样,因为它每轮都要扫描整个未排序区间才能确认最小值。插入排序的最好情况是已有序,但最坏是逆序。能把这些推导讲清楚,说明你真的理解了。

5.2 实际开发里该怎么选

如果只是当面试题,记住结论就行:日常小数组首选插入排序,数据基本有序更要用它;对写入寿命敏感的设备或需要极低交换次数的场景,考虑选择排序;冒泡排序基本只剩下教学意义。

但在真实工程里,你几乎不会单独手写这三种排序,因为各语言标准库已经足够强。那学习它们还有什么用?答案是它们会作为“最后一块拼图”出现在高级排序算法里。比如你打开某个高性能排序库的源码,大概率能在快排递归的底层看到插入排序的影子;归并排序的优化版本里也可能有小数组用插排的代码。原因很简单:递归越深,区间越小,但函数调用开销依然存在。此时插入排序的常数小,且缓存友好,用它的收益是实打实的。我特别喜欢跟人说:高级排序算法是“大骨架”,O(n²)排序是“小关节”,少了它们,整个算法的性能就提不上去。

5.3 想用实验说服自己?代码给你

纸上谈兵终觉浅,我建议你跑一跑对比测试。下面这段代码可以在你自己的电脑上验证三种算法的耗时趋势:

import random import time def time_measure(sort_func, arr): data = arr[:] start = time.perf_counter() sort_func(data) return time.perf_counter() - start data_random = [random.randint(0, 10000) for _ in range(1000)] data_nearly_sorted = list(range(1000)) data_nearly_sorted[500] = -1 # 只制造一个逆序 for name, func in [("bubble", bubble_sort), ("selection", selection_sort), ("insertion", insertion_sort)]: t = time_measure(func, data_random) print(f"{name} random: {t:.5f}s") # 在n=1000随机数据下,通常插入排序耗时约为冒泡的1/2到1/3

注意,不同硬件结果差异很大,但同一台机器上的相对排序通常是插入排序 < 选择排序 < 冒泡排序。当数据改成近乎有序时,插入排序的优势会拉到绝望级,而选择排序几乎纹丝不动,这个对比能给你非常直观的感受。

6. 常见问题与调试心得

6.1 边界条件总是错?给你一套排查套路

算法新人写这三个排序,错误几乎都集中在循环边界和下标更新上。我总结一下最容易翻车的三个点:

  • 冒泡排序内层:for j in range(n - 1 - i),少写-i会多做无用功,但结果一般没错;写多一个元素(比如range(n))则可能访问到已排好的最后一个元素,虽然没有越界但也多余。
  • 选择排序:找最小值的min_idx更新一定要在if arr[j] < arr[min_idx]内,不要写成和当前i比较,否则逻辑全乱。
  • 插入排序:while循环结束后的赋值位置是arr[j + 1] = key,很多人写成arr[j] = key,漏了一个加一,导致元素覆盖错误。

我的排查套路是三步走。第一步,n=2和n=3的极小用例手工推一遍,如果结果不对,逐行打印每一步之后数组内容;第二步,在函数入口加assert sorted(arr) == expected,自动化验证;第三步,构造逆序、升序、随机、含重复元素的四类测试数据,一次跑完。这套流程能帮你快速把“感觉不对”变成“明确知道哪里不对”。

6.2 如何自动化验证排序的稳定性

很多同学不确定自己写的排序是不是稳定,我教你一个简单可靠的测试方法:给每个元素套一个“原子序号”,排序后检查序号顺序。比如构造列表[(key, index)],按键排序,最后检查所有相同key的index是否保持升序。

def is_stable(sort_func, data): tagged = [(val, idx) for idx, val in enumerate(data)] sort_func(tagged) for i in range(len(tagged) - 1): if tagged[i][0] == tagged[i + 1][0]: if tagged[i][1] > tagged[i + 1][1]: return False return True arr = [3, 1, 2, 3, 1] print(is_stable(bubble_sort, arr)) # True print(is_stable(selection_sort, arr)) # False 大概率

注意这里要求排序函数支持对元组按第一项排序,所以我们的实现默认用>或<比较整个元素,不会影响正确性。这个测试在面试复盘时特别好用,也能帮你加深对稳定性的理解。

6.3 我踩过的坑和压箱底的小技巧

最后分享几个实际写过很多遍之后才体会到的点。

第一个是Python里用局部变量消除重复查找。比如在插入排序里,arr[j]在while循环里被频繁读取,你可以先用key保存当前值,再通过“移动”而不是“交换”来减少对列表的写入次数。这个优化在纯Python里效果有限,但养成了习惯,写C/C++时自然也会带上。

第二个是千万别小看函数调用的代价。如果你把len(arr)写成每次循环都执行一次,比如for i in range(len(arr)),Python每次都会调用一次len(),虽然开销不大,但数据规模一大,也是肉眼可见的浪费。更好的写法是提前n = len(arr),只取一次。

第三个是排序函数尽量设计为原地修改,同时在调用前复制一份。我经常看到有人用data = arr“浅拷贝”了一个引用,结果原数组还是被改了。正确做法是data = arr[:]或者copy.copy(arr)。这个细节看起来很基础,但在实际项目中因为传引用导致的bug让人抓狂。

第四个是关于性能测试的启发:你在本地跑普通Python排序时,不要拿内建的sorted来和手写冒泡比较,因为前者是C实现的TimSort,差距太大,这种对比没有意义。要比较就三个手写算法互相比,才能看出O(n²)家族内部的差异。

我个人在实际操作中的体会是:这三种排序,与其背代码,不如亲手写五遍。第一遍照着思路写,第二遍不看代码写,第三遍加上边界条件注解写,第四遍自己跑出稳定性测试,第五遍尝试优化。五遍下来,它们内化成你的肌肉记忆,后面去啃快排、归并、堆排时,你会自然地发现很多“高级技巧”都是对这三种基础思路的改进和取舍。排序这个主题很宽,但根基扎稳了,后面全是顺水推舟的事。

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

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

立即咨询