☰
选择排序与冒泡排序:从原理到稳定性的基础排序算法详解
2026/10/21 3:15:44 网站建设 项目流程

1. 选题解读:为什么要死磕这两个“老古董”排序

基础算法集训进入第 04 天,轮到选择排序和冒泡排序登场。很多同学一看到这两个名字就皱眉:都什么年代了,业务代码里根本不会手写这俩玩意儿,Java 里Arrays.sort()、Python 里sorted()一把梭,干嘛还要花一天时间去背这种“面试才会考”的算法?

这个想法我特别理解,但真在集训里带过几轮之后,我反而越来越坚定地把这两个“老古董”放在第四天来讲。原因很简单:这两个排序算法是理解算法复杂度和稳定性的最佳样本。它们代码短、逻辑直观、调试容易,但又恰好能逼你去思考交换次数、比较次数、最好情况和最坏情况这些最基础也最核心的概念。换句话说,它们是用来建立“算法思维直觉”的杠杆点,不是用来写进生产代码的。

先说清楚它们解决什么问题。选择排序的核心思路是“每轮挑出剩余元素中的最小值,放到正确的位置”;冒泡排序的核心思路是“相邻元素两两比较,把较大的元素一路往后顶”。两种算法都能把一组无序数据整理成有序序列,而且都是原地排序,不需要额外的大块内存。但它们在比较次数、交换次数、稳定性这些维度表现完全不同——这些差异就是今天要拆透的重点。

这篇内容适合谁?零基础刚接触算法的新手、准备面试的应届生、以及想把排序这块基础补扎实的开发者。如果你已经能闭着眼睛写出快排和归并,那可以跳过今天的代码部分,但后半段关于稳定性和优化细节的对比分析还是值得一看——很多人在面试里翻车,恰恰是栽在这些不起眼的细节上。

2. 整体设计与方案选型:先搭骨架,再填血肉

2.1 为什么选择“先比较,后交换”作为教学主线

在集训设计里,第四天放在第三天的基础数据结构(数组、链表)之后、第五天的插入排序和希尔排序之前,这个顺序是有讲究的。选择排序和冒泡排序都基于数组这种最简单的存储结构,只涉及相邻或全局的比较与交换,不涉及复杂的内存移动操作,也不涉及递归和分治思想。拿它们作为“排序”这个主题的开场,既不会让新手瞬间懵掉,又能顺利引出后续插入排序那类“局部有序”的思路演进。

从方案选型的角度看,这两兄弟其实是两种截然不同的策略代表:

  • 冒泡排序:每一轮只处理相邻元素,通过反复“挤压”把最大/最小值送到边界。它是“局部交换逐步扩散到全局”的思路,缺点是计算量恒定且大,但有提前退出的优化空间。
  • 选择排序:每一轮全局扫描剩余区间,记录最小值的位置,最后只交换一次。它是“减少交换次数”的思路,牺牲了比较次数上的可优化性,换来了最稳定的交换行为。

这两种策略对比着学,你会发现“排序”这件事没有唯一的答案——在不同的约束条件(内存、数据规模、稳定性要求)下,不同的算法有各自的偏科。这种“不是非此即彼”的训练,恰恰是算法学习的核心价值。

2.2 从复杂度视角看选型背后的权衡

作为从业者,我习惯在讲任何排序时都先把复杂度摆上桌。先给结论,再逐步拆解计算过程:

指标选择排序冒泡排序
最好时间复杂度O(n²)O(n)——提前退出优化后
平均时间复杂度O(n²)O(n²)
最坏时间复杂度O(n²)O(n²)
空间复杂度O(1)O(1)
是否稳定不稳定(实现不当会丢稳定性)稳定
每轮交换次数最多 1 次可多轮交换

先解释清楚“为什么选择排序最好也是 O(n²)”。因为它每一轮都要从剩余区间里“扫描到底”找最小值,哪怕数据已经全部有序,这个扫描动作一个字节都省不了。它不知道也不需要知道数据的状态——这正是它简单但低效的根源。

再来看冒泡排序为什么在有序时能变快。冒泡排序每轮会比较相邻两个数,如果这一轮下来一个交换都没发生,说明所有元素都已经排好序了,整个算法可以直接结束。这个“提前退出”的逻辑非常容易实现,也是它在面试题里最常见的变形点。关于这一点,后面实战部分我会给出代码示例。

