选择排序(Selection Sort)
选择排序(Selection Sort)
一、算法思想
选择排序的策略比冒泡排序更"果断":每一轮从未排序区域中选出最小的元素,直接放到已排序区域的末尾。
冒泡排序是"边走边换",每对相邻元素都可能触发交换;选择排序是"先看后选",一轮只在最后做一次交换。
以 [5, 3, 8, 1, 2] 为例:
第1轮: 从 [5, 3, 8, 1, 2] 中找到最小值 1,与第1位 5 交换
→ [1, 3, 8, 5, 2]
第2轮: 从 [3, 8, 5, 2] 中找到最小值 2,与第2位 3 交换
→ [1, 2, 8, 5, 3]
第3轮: 从 [8, 5, 3] 中找到最小值 3,与第3位 8 交换
→ [1, 2, 3, 5, 8]
第4轮: 从 [5, 8] 中找到最小值 5,已在正确位置,无需交换
→ [1, 2, 3, 5, 8]
排序完成!
核心步骤拆解:
- 将数组分为已排序区(左侧)和未排序区(右侧)
- 每轮遍历未排序区,找到最小值的下标
- 将最小值与未排序区的第一个元素交换
- 已排序区向右扩展一位
二、Go 实现
2.1 基础版本
package main
import "fmt"
// SelectionSort 选择排序
func SelectionSort(arr []int) {
n := len(arr)
for i := 0; i < n-1; i++ { // 外层:已排序区的右边界
minIdx := i // 默认当前最小值在位置 i
for j := i + 1; j < n; j++ { // 内层:在未排序区中寻找更小值
if arr[j] < arr[minIdx] {
minIdx = j // 更新最小值下标
}
}
if minIdx != i { // 如果最小值不在位置 i,交换
arr[i], arr[minIdx] = arr[minIdx], arr[i]
}
}
}
func main() {
arr := []int{5, 3, 8, 1, 2, 7, 4, 6}
fmt.Println("排序前:", arr)
SelectionSort(arr)
fmt.Println("排序后:", arr)
}
运行结果:
排序前: [5 3 8 1 2 7 4 6]
排序后: [1 2 3 4 5 6 7 8]
2.2 双向选择排序(鸡尾酒选择排序)
每一轮同时选出未排序区的最小值和最大值,分别放到左端和右端,这样可以减少一半的轮数:
// SelectionSortBidirectional 双向选择排序
func SelectionSortBidirectional(arr []int) {
n := len(arr)
left, right := 0, n-1
for left < right {
// 同时找最小和最大
minIdx, maxIdx := left, right
for j := left; j <= right; j++ {
if arr[j] < arr[minIdx] {
minIdx = j
}
if arr[j] > arr[maxIdx] {
maxIdx = j
}
}
// 先交换最小值到左端
arr[left], arr[minIdx] = arr[minIdx], arr[left]
// 注意:如果 maxIdx == left,刚才的交换已经把最大值移到了 minIdx 位置
if maxIdx == left {
maxIdx = minIdx
}
// 再交换最大值到右端
arr[right], arr[maxIdx] = arr[maxIdx], arr[right]
left++
right--
}
}
关键陷阱:当最大值恰好在 left 位置时,第一次交换会把它移走,所以需要修正 maxIdx。这是双向选择排序最容易出 bug 的地方。
三、复杂度分析
| 情况 | 时间复杂度 | 说明 |
|---|---|---|
| 最坏情况 | O(n²) | 完全逆序,每轮都要全量扫描 |
| 最好情况 | O(n²) | 已排序,每轮仍需扫描确认最小值 |
| 平均情况 | O(n²) | 每轮扫描约 n-i 个元素 |
| 空间复杂度 | O(1) | 原地排序 |
3.1 为什么最好情况也是 O(n²)
这是选择排序和冒泡排序的关键区别!冒泡排序(优化版)在已排序数组上只需一轮就能检测到没有交换并提前终止——O(n)。但选择排序不管数据是否已排好,每一轮都必须扫描完整个未排序区域来确认最小值位置。
即使 [1, 2, 3, 4, 5] 已经有序,第 1 轮还是要从头到尾扫一遍确认 1 是最小值,第 2 轮还要扫一遍确认 2 是次小值……
3.2 比较次数
- 第 1 轮比较 n-1 次
- 第 2 轮比较 n-2 次
- ...
- 第 n-1 轮比较 1 次
总比较次数 = n(n-1)/2 → O(n²)(无论什么输入)
3.3 交换次数
选择排序每轮最多交换 1 次,总交换次数最多 n-1 次 → O(n)
这是选择排序相对于冒泡排序的优势——交换操作远少于冒泡。在交换代价高的场景(如交换大对象、交换涉及磁盘IO)中,这个优势有意义。
四、稳定性分析
选择排序是不稳定的。
原因在于交换可能跨越相等元素。看这个例子:
原始: [5a, 5b, 3, 2] (5a 和 5b 值相同,a 在 b 前面)
第1轮: 最小值是 2(位置3),与 5a(位置0)交换
→ [2, 5b, 3, 5a] ← 5a 和 5b 的相对顺序改变了!不稳定 ✗
交换把位置 0 的 5a 直接搬到了位置 3,跳过了中间的 5b——两者的相对顺序从 5a在前 变成了 5b在前。
如何让选择排序变稳定?用插入代替交换——找到最小值后,不直接与首位交换,而是把最小值取出,首位及中间元素依次右移一格,再把最小值插入首位。这本质上就是插入排序了。
五、选择排序 vs 冒泡排序
| 对比维度 | 选择排序 | 冒泡排序 |
|---|---|---|
| 比较次数 | 固定 n(n-1)/2 | 固定 n(n-1)/2(最坏) |
| 交换次数 | 最多 n-1 次 | 最坏 n(n-1)/2 次 |
| 最好时间 | O(n²) | O(n)(优化版) |
| 稳定性 | ❌ 不稳定 | ✅ 稳定 |
| 适应性 | 不适应(对有序数据无加速) | 适应(优化版能提前终止) |
核心区别:
- 冒泡排序适合近乎有序的数据——优化版可以提前终止
- 选择排序适合交换代价高的场景——交换次数远少于冒泡
- 冒泡排序稳定,选择排序不稳定——需要稳定性时只能选冒泡
六、适用场景
选择排序在实际开发中同样很少使用,但以下场景可以考虑:
- 交换代价极高:当元素是大型结构体或涉及磁盘IO时,减少交换次数有实际意义
- 小数据量:n < 20 时,O(n²) 排序的开销差异不大
- 内存极其受限:选择排序只需要常数额外空间和最少的交换次数
- 辅助理解排序:与冒泡排序对比,理解"扫描式选择"和"逐步式冒泡"两种策略的差异
七、选择排序的变体应用
7.1 每轮选出 Top-K
不需要完全排序,只跑 k 轮选择排序,就能把前 k 个最小值放到数组前部:
// SelectTopK 将前 k 个最小元素放到数组前 k 位
func SelectTopK(arr []int, k int) {
n := len(arr)
for i := 0; i < k && i < n-1; i++ {
minIdx := i
for j := i + 1; j < n; j++ {
if arr[j] < arr[minIdx] {
minIdx = j
}
}
if minIdx != i {
arr[i], arr[minIdx] = arr[minIdx], arr[i]
}
}
}
func main() {
arr := []int{8, 3, 5, 1, 7, 2, 6, 4}
SelectTopK(arr, 3)
fmt.Println("前3小:", arr[:3]) // [1, 2, 3]
}
时间复杂度 O(n × k),比完全排序 O(n²) 快。
八、小结
选择排序的核心思想是每轮选最小,一次交换到位。与冒泡排序的"边比边换"相比,它更"先看后做"——先扫描整个区间找到目标,再一次性交换。
关键记忆点:
- 选择排序每轮只交换一次,这是它相对于冒泡排序的核心优势
- 最好情况也是 O(n²)——因为它无法利用数据的有序性加速
- 不稳定——交换可能跨越相等元素,破坏相对顺序
- 与冒泡排序形成对照:冒泡稳定但交换多,选择交换少但不稳定
冒泡和选择是排序入门的两块基石——理解它们各自的优缺点,才能在后续学习插入排序、快排、归并时做出更好的选择。

浙公网安备 33010602011771号