外部排序全解析:败者树、置换-选择排序与最佳归并树
文章目录
- 外部排序全解析:败者树、置换-选择排序与最佳归并树
-
- 第一节:为什么需要外部排序?(小书桌与大仓库的矛盾)
-
- [1.1 为什么快排、堆排突然失灵了?](#1.1 为什么快排、堆排突然失灵了?)
- [1.2 外部排序的破局之道:"化整为零" + "合零为整"](#1.2 外部排序的破局之道:“化整为零” + “合零为整”)
- [1.3 外部排序的"耗时账单"与三大破案方向](#1.3 外部排序的“耗时账单”与三大破案方向)
- [1.4 核心专业术语对照](#1.4 核心专业术语对照)
- [1.5 随堂思考题与答案解析](#1.5 随堂思考题与答案解析)
-
- [题目 1:为什么不能把归并路数 k k k 无限增大?](#题目 1:为什么不能把归并路数 k k k 无限增大?)
- [第二节:第一道防线 ------ 败者树(让多路归并告别"手忙脚乱")](#第二节:第一道防线 —— 败者树(让多路归并告别“手忙脚乱”))
-
- [2.1 胜者树 vs 败者树:为什么"留败者当守门员"才是神来之笔?](#2.1 胜者树 vs 败者树:为什么“留败者当守门员”才是神来之笔?)
-
- [流派一:胜者树(Winner Tree ------ 赢家占领父节点)](#流派一:胜者树(Winner Tree —— 赢家占领父节点))
- [流派二:败者树(Loser Tree ------ 输家留在原地当"守门员"!)](#流派二:败者树(Loser Tree —— 输家留在原地当“守门员”!))
- [2.2 败者树手把手推演:4 位选手的晋级与动态替换全过程](#2.2 败者树手把手推演:4 位选手的晋级与动态替换全过程)
-
- 第一幕:初始建树完成后的擂台状态
- [第二幕:0 号冠军出局,换入新数 `25` 向上单挑!](#第二幕:0 号冠军出局,换入新数
25向上单挑!) - [第三幕:2 号冠军出局,换入新数 `18` 再次向上单挑!](#第三幕:2 号冠军出局,换入新数
18再次向上单挑!)
- [2.3 底层数组映射秘籍:两个精妙的工程巧思](#2.3 底层数组映射秘籍:两个精妙的工程巧思)
-
- [巧思 1:叶子选手 `s` 怎么一步算出亲爹擂台的下标?](#巧思 1:叶子选手
s怎么一步算出亲爹擂台的下标?) - [巧思 2:初始建树时,为什么要请出"无敌哨兵 `INT_MIN`"并从后往前调整?](#巧思 2:初始建树时,为什么要请出“无敌哨兵
INT_MIN”并从后往前调整?)
- [巧思 1:叶子选手 `s` 怎么一步算出亲爹擂台的下标?](#巧思 1:叶子选手
- [2.4 败者树完整 C++ 代码实现](#2.4 败者树完整 C++ 代码实现)
- [2.5 随堂思考题与答案解析](#2.5 随堂思考题与答案解析)
-
- [题目 1:败者树到底省了多少次比较?](#题目 1:败者树到底省了多少次比较?)
- [第三节:第二道防线 ------ 置换-选择排序(打破内存限制的"水涨船高法")](#第三节:第二道防线 —— 置换-选择排序(打破内存限制的“水涨船高法”))
-
- [3.1 传统切分法的死穴:3 人小桌子难道只能排 3 张牌?](#3.1 传统切分法的死穴:3 人小桌子难道只能排 3 张牌?)
- [3.2 置换-选择排序的"三条桌面铁律"](#3.2 置换-选择排序的“三条桌面铁律”)
- [3.3 纯手工推演:容量 w = 3 w = 3 w=3 如何造出长度为 5 的超长归并段?](#3.3 纯手工推演:容量 w = 3 w = 3 w=3 如何造出长度为 5 的超长归并段?)
- [3.4 工程神级设计:当"带段号的结构体"遇上"败者树"](#3.4 工程神级设计:当“带段号的结构体”遇上“败者树”)
- [3.5 置换-选择排序完整 C++ 代码实现](#3.5 置换-选择排序完整 C++ 代码实现)
- [3.6 随堂思考题与答案解析](#3.6 随堂思考题与答案解析)
-
- [题目 1:置换-选择排序的最坏情况与最好情况](#题目 1:置换-选择排序的最坏情况与最好情况)
- [第四节:第三道防线 ------ 最佳归并树(哈夫曼树与"消消乐"虚段公式)](#第四节:第三道防线 —— 最佳归并树(哈夫曼树与“消消乐”虚段公式))
-
- [4.1 归并顺序的猫腻:先合短的还是先合长的?](#4.1 归并顺序的猫腻:先合短的还是先合长的?)
- [4.2 深度拆解核心公式:如何用"消消乐游戏"秒懂 ( n − 1 ) ( m o d k − 1 ) (n - 1) \pmod{k - 1} (n−1)(modk−1)?](#4.2 深度拆解核心公式:如何用“消消乐游戏”秒懂 ( n − 1 ) ( m o d k − 1 ) (n - 1) \pmod{k - 1} (n−1)(modk−1)?)
-
- [情况 A:余数 R = = 0 R == 0 R==0(天生完美,不用补虚段)](#情况 A:余数 R = = 0 R == 0 R==0(天生完美,不用补虚段))
- [情况 B:余数 R ≠ 0 R \ne 0 R=0(有人落单了,必须补"空气工具人"虚段!)](#情况 B:余数 R ≠ 0 R \ne 0 R=0(有人落单了,必须补“空气工具人”虚段!))
- [4.3 最佳归并树完整 C++ 代码实现](#4.3 最佳归并树完整 C++ 代码实现)
- [4.4 终极通关测试与答案解析](#4.4 终极通关测试与答案解析)
-
- [题目 1:手算最佳归并树与虚段(考研 408 真题级演练)](#题目 1:手算最佳归并树与虚段(考研 408 真题级演练))
- [第五节:📝 复习心得与大白话核心笔记(考前速查)](#第五节:📝 复习心得与大白话核心笔记(考前速查))
第一节:为什么需要外部排序?(小书桌与大仓库的矛盾)
1.1 为什么快排、堆排突然失灵了?
假设你有一张最多只能摆下 10 张扑克牌的小书桌(内存 RAM) ,但现在老板拉来了一大卡车、足足有 100 万张乱序的扑克牌 堆在门外的大仓库(磁盘 Disk)里,让你把这 100 万张牌从小到大排好序。
text
【门外的大仓库(磁盘:容量极大,但拿牌极慢)】
[ 99 | 12 | 5 | 88 | 31 | 42 | ... 足足 100 万张乱序扑克牌 ... ]
||
|| (每次只能跑腿搬 10 张进屋,跑一趟腿累断腰:磁盘 I/O)
\/
【屋里的小书桌(内存:速度极快,但最多只放得下 10 张牌!)】
+-----------------------------------------+
| [ 99 ] [ 12 ] [ 5 ] ... (最多10张) | <-- 快排、堆排要求所有牌同时在桌上,直接罢工!
+-----------------------------------------+
- 内部排序失灵的原因 :快速排序、堆排序、希尔排序等内部排序算法,都要求所有数据同时装进内存才能随时按下标随机访问。内存只有一点点大,面对远超内存容量的海量磁盘文件,根本装不下。
- 真正卡脖子的死穴(磁盘 I/O) :CPU 在小书桌上洗牌(内存比较)快如闪电,几乎不花时间;真正的耗时全花在从门外大仓库往返书桌搬运扑克牌(磁盘读写 I/O)上。谁跑仓库的趟数少,谁的算法就快!
1.2 外部排序的破局之道:"化整为零" + "合零为整"
既然桌子一次只能放一点点牌,工程师们采用了分两步走的归并策略:
第一阶段:生成初始归并段(化整为零)
每次从仓库抱一摞能塞满小书桌的牌进屋,在桌上用快排排好序,再捆成一小扎送回仓库。这一小扎内部有序的牌,就叫一个初始归并段(Initial Run)。
text
[乱序大文件] ──(分批抱上小书桌排好序)──> 归并段R1: [5, 12, 99]
──> 归并段R2: [8, 23, 45]
──> 归并段R3: [2, 19, 60]
──> ... (生成 r 个初始归并段)
第二阶段:多路归并(合零为整)
现在仓库里有了很多捆内部从小到大排好序的小牌堆。我们只需要把每捆牌的最上面一张(当前最小牌)拿上书桌比大小:谁最小,就把谁打出去写回新文件,然后从那一捆里再补抽下一张牌上来继续比!
text
【2路归并流水线(把小书桌切分成:2个输入缓冲区 + 1个输出缓冲区)】
仓库归并段 R1: [12, 99] ──> [ 输入缓冲1: 5 ] \
├──(比大小:5 < 8,打出 5!)──> [ 输出缓冲: 5 ] ──> 写回磁盘
仓库归并段 R2: [23, 45] ──> [ 输入缓冲2: 8 ] /
1.3 外部排序的"耗时账单"与三大破案方向
每把仓库里所有的数据完整读进内存一次、排好后再完整写回仓库一次,叫做一趟归并 。
如果有 r r r 个初始归并段,采用 k k k 路归并,总共需要多少趟归并?
归并趟数 S = ⌈ log k r ⌉ \text{归并趟数 } S = \lceil \log_k r \rceil 归并趟数 S=⌈logkr⌉
text
【举例:8 个初始归并段,采用 2 路归并(每次 2 合 1)】
初始段: [R1] [R2] [R3] [R4] [R5] [R6] [R7] [R8] (r = 8)
\ / \ / \ / \ /
第 1 趟: [段1] [段2] [段3] [段4] (读写全部数据 1 次)
\ / \ /
第 2 趟: [大段1] [大段2] (读写全部数据 1 次)
\ /
第 3 趟: [最终有序文件] (共 S = log2(8) = 3 趟!)
要想减少跑仓库的次数(降低磁盘 I/O),看着公式 S = ⌈ log k r ⌉ S = \lceil \log_k r \rceil S=⌈logkr⌉,只有 3 条逃生通道 ,这也正是整章外部排序层层递进的三大核心武器:
- 方向一:把底数 k k k 变大(多路归并)
- 如果从 2 路归并改成 8 路归并,8 个段 1 趟 就合完了!
- 引发的新痛点 : k k k 变大后,桌上同时摆着 k k k 张牌,每挑出一个最小值都要肉眼比 k − 1 k-1 k−1 次,CPU 累趴下了!
- 破局武器(第二节) :败者树(Loser Tree) ------ 把每次选冠军的比较次数从 k − 1 k-1 k−1 暴降到 ⌈ log 2 k ⌉ \lceil \log_2 k \rceil ⌈log2k⌉!
- 方向二:把真数 r r r 变小(减少初始归并段的个数)
- 总数据量 N N N 固定,如果每个初始归并段造得越长 ,切出来的总段数 r r r 就越少!
- 引发的新痛点 :小书桌容量只有 w w w,传统办法排出来的段长死死卡在 w w w,怎么突破桌子的物理限制?
- 破局武器(第三节) :置换-选择排序(Replacement-Selection Sort) ------ 边进边出,造出平均长度达到 2 w 2w 2w 的超长归并段!
- 方向三:长短不一的段,怎么合最省力?
- 置换-选择排序造出来的归并段有长有短,如果让大胖子段反复搬运,磁盘又得累死。
- 破局武器(第四节) :最佳归并树(Optimal Merge Tree) ------ 用带虚段的 k k k 叉哈夫曼树,把磁盘读写次数压榨到理论极限!
1.4 核心专业术语对照
| 重要程度 | 专业术语 | 大白话解释 |
|---|---|---|
| ★ 核心必透 | 归并段 / 顺串(Run) | 一小捆内部已经排好序的数据片段。外部排序的第一步就是把乱序大文件切成若干个初始归并段。 |
| ★ 核心必透 | 外部排序总时间 | 总时间 = 内部排序生成初始段的时间 + 磁盘 I/O 读写时间 + 内部归并比较时间 \text{总时间} = \text{内部排序生成初始段的时间} + \text{磁盘 I/O 读写时间} + \text{内部归并比较时间} 总时间=内部排序生成初始段的时间+磁盘 I/O 读写时间+内部归并比较时间。其中磁盘 I/O 时间占绝对大头。 |
| ★ 核心必透 | 归并趟数公式 | S = ⌈ log k r ⌉ S = \lceil \log_k r \rceil S=⌈logkr⌉( r r r 是初始归并段个数, k k k 是归并路数)。每多一趟,所有数据就要在磁盘上一进一出折腾一回。 |
1.5 随堂思考题与答案解析
题目 1:为什么不能把归并路数 k k k 无限增大?
既然增大归并路数 k k k 可以减少归并趟数 S S S,那如果有 1000 个初始归并段,我们直接搞"1000 路归并",一趟搞定岂不美哉?为什么现实中不能无限制增大 k k k?
【答案与解析】
不能无限增大 k k k,受限于内存空间和单块读写效率:
- 内存缓冲区被切得太碎 :内存总大小是固定的。如果搞 1000 路归并,就得在内存里划分出 1000 个输入缓冲区,导致每个输入缓冲区只有指甲盖那么大。
- 磁盘寻道次数反而暴增:缓冲区太小,每次只能从磁盘读一点点数据就耗尽了,磁头需要在 1000 个归并段之间疯狂来回跳动(随机寻道时间极长),反而彻底抵消了趟数减少带来的好处。
第二节:第一道防线 ------ 败者树(让多路归并告别"手忙脚乱")
2.1 胜者树 vs 败者树:为什么"留败者当守门员"才是神来之笔?
在 k k k 路归并中,为了快速从 k k k 个归并段里挑出最小的元素,最自然的想法是办一场锦标赛(用二叉树记录比赛结果)。但这引出了两种截然不同的流派:
流派一:胜者树(Winner Tree ------ 赢家占领父节点)
每两个选手PK,数值小的赢家晋级写到父节点上。虽然能选出总冠军,但在冠军出局、换上替补选手重新比赛时,麻烦来了:
text
[0号:9] (总冠军)
|
[0号:9] (半决赛台) <-- 当 0号 换成新数 25 往上爬时:
/ \ 半决赛台写着它自己(0号)!它不知道对手是谁,
[0号:9] [1号:30] 必须扭头去查右边的兄弟节点 [1号:30] 才能比!
- 胜者树的尴尬 :父节点记的是上次赢家自己。当赢家换了新牌往上重赛时,看父节点毫无意义,必须额外访问兄弟节点才能找到当年的手下败将,既多走内存弯路,又容易引发 CPU 分支预测惩罚。
流派二:败者树(Loser Tree ------ 输家留在原地当"守门员"!)
工程师们脑洞大开:谁输了,谁就留在当前的父节点(擂台)上当守门员;谁赢了,手里拿着牌继续往上走,去挑战更高一层的守门员!最后的通关全胜者,单独供奉在树顶王座 ls[0] 上!
text
王座 ls[0]: [0号:9] (全场唯一通关的总冠军!)
|
擂台 ls[1]: [2号:15] (半决赛输家在这当守门员)
/ \
擂台 ls[2]: [1号:30] 擂台 ls[3]: [3号:20] (初赛输家在这当守门员)
/ \ / \
[0号:9] [1号:30] [2号:15] [3号:20] <-- 底下 4 个叶子选手专座
- 败者树的绝妙之处 :当总冠军
0号被拿走输出、原座位换上新数字25后,它只需要闷头顺着自己的亲爹路径一路往上爬 !路上遇到的每一个父节点里,都老老实实坐着当年被它打败的守门员 。完全不需要看兄弟节点一眼,只管跟父节点单挑!
2.2 败者树手把手推演:4 位选手的晋级与动态替换全过程
假设我们进行 4 路归并( k = 4 k = 4 k=4) ,4 个归并段派出的首发选手分别是:
0号 = 9、1号 = 30、2号 = 15、3号 = 20(数值越小越强)。
第一幕:初始建树完成后的擂台状态
- 左半区初赛(擂台
ls[2]) :0号(9)PK1号(30)→ \to →1号(30)输了留在ls[2]当守门员,0号(9)拿着牌往上走。 - 右半区初赛(擂台
ls[3]) :2号(15)PK3号(20)→ \to →3号(20)输了留在ls[3]当守门员,2号(15)拿着牌往上走。 - 决赛(擂台
ls[1]) :晋级的0号(9)PK2号(15)→ \to →2号(15)输了留在ls[1]当守门员,全胜的0号(9)登上顶峰王座ls[0]!
text
【初始状态:0号(9) 登顶夺冠】
王座 ls[0] = 0号(9)
|
擂台 ls[1] = 2号(15)
/ \
ls[2] = 1号(30) ls[3] = 3号(20)
/ \ / \
0号(9) 1号(30) 2号(15) 3号(20)
第二幕:0 号冠军出局,换入新数 25 向上单挑!
把总冠军 9 输出后,0 号归并段派出了下一位替补选手 25 坐进 0 号专座,沿着父节点一路向上挑战:
- 第一关(挑战亲爹
ls[2]的守门员1号:30):
- 挑战者
0号(25)PK 守门员1号(30)。 - 因为 25 < 30 25 < 30 25<30,挑战者
0号(25)赢了 !守门员不变,0号(25)继续往上走。
- 第二关(挑战决赛台
ls[1]的守门员2号:15):
- 挑战者
0号(25)PK 守门员2号(15)。 - 因为 25 > 15 25 > 15 25>15,挑战者
0号(25)输了! - 触发铁律(身份互换) :输家
0号(25)留下来接替ls[1]当新守门员;原守门员2号(15)翻身成为新胜者,继续往上走!
- 登顶 :
2号(15)走完所有擂台,坐上王座ls[0]!
text
【换入 25 后的状态:0号卡在 ls[1],2号(15) 晋级登顶!】
王座 ls[0] = 2号(15) <-- 新总冠军!
|
擂台 ls[1] = 0号(25) <-- 0号(25)输给15,留在这当新守门员!
/ \
ls[2] = 1号(30) ls[3] = 3号(20)
/ \ / \
0号(25) 1号(30) 2号(15) 3号(20)
第三幕:2 号冠军出局,换入新数 18 再次向上单挑!
把新冠军 15 输出后,2 号专座换入替补选手 18:
- 第一关(挑战亲爹
ls[3]的守门员3号:20) :2号(18)<3号(20),赢!继续往上。 - 第二关(挑战决赛台
ls[1]的守门员0号:25) :2号(18)<0号(25),又赢!继续往上。 - 登顶 :
2号(18)一路横扫,再次坐上王座ls[0]!
2.3 底层数组映射秘籍:两个精妙的工程巧思
在写 C++ 代码时,我们不需要真的去 new 二叉树指针,只用两个一维数组就能完美装下整棵树:
ls[0 ... k-1]:存放内部节点(ls[0]是王座,ls[1 ... k-1]是 k − 1 k-1 k−1 个擂台,存的都是选手的段号下标)。leaves[0 ... k]:存放 k k k 个归并段当前的真实数值(多开第k号位置放哨兵)。
巧思 1:叶子选手 s 怎么一步算出亲爹擂台的下标?
在一棵满二叉树中,内部节点编号为 1 ~ k-1,底下的叶子选手 0 ~ k-1 实际上相当于排在虚拟编号 s + k 的位置。根据完全二叉树"子节点除以 2 等于父节点"的铁律:
parent = s + k 2 \text{parent} = \frac{s + k}{2} parent=2s+k
- 比如 k = 4 k = 4 k=4 时:
0号和1号叶子的亲爹是 ( 0 + 4 ) / 2 = 2 (0+4)/2 = 2 (0+4)/2=2(即ls[2]);2号和3号的亲爹是 ( 2 + 4 ) / 2 = 3 (2+4)/2 = 3 (2+4)/2=3(即ls[3]),严丝合缝!
巧思 2:初始建树时,为什么要请出"无敌哨兵 INT_MIN"并从后往前调整?
刚开始建树时,所有擂台都是空的,没法直接两两比试。工程师用了一个极简妙招:
- 在
leaves[k]放一个战力天花板的无敌哨兵INT_MIN(负无穷) ,并让所有擂台ls[0 ... k-1]一开始全都坐着这位哨兵。 - 然后让
0 ~ k-1号真实选手依次调用adjust(i)往上单挑。因为所有真实数字都比INT_MIN大(全都会输给哨兵),真实选手就会在爬树的过程中,乖乖把自己填进各个空擂台里,把哨兵一步步顶上王座ls[0]! - 为什么必须从后往前(
i = k - 1到0)调整?
为了确保每个子树里的两个兄弟先填满底层擂台,最后到达的节点把潜伏在里面的哨兵彻底挤出树外,不会发生半路把真实胜者覆盖丢失的情况。
2.4 败者树完整 C++ 代码实现
cpp
#include <iostream>
#include <vector>
#include <algorithm>
#include <climits>
class LoserTree {
private:
int k; // k路归并
std::vector<int> ls; // 内部结点(ls[0]存总冠军下标,ls[1..k-1]存败者守门员下标)
std::vector<int> leaves; // 叶子结点(leaves[0..k-1]存各段当前值,leaves[k]存哨兵)
public:
LoserTree(std::vector<int>& initial_keys) {
k = static_cast<int>(initial_keys.size());
ls.resize(k);
leaves.resize(k + 1);
for (int i = 0; i < k; i++) {
leaves[i] = initial_keys[i];
}
// 第 k 号位置放入无敌哨兵 INT_MIN
leaves[k] = INT_MIN;
// 初始所有擂台均由哨兵坐镇
for (int i = 0; i < k; i++) {
ls[i] = k;
}
// 从后往前依次调整每个叶子选手,完成初始建树
for (int i = k - 1; i >= 0; i--) {
adjust(i);
}
}
// 核心单挑函数:选手 s 顺着父节点一路向上挑战守门员
void adjust(int s) {
int parent = (s + k) / 2; // 算出叶子 s 的亲爹擂台下标
while (parent > 0) {
// s 比守门员还大(说明挑战者 s 输了!)
// 败者 s 留下当新守门员,原守门员成为新胜者代替 s 继续向上挑战
if (leaves[s] > leaves[ls[parent]]) {
std::swap(s, ls[parent]);
}
parent = parent / 2; // 继续向上爬一层
}
ls[0] = s; // 最终全胜者登顶王座 ls[0]
}
int GetWinnerIndex() const {
return ls[0];
}
int GetWinnerValue() const {
return leaves[ls[0]];
}
void ReplaceWinner(int new_val) {
int winner = GetWinnerIndex();
leaves[winner] = new_val;
adjust(winner);
}
};
// ----------------------测试代码----------------------------
int main() {
// 1. 对应手动推演的 4 位选手:0号=9, 1号=30, 2号=15, 3号=20
std::vector<int> init_vals = {9, 30, 15, 20};
LoserTree tree(init_vals);
std::cout << "初始冠军: " << tree.GetWinnerIndex()
<< "号选手 (值: " << tree.GetWinnerValue() << ")\n";
// 2. 模拟第一轮:0号出局,换成 25(0号输给2号留在ls[1],2号晋级登顶)
tree.ReplaceWinner(25);
std::cout << "换入25后新冠军: " << tree.GetWinnerIndex()
<< "号选手 (值: " << tree.GetWinnerValue() << ")\n";
// 3. 模拟第二轮:2号出局,换成 18(一路高歌杀到 ls[0])
tree.ReplaceWinner(18);
std::cout << "换入18后新冠军: " << tree.GetWinnerIndex()
<< "号选手 (值: " << tree.GetWinnerValue() << ")\n";
return 0;
}
2.5 随堂思考题与答案解析
题目 1:败者树到底省了多少次比较?
假设现在要把 k = 1024 k = 1024 k=1024 个初始归并段合并成一个文件,总共有 N N N 个元素。使用普通顺序扫描选最小值 vs 使用败者树,在内部归并阶段分别需要比较多少次?
【答案与解析】
- 普通顺序扫描 :每选出 1 个最小值,都要把 1024 个数挨个比一遍,比 k − 1 = 1023 k - 1 = 1023 k−1=1023 次。排完 N N N 个元素的总比较次数为 1023 N 1023 N 1023N。
- 使用败者树 :树的高度为 log 2 1024 = 10 \log_2 1024 = 10 log21024=10。每次冠军出局换入新数后,只需要顺着父节点向上单挑 10 次 !排完 N N N 个元素的总比较次数瞬间降为 10 N 10 N 10N (效率提升了整整 100 倍 ),且彻底消除了对 k k k 的线性依赖!
第三节:第二道防线 ------ 置换-选择排序(打破内存限制的"水涨船高法")
3.1 传统切分法的死穴:3 人小桌子难道只能排 3 张牌?
回到第一节的公式 S = ⌈ log k r ⌉ S = \lceil \log_k r \rceil S=⌈logkr⌉。除了增大 k k k,我们还想把初始归并段的个数 r r r 变少。
- 传统的笨办法 :如果内存工作区容量 w = 3 w = 3 w=3,每次只能读入 3 个数排好序整批轰走,造出来的初始归并段长度死死卡在 3 。如果有 24 个数,就必须切成 24 ÷ 3 = 8 24 \div 3 = 8 24÷3=8 个段。
- 置换-选择排序的野心 :同样只给你容量为 w = 3 w = 3 w=3 的小桌子,通过"边出边进、水涨船高"的流水线玩法,硬生生造出 平均长度达到 2 w 2w 2w(即长度 6)的超长归并段!
3.2 置换-选择排序的"三条桌面铁律"
我们在容量为 w = 3 w = 3 w=3 的小桌子上立下 3 条规矩:
- 底线法则(
MINMAX):
- 桌子旁边立一块牌子,记录当前归并段刚刚打出去的最后一个数字 ,称为当前底线
MINMAX。 - 因为同一个归并段必须从小到大递增,所以接下来能从桌上打出去的牌,必须 ≥ MINMAX \ge \text{MINMAX} ≥MINMAX!
- 冰封法则(贴封条
*):
- 每从桌上打出一张牌,立刻从门外输入流摸进下一张新牌补上空位。
- 如果新摸进来的牌 < < < 底线
MINMAX:它已经接不上当前这串递增队伍了! - 怎么办?当场给它贴上冰封封条(记作
*)扣在桌上装睡!禁止它参加当前归并段的选拔,留给下一段用!
- 换届法则(全员解冻开启新段):
- 只要桌上还有没被冰封的"活牌",就继续挑最小的活牌打出去,让底线水涨船高。
- 一旦桌上 所有座位全都被贴上了冰封封条
*,说明已经没有任何人能跨过当前底线了。此时当前归并段正式封箱结案!我们撕掉桌上所有封条(全员原地复活),底线清零,开启下一个全新的归并段!
3.3 纯手工推演:容量 w = 3 w = 3 w=3 如何造出长度为 5 的超长归并段?
给定输入数据流:[17, 21, 5, 44, 10, 12, 56, 32],内存工作区容量 w = 3 w = 3 w=3。我们看桌子上的神奇变化:
text
【初始装填】摸入前3个数: [ 17, 21, 5 ]
--------------------------------------------------------------------------------
步骤1: 选最小活牌 [5] 打出! --> 底线 MINMAX = 5 | 摸入 44 (44>=5, 活牌!)
当前桌面: [ 17, 21, 44 ] | 当前 R1: [5]
步骤2: 选最小活牌 [17] 打出! --> 底线 MINMAX = 17 | 摸入 10 (10<17, 冰封为 10*!)
当前桌面: [ 10*, 21, 44 ] (剩2个活座位) | 当前 R1: [5, 17]
步骤3: 选最小活牌 [21] 打出! --> 底线 MINMAX = 21 | 摸入 12 (12<21, 冰封为 12*!)
当前桌面: [ 10*, 12*, 44 ] (只剩1个独苗活牌!)| 当前 R1: [5, 17, 21]
步骤4: 选唯一活牌 [44] 打出! --> 底线 MINMAX = 44 | 摸入 56 (56>=44, 活牌续命!!)
当前桌面: [ 10*, 12*, 56 ] (成功续命!) | 当前 R1: [5, 17, 21, 44]
步骤5: 选唯一活牌 [56] 打出! --> 底线 MINMAX = 56 | 摸入 32 (32<56, 冰封为 32*!)
当前桌面: [ 10*, 12*, 32* ] <-- 【全员冰封!第一段 R1 正式结案!】
--------------------------------------------------------------------------------
★ 第一段战果:R1 = [ 5, 17, 21, 44, 56 ] (桌子容量只有 3,却造出了长度为 5 的归并段!)
★ 换届解冻:撕掉 10*, 12*, 32* 的封条,依次输出形成第二段 R2 = [ 10, 12, 32 ](长度 3)。
3.4 工程神级设计:当"带段号的结构体"遇上"败者树"
在纸上推演时,我们人眼能一眼看出谁带 * 号、谁是活牌。但写代码时,难道要写一堆繁琐的 bool frozen 标志位去过滤吗?
完全不需要!这里有一个拍案叫绝的工程设计:
我们把工作区里的每个元素包装成一个结构体 Element,里面自带它所属的归并段号 run_id:
cpp
struct Element {
int value; // 真实的数值(如 17, 21, 5)
int run_id; // 所属的归并段批次号(1 代表第1段,2 代表被冰封归入第2段...)
};
然后给它定下一条铁打的比大小规矩(重载运算符):
- 先比段号
run_id:段号小的无条件获胜(第 1 段的活牌永远打赢第 2 段的冰封牌)! - 段号相同时,才比数值
value:数值小的获胜!
text
【为什么这个设计能和败者树天衣无缝地合体?】
1. 当前段的活牌:run_id = 1
2. 被冰封的新牌:因为比刚输出的冠军小,直接给它打上 run_id = 2 扔进败者树!
3. 败者树在向上单挑时:
哪怕冰封牌的 value 只有 10,只要它遇到 run_id = 1 的活牌(比如 44),
因为 2 > 1,冰封牌 [10, 第2段] 必然判定为"输家",老老实实留在半路擂台当守门员!
4. 什么时候说明"全员冰封"了?
当败者树王座 ls[0] 选出来的总冠军,它的 run_id 居然变成了 2!
说明树里所有 run_id = 1 的活牌已经全部打光了,自动平滑切换到第 2 段!
3.5 置换-选择排序完整 C++ 代码实现
cpp
#include <iostream>
#include <vector>
#include <string>
#include <climits>
#include <algorithm>
// 工作区元素:真实数值 + 所属归并段号
struct Element {
int value;
int run_id;
// 严格弱序:先比段号(段号小的优先),段号相同再比数值(数值小的优先)
bool operator<(const Element& other) const {
if (run_id != other.run_id) {
return run_id < other.run_id;
}
return value < other.value;
}
bool operator>(const Element& other) const {
if (run_id != other.run_id) {
return run_id > other.run_id;
}
return value > other.value;
}
};
// 专为 Element 定制的败者树
class ReplacementSelectionTree {
private:
int w; // 工作区容量(败者树路数)
std::vector<int> ls; // 败者树内部节点
std::vector<Element> leaves; // 工作区叶子卡槽
public:
void adjust(int s) {
int parent = (s + w) / 2;
while (parent > 0) {
// 若挑战者 leaves[s] 大于守门员(即段号更大,或同段号但数值更大),则挑战者输
if (leaves[s] > leaves[ls[parent]]) {
std::swap(s, ls[parent]);
}
parent /= 2;
}
ls[0] = s;
}
ReplacementSelectionTree(std::vector<Element>& init_elements) {
w = static_cast<int>(init_elements.size());
ls.resize(w);
leaves.resize(w + 1);
for (int i = 0; i < w; i++) {
leaves[i] = init_elements[i];
}
// 哨兵:段号极小、数值极小的无敌宗师
leaves[w] = {INT_MIN, INT_MIN};
for (int i = 0; i < w; i++) {
ls[i] = w;
}
for (int i = w - 1; i >= 0; i--) {
adjust(i);
}
}
int GetWinnerIndex() const {
return ls[0];
}
Element GetWinner() const {
return leaves[ls[0]];
}
void ReplaceWinner(const Element& new_ele) {
int winner = GetWinnerIndex();
leaves[winner] = new_ele;
adjust(winner);
}
};
// 置换-选择排序主驱动函数
std::vector<std::vector<int>> ReplacementSelectionSort(const std::vector<int>& input, int w) {
int n = static_cast<int>(input.size());
std::vector<std::vector<int>> res;
if (n == 0 || w <= 0) return res;
// 1. 初始填满容量为 w 的工作区,初始段号全为 1
std::vector<Element> init_elements;
int input_idx = 0;
for (; input_idx < w && input_idx < n; input_idx++) {
init_elements.push_back({input[input_idx], 1});
}
ReplacementSelectionTree tree(init_elements);
int cur_runid = 1;
std::vector<int> cur_run;
// 2. 边出边进流水线
while (true) {
Element winner = tree.GetWinner();
// 若选出的总冠军是结束哨兵 INT_MAX,说明所有有效元素已排空
if (winner.run_id == INT_MAX) {
if (!cur_run.empty()) res.emplace_back(cur_run);
break;
}
// 若总冠军的段号大于当前段号,说明上一段已全员冰封,正式换届开启新段!
if (winner.run_id > cur_runid) {
res.emplace_back(cur_run);
cur_run.clear();
cur_runid = winner.run_id;
}
// 输出当前冠军值(同时它自动成为了新的底线 MINMAX)
cur_run.emplace_back(winner.value);
// 补充新牌入树
if (input_idx < n) {
int new_val = input[input_idx++];
// 底线判定:新数 >= 刚输出的冠军,则继承当前段号;否则段号 + 1(冰封归入下一段)
int new_id = (new_val >= winner.value) ? winner.run_id : winner.run_id + 1;
tree.ReplaceWinner({new_val, new_id});
} else {
// 输入流耗尽,塞入极大值哨兵将树中剩余元素陆续顶出
tree.ReplaceWinner({INT_MAX, INT_MAX});
}
}
return res;
}
// ----------------------测试代码-------------------------
int main() {
std::vector<int> data = {17, 21, 5, 44, 10, 12, 56, 32};
int w = 3;
std::cout << "输入序列: ";
for (int x : data) std::cout << x << " ";
std::cout << "\n工作区容量 w = " << w << "\n\n";
auto runs = ReplacementSelectionSort(data, w);
std::cout << "=== 置换-选择排序生成结果 ===\n";
std::cout << "共生成了 " << runs.size() << " 个初始归并段:\n";
for (size_t i = 0; i < runs.size(); ++i) {
std::cout << "段 R" << (i + 1) << " (长度 " << runs[i].size() << "): ";
for (int x : runs[i]) {
std::cout << x << " ";
}
std::cout << "\n";
}
return 0;
}
3.6 随堂思考题与答案解析
题目 1:置换-选择排序的最坏情况与最好情况
都说置换-选择排序生成的初始归并段平均长度是 2 w 2w 2w。请问:在什么极端情况下,它生成的归并段长度最长?什么极端情况下最短?
【答案与解析】
- 最好情况(输入数据原本就是从小到大递增的,如
1, 2, 3, 4, 5...):
- 每次摸进来的新牌永远比刚打出去的底线大,永远不会有任何一张牌被冰封 !整个文件从头到尾直接一气呵成,只生成 1 个长度为 N N N 的巨型归并段。
- 最坏情况(输入数据原本是严格从大到小逆序的,如
99, 88, 77, 66...):
- 只要打出一张牌,后面摸进来的新牌必然比它小,摸一张冻一张,百发百中全被冰封 !最终每个归并段的长度退化为固定的 w w w(和传统内部排序切分法打成平手,绝不会比传统方法更差)。
- 平均情况(随机分布数据) :由高德纳(Knuth)通过"扫雪机模型"严格证明,生成的归并段期望平均长度为 2 w 2w 2w。
第四节:第三道防线 ------ 最佳归并树(哈夫曼树与"消消乐"虚段公式)
4.1 归并顺序的猫腻:先合短的还是先合长的?
经过置换-选择排序后,我们手头拿到了一批长短不一 的初始归并段。
假设现在有 3 个初始归并段,长度(所占磁盘块数)分别是:2、3、7 。我们要用 2 路归并把它们合成 1 个大文件,看看两种合并顺序的磁盘读写账单:
text
【方案 A:傻瓜式先合大的(先合 3 和 7)】 【方案 B:聪明式先合小的(先合 2 和 3)】
[12] <-- 第2次合: 2+10=12 [12] <-- 第2次合: 5+7=12
/ \ / \
[2] [10] <-- 第1次合: 3+7=10 [5] [7]
/ \ / \
[3] [7] [2] [3] <-- 第1次合: 2+3=5
总搬运代价 = 10 + 12 = 22 总搬运代价 = 5 + 12 = 17(省了 5 个块的读写!)
- 核心规律 :越早参与合并的段,在树里埋得越深(离根越远),后面就会跟着合成的大段被反复从磁盘读出来又写回去!
- 所以:短的段必须先合并(放树底下多搬几次也不心疼),长的段必须晚合并(放树根附近少搬运)!
- 每个叶子节点的权值 w i w_i wi 乘以它到根节点的路径长度 l i l_i li,总和就是整棵树的带权路径长度(WPL):
WPL = ∑ i = 1 n ( w i × l i ) \text{WPL} = \sum_{i=1}^{n} (w_i \times l_i) WPL=i=1∑n(wi×li)
- 重要结论 :每次归并都要"读 1 次 + 写 1 次",所以外部排序的总磁盘 I/O 读写块数 = 2 × WPL 2 \times \text{WPL} 2×WPL 。要让 I/O 最少,就是要构造一棵 k k k 叉哈夫曼树(即最佳归并树)!
4.2 深度拆解核心公式:如何用"消消乐游戏"秒懂 ( n − 1 ) ( m o d k − 1 ) (n - 1) \pmod{k - 1} (n−1)(modk−1)?
在 2 路归并(二叉哈夫曼树)中,每次抓 2 个最小的合为 1 个,随便几个段都能合完。
但到了 k k k 路归并( k ≥ 3 k \ge 3 k≥3) ,一个致命陷阱出现了:如果最后凑不够 k k k 个段怎么办?
很多同学在背 ( n − 1 ) ( m o d k − 1 ) (n - 1) \pmod{k - 1} (n−1)(modk−1) 这个补虚段公式时非常痛苦,我们直接把它还原成桌上玩硬币消消乐的物理过程,一秒通透:
text
【消消乐物理模型】
● 初始状态:桌上有 n 堆硬币(代表 n 个初始归并段)。
● 终极目标:最后桌上只能剩下 1 大堆硬币(代表整棵归并树的根节点)。
● 出招规则(k路归并):每次出招,必须从桌上抓起 k 堆,融合成 1 堆放回桌上!
请问:每出招一次,桌上的硬币总堆数"净减少(消灭)"了多少堆?
答:拿走 k 堆,放回 1 堆 ──> 每次固定消灭 (k - 1) 堆!
现在,公式里的每一个部分都活过来了:
- 为什么分子是
n - 1?(需要消灭的总差额)
- 起点有 n n n 个节点,终点必须只剩 1 1 1 个根节点。
- 所以从头到尾,我们总共需要从桌上彻底消灭掉 n − 1 n - 1 n−1 个节点!
- 为什么分母是
k - 1?(每次出招固定消灭的步长)
- 每进行一次满编的 k k k 路合并( k k k 合 1 1 1),正好消灭 k − 1 k - 1 k−1 个节点。
- 取模结果 R = ( n − 1 ) ( m o d k − 1 ) R = (n - 1) \pmod{k - 1} R=(n−1)(modk−1) 到底代表什么?
- 用"总共要消灭的人数 ( n − 1 ) (n-1) (n−1)"除以"每次能消灭的人数 ( k − 1 ) (k-1) (k−1)",算出来的余数 R R R,就是按标准大招一路消到底之后,最后留在桌上消不掉的"落单零头"!
情况 A:余数 R = = 0 R == 0 R==0(天生完美,不用补虚段)
- 比如 n = 5 n = 5 n=5 个段,采用 k = 3 k = 3 k=3 路归并:
- 总共要消灭: 5 − 1 = 4 5 - 1 = 4 5−1=4 个节点。
- 每次 3 合 1 能消灭: 3 − 1 = 2 3 - 1 = 2 3−1=2 个节点。
- 4 ( m o d 2 ) = 0 4 \pmod 2 = 0 4(mod2)=0!正好出招 2 次,每次都是满满当当的 3 合 1,不需要补任何虚段!
情况 B:余数 R ≠ 0 R \ne 0 R=0(有人落单了,必须补"空气工具人"虚段!)
-
比如 n = 8 n = 8 n=8 个段,采用 k = 3 k = 3 k=3 路归并:
-
总共要消灭: 8 − 1 = 7 8 - 1 = 7 8−1=7 个节点。
-
每次 3 合 1 能消灭: 3 − 1 = 2 3 - 1 = 2 3−1=2 个节点。
-
7 ÷ 2 = 3 次 ... ... 余 1 7 \div 2 = 3 \text{ 次} \dots\dots \text{余 } \mathbf{1} 7÷2=3 次......余 1(即 R = 1 R = 1 R=1)。
-
如果不补虚段会发生什么惨剧?
你如果在最底下硬抓 3 个合成 1 个,合了 3 次(消掉 6 个节点)后,到了树根最后一次合并时,桌上只剩 2 个大胖子段了(凑不够 3 个)!树根被迫退化成 2 叉,等于白白浪费了一个宝贵的 3 路通道,害得底下某一个段多爬了一层楼!
-
怎么补救?(补充权值为 0 的虚段 Dummy Run)
-
现在最后还剩 R = 1 R = 1 R=1 个零头要消灭,而我们每次大招必须消灭 k − 1 = 2 k - 1 = 2 k−1=2 个。
-
缺口是多少? 就差 ( k − 1 ) − R (k - 1) - R (k−1)−R 个!
-
所以我们要往最底下的最小堆里,人为塞入 u = ( k − 1 ) − R u = (k - 1) - R u=(k−1)−R 个长度为 0 的"空气虚段",凑满一次标准大招:
需补充的虚段个数 u = ( k − 1 ) − ( ( n − 1 ) ( m o d k − 1 ) ) \text{需补充的虚段个数 } u = (k - 1) - \big((n - 1) \pmod{k - 1}\big) 需补充的虚段个数 u=(k−1)−((n−1)(modk−1))
text
【n = 8, k = 3 时的补虚段示意:R = (8-1) % (3-1) = 1,需补 u = 2 - 1 = 1 个虚段】
原本要消 7 个:[●] [●] | [●] [●] | [●] [●] | [●] (落单1个!)
补入1个虚段后:[●] [●] | [●] [●] | [●] [●] | [●] [虚0] <-- 凑齐8个待消节点,完美 4 次 3合1!
(特别注意:虚段的权值是 0 ,所以在第一次合并挑选最小的 k k k 个段时,虚段必然排在最前面被优先合并掉,绝对不会拖累任何真实数据!)
4.3 最佳归并树完整 C++ 代码实现
在代码中,我们利用 STL 的优先队列 std::priority_queue 构造小顶堆 ,先算好虚段塞入堆中,然后每次弹出最小的 k k k 个节点合并:
cpp
#include <iostream>
#include <vector>
#include <queue>
#include <algorithm>
#include <functional>
// 最佳归并树节点定义
struct MergeTreeNode {
int weight; // 归并段长度(权值)
std::vector<MergeTreeNode*> children; // 保存 k 个子分支,记录归并调度方案
MergeTreeNode(int w) : weight(w) {}
};
// 小顶堆比较仿函数(注意:priority_queue 默认是大顶堆,重载 > 才是小顶堆!)
struct CompareNode {
bool operator()(const MergeTreeNode* a, const MergeTreeNode* b) const {
return a->weight > b->weight;
}
};
// 构建 k 路最佳归并树,并通过 total_cost 返回总 WPL 代价
MergeTreeNode* BuildOptimalMergeTree(const std::vector<int>& runs_weight, int k, int& total_cost) {
total_cost = 0;
if (runs_weight.empty() || k < 2) return nullptr;
int n = static_cast<int>(runs_weight.size());
// 1. 用消消乐公式计算是否需要补充权值为 0 的虚段
int dummy_count = 0;
int remainder = (n - 1) % (k - 1);
if (remainder != 0) {
dummy_count = (k - 1) - remainder;
}
std::priority_queue<MergeTreeNode*, std::vector<MergeTreeNode*>, CompareNode> min_pq;
// 2. 将所有真实归并段与虚段(权值0)装入小顶堆
for (int w : runs_weight) {
min_pq.push(new MergeTreeNode(w));
}
for (int i = 0; i < dummy_count; i++) {
min_pq.push(new MergeTreeNode(0));
}
// 3. 贪心合并:每次弹出最小的 k 个节点,合成为 1 个新父节点
while (min_pq.size() > 1) {
int merge_weight = 0;
MergeTreeNode* parent = new MergeTreeNode(0);
for (int i = 0; i < k; i++) {
auto child = min_pq.top();
min_pq.pop();
merge_weight += child->weight;
parent->children.emplace_back(child);
}
parent->weight = merge_weight;
// 每次合并生成的非叶子节点权值之和,就等于整棵树的 WPL!
total_cost += merge_weight;
min_pq.emplace(parent);
}
return min_pq.top();
}
// 递归打印归并树形态
void PrintMergeTree(MergeTreeNode* root, int depth = 0) {
if (!root) return;
for (int i = 0; i < depth; ++i) {
std::cout << " ";
}
if (root->children.empty()) {
if (root->weight == 0) {
std::cout << "[虚段: 0]\n";
} else {
std::cout << "段长度: " << root->weight << "\n";
}
} else {
std::cout << "归并节点(权值 " << root->weight << "):\n";
for (auto* child : root->children) {
PrintMergeTree(child, depth + 1);
}
}
}
// 递归释放树内存
void FreeMergeTree(MergeTreeNode* root) {
if (!root) return;
for (auto* child : root->children) {
FreeMergeTree(child);
}
delete root;
}
int main() {
// ---------------- 测试 1: 5 个初始归并段,3 路归并(刚好整除)----------------
std::cout << "=== 测试 1: 5 个初始段,3 路归并 ===\n";
std::vector<int> runs1 = {2, 3, 5, 7, 9};
int k1 = 3;
int total_cost1 = 0;
MergeTreeNode* root1 = BuildOptimalMergeTree(runs1, k1, total_cost1);
PrintMergeTree(root1);
std::cout << "总 WPL 代价: " << total_cost1 << "\n\n";
FreeMergeTree(root1);
// ---------------- 测试 2: 8 个初始归并段,3 路归并(触发补 1 个虚段)----------------
std::cout << "=== 测试 2: 8 个初始段,3 路归并(触发虚段)===\n";
std::vector<int> runs2 = {2, 3, 6, 9, 12, 17, 25, 30};
int k2 = 3;
int total_cost2 = 0;
MergeTreeNode* root2 = BuildOptimalMergeTree(runs2, k2, total_cost2);
PrintMergeTree(root2);
std::cout << "总 WPL 代价: " << total_cost2 << "\n";
FreeMergeTree(root2);
return 0;
}
4.4 终极通关测试与答案解析
题目 1:手算最佳归并树与虚段(考研 408 真题级演练)
已知通过置换-选择排序得到了 9 个初始归并段 ,其长度(块数)分别为:
9, 30, 12, 18, 3, 17, 2, 6, 24。现在要进行 4 路归并( k = 4 k = 4 k=4)。
- 请问需要补充几个长度为 0 的虚段?
- 构造出这棵 4 路最佳归并树,并求出它的带权路径长度 W P L WPL WPL 是多少?
【答案与解析】
第 1 问解答(算虚段):
- 初始段数 n = 9 n = 9 n=9,归并路数 k = 4 k = 4 k=4。
- 消消乐法则 :总共要消灭 n − 1 = 8 n - 1 = 8 n−1=8 个节点;每次 4 合 1 能固定消灭 k − 1 = 3 k - 1 = 3 k−1=3 个节点。
- 求余数: R = 8 ( m o d 3 ) = 2 R = 8 \pmod 3 = 2 R=8(mod3)=2(最后落单了 2 个节点要消,还差 1 个才够放一次大招)。
- 需补虚段数 : u = ( k − 1 ) − R = 3 − 2 = 1 u = (k - 1) - R = 3 - 2 = \mathbf{1} u=(k−1)−R=3−2=1 个虚段(权值为
0)!
第 2 问解答(排好序贪心合并求 WPL):
先把 9 个真实段和 1 个虚段从小到大排好队:
[0, 2, 3, 6, 9, 12, 17, 18, 24, 30](共 10 个节点)
- 第 1 次合并(抓最小的 4 个:
0, 2, 3, 6):
- 合成新节点: 0 + 2 + 3 + 6 = 11 0 + 2 + 3 + 6 = \mathbf{11} 0+2+3+6=11。
- 放回队伍重新排序:
[9, 11, 12, 17, 18, 24, 30]
- 第 2 次合并(抓最小的 4 个:
9, 11, 12, 17):
- 合成新节点: 9 + 11 + 12 + 17 = 49 9 + 11 + 12 + 17 = \mathbf{49} 9+11+12+17=49。
- 放回队伍重新排序:
[18, 24, 30, 49]
- 第 3 次合并(抓最后剩下的 4 个:
18, 24, 30, 49):
-
合成最终根节点: 18 + 24 + 30 + 49 = 121 18 + 24 + 30 + 49 = \mathbf{121} 18+24+30+49=121。
-
计算总 WPL(把所有合并生成的非叶子节点权值直接相加即可,最快最准!):
WPL = 11 + 49 + 121 = 181 \text{WPL} = 11 + 49 + 121 = \mathbf{181} WPL=11+49+121=181
(如果题目进一步问"总的磁盘读写次数是多少",记得乘以 2,即 181 × 2 = 362 181 \times 2 = 362 181×2=362 次块读写!)
第五节:📝 复习心得与大白话核心笔记(考前速查)
- 串起整个外部排序的逻辑闭环(千万别把四个知识点当成孤岛背!)
- 核心痛点 :文件太大内存装不下,只能分块排好再归并,磁盘 I/O 读写次数是唯一生死线。
- 看公式行事: S = ⌈ log k r ⌉ S = \lceil \log_k r \rceil S=⌈logkr⌉。
- 想把 k k k 变大 又怕 CPU 比较太慢 → \to → 请出 败者树 (比较次数压到 ⌈ log 2 k ⌉ \lceil \log_2 k \rceil ⌈log2k⌉)。
- 想把 r r r 变小 又受限于内存工作区大小 w w w → \to → 请出 置换-选择排序 (平均段长翻倍到 2 w 2w 2w)。
- 造出来的段有长有短 怕大段反复读写 → \to → 请出 最佳归并树(短段先合、长段晚合,WPL 最小)。
- 败者树(Loser Tree)防坑口诀
- "败者留守当门神,胜者拿牌往上冲,全胜冠军坐树顶
ls[0]"。 - 为什么不用胜者树?因为胜者树父节点记的是自己,重赛时还要去查兄弟节点;败者树父节点记的是上次的手下败将,换新牌后顺着
parent = (s + k) / 2一路跟亲爹单挑就行! - k k k 路归并的败者树有 k k k 个叶子专座 ,k − 1 k - 1 k−1 个内部擂台 + 1 1 1 个王座
ls[0](内部节点数组总大小正好也是 k k k)。
- 置换-选择排序(Replacement-Selection Sort)保命细节
- 脑子里记住三件事:比刚打出的底线
MINMAX大或相等就作为活牌;比底线小就贴封条*冰封(段号run_id + 1);全桌都被冰封就撕封条开启下一段。 - 算题时一定要盯紧:每次是从"未被冰封的活牌"里选最小的打出去 ,千万别把带
*的冰封牌提前打出去了! - 记住三个结论:平均归并段长度是 2 w 2w 2w ;输入正序时只生成 1 1 1 个 段;输入逆序时每段长度退化为 w w w。
- 最佳归并树与虚段公式:草稿纸上画"消消乐"
-
拿到题目给的 n n n 个段和 k k k 路归并,第一步永远是先算要不要补虚段!不判断虚段直接画树必错!
-
怎么算?想消消乐:总共要消灭 n − 1 n - 1 n−1 个,每次大招消灭 k − 1 k - 1 k−1 个。
-
算余数 R = ( n − 1 ) ( m o d k − 1 ) R = (n - 1) \pmod{k - 1} R=(n−1)(modk−1)。
-
若 R = = 0 R == 0 R==0,直接画树;若 R ≠ 0 R \ne 0 R=0,立刻在最小的数前面补上 ( k − 1 ) − R (k - 1) - R (k−1)−R 个
0(虚段)! -
算 WPL 的考场秒杀技巧:不用挨个去数每个叶子在第几层,直接把每次合并产生的新圆圈(非叶子节点)的数字全部加起来 ,和 ∑ ( w i × l i ) \sum (w_i \times l_i) ∑(wi×li) 的结果分毫不差,又快又不容易算错!