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. 重复以上过程直到文件处理完毕

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

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

当各归并段长度不等时,归并顺序会影响总 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 叉哈夫曼树,需要掌握虚段补充规则

真题练习

相关真题(5题)

2026Q11选择题2分

外部排序:k 路归并趟数 d=⌈log_k(m)⌉,内存大小影响初始归并段长度

2024Q11选择题2分

败者树:外排序中败者树的中间结点记录的是败者的归并段号

2023Q42综合题10分

外部排序:置换-选择排序生成初始归并段,求归并段数和首段长度范围

2019Q11选择题2分

外部排序:多路归并中虚段数的计算

2016Q11选择题2分

外部排序:大规模数据排序适合使用归并排序