你写过选择排序吗?我经常在技术面试的最后环节问这个问题——尤其是对刚毕业的候选人。很多人能背出冒泡排序,但一写选择排序就容易在边界条件上翻车。选择排序不复杂,原理一句话就能说清楚:每一轮从剩下的元素里挑出最小的,放到前面。可恰恰是因为简单,反而特别考验对索引、交换和循环边界的理解。
这篇文章我会从选择排序的核心原理讲起,逐渐过渡到 Python 实现,包括最朴素的“抽出去”版本、面试里真正会用的原地交换版本,以及一个比较装的双向选择版本。还会把复杂度、稳定性、常踩的坑和面试官喜欢追问的变形题全部拆开聊一遍。适合刚学数据结构的初学者,也适合正在准备算法面试的开发者。
1. 选择排序的核心思想:先把“选”这件事想清楚
1.1 一个生活化场景:整理试卷
想象你在整理一摞乱掉的试卷,要求最后按重要程度从左往右排好。最简单的方法是什么?不是从左到右一张一张和后面的比较(那是冒泡的思路),而是先整体扫一遍,把最不重要的那张抽出来放到最左边,然后在剩下的试卷里再扫一遍,抽出第二不重要的放到第二位。每抽出一张,需要处理的区域就缩小一格,直到最后只剩一张,它自动就在正确的位置上。
这就是选择排序最朴素的模型:无序区不断收缩,有序区不断扩大。每一轮只做两件事——在无序区里找到最小值,把它和无序区的第一个元素交换。注意,整轮下来只交换一次,这也是它和冒泡排序最直观的区别。冒泡是“一路比一路换”,选择是“先选定,再交换”。
我遇到过不少人在纸上推演的时候完全明白这个逻辑,但一写代码就乱,原因通常只有一个:没有把“无序区”的边界用变量清晰地表示出来。选择排序的整个代码结构其实就变量 i 代表了当前无序区的起点,所有需要认真处理的索引关系都围绕它展开。
1.2 一组数据的完整变化过程
为了把过程展示清楚,我用一个经典的教学用例:[64, 25, 12, 22, 11]。一共五个元素,需要经过四轮扫描。我把每一轮结束后数组的样子,以及本轮找到的最小值列出来。
| 轮次 | 无序区范围 | 本轮最小值 | 交换位置 | 交换后的数组 |
|---|---|---|---|---|
| 1 | 索引0到4 | 11 | 索引0与索引4 | [11, 25, 12, 22, 64] |
| 2 | 索引1到4 | 12 | 索引1与索引2 | [11, 12, 25, 22, 64] |
| 3 | 索引2到4 | 22 | 索引2与索引3 | [11, 12, 22, 25, 64] |
| 4 | 索引3到4 | 25 | 索引3与索引4 | [11, 12, 22, 25, 64] |
到第四轮结束时,最后一个元素 64 自动在正确位置,不需要再处理。所以在代码里外层循环只需要跑 n-1 次,这就是选择排序循环边界的出处。最后一轮哪怕最小值交换给自己也是允许的,但为了干净,我们在代码里通常加一个 if 判断,相等就不交换。实际工程中这个细节可以减少无意义的写操作,尤其是当排序元素是复杂对象时,交换代价可能远高于一次比较。
2. Python 实现:从直觉版本到标准版本
2.1 版本一:符合直觉的“抽出去”写法
很多初学者第一次写选择排序,脑子里想的并不是交换,而是“每轮选一个最小的放到新列表里”。这完全符合人的直觉,代码也特别好懂。
def selection_sort_simple(arr): result = [] temp = arr[:] for _ in range(len(temp)): min_idx = 0 for j in range(1, len(temp)): if temp[j] < temp[min_idx]: min_idx = j result.append(temp.pop(min_idx)) return result这个版本用 pop 把最小值“抽”出去,再 append 到新列表。逻辑清晰,面试时先写这个版本作为过渡是可以的,但你必须知道它有明显问题。
首先是额外内存。result 和 temp 各占一份空间,空间复杂度从理论上的 O(1) 直接变成 O(n)。其次是运行效率,pop 操作在弹出非末尾元素时会导致后续元素整体前移,这一步本身又是 O(n) 的开销,整个排序的实际工作量会比标准版大不少。第三点比较隐晦:这个版本破坏了稳定性,比如同样数值的元素,用 pop 弹掉第一个后,后面相同值的元素顺序会被打乱。虽然选择排序本来就不稳定,但“本来就不稳定”和“因为实现方式更不稳定”是两回事,面试官追着问时容易说不清楚。
所以我的建议是,这个版本只用来帮助自己理解“选择”这个动作,真要交作业或者上生产代码,用下面这个版本。
2.2 版本二:标准原地交换写法
原地版本的思路是:不需要新列表,直接在原数组上维护两个区域。索引 i 左边是已排序区,i 右边到结尾是无序区。每一轮从无序区找到最小值,与 arr[i] 交换,然后 i 前进一位。
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代码就这么点,但每一行都有讲究。
外层 for i in range(n - 1) 保证最后一轮只剩最后一个元素时不需要再扫描,如果写成 range(n) 也不报错,只是最后一轮在跟自己比较,纯浪费。内层 for j in range(i + 1, n) 的起点是 i+1,目的是从无序区的第二个元素开始比较,避免与自己比较。很多人一开始会写成 range(i, n),后果是多一次没有意义的自比较,在数据量大时积累下来也是一笔开销。
min_idx 的语义是“当前扫描到的最小值所在的位置”。每次找到更小的元素就更新它,而不是直接交换。为什么要先记位置而不是立即交换?因为交换是很重的操作,尤其当 list 里存的是大对象或者嵌套结构时,交换一次可能比比较十次还贵。先记下标,整轮扫完只交换一次,这是选择排序的精髓,也是它和其他排序最大的区别。
我建议你在学这个算法时,往代码里加一行 print,看每一轮数组的变化,配合前面的表格,理解会更扎实。
def selection_sort_debug(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] print(f"第{i+1}轮后: {arr}") return arr运行 selection_sort_debug([64, 25, 12, 22, 11]) 会看到数组像前面表格描述的那样一点点变成有序。这种“肉眼可见”的反馈,比任何调试器都管用。
2.3 版本三:双向选择的“鸡血版”
如果想把代码写得让面试官眼前一亮,可以试试双向选择排序。思路是每一轮同时找最小值和最大值,分别放到无序区的左边和右边,这样外层循环只需要跑一半。
def selection_sort_bidirectional(arr): n = len(arr) left, right = 0, n - 1 while left < right: min_idx = max_idx = left for i in range(left, right + 1): if arr[i] < arr[min_idx]: min_idx = i if arr[i] > arr[max_idx]: max_idx = i arr[left], arr[min_idx] = arr[min_idx], arr[left] if max_idx == left: max_idx = min_idx arr[right], arr[max_idx] = arr[max_idx], arr[right] left += 1 right -= 1 return arr这里有一个特别容易踩的坑:如果最大值一开始就在 left 位置,那么当 left 与 min_idx 交换后,原来的最大值已经跑到了 min_idx 位置。如果直接用刚才记录的 max_idx 去交换,就会把错误的值放到 right 位置。所以交换后必须检查一次 max_idx 是否等于 left,如果是,就把 max_idx 更新为 min_idx。这个细节是很多双选排序翻车的根源。
双向选择排序并没有改变复杂度,仍然是 O(n²),但交换次数进一步减少到大约 n/2 次,在常数系数上比标准版小一点。面试时如果你能写出这个版本并讲清楚 max_idx 的修正逻辑,基本能证明你的代码习惯很好。
3. 复杂度与横向对比:为什么说它“简单但不快”
3.1 比较次数与交换次数:复杂度到底怎么算
选择排序的时间复杂度计算,本质上是统计比较次数。第一轮要比较 n-1 次,第二轮 n-2 次,直到最后一轮 1 次。总次数是 (n-1) + (n-2) + ... + 1 = n(n-1)/2。所以时间复杂度的上界、下界、平均情况全都是 O(n²)。这一点和冒泡排序不一样。
冒泡排序在数组已经有序时,通过一个标志位可以提前退出,最好情况能做到 O(n)。选择排序做不到,因为它每一轮必须完整扫描无序区,才能确定哪个是最小值。哪怕数组原本就有序,选择排序依然要老老实实比较 n(n-1)/2 次。这也能解释为什么有些优化技巧到选择排序这里使不上劲。
交换次数则是另一笔账。每一轮最多交换一次,所以总交换次数最多 n-1 次。对于交换成本远高于比较成本的场景,比如内存中存的是大对象、结构体、复杂的数据记录,选择排序的交换次数优势就体现出来了。这也是它在教学之外仍然有存在价值的一个理由。
3.2 稳定性问题:一个容易忽略的细节
稳定性说的是:排序前两个相等元素的相对顺序,排序后是否保持不变。如果保持不变,称这个排序是稳定的。选择排序不是稳定排序。
我用一个例子演示。假设数组是 [5a, 8, 5b, 2, 9],其中 5a 和 5b 数值相同,但用字母区分先后顺序。第一轮扫描找到最小值 2,与索引 0 的 5a 交换,数组变成 [2, 8, 5b, 5a, 9]。注意看,5a 被换到了 5b 的后面,顺序反了。这就是不稳定。
工程中为什么会关心稳定性?典型场景是排序对象带有多个字段。比如先按成绩排序,成绩相同要保留原来的学号顺序,如果排序算法不稳定,你就得额外处理。后面我会讲一个工程上的变通方案。
3.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) | 稳定 |
冒泡排序的优势是有序时能提前退出,插入排序的优势是对“近似有序”的数据非常友好,只有选择排序是“油盐不进”——无论数据长什么样,都得跑满全部的比较轮回。这也是它常被批评的点。
但选择排序也有自己的优势:交换次数最少,写起来最简单,行为完全可预测。在实际业务里,如果明确知道数组规模很小(比如一百以内),选择排序反而是一种很稳妥的选择,它没有插入排序那么复杂的“后移”操作,代码也几乎不存在隐性问题。
4. 常见错误、工程变通与面试考点
4.1 高频错误一:边界条件写错
选择排序的边界错误基本逃不出这三类:
错误一:外层写成 for i in range(n)。这样会多做一轮无效扫描,最后一个元素会和自己比较、自己和自己交换,虽然结果正确,但白浪费一轮时间。对 n 比较小的数组看不出问题,但随着 n 增大,这种无意义操作会越来越碍眼。
错误二:内层写成 for j in range(i, n)。这样 j 一开始就等于 i,会和自己比较一次。如果数组元素数量很大,每次多一次比较,累计下来就是额外的 n(n-1)/2 次——直接把复杂度变成了两倍。
错误三:忘记更新 min_idx。这是最隐蔽的,代码可能长这样:
for j in range(i + 1, n): if arr[j] < arr[min_idx]: min_idx = j # 这里只更新一次,后续更小的元素被漏掉如果只把第一处更小的元素记下来,后面还有更小的元素时就不会更新了。结果就是排序完成后数组局部有序但整体不对,数据量小的时候肉眼可能都看不出来。排查这种问题时可以打印每一轮选择出来的 min_idx 和对应值,看是不是始终是无序区里的最小值。
我建议你把上面三个错误各写一个版本,然后对同一个乱序数组分别执行,观察输出差异。能亲手制造 Bug 并修复它,比单纯看正确代码印象深得多。
4.2 高频错误二:交换环节踩坑
Python 的元组交换很优雅,但很多从 C 语言转过来的开发者,手里还留着用临时变量交换的肌肉记忆。一旦写成下面这样就会出问题:
# 错误的交换方式 arr[i] = arr[min_idx] arr[min_idx] = arr[i] # 此时 arr[i] 已经是原来的 arr[min_idx]错误原因在于 Python 的等号赋值是顺序执行的,两条赋值语句之间 arr[i] 已经被修改了。第二行再把 arr[i] 赋给 arr[min_idx],实际上两边都是同一个值,等于把元素复制了一份,原数据被覆盖丢失。正确写法是利用元组同步赋值:arr[i], arr[min_idx] = arr[min_idx], arr[i],Python 会先计算右边的元组,再按顺序赋值给左边。
如果你在写版本三那种双向选择排序,还要再小心一层:交换的最小值和最大值可能重叠,自己覆盖自己。我前面提到的 max_idx == left 的修正,就是这种重叠情况的具体处理。写排序算法的时候,把“交换后的索引失效”当成一个常规检查项,能帮你避免一大批隐蔽 Bug。
4.3 不稳定怎么办:两种工程变通方案
有一种很实用的工程技巧,可以强行让选择排序变得稳定。思路是:不要直接交换值,而是给每个元素加一个序号。如果元素本身是元组,可以把原始索引一起放进去比较;如果元素是数字,可以先构建成 (value, index) 的元组列表。
def stable_selection_sort(arr): indexed = list(enumerate(arr)) n = len(indexed) for i in range(n - 1): min_pos = i for j in range(i + 1, n): if indexed[j][1] < indexed[min_pos][1]: min_pos = j indexed[i], indexed[min_pos] = indexed[min_pos], indexed[i] return [val for _, val in indexed]这样做仍然不符合稳定性在严格定义上的要求,但通过把原始顺序作为次关键字参与比较,可以达到“业务上稳定”的效果。代价是需要额外空间和时间,所以一般只在数据量小或者硬性要求稳定时才这么干。
另一种更推荐的做法是:如果业务真的需要稳定排序,直接用 Python 内置的 sorted。Python 的 Timsort 是稳定排序,而且对真实世界的数据模式做了大量优化,绝大多数场景下都比你自己手写排序要快。
4.4 面试官会怎么追问
面试官问到排序算法时,一般不会让你写完就结束。常见追问大概有这几个方向:
第一,问“最好最坏平均复杂度分别是什么”。你要能立刻回答:选择排序三者都是 O(n²)。同时补充说明它最好情况下也不能提前退出。
第二,问“为什么选择排序不稳定”。拿 [5a, 8, 5b, 2, 9] 这个例子现场推一遍即可,这个例子我前面已经写过,建议背下来。
第三,问“如果我要找到数组中第 k 小的元素,你会怎么做”。选择排序可以改造成部分选择排序:外层循环只需要执行 k 轮,每一轮找到当前最小值放到前面,第 k 轮结束时的最小值就是第 k 小的元素。时间复杂度的复杂度从 O(n²) 降到 O(kn),当 k 比较小时效果明显。
第四,问“能不能倒过来选,每次选最大值放到最后”。完全可以,就是把内层条件从找最小值改成找最大值,交换时与 arr[n-1-i] 交换。方向反转不影响复杂度,但能看出你是否真的理解了算法结构。
另外还有一个问题容易被问到:“选择排序和冒泡排序都在原地交换元素,为什么选择排序更快”。答案是冒泡排序每轮可能要交换多次,而选择排序每轮最多一次,交换成本在元素是大对象时差距很大。
我在实际带人和面试中观察到一个规律:能一遍写对选择排序的人,通常不是记忆力强,而是真正理解了两条核心原则——外层循环控制无序区边界,内层循环负责在边界内搜索最值。只要这两条主线刻在脑子里,所有变形题、追问、边界陷阱,都能迎刃而解。
最后分享一个小习惯。我每次拿到一个新的排序算法,都会先用一个长度五六位的乱序数组手推一遍,然后对照代码逐行运行,最后再故意写几个错误版本看看输出有什么不同。这三个步骤做下来,这个算法基本就长在脑子里了。选择排序尤其适合这种训练方式,因为它的代码足够短,容错空间又足够小。等你把选择排序玩熟了,再去碰插入排序、快速排序,会有一种明显轻松很多的感觉。