外部排序全解析:败者树、置换-选择排序与最佳归并树

外部排序全解析:败者树、置换-选择排序与最佳归并树

文章目录

  • 外部排序全解析:败者树、置换-选择排序与最佳归并树
    • 第一节:为什么需要外部排序?(小书桌与大仓库的矛盾)
      • [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”并从后往前调整?)
      • [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 条逃生通道 ,这也正是整章外部排序层层递进的三大核心武器:

  1. 方向一:把底数 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⌉!
  1. 方向二:把真数 r r r 变小(减少初始归并段的个数)
  • 总数据量 N N N 固定,如果每个初始归并段造得越长 ,切出来的总段数 r r r 就越少!
  • 引发的新痛点 :小书桌容量只有 w w w,传统办法排出来的段长死死卡在 w w w,怎么突破桌子的物理限制?
  • 破局武器(第三节) :置换-选择排序(Replacement-Selection Sort) ------ 边进边出,造出平均长度达到 2 w 2w 2w 的超长归并段!
  1. 方向三:长短不一的段,怎么合最省力?
  • 置换-选择排序造出来的归并段有长有短,如果让大胖子段反复搬运,磁盘又得累死。
  • 破局武器(第四节) :最佳归并树(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,受限于内存空间和单块读写效率:

  1. 内存缓冲区被切得太碎 :内存总大小是固定的。如果搞 1000 路归并,就得在内存里划分出 1000 个输入缓冲区,导致每个输入缓冲区只有指甲盖那么大。
  2. 磁盘寻道次数反而暴增:缓冲区太小,每次只能从磁盘读一点点数据就耗尽了,磁头需要在 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(数值越小越强)。

第一幕:初始建树完成后的擂台状态
  1. 左半区初赛(擂台 ls[2]) :0号(9) PK 1号(30) → \to → 1号(30) 输了留在 ls[2] 当守门员,0号(9) 拿着牌往上走。
  2. 右半区初赛(擂台 ls[3]) :2号(15) PK 3号(20) → \to → 3号(20) 输了留在 ls[3] 当守门员,2号(15) 拿着牌往上走。
  3. 决赛(擂台 ls[1]) :晋级的 0号(9) PK 2号(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 号专座,沿着父节点一路向上挑战:

  1. 第一关(挑战亲爹 ls[2] 的守门员 1号:30):
  • 挑战者 0号(25) PK 守门员 1号(30)。
  • 因为 25 < 30 25 < 30 25<30,挑战者 0号(25) 赢了 !守门员不变,0号(25) 继续往上走。
  1. 第二关(挑战决赛台 ls[1] 的守门员 2号:15):
  • 挑战者 0号(25) PK 守门员 2号(15)。
  • 因为 25 > 15 25 > 15 25>15,挑战者 0号(25) 输了!
  • 触发铁律(身份互换) :输家 0号(25) 留下来接替 ls[1] 当新守门员;原守门员 2号(15) 翻身成为新胜者,继续往上走!
  1. 登顶 :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:

  1. 第一关(挑战亲爹 ls[3] 的守门员 3号:20) :2号(18) < 3号(20),赢!继续往上。
  2. 第二关(挑战决赛台 ls[1] 的守门员 0号:25) :2号(18) < 0号(25),又赢!继续往上。
  3. 登顶 :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"并从后往前调整?

刚开始建树时,所有擂台都是空的,没法直接两两比试。工程师用了一个极简妙招:

  1. 在 leaves[k] 放一个战力天花板的无敌哨兵 INT_MIN(负无穷) ,并让所有擂台 ls[0 ... k-1] 一开始全都坐着这位哨兵。
  2. 然后让 0 ~ k-1 号真实选手依次调用 adjust(i) 往上单挑。因为所有真实数字都比 INT_MIN 大(全都会输给哨兵),真实选手就会在爬树的过程中,乖乖把自己填进各个空擂台里,把哨兵一步步顶上王座 ls[0]!
  3. 为什么必须从后往前(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 条规矩:

  1. 底线法则(MINMAX):
  • 桌子旁边立一块牌子,记录当前归并段刚刚打出去的最后一个数字 ,称为当前底线 MINMAX。
  • 因为同一个归并段必须从小到大递增,所以接下来能从桌上打出去的牌,必须 ≥ MINMAX \ge \text{MINMAX} ≥MINMAX!
  1. 冰封法则(贴封条 *):
  • 每从桌上打出一张牌,立刻从门外输入流摸进下一张新牌补上空位。
  • 如果新摸进来的牌 < < < 底线 MINMAX:它已经接不上当前这串递增队伍了!
  • 怎么办?当场给它贴上冰封封条(记作 *)扣在桌上装睡!禁止它参加当前归并段的选拔,留给下一段用!
  1. 换届法则(全员解冻开启新段):
  • 只要桌上还有没被冰封的"活牌",就继续挑最小的活牌打出去,让底线水涨船高。
  • 一旦桌上 所有座位全都被贴上了冰封封条 * ,说明已经没有任何人能跨过当前底线了。此时当前归并段正式封箱结案!我们撕掉桌上所有封条(全员原地复活),底线清零,开启下一个全新的归并段!

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段...)
};

然后给它定下一条铁打的比大小规矩(重载运算符):

  1. 先比段号 run_id :段号小的无条件获胜(第 1 段的活牌永远打赢第 2 段的冰封牌)!
  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. 最好情况(输入数据原本就是从小到大递增的,如 1, 2, 3, 4, 5...):
  • 每次摸进来的新牌永远比刚打出去的底线大,永远不会有任何一张牌被冰封 !整个文件从头到尾直接一气呵成,只生成 1 个长度为 N N N 的巨型归并段。
  1. 最坏情况(输入数据原本是严格从大到小逆序的,如 99, 88, 77, 66...):
  • 只要打出一张牌,后面摸进来的新牌必然比它小,摸一张冻一张,百发百中全被冰封 !最终每个归并段的长度退化为固定的 w w w(和传统内部排序切分法打成平手,绝不会比传统方法更差)。
  1. 平均情况(随机分布数据) :由高德纳(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) 堆!

现在,公式里的每一个部分都活过来了:

  1. 为什么分子是 n - 1?(需要消灭的总差额)
  • 起点有 n n n 个节点,终点必须只剩 1 1 1 个根节点。
  • 所以从头到尾,我们总共需要从桌上彻底消灭掉 n − 1 n - 1 n−1 个节点!
  1. 为什么分母是 k - 1?(每次出招固定消灭的步长)
  • 每进行一次满编的 k k k 路合并( k k k 合 1 1 1),正好消灭 k − 1 k - 1 k−1 个节点。
  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)。

  1. 请问需要补充几个长度为 0 的虚段?
  2. 构造出这棵 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. 第 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]
  1. 第 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]
  1. 第 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 次块读写!)


