Skip to content

外部排序

数据量超过内存怎么办

当数据量大到内存放不下(比如 10GB 的文件),所有内部排序算法都失效了。外部排序的策略是:先把数据分成小块,每块在内存中排好序写回磁盘(生成初始归并段),然后用多路归并把所有归并段合并成最终有序文件。

408 考研中,外部排序是排序章节的收官内容,核心瓶颈在于磁盘 I/O——算法设计目标是尽量减少磁盘读写次数。

核心思想

外部排序的基本策略:

  • 生成初始归并段(Run):将大文件分成若干段,每段读入内存进行内部排序,排好序后写回外存,形成有序的初始归并段
  • 多路归并:对所有初始归并段进行多趟归并,每趟将若干个有序段合并为更大的有序段,直到整个文件有序
  • 优化方向:减少归并趟数、减少每趟的 I/O 次数、加速归并过程中的比较操作

外部排序的总 I/O 次数 = 初始归并段生成的 I/O + 归并阶段的 I/O。归并阶段的 I/O 次数与归并趟数直接相关,因此减少趟数是关键。

交互可视化

通过下方的交互动画,你可以逐步观察外部排序的执行过程:

加载可视化中...

操作详解

外部排序的概念

外部排序的基本流程分为两个阶段:

  1. 生成初始归并段:假设文件共有 N 条记录,内存工作区可容纳 M 条记录。每次读入 M 条记录到内存,用内部排序算法排好序后写回磁盘,共生成 m = ⌈N/M⌉ 个初始归并段
  2. 多路归并:对 m 个初始归并段进行 k 路归并,每趟将 k 个有序段合并为一个更大的有序段,经过多趟归并后得到最终有序文件
文件(N条记录)
  ↓ 内部排序
[段1] [段2] [段3] ... [段m]    ← m个初始归并段
  ↓ k路归并(第1趟)
[大段1] [大段2] ...            ← ⌈m/k⌉个归并段
  ↓ k路归并(第2趟)
[更大段1] ...                  ← 段数继续减少
  ↓ ...
[最终有序文件]

多路归并

设有 m 个初始归并段,采用 k 路归并,则归并趟数为:

S = ⌈log_k(m)⌉

这是外部排序最核心的公式。减少归并趟数 S 的两种途径:

策略方法效果
增大归并路数 k使用多路归并(如 5 路、10 路)S = ⌈log_k(m)⌉ 随 k 增大而减小
减少初始归并段数 m使用置换-选择排序生成更长的归并段m 减小,S 随之减小

但增大 k 也有代价:在 k 路归并中,每次从 k 个段的当前元素中选最小值,简单方法需要 k-1 次比较,k 越大比较次数越多。解决方案是使用败者树,将每次选最小值的比较次数降为 ⌈log₂k⌉

每趟归并需要将所有记录读一遍、写一遍,I/O 次数与数据总量成正比。因此:

总 I/O 次数 ∝ 归并趟数 S = ⌈log_k(m)⌉

败者树

败者树是一棵完全二叉树,用于加速 k 路归并中"从 k 个元素中选最小值"的过程。

工作原理:

  • 叶子结点存放 k 个归并段的当前元素
  • 内部结点记录"败者"(较大者)的来源编号
  • 根结点之上额外有一个结点记录"冠军"(最小者)
  • 每次取走最小元素后,只需沿该元素所在叶子到根的路径进行调整,比较次数为 ⌈log₂k⌉

败者树的关键优势:

选最小值方式比较次数适用场景
直接比较k-1k 较小时可用
败者树⌈log₂k⌉k 较大时显著优于直接比较

例如 8 路归并:直接比较需 7 次,败者树只需 3 次。k 越大,败者树的优势越明显。

败者树手算示例

以 4 路归并为例,4 个归并段的当前元素分别为:

归并段b₀b₁b₂b₃
当前元素51239

第一步:初始建树

从叶子向上两两比较,败者(较大者)记录在内部结点,胜者继续向上比较:

叶子层:    b₀(5)   b₁(12)   b₂(3)   b₃(9)