复杂度这个事,很多人是背结论背出来的,但我建议你动手数一次。下文实操部分我会带着你手跑一轮完整流程,把每一轮的比较次数、交换次数都列出来,这样你就知道 O(n²) 到底是怎么从代码里“长”出来的,而不是从 PPT 里抄下来的。

3. 核心细节解析与实操要点

3.1 冒泡排序的原理拆解:相邻交换如何“冒”出顺序

冒泡排序的逻辑一句话就能说清:从第一个元素开始,依次比较相邻两个元素,如果前一个比后一个大,就交换它们,这样一轮下来,最大的元素就被“推”到了最后。接着从头再来一轮,把第二大的元素推到倒数第二位,如此往复,直到没有需要交换的元素为止。

但这句话背后有个新手很容易忽略的细节——内层循环的范围每一轮都在缩短。第一轮可能会比较到下标 n-1 和 n-2 这一对,交换结束后最大元素落在 n-1 位置;第二轮就不需要再碰 n-1 和 n-2 这一对了,因为最大元素已经到位。所以内层循环的终点是n-1-i,这个i是已经完成的轮数。很多人写冒泡排序翻车,就是忘掉这个不断收缩的区间,导致不必要的比较,甚至出现数组越界。

我用一个生活化的例子帮助理解:想象一排在泳池边排队的人,从第一个位置开始,每个人都和旁边的人比身高,高的人往后挪一步,挪到不能再挪为止。一轮走完,最高的人就像气泡一样浮到了队尾。再来一轮,第二高的人也浮到队尾前一个位置。这就是“冒泡”的含义——大的元素像气泡一样往上升。

代码长这个样子:

def bubble_sort(arr): n = len(arr) for i in range(n - 1): # 外层循环:总共需要 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

很多教材在讲冒泡排序时是两层for死板嵌套,不写swapped标志位。这里我必须强调:不加标志位的冒泡排序和加了标志位,在时间复杂度上完全是两个物种。前者即使数组已经有序,也老老实实跑完n(n-1)/2次比较;后者在最好情况下只需要n-1次比较,一轮扫描发现没交换直接返回。所以你在面试里千万不要把最基础的版本写漏了优化标记。

3.2 选择排序的原理拆解:人肉挑选最小值

选择排序的思路更加“简单粗暴”:每一轮从待排序区间里找到最小元素的下标,等这一轮扫描结束,把最小元素和当前区间首位交换。稍微形象一点说,相当于你在一堆牌里一张一张看过去,记住最小牌的位置,看完一整轮之后,把这张牌放到最前面,然后处理下一堆牌。

代码同样不长:

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 if min_idx != i: # 只有一个目标交换点 arr[i], arr[min_idx] = arr[min_idx], arr[i] return arr

选择排序最反直觉的地方在于:交换次数极少,但比较次数一棵树是死的。不管数据是有序还是完全逆序,外层循环走n-1轮,内层循环每轮走n-i-1趟,总的比较次数恒定为n(n-1)/2。它没有办法提前退出,因为哪怕剩下的元素已经是有序的,程序依然需要遍历一遍才能“确认”这一点。这就是刚才表格里“最好时间复杂度也是 O(n²)”的来源。

我之前遇到一个来集训的学生,他实现选择排序时在每轮比较中直接交换,也就是发现一个更小的值就立即换过来。这确实也能得到有序结果,操作次数甚至更像“选择”,但它有两个坏处:一是交换次数从最多 n 次暴涨到接近比较次数的一半,失去了选择排序交换成本低的优势;二是整个排序过程会变得非常不稳定——原本已经归位的小元素可能又被后面发现的更小元素顶出去。所以请记住,选择排序的标准写法一定是“记录下标,一轮完事后再交换”,不是“边扫边换”。

3.3 稳定性差异:冒泡稳,选择不稳

“稳定性”这个词在排序里指的是:如果两个元素值相等,排序后它们的相对顺序是否保持不变。保持了,就叫稳定排序;不保持,就叫不稳定排序。它是面试里最容易突然被追问的一个概念,也是两种算法差异最具代表性的一个点。

冒泡排序比较时用的是>而不是>=,所以值相等的元素永远不会交换位置,相对顺序自然保持不变——稳定。

选择排序呢?假设数组里有[3, 1, 2, 3'],第一轮从头找到的最小值是下标 1 的那个1,和位置 0 的3交换一下,数组变成[1, 3, 2, 3']。到这都没问题。但想象另一个场景,数组[3, 1, 3', 0]里第一轮找到最小值下标是 3 的0,和位置 0 的3交换,数组变成[0, 1, 3', 3]。此时原来的3出现在3'后面,两个值相等的元素相对顺序反了——不稳定。

