Appearance
14. 扑克牌理牌(插入排序)
知识点:插入排序 —— 新牌插到正确位置(O(n²),但对近排序数据很快)
项目:摸牌理牌
故事开场:摸一张,理一张
周末,你和爷爷打扑克。爷爷理牌的方法和你不一样:
你是把所有牌摸完之后,才一起整理;爷爷是摸一张,理一张——每摸到 一张新牌,就立刻把它插到手里已经排好的牌堆的正确位置。
你好奇地看了看爷爷的手牌:小到大排得整整齐齐。摸到一张"7", 他扫了一眼手里的牌,把 7 插到了 5 和 8 之间。全程就动了一张牌的位置。
你心想:这个办法,比冒泡和选择都省事啊!
回想前两章:
- 冒泡排序:一趟一趟冒泡,全部数据比来比去
- 选择排序:每趟从剩下的全部里挑最小的
而爷爷的办法是:手里已有的牌已经排好了,新牌只需要找到自己的位置, 插进去。已经排好的部分不用再动——只用处理一张新牌!
笨办法先行:每次重新排一遍?
你马上想到了"用程序模拟摸牌"。可你的第一版程序是这样的:
ts
const hand: number[] = [] // 手里的牌,从空开始
// 摸到一张新牌……怎么插进手里已经排好的牌堆?
function drawCard(card: number): void {
hand.push(card) // 先放手里
// ……然后呢?重新把整手牌排一遍?
}然后你发现,你只会用上一章的 selectionSort,把整手牌重新排一遍:
ts
function drawCard(card: number): void {
hand.push(card)
selectionSort(hand) // 把整手牌重新排一遍!
}能排对,可你隐隐觉得不对劲:
每次摸一张牌,就把整手牌重新排一遍——摸到第 10 张牌时, 要把 10 张牌全排一遍;摸到第 20 张时,要排 20 张。 爷爷明明只用插一张,你却在反复重排所有牌!
牌少还好,要是手里有 100 张牌,每摸一张都重排 100 张, 摸 100 张牌就排了 100 × 100 = 10000 次工作——又回到 O(n²) 了, 而且做的是无用功:除了新牌,其他牌的位置根本不用动!
引出知识点:插入排序——只需要挪"比新牌大"的
爷爷的办法到底妙在哪?拆开看:
假设手里已经有排好的牌:[2, 5, 8, 10],现在摸到一张 7。
正确的插入方法是从后往前看:
[2, 5, 8, 10, 7] ← 7 先放到最后
↑ 10 比 7 大 → 10 往后挪一格
[2, 5, 8, -, 10]
↑ 8 比 7 大 → 8 往后挪一格
[2, 5, -, 8, 10]
↑ 5 比 7 小 → 停!就是这里
[2, 5, 7, 8, 10] ← 7 放进去规则只有一条:从后往前,凡是比新牌大的,都往后挪一格; 遇到比新牌小(或相等)的,就停下来,把新牌放进去。
注意:只有比新牌大的牌才挪——比新牌小的(2 和 5)纹丝不动。 这就是"插入"和"重排"的区别:重排动了所有牌,插入只动该动的。
动手实现:插入排序
先写"插一张牌"的动作:
ts
// 把 card 插进 hand(假设 hand 是排好序的)
function insertCard(hand: number[], card: number): void {
hand.push(card) // 新牌先放到最后
let pos = hand.length - 1 // 从最后一张开始往前看
while (pos > 0 && hand[pos - 1] > card) {
hand[pos] = hand[pos - 1] // 前一张比新牌大?往后挪一格
pos-- // 继续往前看
}
hand[pos] = card // 找到位置,放下新牌
}用 insertCard 模拟摸牌:
ts
const hand: number[] = []
insertCard(hand, 8)
insertCard(hand, 2)
insertCard(hand, 5)
insertCard(hand, 10)
insertCard(hand, 7)
console.log(hand)输出:
[2, 5, 7, 8, 10]每摸一张,手里都是排好的!这就是爷爷的理牌法。
现在,把"摸一张插一张"应用到整个数组上——数组的前面部分 就是"手里已排好的牌",从第 1 张开始,一张张往已排好的部分插:
ts
// 插入排序:前 i 个保持排好序,把第 i 个插进去
function insertionSort(list: number[]): void {
for (let i = 1; i < list.length; i++) {
const card = list[i] // 摸到的新牌
let pos = i
// 从新牌的位置往前看,比新牌大的都往后挪一格
while (pos > 0 && list[pos - 1] > card) {
list[pos] = list[pos - 1]
pos--
}
list[pos] = card // 把新牌放到腾出来的位置
}
}
const numbers = [8, 2, 5, 10, 7]
insertionSort(numbers)
console.log(numbers)输出:
[2, 5, 7, 8, 10]跑起来:插入排序的每一步
用慢动作看 [8, 2, 5, 10, 7] 是怎么被排好的:
初始: [8, 2, 5, 10, 7]
摸到 2: [2, 8, 5, 10, 7] 2 比 8 小,8 往后挪,2 到最前
摸到 5: [2, 5, 8, 10, 7] 5 插到 2 和 8 之间
摸到 10: [2, 5, 8, 10, 7] 10 已经最大,不用动
摸到 7: [2, 5, 7, 8, 10] 7 插到 5 和 8 之间每摸一张牌,前面已经排好的部分一个不多动。
自己用代码验证:
ts
const test = [8, 2, 5, 10, 7]
insertionSort(test)
console.log(test)输出:
[2, 5, 7, 8, 10]插入排序的"秘密武器":对快排好的数据特别快
插入排序最有趣的地方是:它有个"隐藏技能"。
想一想:如果数组本来就是排好序的(比如 [1, 2, 3, 4, 5]), 插入排序会做多少事?
- 摸到 2:
2 > 1?不需要挪,直接放 - 摸到 3:
3 > 2?不需要挪 - ……每张牌都"直接放",一次都不挪!
数据本来就有序时,插入排序只做 n 次比较,是 O(n)!
对比一下:
| 数据状态 | 冒泡 | 选择 | 插入 |
|---|---|---|---|
| 完全乱序 | O(n²) | O(n²) | O(n²) |
| 本来就排好 | O(n²)(优化版可以 O(n)) | O(n²)(不知道省事) | O(n) |
| 接近排好 | 一般 | 一般 | 接近 O(n) |
所以现实程序里,插入排序常被用来处理"数据基本有序"的情况—— 比如新同学插进已按学号排好的名单、往排好序的成绩单里插入新成绩。
三兄弟总结(O(n²) 三兄弟): 冒泡——简单直观,但交换多; 选择——交换少,适合"搬数据很贵"的场合; 插入——对"本来就排好/接近排好"的数据有隐藏加速,日常最常用。
小挑战
- 从大到小:把插入排序改成从大到小排(提示:改
while里的 一个符号,让"比新牌小的"往后挪)。 - 空手摸牌:用
insertCard模拟"从一副打乱的牌里摸 10 张": 造一个打乱的数组,一张张insertCard进空手,验证手里始终有序。 - 新同学插队:班级名单
["小丽", "小刚", "小明", "小红", "小雨"]已经按"字的编号"排好,新同学"小华"要插进来。把insertCard改成字符串版(参数类型从number[]改成string[]即可—— 字符串之间也能用>比较,比的就是字的编号), 把"小华"插到正确位置,打印新名单。
课后练习
第 1 题(动手题):给成绩单插入新成绩
成绩单 [65, 70, 78, 88, 92] 已经排好序,现在插入一个新成绩 85, 要求插入后依然有序。用 insertCard 实现并验证。
参考答案(点开查看)
ts
const scores = [65, 70, 78, 88, 92]
insertCard(scores, 85)
console.log(scores)
// [65, 70, 78, 85, 88, 92]insertCard 会从后往前看:92 > 85 挪,88 > 85 挪,78 < 85 停, 85 插到 78 和 88 之间。只有两个数挪了位置——其他四个纹丝不动。 这就是插入排序"只动该动的"。
第 2 题(思考题):为什么从后往前?
insertCard 为什么从后往前看,而不是从前往后? (提示:想想"往后挪一格"会覆盖谁。)
参考答案(点开查看)
从前往后看,你会遇到"把新牌放哪"的尴尬:比如 [2, 5, 8, 10] 插 7, 从前往后先看到 2(比 7 小),再看到 5(比 7 小),看到 8(比 7 大), "应该插在 8 前面"——可这时候你还没把 8 往后挪,直接把 7 放进去 就把 8 覆盖了。
从后往前就不会:先把比新牌大的挪走(腾出空位),再放下新牌。 "先腾地方,再放东西",顺序对了,一个数据都不会丢。 (这和第 13 章"先想清楚会不会覆盖"是同一个道理!)
第 3 题(思考题):隐藏加速
数组 [1, 2, 3, 4, 5, 6] 本来就排好了,用插入排序排它, 一共要做多少次"比较"?用 O(几) 表示。
参考答案(点开查看)
每个数插入时只看一眼"前面那个数":1 < 2?是,直接放。 一共只比较 5 次(n−1 次),是 O(n)。
这就是插入排序的隐藏加速:数据越接近有序,它越快。 最乱的情况才是 O(n²)。如果数据完全逆序([6, 5, 4, 3, 2, 1]), 每个数都要挪到底,才是最坏情况 O(n²)。
预告:冒泡、选择、插入——O(n²) 三兄弟都认识了吧! 下一章来点不一样的:套娃。打开一个套娃,里面还是套娃…… 有一种函数,它打开自己,里面还是自己。它叫递归, 它能一口气解决"汉诺塔"这种循环几乎写不出来的难题。