第五节:📝 复习心得与大白话核心笔记(考前速查)

  1. 串起整个外部排序的逻辑闭环(千万别把四个知识点当成孤岛背!)
  • 核心痛点 :文件太大内存装不下,只能分块排好再归并,磁盘 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 最小)。
  1. 败者树(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)。
  1. 置换-选择排序(Replacement-Selection Sort)保命细节
  • 脑子里记住三件事:比刚打出的底线 MINMAX 大或相等就作为活牌;比底线小就贴封条 * 冰封(段号 run_id + 1);全桌都被冰封就撕封条开启下一段。
  • 算题时一定要盯紧:每次是从"未被冰封的活牌"里选最小的打出去 ,千万别把带 * 的冰封牌提前打出去了!
  • 记住三个结论:平均归并段长度是 2 w 2w 2w ;输入正序时只生成 1 1 1 个 段;输入逆序时每段长度退化为 w w w。
  1. 最佳归并树与虚段公式:草稿纸上画"消消乐"
  • 拿到题目给的 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) 的结果分毫不差,又快又不容易算错!

相关推荐
库玛西1 小时前
计算机网络第三章:数据链路层
服务器·网络·计算机网络·考研·算法·面试
摇滚侠1 小时前
《Java并发编程之美》第 2 章 并发编程的其他基础知识 阅读笔记 3
android·java·笔记
邹霍梁@开源软件GoodERP1 小时前
【出海 ERP】本地没库存时:自产自销还是转口贸易?一张决策树给财务和 IT
算法·决策树·机器学习
程序 代码狂人1 小时前
经典 SQL 面试题:连续登录问题的两种经典解法
java·开发语言·算法
大熊背1 小时前
一种数码相机快速自动曝光控制方法解读
算法·计算机视觉·自动曝光·快速收敛
程序猿阿森2 小时前
深入解析 NLI:RAG 与大模型防幻觉的“最后一道防线”
前端·人工智能·算法
ss2732 小时前
AI时代首先数据库-PostgreSQL实战笔记 | 18 PostgreSQL基本索引原理与创建
数据库·笔记·postgresql
General_G2 小时前
创建型模式—原型(Prototype)模式
c++·设计模式·原型模式