优化与稳定性分析
基于通用算法套路 · 核于 2026-07
速查
- 冒泡优化①标志位:每轮用一个
swapped标志,本轮无交换说明已有序,break提前终止——已有序输入从 O(n²) 降到 O(n)。 - 冒泡优化②记录最后交换位置:每轮记最后一次交换的位置
lastSwap,下一轮只需扫到lastSwap(其后已有序),减少无效比较。 - 选择排序为何不稳定:跨距离交换——把远处的最小值换到位置
i,会越过中间的相等元素。反例[5a, 5b, 2]→ 交换2与5a得[2, 5b, 5a],两个 5 调位。 - 稳定性正式定义:排序后,值相等的元素仍保持原序列中的相对顺序,则稳定;否则不稳定。
- 稳定性判定口诀:「只比较/交换相邻」且「相等不动」→ 稳定;「跨距离交换」→ 可能不稳(选择、快排、堆排都属于此类)。
- 插入排序的工程价值:n 小(≤32~64)或近乎有序时比 O(n log n) 还快——Java
Arrays.sort、PythonTimsort、V8Array.sort在小数据段都退化为插入排序。 - Timsort 的 run:Timsort 先把数组切成「已有序段(run)」,长度不足时用插入排序补齐到
minrun(通常 32~64),再归并——插入排序是小段「找有序」的主力。 - 何时用插入排序:n ≤ 几十、或数据近乎有序、或在线插入场景;逆序大数据则别用。
- 稳定性何时有用:待排序记录有多个 key 时(如先按 A 排,再按 B 排,希望 A 相同者内 B 序不被破坏),必须用稳定排序。
- 一句话:冒泡两种优化让它「沾有序的光」;选择不沾光且不稳;插入沾光且稳定——这就是插入活下来的原因。
一、冒泡排序的两种优化
优化①:标志位提前终止
朴素冒泡即使输入已有序,仍要跑满 n-1 轮。加一个 swapped 标志:若某一轮全程没交换,说明数组已有序,提前终止。
function bubbleSort(a) {
const n = a.length;
for (let i = 0; i < n - 1; i++) {
let swapped = false;
for (let j = 0; j < n - 1 - i; j++) {
if (a[j] > a[j + 1]) {
[a[j], a[j + 1]] = [a[j + 1], a[j]];
swapped = true;
}
}
if (!swapped) break; // 本轮无交换 → 已有序
}
return a;
}效果:已有序输入只跑一轮就发现无交换,复杂度从 O(n²) 降到 O(n)。这是冒泡「最好 O(n)」的来源。
优化②:记录最后交换位置
更精细的观察:每轮最后一次交换位置 lastSwap 之后的元素,其实已经有序了(否则还会交换)。下一轮的内层循环只需扫到 lastSwap 即可,不必扫到 n-1-i。
function bubbleSort(a) {
const n = a.length;
let end = n - 1; // 当前轮内层循环的右边界
while (end > 0) {
let lastSwap = 0; // 本轮最后交换位置
for (let j = 0; j < end; j++) {
if (a[j] > a[j + 1]) {
[a[j], a[j + 1]] = [a[j + 1], a[j]];
lastSwap = j; // 更新最后交换位置
}
}
end = lastSwap; // 下一轮只需扫到这里(其后已有序)
}
return a;
}效果:对「部分有序」的输入(如 [1,2,4,3,5,6,7],只有中段无序),能大幅减少无效比较。优化①是优化②的特例(lastSwap=0 即整体有序)。
二、选择排序为何不稳定
选择排序的「每轮把最小值换到前段」是跨距离交换——这是它不稳定的根源。
形式化反例:对 [5a, 5b, 2](5a、5b 值都为 5,下标区分先后)做选择排序:
- 第 1 轮:在
[5a, 5b, 2]中找最小,得2(下标 2),与位置 0 的5a交换 →[2, 5b, 5a]。 - 第 2 轮:在
[5b, 5a]中找最小,得5b(下标 1),位置 1 就是最小,不交换 →[2, 5b, 5a]。
结果 [2, 5b, 5a]——两个 5 的相对顺序从 5a 在 5b 前 变成了 5b 在 5a 前,稳定性被破坏。
根因:位置 0 的 5a 被远处的 2 顶替,而 5a 被换到了原本 2 的位置(下标 2),越过了一起排在前段的 5b。任何「把远处元素换到当前位置」的操作都可能越过相等元素——这是跨距离交换的通病(快排、堆排的不稳定也源于此)。
对比:冒泡/插入只比较相邻或连续右移,相等元素之间不会发生「越过」,所以稳定。这就是稳定性判定口诀的依据:
只比较/交换相邻 + 相等不动 → 稳定;跨距离交换 → 可能不稳。
能稳定化吗:可以——用「插入」代替「交换」(找到最小后,把 a[i..minIdx-1] 整体右移一位,再把最小插入位置 i),这样最小值的搬移变成连续的,稳定。但失去「交换最少」的优势,工程上需要稳定就直接用插入/冒泡,很少这么做。
三、稳定性的正式定义与判定
正式定义:设原序列中 a[i] == a[j] 且 i < j(即 a[i] 在 a[j] 前)。若排序后 a[i] 仍在 a[j] 前,则该排序稳定;否则不稳定。
稳定性何时有意义:仅当「待排序记录含多个相同 key」时才有意义——所有 key 都互不相同时,稳不稳都一样。
判定口诀与常见算法:
| 算法 | 稳定? | 判定依据 |
|---|---|---|
| 冒泡 | 稳定 ✅ | 相邻交换,相等不动(用 >) |
| 插入 | 稳定 ✅ | 连续右移,相等停下插右侧(用 >) |
| 选择 | 不稳定 ❌ | 跨距离交换越过相等元素 |
| 归并 | 稳定 ✅ | merge 时相等取左边(保证稳定) |
| 快排 | 不稳定 ❌ | 划分是跨距离交换 |
| 堆排 | 不稳定 ❌ | 堆调整跨距离搬移 |
稳定性何时有用:多 key 排序。例如员工表先按部门排序,再按工龄排序——若第二次排序不稳,同部门内工龄序会被打乱。这时第二次(决定主序的)必须用稳定排序。这是「稳定」真正的工程价值,而非理论洁癖。
四、插入排序的工程价值
插入排序并非「玩具」——它活在所有现代排序的小数组分支里:
- Java
Arrays.sort(int[]):双轴快排(Dual-Pivot Quicksort),但n < 47时切回插入排序。 - Python
list.sort/sorted:Timsort,每个 run 长度不足minrun(典型 32~64)时用插入排序补齐。 - V8
Array.sort:Timsort,小数组(≤32 左右)用成对插入排序(pair insertion sort)。
为什么小数组插入更快:O(n log n) 排序有递归调用、分治、辅助数组等常数开销,n 小时这些开销超过 O(n²) 的简单扫描;插入排序常数极小(内层就一个 while 循环 + 连续右移),n ≤ 几十时反而赢。
Timsort 的 run:插入排序做「找有序」主力
Timsort 的核心是利用「真实数据常含已有序段」。它先把数组切成若干 run(严格升序或非严格降序的连续段):
- 扫描天然 run;若长度不足
minrun(由n算出,典型 32~64),用插入排序补齐到minrun。 - 然后用类似归并的方式,在 run 间做稳定归并。
这里插入排序的作用是「把零碎的短段快速整成有序的 minrun」——因为 minrun 很小,插入排序刚好最快。理解了这一点,就理解了「插入排序是 Timsort 的零件」。
五、何时该用插入排序
| 场景 | 是否用插入排序 | 理由 |
|---|---|---|
| n ≤ 几十(小数据) | ✅ 推荐 | 常数最小,比快排/归并快 |
| 数据近乎有序 | ✅ 推荐 | 最好 O(n) |
| 在线/流式(来一个排一个) | ✅ 合适 | 算法逻辑支持增量插入 |
| 大数据、逆序、无序 | ❌ 别用 | 退化 O(n²) |
| 需稳定 + 元素交换昂贵 | ⚠️ 可选 | 插入搬移多;选择交换少但不稳,权衡 |
实战口诀:n 小或近有序 → 插入;大数据 → 上 O(n log n)(快排/归并/堆排);多 key 要稳 → 归并/Timsort。
交互演示
下一步
掌握了优化与稳定性分析后,若要查复杂度表、代码模板、对比矩阵与易错点,见参考;若要进入 O(n log n) 排序,可继续后续章节(归并/快排/堆排)。