有段时间我几乎每天都要回答同一个问题:Python 里自己实现排序,先学哪个?我的答案永远是选择排序。原因很简单——它大概是所有排序算法里最贴近人类直觉的一个:把一堆东西按从小到大的顺序排好时,我们不假思索的行为就是"先把最小的拎出来放左边,然后在剩下的里面再找最小的",如此重复。这个动作翻译成代码,就是 Python 选择排序。这篇偏实战的笔记,我会从原理讲起,给出可运行的示例代码,再把复杂度、稳定性、优化方向这些教材常讲但很少讲透的内容掰开揉碎,最后聊一下实际工程里到底什么时候值得用它、面试里可能被追问什么。无论你是刚开始学算法的初学者,还是准备面试的开发者,这篇应该都能帮到你。
1. 选择排序到底在"选"什么:先把思维模型建立起来
1.1 一句话本质和一个生活化类比
选择排序(Selection Sort)的完整定义可以做成一句话:每一轮从未排序区间中选出最小元素,放到已排序区间的末尾,重复 n-1 轮。
这句话里藏着两个核心动作——"选"和"放"。选,是在剩下那一堆里找最小;放,是把它挪到前面已经排好的队伍后面。
类比一下你可能立刻懂了:老师让全班按身高从矮到高排队,最简单的方法不是让大家全部站起来重新两两比较,而是让所有人先站好,老师走到队伍里把最矮的拉到第一位,然后从剩下的人里再找最矮的放到第二位,如此反复。这个过程就是选择排序。它的特点非常鲜明:每一轮只做一次"全局挑选",不会像冒泡排序那样相邻两个反复交换。
1.2 从 [64, 25, 12, 22, 11] 看完整趟选择过程
光讲抽象概念容易飘,我们不如直接拿一个具体数组走一遍。以[64, 25, 12, 22, 11]为例,目标是升序排序。
- 第 1 轮:扫描整个数组,找到最小值 11,下标为 4,与下标 0 的 64 交换,得到
[11, 25, 12, 22, 64] - 第 2 轮:扫描下标 1 到 4 的区间,最小值是 12,下标为 2,与下标 1 的 25 交换,得到
[11, 12, 25, 22, 64] - 第 3 轮:扫描下标 2 到 4 的区间,最小值是 22,下标为 3,与下标 2 的 25 交换,得到
[11, 12, 22, 25, 64] - 第 4 轮:扫描下标 3 到 4 的区间,最小值是 25,下标为 3,本来就在正确位置,不需要交换,得到最终结果
[11, 12, 22, 25, 64]
注意这里的细节:一共进行了 4 轮,而不是 5 轮。当剩下最后一个元素时,它必然是全局最大,无需再处理。这就是为什么外层循环通常是range(n-1)而不是range(n)。
1.3 为什么记录的是"最小值的下标"而不是"最小值本身"
一个初学者很容易踩的思维误区是:既然要选最小,那我直接一个变量存最小值不就行了吗?不行。排序不是让你把最小值打印出来,而是要把元素挪到正确的位置,这意味着你需要知道"它在哪个位置",然后才能完成交换。所以代码里真正维护的是min_idx,即最小值的下标。每次比较时,如果发现arr[j] < arr[min_idx],我们就更新min_idx = j。等到一轮扫描结束,min_idx指向的才是真正的最小值所在位置。
我见过不少同学在这里写出类似min_val的版本,结果陷入"值找到了但位置忘了"的窘境,被迫再用arr.index(min_val)去反查下标,白白多一次 O(n) 扫描。记住:选择排序维护的一直是下标,不是值。
2. 手写 Python 实现:从最直白版本到可以"抄作业"的稳定版
2.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] return arr代码逻辑非常清晰:外层循环i代表当前要确定的位置,也就是"已排序区间的末尾";内层循环从i + 1开始扫描右侧未排序区间,寻找最小值下标;内层结束后,把最小值和arr[i]交换。
这里有一个容易忽略的小细节:range(i + 1, n)为什么从i + 1开始,而不是从i开始?因为min_idx的初始值就是i,也就是说我们默认当前位置就是最小值。内层从i开始纯粹是拿当前值和自身比一次,毫无意义;从i + 1开始才是真正的"和其他元素比较"。
2.2 加一个判断,减少无意义的自我交换
上面的版本即使min_idx == i(也就是最小元素本来就在正确位置),也会执行一次arr[i], arr[i] = arr[i], arr[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这个判断在元素较多时能省下不少无意义的赋值操作。真实业务里如果排序的对象是开销较大的对象——比如包含大量字段的字典,或者后续要触发某些监听逻辑的自定义对象——每次交换的成本都不低,这种小优化价值就体现出来了。
2.3 处理重复元素:稳定版选择排序应该怎么写
经典选择排序是不稳定的,因为交换可能会把后面相等的元素挪到前面相等的元素之前。但如果我们要求排序后相等元素的原始相对顺序保持不变,就需要换一种"搬运"方式:不再做交换,而是做插入。
思路是这样:每一轮仍然找出未排序区间的最小值下标min_idx,但下一步不是直接交换,而是把arr[i]到arr[min_idx-1]这些元素整体向右移动一格,再把最小值放到arr[i]位置。这其实是一种"平移插入"操作。
def selection_sort_stable(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: min_val = arr[min_idx] # 把 i 到 min_idx-1 的元素整体右移一位 for k in range(min_idx, i, -1): arr[k] = arr[k - 1] arr[i] = min_val return arr为什么这样是稳定的?因为移动是"整体平移",相等元素之间谁也不会越过谁,只是各自往右错开一个位置。不过要提醒你,这个版本的代价是内部多了一层for k循环,最坏情况下总移动次数接近 O(n²),常数比经典版本大不少。所以稳定版选择排序更多是为了理解"稳定性"这个理论概念,工程上很少这么用。
2.4 用一段带打印的版本观察执行过程
如果你想真切感受选择排序的每一步,给代码加一行print是最直观的方式:
def selection_sort_verbose(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_verbose([64, 25, 12, 22, 11])输出结果:
第 1 轮: [11, 25, 12, 22, 64] 第 2 轮: [11, 12, 25, 22, 64] 第 3 轮: [11, 12, 22, 25, 64] 第 4 轮: [11, 12, 22, 25, 64]用这种方式观察算法过程,比死记代码强得多。我建议初学者都自己跑一遍这个打印版本,把每一轮的变化都写下来,形成肌肉记忆。
3. 复杂度与稳定性的真相:为什么"三线合一"又"不稳定"
3.1 比较次数恒定:最好、最坏、平均都是 O(n²)
选择排序最特殊的地方在于:它的比较次数完全不依赖输入数据是否有序。
推导很简单:第 1 轮要在 n 个元素中找最小,需要比较 n-1 次;第 2 轮在 n-1 个元素中找最小,需要比较 n-2 次;第 i 轮需要比较 n-i 次。总比较次数为:
(n-1) + (n-2) + ... + 1 = n(n-1)/2无论你给它一个完全逆序的数组,还是给它一个已经排好序的数组,内层循环都踏踏实实跑满,比较次数一模一样。这才是"三线合一"的真正含义——最好情况、最坏情况、平均情况的时间复杂度全是 O(n²)。
这跟冒泡排序、插入排序形成鲜明对比。那两位在数据接近有序时,最好情况可以"偷懒"到 O(n);选择排序完全没这个福气。所以如果你面对的是一份"大部分已经有序"的数据,选择排序绝对是最差的选择之一。
3.2 交换次数至多 n-1:这个优点被严重低估
虽然比较次数很糟糕,但选择排序有个很少被提及的优点:它的交换次数非常少,最多只有 n-1 次。
对比一下冒泡排序,逆序数据下交换次数接近 n²/2;而选择排序每一轮最多交换一次,整个排序过程最多交换 n-1 次。这意味着什么?在"比较成本低、交换成本极高"的场景里,选择排序反而可能比冒泡更快。举个现实例子:如果你排序的不是内存中的整数,而是数据库里的记录,每次交换都对应一次真实的磁盘或内存块移动,那么选择排序的交换次数优势就非常可观。
空间复杂度更不用多说:整个排序过程只需要一个临时变量用来做交换,属于典型的原地排序,空间复杂度 O(1)。
3.3 稳定性被"跨越式交换"破坏的细节
"不稳定"这三个字,光背结论是不够的,你得能举出实例。看这个数组:[5a, 5b, 3],其中5a和5b数值相等,只是用来区分原始顺序。
第 1 轮扫描全部找最小,发现 3 最小,于是把下标 0 的5a和下标 2 的3交换,数组变成[3, 5b, 5a]。问题出现了:原本在前面的5a跑到了5b后面,两个相等元素的相对顺序被颠倒,稳定性丢失。
这就是"跨越式交换"的代价:交换的两个元素中间隔了好几个位置,它会像移花接木一样把后面某个元素一路搬到前面,途中经过的所有相等元素都被"越过"了。所以经典选择排序一定不稳定,这不是实现细节问题,而是算法策略本身决定的。
3.4 与冒泡排序、插入排序的直观对比
| 算法 | 最好情况时间复杂度 | 平均/最坏情况时间复杂度 | 空间复杂度 | 稳定性 | 交换/移动次数 |
|---|---|---|---|---|---|
| 冒泡排序 | O(n) | O(n²) | O(1) | 稳定 | 最坏 O(n²) 次交换 |
| 插入排序 | O(n) | O(n²) | O(1) | 稳定 | 最坏 O(n²) 次移动 |
| 选择排序 | O(n²) | O(n²) | O(1) | 不稳定 | 最多 n-1 次交换 |
这张表值得仔细看几遍。三者虽然都是 O(n²) 级别的排序,但各有各的"脾气":插入排序对近似有序的数据异常友好,冒泡排序能提前终止,而选择排序胜在交换次数少且代码最简单。没有谁是绝对王者,关键在于匹配场景。
4. 让选择排序"少干活"的两个优化方向
4.1 双向选择:每一轮同时找最大和最小
标准的单向选择排序,每一轮只确定一个位置。但如果想减少循环轮数,可以改成双向选择排序:每轮同时找出未排序区间的最小值和最大值,最小值放到最左端,最大值放到最右端,这样每一轮能确定两个元素的位置。
def selection_sort_bi(arr): n = len(arr) left = 0 right = n - 1 while left < right: min_idx = left max_idx = right 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 # 最小值放到 left 位置 arr[left], arr[min_idx] = arr[min_idx], arr[left] # 如果最大值原本在 left 位置,上面的交换会把最大值换走 if max_idx == left: max_idx = min_idx # 最大值放到 right 位置 arr[right], arr[max_idx] = arr[max_idx], arr[right] left += 1 right -= 1 return arr这个版本有个经典的坑必须注意:如果最大值原本就在left位置,第一步交换会把最大值换到min_idx位置,所以第二步前必须修正max_idx。我当年第一次写双向版本的时候就在这里翻过车,排出来的数组完全乱套。这个坑面试官也特别喜欢让人踩。
双向选择的收益是循环轮数从 n-1 减到 n/2,但内层扫描的范围也随之收缩,比较次数大约是单向的一半左右。不过它仍然属于 O(n²),只是常数变小,所以训练意义大于实际应用意义。
4.2 "优化"的边界:比较次数无法减少的本质
很多人会问:能不能不扫描那么多次,直接从剩下的数据里"知道"最小的是谁?很遗憾,在没有任何额外数据结构辅助的情况下,你要在一堆无序数据里确认最小值,唯一可靠的方法就是两两比较。除非你像堆排序那样事先维护一个二叉堆,每次取堆顶都是 O(log n),否则选择排序"每轮扫描找最小"这个行为是躲不掉的。
这就是选择排序的天花板:它可以优化交换次数,但比较次数是硬成本。也正是因为这个原因,工程领域宁可把元素先建堆再去取,也不愿老老实实一遍遍扫全量数据找最小。从选择排序到堆排序的演变,本质上就是"拿预处理的成本换查询的效率"。
4.3 一句话劝退:小数组玩玩,大数组交给 Timsort
我用 Python 写排序相关代码有几年了,最后想对你说的经验是:别用选择排序处理大数组。Python 内置的sorted()和list.sort()底层是 Timsort,混合了归并排序和插入排序思想,对现实数据做了各种优化,无论你手写哪个 O(n²) 算法,在大数据量下都很难打赢它。选择排序适合的场景其实是:数组很小、你需要一个简单可靠且交换次数尽量少的实现、或者你在学习和面试。真到了几十万个元素,直接sorted(arr)是正确做法。
5. Python 特色写法盘点:列表推导式、min() 封装与性能陷阱
5.1 最"偷懒"的写法为什么不可取
Python 有几行就能写完的"伪选择排序",比如:
def selection_sort_lazy(arr): result = [] while arr: min_val = min(arr) result.append(min_val) arr.remove(min_val) return result这个写法非常 Pythonic,看起来逻辑和"每轮挑最小的"完全一致,但性能上是个灾难。min(arr)本身是 O(n) 没错,可arr.remove(min_val)同样需要 O(n) 时间去定位元素并移动后续位置,而它返回的是一个新列表,原地排序的优势也没了。虽然总复杂度仍然是 O(n²),但常数比手写版本大了不少,空间还多一份。
如果面试时你写出这种版本,面试官大概率会追问:"那remove的复杂度是多少?"如果你答不上来"最坏 O(n)",印象分会大打折扣。所以在面试场景里,我还是建议老老实实写两层循环加交换。
5.2 列表推导式实现:花哨但仅适合教学
还有一类更炫的写法:
def selection_sort_comprehension(arr): return [arr.pop(arr.index(min(arr))) for _ in range(len(arr.copy()))]这个版本通过列表推导式不断弹出最小值来构造结果。看起来一行搞定,但实际上隐藏着三个问题:第一,arr.copy()和arr.pop()会改变原列表,副作用很大;第二,arr.index()和min()分别做了两次扫描,常数翻倍;第三,推导式内部无法轻易打断或调试,出错之后排查起来很痛苦。
这种写法我一般只在"展示 Python 语言表达能力"的场合用一下,日常项目里谁要这么写我会劝他换回sorted()。
5.3 理解 sorted 和 sort 的区别是排序学习的第一步
最后说一个很多初学者会混淆的点。Python 里有两个内置排序工具:
sorted(arr):返回一个新的已排序列表,不修改原列表arr.sort():就地修改原列表,返回值是None
选择排序的两个版本正好对应这两种模式:我们的示例属于sort风格,直接原地修改列表。如果你希望不改动原列表,就自己先arr[:]拷贝一份再排。这个"原列表会不会被改动"的问题在实际项目里很关键,很多人因为没搞清楚这个,调试了半天才发现原始数据被悄悄改了。
6. 工程与面试中的正确打开方式:选它不选它的理由与高频坑
6.1 三个真正适合选择排序的场景
选择排序在工程里的存在感确实不高,但也不是完全没用。我个人经历过三个还算合理的场景:
一是交换成本远高于比较成本的场景,比如排序的元素是巨大的对象或存储在外部设备上的记录,此时选择排序"交换最多 n-1 次"的特性是货真价实的优势。
二是数据量很小且对稳定性无要求的场景。比如你写一个工具函数,要排一个二十个元素的列表,没有外部依赖,手写个选择排序完全够用,而且它代码短、逻辑直白,后人维护起来零负担。
三是教学与面试。选择排序是理解"结构化思考"的敲门砖,它帮你建立"先想清楚每一步在做什么,再翻译成代码"的思路,这个能力迁移到任何算法上都受益。
6.2 面试追问会围绕哪些点展开
以我面试别人和被人追问的经验来看,关于选择排序,面试官最喜欢连环追问三个问题:
第一个是"复杂度是多少?"你需要能立刻说出最好 O(n²)、最坏 O(n²)、平均 O(n²)、空间 O(1)。如果还能补充"比较次数固定为 n(n-1)/2,交换次数至多 n-1",会显得你真的懂,而不是只会背结论。
第二个是"稳定吗?为什么?"你要能说出"不稳定,因为跨越式交换可能改变相等元素相对顺序",最好顺手举一个[5a, 5b, 3]这样的例子现场演示。
第三个是"它和冒泡排序相比有什么优劣?"这里的关键是突出"交换少但是比较多"的权衡,以及"冒泡可以提前结束,选择排序每一轮都必须扫完全部剩余区间"的差异。
6.3 四个高频 Bug:边界、起点、交换与返回值
手写选择排序时最容易出错的四个地方,我几乎在每个人身上都见过,这里集中列一下:
- 外层循环写错:写成
range(n)会导致最后一轮做一次无意义的自我扫描,虽然结果没错,但不够规范。正确写法是range(n-1)。 - 内层起点写错:写成
range(i, n)而不是range(i + 1, n),浪费一次比较。严格来说不算错,但面试官会盯着这种细节。 - 交换位置写错:交换时把
arr[i], arr[min_idx]写反,或者先用临时变量tmp = arr[i]却忘记赋值回去,都会造成数据丢失。 - 返回值问题:函数在就地排序后没有
return arr,调用方以为拿到None再去使用,直接报错。如果你设计的是原地修改,最好在文档里写明;如果你希望返回新列表,记得先拷贝。
6.4 用断言函数验证排序结果,养成测试习惯
最后送你一个我在练习排序算法时必用的验证方法:
import random def is_sorted(arr): return all(arr[i] <= arr[i + 1] for i in range(len(arr) - 1)) def test_selection_sort(sort_func, num_tests=1000, size=20): for _ in range(num_tests): arr = [random.randint(-100, 100) for _ in range(size)] expected = sorted(arr) sort_func(arr) assert arr == expected, f"排序失败: {arr} != {expected}" print("全部测试通过") test_selection_sort(selection_sort) test_selection_sort(selection_sort_bi)这段代码用sorted()作为标准答案,随机生成 1000 组数组反复测试。等你以后写快排、归并排序、堆排序时,这个测试函数可以直接复用,改一行参数就能验证任何排序算法。写算法不要只靠眼睛看,让机器帮你验证才是效率最高的方式。
最后再分享一点个人体会:我最早学选择排序时总觉得它没什么用,直到后来在某次数据迁移任务里,面对一批"量不大但交换代价极高"的记录,才真正体会到它"少交换"这个特性的价值。从那以后我养成了一个习惯——学任何算法先问三个问题:它省的是什么资源?它费的又是什么资源?什么场景下这两个"省与费"恰好匹配?想清楚这三个问题,比单纯背十个算法都管用。