1. 每天二十分钟的算法课:为什么会先拿选择排序开刀
很多朋友问过我一个问题:你说“每日一算法”,第一道题为什么不是二分查找,不是链表反转,偏偏是看起来最没技术含量的选择排序?
我的回答很简单:因为选择排序是唯一一个从第一行代码起就强制你理解两个核心概念的算法——循环边界和区间维护。别的排序你一上来可以背模板,但选择排序背不了,它的逻辑太直观了,只要理解一遍,你手上就多了一把衡量所有其他排序的尺子。
这个系列我计划连续更新三个月,定位是给有一定编程基础、但没系统学过算法的同学准备的。选排序算法作为起点,是因为它在时间复杂度上是最朴素的O(n²)级别,实现路径最短,但能牵扯出的知识点却一点都不少:稳定性、原地算法、比较次数与交换次数、部分排序优化……后面再学冒泡、插入、归并、快排,甚至堆排序,你都能站在选择排序的肩膀上去理解它们。
2. 手推整轮选择排序:把“最小的挑出来丢前面”做到位
2.1 核心思想:每一轮锁定一个最终位置
选择排序的思路用一句话说就是:在未排序的区间里找出最小的元素,把它放到这个区间的起始位置,然后把区间起始位置向后挪一位,重复操作直到整个数组有序。
这句话看起来跟冒泡很像,但有一个本质区别:冒泡排序是“相邻元素两两比较,大的慢慢往后浮”,一趟内可能发生很多次交换;选择排序是“每轮只找最小值的下标,找到后最多交换一次”。这个区别直接影响了两种算法的性能画像,后面我会具体算这笔账。
我用一个具体例子手推一遍。假设有一个数组:
[29, 10, 14, 37, 13]第1轮(i=0,处理区间是下标0到4):
- 先假设最小元素在下标0,也就是29。
- 从下标1开始逐个比较:10小于29,更新最小下标为1;14大于10,不动;37大于10,不动;13大于10且小于29,但仍大于10,所以最小下标保持为1。
- 本轮结束,最小下标是1,对应元素10。
- 将下标0的元素29与下标1的元素10交换,数组变成
[10, 29, 14, 37, 13]。 - 此时位置0已经确定,它就是整个数组的最小值,以后不再参与任何比较。
第2轮(i=1,处理区间是下标1到4):
- 数组当前为
[10, 29, 14, 37, 13],只看[29, 14, 37, 13]。 - 假设最小值在29(下标1),从下标2开始比较:14小于29,更新最小下标为2;37大于14,不动;13小于14,更新最小下标为4。
- 本轮最小下标是4,对应元素13。
- 把29与13交换,数组变成
[10, 13, 14, 37, 29]。 - 此时位置1确定,它是剩余区间的最小值13。
第3轮(i=2,处理区间是下标2到4):
- 当前
[10, 13, 14, 37, 29],只看[14, 37, 29]。 - 假设最小值在14(下标2),从下标3开始比较:37大于14,不动;29大于14,不动。
- 最小下标就是2,元素14已经在正确位置上,不需要交换。
- 位置2确定。
第4轮(i=3,处理区间是下标3到4):
- 当前
[10, 13, 14, 37, 29],只看[37, 29]。 - 37与29比较,最小下标更新为4。
- 交换37与29,数组变成
[10, 13, 14, 29, 37]。 - 位置3确定,最后一个位置4自然有序,排序结束。
5个元素只需要4轮,最后一个元素不用处理,因为当剩下两个元素时,处理完前一个,后一个的位置也就自动确定了。这个“n-1轮”的结论,很多人写代码的时候会记错成n轮,后面我会在踩坑环节专门讲。
2.2 代码实现:Python与C++的两种写法
理解了手推过程,代码就只是把手动步骤翻译成循环。我用最直白的Python写一个:
def selection_sort(arr): n = len(arr) for i in range(n - 1): # 只需要 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 arrC++版本也很简单,这里我用模板函数写一版,方便直接处理不同数据类型:
#include <vector> template <typename T> void selectionSort(std::vector<T>& arr) { size_t n = arr.size(); for (size_t i = 0; i + 1 < n; ++i) { size_t minIdx = i; for (size_t j = i + 1; j < n; ++j) { if (arr[j] < arr[minIdx]) { minIdx = j; } } if (minIdx != i) { std::swap(arr[i], arr[minIdx]); } } }两个版本的核心逻辑完全一致:外层循环控制“当前要确定哪个位置”,内层循环在未排序区间中做遍历找最小。注意我在交换前加了一个判断if min_idx != i,这个不是性能优化,而是为了减少无意义的自我交换——虽然两个相同的变量交换也不会有问题,但加了这层判断,调试时看到的是更干净的轨迹。
2.3 稳定性的坑:相等元素会被“悄悄换位”
选择排序有一个容易被忽视的性质:它是不稳定排序。什么意思?我用一个经典例子演示一下。
注意看这个数组:
[5, 8, 5, 2]我故意让两个5同时出现。第一轮找最小值时,从下标1开始比较:8大于5不动,第二个5与第一个5相等所以也不会更新最小下标,最后遇到2更新最小下标为3。交换下标0的5和下标3的2之后,数组变成:
[2, 8, 5, 5]看到问题了吗?原来的第一个5(我用红色标记的那个)被换到了下标3,而第二个5落在了下标2。两个等值元素的相对位置发生了变化——这就是“不稳定”的定义。
如果排序对象只是数字,这种不稳定没有影响。但如果排序的是对象,比如按成绩排序的学生列表,成绩相同的两个学生原本按姓名排列的顺序被打乱了,这在某些业务场景下就是不可接受的。为什么选择排序会不稳定?根源在于“跨度大的交换”:你拿很远位置上的元素与当前位元素交换,跳过了中间所有等值元素,自然可能越过它们的相对顺序。
3. 复杂度背后的取舍:选择排序为什么“稳但是慢”
3.1 无论数组是否有序,比较次数都是铁打的O(n²)
这是选择排序最“耿直”的地方:它的比较次数不随输入数据的有序程度而改变。
第一轮要比较n-1次,第二轮n-2次,第三轮n-3次……累计下来:
[ (n-1) + (n-2) + \dots + 1 = \frac{n(n-1)}{2} ]
也就是说,对于一个长度为n的数组,无论它原本是乱的、反序的、还是已经有序的,选择排序都比较固定规模的次数。这一点跟冒泡排序有本质区别——冒泡排序在数组基本有序时,可以通过“本轮没有发生交换就提前结束”来大幅减少比较;而选择排序因为每一轮必须确认“剩余区间的真正最小值在哪里”,无法跳过任何一次比较。
所以它的时间复杂度是稳定的O(n²):最好情况、最坏情况、平均情况全部一样。用大O记号写出来就是:
| 情况 | 时间复杂度 |
|---|---|
| 最优情况(数组已有序) | O(n²) |
| 平均情况 | O(n²) |
| 最坏情况(倒序) | O(n²) |
很多人觉得“数组有序时算法应该快一点”,这在选择排序身上不成立。有序只能让它少做交换,不能让它少做比较。
3.2 交换次数少:比冒泡强在“元素搬运”成本
相比之下,选择排序在交换次数上的表现相当优秀。每一轮最多发生一次交换,整个排序过程最多交换n-1次;如果最小值恰好就在当前位置,那一轮连交换都省了。
冒泡排序的交换则频繁得多,最坏情况下每轮都要冒泡,总交换次数是O(n²)级别。假设数组长度是10000,选择排序最多交换9999次,而冒泡排序可能交换接近5000万次。
这个差距在实际系统中非常明显。如果数组里存放的不是简单的整数,而是体积庞大的对象或结构体,交换一次意味着内存中挪动大量数据,此时交换次数少就成了压倒性的优势。我当年在学校机房用Turbo C写学生管理系统,每条记录几百个字节,排序一万条数据,选择排序能明显感觉到比冒泡快,原因就在这。
空间复杂度方面,选择排序只借助一个临时变量做交换,额外空间是O(1),属于原地排序。这一点在内存受限的嵌入式环境下很宝贵。
3.3 与其他排序算法的横向对比:它站在哪个位置
我把选择排序放进排序家族里做个对照,这样更容易理解它的定位:
| 排序算法 | 平均时间复杂度 | 最坏复杂度 | 空间复杂度 | 稳定性 |
|---|---|---|---|---|
| 选择排序 | O(n²) | O(n²) | O(1) | 不稳定 |
| 冒泡排序 | O(n²) | O(n²) | O(1) | 稳定 |
| 插入排序 | O(n²) | O(n²) | O(1) | 稳定 |
| 归并排序 | O(n log n) | O(n log n) | O(n) | 稳定 |
| 快速排序 | O(n log n) | O(n²) | O(log n) | 不稳定 |
| 堆排序 | O(n log n) | O(n log n) | O(1) | 不稳定 |
注意,这里每一列都不是“越好越好”,而是要结合场景看。比如插入排序在数组基本有序时效率极高,几乎是O(n);归并排序时间上很稳,但要付出O(n)的辅助空间;快速排序虽然有最坏O(n²)的风险,但实践中平均表现优秀。
选择排序在这张表里的角色是:时间上不算快,但交换代价极低、空间占用极小、实现逻辑极简。如果说排序算法是一个工具箱,选择排序就是那把不吃保养、不容易失灵的老扳手——不一定最高效,但一定可靠。
4. 不要小看“笨”算法:选择排序的真实应用场景与变种
4.1 什么时候该用它:数据量小和交换代价高的场景
很多人学完O(n²)级别的排序后会产生一种错觉:这些算法除了考试没有任何价值。实际上,工程里处处需要排序,大型排序框架的“底座”往往就是简单排序。
举个很典型的场景:当数据量小于某个阈值时,高级排序算法会“降级”用插入排序或选择排序。Java的Arrays.sort对长度小于47的数组用的是插入排序,很多内核实现同理——因为递归切分小数组带来的函数调用开销,比直接O(n²)暴力排序还要大。选择排序在小数组上虽然不如插入排序那么快(插入排序在接近有序时表现更好),但它没有“移动元素腾位置”的额外操作,对数组实现来说反而更直接。
另一个场景是记录体积大、交换成本高。回想我在学校写的那个学生管理系统,每条记录几百字节,如果用冒泡去频繁交换,光内存拷贝就够呛;用选择排序每轮只做一次交换,总交换次数线性增长,这就把“比较”和“搬运”解耦了——比较是CPU操作,便宜;搬运是内存操作,贵。
再有就是嵌入式场景。单片机内存常常只有几十KB,一次完整排序可能连临时数组都开不出来,这时候原地排序是硬性要求。选择排序代码量小,逻辑简单,不容易引入隐藏bug,对讲求稳定性的嵌入式固件来说,反而是个稳妥选择。
4.2 双向选择排序:一次循环同时找出最大值和最小值
觉得普通选择排序每轮只确定一个位置太浪费?可以做个简单的优化:每一轮同时找最小值和最大值,分别放到未排序区间的两端,这样每轮能确定两个元素的位置,总轮数减少一半。
def double_selection_sort(arr): n = len(arr) left, right = 0, n - 1 while left < right: min_idx = left 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] # 如果最大值本来在 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位置,第一轮交换会把最大值和最小值对调,此时max_idx就失效了,必须把它指向被交换后的位置(也就是min_idx变成了新的max_idx位置)。这个坑我踩过不止一次,调试了半小时才发现是索引修正的问题。
双向选择的比较次数仍然是O(n²),常数上减少了大约一半,实际测试下来数据量在几万以内时感觉更明显,再大就无所谓了,反正O(n²)的天花板摆在那。
4.3 堆排序与锦标赛排序:选择排序的精神续作
如果你理解了选择排序的核心是“每轮从剩余元素里找最小”,那你就已经握住了一把理解更高级算法的钥匙:堆排序本质上就是选择排序的升级版。
选择排序慢在“每次找最小都要从头扫一遍”,扫描成本O(n),要扫n轮,所以是O(n²)。堆排序做了一个关键优化:用一个二叉堆维护剩余区间的数据,取最小值的时间降为O(log n),所以总复杂度降为O(n log n)。
跟选择排序一样,堆排序也是不稳定的原地排序。如果当年你学堆排序时觉得“诶这思路怎么似曾相识”,恭喜你,这不是巧合,它就是选择排序的亲儿子。
类似的还有锦标赛排序(也叫树形选择排序),把找最小值的比较过程组织成一颗锦标赛树,第一轮找最小值需要n-1次比较,后续每轮只需O(log n)次。代价是额外空间多一些。从选择排序这个原点出发,你顺着“如何更快地找最小值”这条线走下去,就能自然推导出half the排序算法家族的进化史。
5. 那些年我在选择排序上踩过的坑:排查链路与避坑清单
5.1 内外层循环边界写错,导致越界或漏排
这是初学者最容易犯的错误。我见过几种典型写法:
# 错误写法1:外层循环到 n for i in range(n): # 当 i = n-1 时,内层 range(n, n) 为空,没问题 # 但多跑一轮没有任何意义 # 错误写法2:内层循环从 0 开始 for j in range(0, n): # 这样会把已经排好的部分重新考虑进去,逻辑错误推荐的外层边界是range(n - 1)。为什么不是range(n - 2)?因为当i走到n-2时,区间只剩两个元素:下标n-2与n-1。这一轮内层循环会比较这两个元素,完成排序后,下标n-1自然就是最大的,所以没有必要再为最后一个位置单独跑一轮。
内层起始位置是i + 1,这很重要。如果从i开始,数组的第i个元素会跟自己比较一次,不影响结果但浪费一次比较。如果从0开始,会把已排定的前缀区间重新扫一遍,虽然不影响最终结果(已排定的最小值不会被替换掉),但白白增加了比较次数,而且语义就完全错了。
5.2 min_idx忘记更新:排序结果永远是原数组
这个问题看起来蠢,但真的会反复出现。尤其是当你习惯用“找最大值”写内层循环时,容易漏掉更新下标:
def wrong_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]: pass # 忘记赋值:min_idx = j arr[i], arr[min_idx] = arr[min_idx], arr[i] return arr这段代码跑完,数组一点变化都没有。因为min_idx永远等于i,每轮都在做自我交换。排查这种问题的建议是:先打印每一轮min_idx的值,再打印交换后的数组,两步就能定位。
我自己总结的调试诀窍是:给选择排序加一个“轮次日志”,像这样:
def selection_sort_with_log(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 print(f"第{i}轮: 最小元素 {arr[min_idx]} 在位置 {min_idx}") arr[i], arr[min_idx] = arr[min_idx], arr[i] print(f"交换后: {arr}") return arr眼睛能看到的执行轨迹比任何调试工具都直观,特别适合学习阶段。
5.3 稳定性问题引起的业务Bug:一个真实项目经历
我之前在做一个简易比赛成绩表时用选择排序按成绩倒序排选手列表,结果发现两组成绩相同的人,每次跑出来的先后顺序不一样。第一反应是“排序函数写随机了”,排查了很久才发现问题出在校验码使用的对象顺序上。
比赛系统里,每个选手对象包含姓名、组别、成绩。后台要求成绩相同的选手按报名时间先后显示。我用选择排序实现按成绩降序,它确实把成绩排好了,但两个成绩同为80分的人,由于选择排序的不稳定性,在排序过程中数组里的相对顺序被打乱了——分组校验时,后台一直按“原始顺序”比对,两边就对不上了。
最后的解决方案也很简单:对这类需要保持顺序的场景,改用稳定排序算法(比如归并排序或插入排序),或者在排序前先把原始顺序存入对象字段,作为次级排序键。
这个案例给我的教训是:学排序时不能只背“稳定不稳定”这个结论,要真的想清楚不稳定意味着什么样的业务影响。
5.4 找不到那个“提前终止”的开关
刚学完冒泡的人接触选择排序,常常会下意识地找“这轮没交换就可以提前退出”的优化点。但选择排序没有这个退路,原因前面已经说过:每轮必须完整扫描剩余区间才能确认最小值,一轮不交换只能说明最小值恰好在首位,不能说明数组整体有序。
举个例子,数组[1, 2, 3, 5, 4],第一轮找最小发现1已经在正确位置,不交换。如果据此认为数组有序直接退出,那后边的5和4就永远不会被纠正。
这个理解上的坎一定要迈过去,否则你会在选择排序的实现里写出一个看似合理实则错误百出的提前退出分支。
6. 从选择排序出发:我的每日算法学习路径建议
6.1 把复杂度分析当成第一优先
学动作先学解剖学。我的建议是,每学一个算法,先不看代码实现,先在纸上把三个问题写清楚:这个算法的最坏时间复杂度是多少?空间复杂度是多少?是否稳定?记住答案之后,再问自己一句:为什么?
以选择排序为例,如果被问“为什么比较次数固定”,你能回答出“因为每一轮必须遍历剩余区间才能找到全局最小值”,这个理解就到位了。这样的理解比背十遍代码都管用——因为它能迁移。后来你学堆排序时,“为什么堆化的比较更少”就变成同一个问题的子问题:堆把“找最小”从O(n)降到了O(logn)。
6.2 刷题与面试中,选择排序会怎么考
不夸张地说,我在面试中至少遇到过三次和选择排序相关的题目:
一种是直接手写排序,最常见的是“写一个原地排序,不要用库函数”,这时候写选择排序是最稳的,因为代码逻辑简单,没有递归没有辅助数据结构,几乎不可能写崩。
一种是变种题,比如“求数组中第k小的元素”。暴力解法是完整排序再取下标,进阶解法是用堆,最优解法是快速选择。但从暴力解法出发,你能想到的中间形态就是——跑k轮选择排序,第k轮结束时位置k-1上的元素就是第k小的元素。这个思路虽然不够快,但在题目要求“只能交换相邻元素”或“限制内存”时,它可能就是你唯一能写出来的解法。
还有一种是关于数据流的场景,比如“如何在不断插入新数据的场景下维护前10大元素”。如果数据总量少,选择排序的思路完全够用;数据量大时你应该换成堆。但核心理解路径是一样的:选择排序让你理解“维护K个最大元素”的朴素做法,堆排序则是它的优化形态。
6.3 每日一算法的节奏安排与休息日
最后分享一点我自己的坚持经验。每天只投入20到30分钟,三周可以把这个系列的核心排序算法过完,重点是不要贪多。一天吃透一个算法,比一天看五个算法然后全部忘记有价值得多。
我从这个项目里收获最大的,其实不是记住了多少算法的实现,而是养成了一个拆解问题的习惯:拿到问题先问复杂度、问边界条件、问稳定性,再动手写代码。这个习惯远比我背下来的那些模板值钱。
如果你正在读这篇文章,我建议你拿纸笔把今天的数组手推一遍,在纸上画完交换过程,再打开编辑器敲一遍代码。手推这一步不能跳,因为它建立的是对循环边界的肌肉记忆。真正写代码时,边界条件要靠这种记忆来兜底。
明天我会在这个系列里讲插入排序。如果你已经把选择排序的代码写熟练了,到时候你会看到一个很有意思的对比:插入排序在几乎有序的数组上表现惊人,而选择排序无论输入如何都保持匀速。同为O(n²)级别,吃相同的大O,但行为模式完全不同——这正是算法的有意思之处。