稳定性的实际意义在于:当你有主关键字和次关键字两段排序需求时,稳定排序可以保证“先按次要字段排一次序后再按主要字段排序”的累积效果。比如先按时间再按用户排序,如果是稳定排序,时间相同的记录会保留用户排序的相对顺序。面试里让你手写排序算法时,主动问一句“对稳定性有要求吗”,是非常加分的表现。

3.4 两种算法的时间复杂度推导方法

既然开篇说复杂度不是用来背的,我就在这里演示一次从代码到 O(n²) 的推导过程,以冒泡排序不加优化的情况为例。

外层循环执行n-1轮。第 1 轮内层循环比较n-1次,第 2 轮比较n-2次,……第n-1轮比较 1 次。总比较次数是等差数列求和:

(n-1) + (n-2) + ... + 1 = n(n-1)/2

在系数和低阶项不影响增长趋势的前提下,这个数就是 O(n²)。选择排序的比较次数同样是n(n-1)/2,但交换次数最多只有n-1次。所以如果你面对的数据是“交换代价极其高昂,但比较代价低”的场景(比如元素是非常重的对象,比较只是读一个 ID),选择排序反而可能是更理性的选择。

冒泡排序不优化时,比较次数相同,但每次遇到逆序都要交换,最坏情况下逆序对数量为n(n-1)/2,所以交换次数也可能达到平方级。这就是为什么同样是 O(n²),实际执行时间在真实数据上能有肉眼可见的差距。空间复杂度都是 O(1),只额外占了一个零时变量用于交换。

4. 实操过程与完整实现

4.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 已经到达末尾。

第二轮:比较 1 和 4,不交换。比较 4 和 2,交换,数组变为[1, 2, 4, 5, 8]。比较 4 和 5,不交换。比较 5 和 8,不交换。这一轮发生了交换,所以继续。到了第三轮,比较 1 和 2、2 和 4、4 和 5,全都没有交换,此时swapped为 False,直接退出。

这里有一个非常典型的教学点:数据集合是否有序,冒泡排序和选择排序的“命运”大不相同。数组在第二轮结束就已经完全有序,但第三轮仍然需要扫描一次才能确认“无需交换”。这多出来的一轮是必要的,它换来的结果是“确认有序”这件事本身的证明,而不是多余开销。这也是swapped标志位设计上的精妙之处——它不只是为了优化,还承担了“判断结束条件”的任务。

4.2 手跑一轮选择排序:每轮只换一次

还是[5, 1, 4, 2, 8]。第一轮,i = 0,初始min_idx = 0。扫描下标 1 到 4:1 < 5,更新min_idx = 1;4 > 1,不更新;2 > 1,不更新;8 > 1,不更新。扫描结束,min_idx = 1,交换下标 0 和 1,数组变为[1, 5, 4, 2, 8]。

第二轮,i = 1,初始min_idx = 1。扫描下标 2 到 4:4 < 5,更新min_idx = 2;2 < 4,更新min_idx = 3;8 > 2,不更新。扫描结束,min_idx = 3,交换下标 1 和 3,数组变为[1, 2, 4, 5, 8]。

第三轮,i = 2,扫描下标 3 到 4,5 > 4、8 > 4,最小值还是 4 自己,min_idx == i,不需要交换。第四轮同理。最终得到[1, 2, 4, 5, 8]。

你可以清楚看到,第二轮结束后数组已经有序,但选择排序的第三轮、第四轮依然要继续扫描剩余区间。这是它“无法提前退出”的直观体现。所以如果面试官问“这个选择排序能不能优化提前退出”,答案是不行——除非你在每轮扫描中去额外判断“剩余区间是否有序”,但那样等于在算内部循环里跑一次快速检查,得不偿失。

4.3 数据规模不同,选择策略完全不同

刷过题、处理过真实数据之后你会明白,选择排序不是“一无是处”。我做一个场景模拟来帮你看清这一点:假设数组元素是一个个重量级对象,比较它们的大小只需要读取一个整数 ID,但“交换”意味着要搬动整块对象的内存。这时候选择排序的优势就体现出来了——它每轮最多交换一次,总共最多n-1次交换,而冒泡排序在最坏情况下可能需要将近n²/2次交换。

