外部排序与败者树
8.7 是全章唯一在算法竞赛里完全没有对应物的一节——它的优化目标是磁盘 I/O,不是 CPU 时间。
教材开门见山给了考查范围和全节的五条线索:
外部排序可能会考查相关概念、方法和排序过程,外部排序的算法比较复杂,不会在算法设计上进行考查。 本节的主要内容有: ① 外部排序指的是大文件的排序,即待排序的记录存储在外存中,待排序的文件无法一次性装入内存,需要在内存和外存之间进行多次数据交换,以达到排序整个文件的目的。 ② 为减少平衡归并中外存读/写次数所采取的方法:增大归并路数和减少归并段个数。 ③ 利用败者树增大归并路数。 ④ 利用置换-选择排序增大归并段长度来减少归并段个数。 ⑤ 由长度不等的归并段进行多路平衡归并,需要构造最佳归并树。
「不会在算法设计上进行考查」——这一节不用背代码,全部精力放在「数出趟数和 I/O 次数」。
机制
基本概念与代价模型
文件通常是按块存储在磁盘上的,操作系统也是按块对磁盘上的信息进行读/写的。因为磁盘读/写的机械动作所需的时间远远超过在内存中进行运算的时间(相比而言可以忽略不计),因此在外部排序过程中的时间代价主要考虑访问磁盘的次数,即 I/O 次数。
显然,外存信息读/写的时间远大于内部排序和内部归并的时间,因此应着力减少 I/O 次数。
归并段与两个阶段
外部排序通常采用归并排序算法,包括两个阶段:
- 根据内存缓冲区大小,将外存上的文件分成若干长度为
的子文件,依次读入内存并利用内部排序算法对它们进行排序,并将排序后得到的有序子文件重新写回外存,称这些有序子文件为归并段或顺串。 - 对这些归并段进行逐趟归并,使归并段(有序子文件)逐渐由小到大,直至得到整个有序文件为止。
教材的例子:一个含有 2000 个记录的文件,每个磁盘块可容纳 125 个记录,首先通过 8 次内部排序得到 8 个初始归并段
内存工作区等分为三个缓冲区:两个输入缓冲区,一个输出缓冲区。从两个输入归并段中分别读入一个块,在内存中进行二路归并,归并后的对象顺序存放在输出缓冲区中;若输出缓冲区存满,则将其顺序写到输出归并段中,再清空输出缓冲区;若某个输入缓冲区取空,则从对应的输入归并段中再读取下一块。
归并趟数与 I/O 次数
一般地,对
「只要增大归并路数
教材的算例(必须会数):2000 个记录、每块 125 个记录
| 方案 | 趟数 | 总读/写次数 |
|---|---|---|
| 二路归并( | 每趟 16 读 + 16 写; | |
| 四路归并( |
末尾那个
为什么不能无限增大 :败者树
增加归并路数
式中,
为了使内部归并不受
败者树
败者树是树形选择排序的一种变体,可视为一棵完全二叉树。
个叶结点分别存放 个归并段在归并过程中当前参加比较的元素; - 内部结点用来记忆左右子树中的「失败者」,而让胜利者往上继续进行比较,一直到根结点;
- 若比较两个数,大的为失败者、小的为胜利者,则根结点指向的数为最小数。
教材图 8.17(5 路归并)的过程:ls[4]。ls[3]。ls[2]。最后两个胜者 ls[1];而将胜者 ls[0]。此时,根结点 ls[0] 所指的段的关键字最小。
- 对于
路归并,初始构造败者树需要 次比较。 中的元素输出后,将下一关键字填入 ,继续比较。 - 因为
路归并的败者树深度为 ,所以从 个记录中选择最小关键字,仅需进行 次比较。因此总的比较次数约为
「可见,使用败者树后,内部归并的比较次数与
边界辨析:
ls[0]存的是「冠军」的段号,不是败者。 内部结点ls[1]~ls[k-1]存的才是各次比较的败者,ls[0]单独存最终胜者。这是败者树最容易记反的一处。 「败者树」这个名字说的是内部结点存败者,而不是根存败者。
边界辨析:
归并路数
并不是越大越好(教材原话): 「归并路数 增大时,相应地需要增加输入缓冲区的个数。若可供使用的内存空间不变,势必要减少每个输入缓冲区的容量,使得内存、外存交换数据的次数增大。当 值过大时,虽然归并趟数会减少,但读/写外存的次数仍会增加。」 所以「增大
一定更快」是错的。 败者树解决的只是内部比较的瓶颈,没有解决缓冲区变小的瓶颈。
手算模板
算归并趟数与 I/O 次数:
- 数块数
。 - 数初始归并段个数
和路数 。 - 趟数
。 - 归并阶段的读/写
(每趟把所有块读一遍、写一遍)。 - 加上内部排序阶段的读/写
。 - 总计
。
验算教材例子:
败者树的一次调整:
- 输出根
ls[0]所指段的当前元素。 - 该段补入下一个元素到对应叶结点
。 - 从
沿到根的路径逐层与 ls[]中的败者比较:败者留在该结点,胜者继续往上。 - 最后把胜者的段号写入
ls[0]。 - 共比较
次(树高减 1)。
边界
| 说法 | 判断 | 说明 |
|---|---|---|
| 「外部排序会考算法设计」 | ❌ | 教材明说不会在算法设计上进行考查 |
| 「外部排序的时间主要是内部排序时间」 | ❌ | 主要是 I/O 次数 |
| 「归并趟数 | ❌ | |
| 「总 I/O 次数 | ❌ | 还要加内部排序阶段的 |
| 「增大 | ❌ | 缓冲区变小会使交换次数增大, |
| 「用普通内部归并即可」 | ❌ | 内部比较随 |
| 「败者树的内部结点存胜者」 | ❌ | 存败者;ls[0] 存最终胜者 |
| 「 | ❌ | ls[0]) |
| 「初始构造败者树需要 | ❌ | |
| 「每次选最小需要 | ❌ | 用败者树只需 |
| 「败者树深度为 | ❌ | |
| 「内存分两个缓冲区」 | ❌ | 二路归并要三个:两输入 + 一输出 |
口径差异:
算竞里没有「外部排序」这个概念——数据总在内存里,
sort()就够了。 8.7 因此是整章唯一「必须从零学」的部分,也是有算竞背景的人最容易低估的一节。 好消息是它不考代码,只考数数:趟数、比较次数、I/O 次数、缓冲区个数。 把上面那个的模板背下来,这一节的计算题就基本拿下了。
对照速查
| 量 | 式子 |
|---|---|
| 归并趟数 | |
| 总 I/O 次数 | |
| 普通内部归并总比较 | |
| 用败者树后总比较 | |
| 败者树深度 | |
| 选一次最小的比较次数 | |
| 初始建败者树 | |
| 缓冲区个数( |
| 减少 I/O 的两条路 | 手段 |
|---|---|
| 增大归并路数 | 败者树(8.7.3) |
| 减少归并段个数 | 置换-选择排序(8.7.4) |
考点
- 对大文件排序时使用的排序算法(2016 命题追踪):归并排序。
- 归并趟数
与总 I/O 次数的计算。 - 败者树的实现原理(2024 命题追踪):内部结点存败者、
ls[0]存冠军、深度。 - 使用败者树后内部比较次数与
无关。 不是越大越好及其理由。 - 三个缓冲区(两输入一输出)。
链接
- 🏠 返回总览:数据结构第 8 章:排序总览
- ⬅️ 上一节:8.6 各种内部排序算法的比较及应用
- ➡️ 下一节:8.7.4~8.7.5 置换-选择排序与最佳归并树
- 🔗 内存中的归并:8.5.1 归并排序
- 🔗 树形选择的另一个变体:8.4.2 堆排序
- 🔗 磁盘块与 I/O 代价:OS 5.3.1 磁盘
- 📖 名词库:第 8 章名词库