第1轮比较: 5 vs 12 → 败者=b₁  |  3 vs 9 → 败者=b₃
             胜者=b₀(5)            胜者=b₂(3)

第2轮比较: 5 vs 3 → 败者=b₀
              胜者=b₂(3)

败者树结构:
            [冠军: b₂(3)]     ← 最小值
                |
            ls[0] = b₀         ← 记录败者来源
           /        \
      ls[1]=b₁    ls[2]=b₃    ← 记录败者来源
       / \          / \
     b₀(5) b₁(12) b₂(3) b₃(9)  ← 叶子(各段当前元素)

冠军是 b₂(3),即 4 个段中最小的元素是 3。

第二步:输出冠军,调整败者树

输出 3,从 b₂ 段读入下一个元素(假设是 7),只需沿 b₂ 到根的路径调整:

b₂ 的新元素: 7

沿路径向上比较:
  ls[2] 存的败者是 b₃(9),7 vs 9 → 新败者=b₃,胜者=b₂(7) 继续上
  ls[0] 存的败者是 b₀(5),7 vs 5 → 新败者=b₂,胜者=b₀(5) 继续上

新冠军: b₀(5)

调整后:
            [冠军: b₀(5)]
                |
            ls[0] = b₂(7)     ← 更新
           /        \
      ls[1]=b₁(12)  ls[2]=b₃(9)  ← 不变
       / \          / \
     b₀(5) b₁(12) b₂(7) b₃(9)

整个调整过程只比较了 2 次(= ⌈log₂4⌉),而不是重新从 4 个中选最小值的 3 次。

败者树的核心优势:每次输出一个元素后,调整只涉及从被替换叶子到根的一条路径,路径长度 = ⌈log₂k⌉。k 路归并共输出 n 个元素,总比较次数从 n(k-1) 降到 n⌈log₂k⌉。

置换-选择排序

普通方法生成的初始归并段长度等于内存工作区大小 M。置换-选择排序可以生成平均长度为 2M 的初始归并段,从而减少归并段数量 m。

算法步骤:

  1. 从文件读入 M 条记录填满内存工作区(设为 WA)
  2. 从 WA 中选出最小的且不小于上一个输出记录的记录,输出到当前归并段
  3. 从文件读入下一条记录填补 WA 空位
  4. 重复步骤 2-3,直到 WA 中所有记录都小于上一个输出记录
  5. 当前归并段结束,开始生成下一个归并段
  6. 重复以上过程直到文件处理完毕

数值演示(输入序列 17, 21, 5, 44, 10, 12, 56, 32, 29,工作区容量 M = 3):

步骤工作区 WA上次输出(门槛)选出输出读入补位
1−∞544
251710
3172112
4214456
5445632
656全部 < 56,无可选 → 段 1 结束
7−∞(新段)1029
81012(输入已空)
91229
102932

得到两个初始归并段:段 1 = (5, 17, 21, 44, 56) 长 5,段 2 = (10, 12, 29, 32) 长 4。

对照普通方法:9 条记录、工作区 3,每次内排序只能产出长 3 的段,共 3 段;置换-选择只产出 2 段——段 1 的长度 5 已经超过工作区容量,这正是"平均段长 2M"优势的来源。第 5 步是关键观察点:56 比工作区里所有记录都大,照样能进当前段,段就这样被"续命"了。

易错:第 4 步工作区里明明有更小的 10 和 12,却必须输出 44——选择标准是"最小的且不小于上次输出",不是"最小的"。10、12 比上次输出 21 小,进当前段会破坏段内有序,只能留给下一段。

置换-选择排序生成的各个归并段长度通常不等,这引出了最佳归并树的问题。

最佳归并树(类似哈夫曼树):

当各归并段长度不等时,归并顺序会影响总 I/O 次数。为使总 I/O 次数最少,应构造类似哈夫曼树的最佳归并树:

  • 将每个归并段的长度作为叶子结点的权值
  • 构造 k 叉哈夫曼树,权值大的归并段靠近根(晚参与归并)
  • 带权路径长度最小 → 总 I/O 次数最少