用数字说话:n = 1000,选择排序最多交换 999 次;冒泡排序最坏情况交换 499500 次。哪怕单次交换成本是 10 毫秒,前者也就 10 秒,后者却要 4995 秒。所以选择排序更适合“比较廉价、交换昂贵”的场景,而冒泡排序更适合理解“相邻交换如何让元素逐步归位”的教学场景。

4.4 完整可运行的代码示例与验证方法

这里我给出两个可以直接跑的 Python 版本,带有逐步打印功能,方便你在本地环境调试观察排序过程。实战建议:先别急着看后面的内容,把这段代码跑起来,输出每一轮数组变化,再去对照上面的手跑过程,比对着把数组画出来,收获会翻倍。

def bubble_sort_with_log(arr): n = len(arr) print("初始数组:", 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: print(f"第 {i + 1} 轮无交换,提前终止") break return arr def selection_sort_with_log(arr): n = len(arr) print("初始数组:", 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] print(f"第 {i + 1} 轮结束:", arr) return arr test1 = [5, 1, 4, 2, 8] test2 = [5, 1, 4, 2, 8] print("=== 冒泡排序 ===") bubble_sort_with_log(test1) print("\n=== 选择排序 ===") selection_sort_with_log(test2)

验证方法上,除了肉眼观察,我建议你写一个随机测试工具:生成随机数组,用arr_sorted == sorted(arr)来断言结果正确。但这里有个细节——如果数组里允许有重复元素,sorted()也能通过,所以断言结果正确没问题;但如果要在验证稳定性,就得多构造一个“带编号”的数据结构来测试。具体代码如下:

import random def verify_sort(sort_func, n=1000, trials=100): for _ in range(trials): arr = [random.randint(0, 1000) for _ in range(n)] expected = sorted(arr) sort_func(arr) assert arr == expected, f"排序失败: {arr}" print("全部通过")

跑起来后你会发现,随机数据下两种算法都能正确排序,但耗时差距在 n 到达 5000 以上时开始肉眼可见。这比任何文字描述都更有说服力。

5. 常见问题与排查技巧实录

5.1 内层循环边界写错导致的数组越界

这是新手最常见的一个错误。写冒泡排序时,如果把内层循环写成for j in range(n - 1),每一轮都跑满整个数组范围,会怎么样?第一轮没问题,但第二轮时最后一个元素已经排序好,再去比较arr[n-1]和arr[n],越界报错,或者虽然没报错但比较了完全无意义的数据。

正确写法是for j in range(n - 1 - i),每轮边界收缩。很多教材只给代码不解释这个-i的由来,所以我多说一句:i是已经“归位”的元素数量,这些元素在数组尾部,无需再参与后续比较。

选择排序也有类似的问题——内层循环必须从i + 1开始,否则会重复比较自己。虽然不越界,但会多出n-1次无意义比较。它不会导致结果错误,但复杂度多了一个常数项。

5.2 交换写错位置,排序结果“时对时错”

选择排序最容易犯的错误,是在内层循环中发现更小元素就立即交换,而不是记录下标到本轮结束再交换。比如:

# 错误示范:边扫边换 for i in range(n - 1): for j in range(i + 1, n): if arr[j] < arr[i]: arr[i], arr[j] = arr[j], arr[i]

这种方式其实叫做“简单选择排序的劣化版本”,它会破坏前面几轮已经摆好的较小元素位置。用[3, 1, 2]测试,第一轮i=0时,arr[1]=1和arr[0]=3交换,数组变[1, 3, 2];接着j=2时,arr[2]=2和arr[1]=3交换,数组变[1, 2, 3]。第二次比较仍然在i=0这一轮内进行,但是把i位置的值换了又换,最后结果碰巧是对的。

但如果数组是[3, 2, 1],第一轮扫描内,j=1时交换成[2, 3, 1],j=2时交换成[1, 3, 2],第一轮结束得到[1, 3, 2];第二轮i=1,比较arr[2]=2和arr[1]=3,交换成[1, 2, 3]。碰巧也对。

那什么时候会错?当“当前最小值本来就在前面,但后续又出现一个更小值”时,会多做了无用功,却不会错;真正会错的是你连“是否应该交换当前轮首位”都没判断就无条件交换。比如数组[1, 3, 2],第一轮i=0扫描j=1,3 > 1不换;j=2,2 > 1不换;结果min_idx还是 0,不交换,结果正常。这种实现“碰巧都对”的概率极高,因此很多人根本发现不了问题,直到数据量变大、重复值变多才偶现异常。

我的建议是:一定要记录min_idx、本轮结束再交换,不要贪图代码短。标准写法虽然多两行,但避免了所有边界问题。

5.3 使用引用传参导致原始数据被修改

Python 里排序函数直接修改传入的 list,数组是在原地变形的,这不算 Bug,但如果你希望在排序后保留原始数组做对比,就得先拷贝一份:

original = [5, 1, 4, 2, 8] test = original[:] bubble_sort(test) print(original) # 原数组 print(test) # 排序后数组

这一点在写验证逻辑和单元测试时尤其重要。很多人因为排序函数原地修改了数组,导致后面读原始数据读到的是排序后的内容,排查半天才发现问题。

5.4 稳定性判断:拿重复元素做测试

我之前带集训时就发现,有同学听完稳定性概念后,仍然只能靠背结论来记住“冒泡稳定、选择不稳定”。这类理解一旦脱离概念就失效。建议你写一个验证脚本,给每个元素附加一个“序号”来判断相对顺序:

def stability_test(sort_func): arr = [(1, "A"), (3, "B"), (1, "C"), (2, "D")] sort_func(arr, key=lambda x: x[0]) one_positions = [i for i, item in enumerate(arr) if item[0] == 1] # 如果 one_positions 中 "A" 在 "C" 前面,则稳定;否则不稳定 print(arr) return one_positions

冒泡排序输出[(1,"A"), (1,"C"), (2,"D"), (3,"B")],选择排序在不加特殊处理时一般会输出[(1,"C"), (1,"A"), (2,"D"), (3,"B")]这类结果——因为选择了“最后一个最小值”还是“第一个最小值”会影响稳定输出的测试。这里有个知识点:如果你在找最小值时用if arr[j] < arr[min_idx],会保留第一个最小值,但如果用<=,会保留最后一个最小值,这两种写法都可能不满足稳定性。测试脚本就是检验理解的最好工具。

5.5 排序在现场“卡住”的优化建议

有些实际场景中,你用冒泡排序应对几万条数据,会觉得运行时间不可接受。这时候倒不必更换算法,可以先用“提前退出”优化。但要注意,冒泡排序对于“几乎有序”的数据优化效果非常明显——最好情况 O(n)、最坏情况不变。可如果你手上的数据是一串随机打乱的 10 万级数组,冒泡排序再怎么优化也救不回来。该换快排就换快排,别为了让代码跑完去硬等。

选择排序对于“几乎有序”的数据也毫无反应,因为它的最优、最坏复杂度是一样的。它适合的数据形态是:比较开销小、交换开销大、数据规模小(几千量级以内)。一旦超过这个量级,直接用内置排序就完了——这些训练的意义不在于让你替换内置排序,而在于理解内置排序为什么更快、更稳定、更通用。

扩展阅读与后续延伸

学完这两种排序,下一步建议你对比插入排序。插入排序同样也是 O(n²) 级别,最好情况 O(n) 且稳定,但它比冒泡和选择都更“聪明”——它利用局部有序来减少移动次数,实际表现经常是三者中最优秀的。

再往后走,需要理解堆排序:选择排序每轮要花 O(n) 找最小值,堆排序用堆这种数据结构把找最小值的代价降到了 O(log n),于是总复杂度从 O(n²) 降到了 O(n log n)。如果你把选择排序和堆排序放在一起学,你对“数据结构如何优化算法”这件事的理解会非常立体。

关于稳定性,稳定排序的代表是归并排序;不稳定但极快的代表是快速排序、堆排序。各有各的应用场景,理解了为什么,才能在实际工程里做出正确取舍。基础算法这一关绕不过去,但也不用怕——把今天这两个“入门版本”的原理吃透,后面的路会顺畅得多。

结合以往经验,我补充一点:集训时最好每天花 10 分钟,把当天的算法用不同语言各写一遍。我用 Python 写完会用 Java 和 JavaScript 各写一次,虽然慢一点,但能让你分清哪些问题是语言特性造成的,哪些是算法本身的逻辑问题。今天我们讲的这些代码逻辑在 C 语言里同样成立,只是 JVM 或解释器对内存的处理差异可能让性能表现不太一样,但复杂度分析是不变的。

对排序算法这种基础内容,多动笔多调试要比多听课更重要。代码敲坏了不要紧,报错信息就是最好的老师——特别是数组越界和交换位置写错这两类问题,反复错几次,潜意识里就会留下肌肉记忆,下次写的时候手会自动避开。

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

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

立即咨询