虚段补充规则

最佳归并树是一棵严格 k 叉树(每个内部结点恰好有 k 个孩子)。如果初始归并段数 m 不满足条件,直接构造会导致某次归并不满 k 路,降低效率。这时需要补充长度为 0 的虚段(不产生实际 I/O)。

推导过程

设严格 k 叉树中,叶子结点数为 n₀(对应归并段),度为 k 的内部结点数为 nₖ,则:

  • 结点总数:n = n₀ + nₖ
  • 边数 = n - 1 = k·nₖ(每个内部结点贡献 k 条边)
  • 联立得:nₖ = (n₀ - 1) / (k - 1)

因此 (n₀ - 1) 必须能被 (k - 1) 整除,否则无法构成严格 k 叉树。

判断规则

设 u = (m - 1) % (k - 1)

  • u = 0:m 个归并段恰好构成严格 k 叉树,无需补虚段
  • u ≠ 0:需补充 (k - 1 - u) 个虚段(长度为 0 的段)

补充虚段后,叶子总数变为 m + (k-1-u),满足严格 k 叉树条件。

真题示例(2019 统考真题):

设外存上有 120 个初始归并段,进行 12 路归并时,需要补充多少个虚段?

计算:u = (120 - 1) % (12 - 1) = 119 % 11 = 9

需补充虚段数 = (12 - 1) - 9 = 2 个

验证:补充后叶子总数 = 122,(122 - 1) % 11 = 121 % 11 = 0,满足条件。

另一个例子

8 个初始归并段,3 路归并。

u = (8 - 1) % (3 - 1) = 7 % 2 = 1 ≠ 0

需补充 (3 - 1) - 1 = 1 个虚段,叶子总数变为 9,(9 - 1) % 2 = 0,满足条件。

构造过程:将 1 个虚段(权值为 0)和权值最小的 2 个归并段先归并,然后按哈夫曼策略继续构造。

复杂度分析

指标公式/值说明
归并趟数S = ⌈log_k(m)⌉m 为初始归并段数,k 为归并路数
每趟 I/O 次数2 × ⌈N/B⌉N 为记录总数,B 为磁盘块容量(一读一写)
总 I/O 次数S × 2 × ⌈N/B⌉归并阶段的总磁盘读写次数
败者树每次选最小值⌈log₂k⌉ 次比较相比直接比较的 k-1 次显著减少
置换-选择排序平均段长2MM 为内存工作区大小

关键结论:外部排序的时间开销主要取决于磁盘 I/O 次数,而 I/O 次数与归并趟数成正比。

易错:外部排序的性能瓶颈是磁盘 I/O 次数,不是比较次数。减少 I/O 的关键是减少归并趟数:归并趟数 = log_k(m),其中 k 是归并路数,m 是初始归并段数。增大 k 或减少 m 都能减少趟数。

易错:增大归并路数 k 时,k 路归并每次需要从 k 个段中选最小值。直接比较需要 k-1 次比较,用败者树可以优化到 log2(k) 次。408 常考败者树在外部排序中的作用。

考研高频考点

  • 归并趟数公式 S = ⌈log_k(m)⌉ 的计算(选择题/填空题高频考查)
  • 增大归并路数 k 与减少初始归并段数 m 对趟数的影响(选择题高频)
  • 败者树的作用及比较次数 ⌈log₂k⌉(选择题/简答题)
  • 置换-选择排序生成初始归并段的过程(大题偶尔考)
  • 最佳归并树(k 叉哈夫曼树)的构造与虚段补充规则(大题高频)
  • 外部排序总 I/O 次数的计算(填空题)
  • 内部归并排序 vs 外部归并排序的区别(概念题)

相关知识

  • 归并排序——外部排序基于归并思想,内部归并排序是理解外排的基础
  • 堆排序——败者树/堆可用于加速多路归并中的"选最小值"操作
  • B+树索引也是为磁盘设计的数据结构,与外部排序同属"面向磁盘"的算法设计思路
  • 最佳归并树本质上是 k 叉哈夫曼树,需要掌握虚段补充规则

真题练习