Skip to content

DS-07-08 外部排序

408 > ### 数据结构 > #### 外部排序(多路归并/置换-选择排序)


一、定位信息

维度内容
章节归属第7章 排序
知识单元编号DS-07-08
前置知识DS-07-06 归并排序、文件与存储基础
后续衔接DS-07-09 排序算法综合对比
考试大纲要求理解外部排序的基本思想,掌握多路归并和置换-选择排序
预计学习时长45分钟

二、知识点讲解

1. 外部排序的基本概念

外部排序是指待排序数据量太大,无法一次性装入内存,需要在排序过程中进行内、外存数据交换的排序方法。

核心思想:将外存上的数据分段读入内存,在内存中排序后写回外存,形成若干个有序段(初始归并段),然后通过多路归并将有序段逐步合并为一个完整的有序文件。

两个阶段

  1. 生成初始归并段:将外存数据分段读入内存,排序后写回
  2. 多路归并:将多个归并段逐步合并

2. 外部排序的时间代价

外部排序的主要时间代价来自磁盘读写(I/O)。设:

  • 文件总记录数:N
  • 内存工作区大小:M(可容纳 M 个记录)
  • 初始归并段数:⌈N/M⌉ = n

总 I/O 次数 = 2 × 文件大小 × 归并趟数

  • 因子 2:每趟读一次 + 写一次
  • 归并趟数 = ⌈log_k(n)⌉(k 路归并)

优化目标:减少归并趟数 → 增大 k(路数)或减少 n(初始归并段数)

3. 多路归并(k-way merge)

3.1 基本思想

将 k 个有序归并段合并为一个有序段。每趟从 k 个归并段的当前元素中选最小者输出。

3.2 朴素方法 vs 败者树

方法每次选最小的比较次数每趟总比较次数
朴素方法k-1 次(k-1) × (N-1) ≈ O(kN)
败者树⌈log₂k⌉ 次⌈log₂k⌉ × (N-1) ≈ O(N log k)

败者树(Loser Tree) 是一棵完全二叉树,用于高效地从 k 个元素中选出最小值。

3.3 败者树原理

败者树的叶节点存放各归并段的当前元素,内部节点记录败者(较大值)的编号,最终的胜者(最小值)在根节点之外的一个额外位置。

c
// 败者树的调整过程(简化伪代码)
void Adjust(LoserTree ls[], int k, int s) {
    // s 是新元素的编号(叶节点位置)
    // k 是败者树的路数
    int t = (s + k) / 2;  // t 是 s 的父节点
    while (t > 0) {
        if (Loser[ls[t]] < Loser[s]) {
            // s 败了,交换
            int temp = s;
            s = ls[t];
            ls[t] = temp;
        }
        t = t / 2;  // 向上
    }
    ls[0] = s;  // 最终胜者
}

3.4 败者树的时间复杂度

  • 建立败者树:O(k)
  • 每次调整(选出下一个最小值):O(log₂k)
  • 总时间:O(k + N × log₂k)

当 k 增大时:

  • 归并趟数 ⌈log_k(n)⌉ 减少 → I/O 次数减少
  • 但每次调整代价 ⌈log₂k⌉ 增大
  • 存在最优的 k 值,需要权衡

4. 置换-选择排序(Replacement-Selection Sort)

4.1 动机

若初始归并段数 n 减少,则归并趟数减少。置换-选择排序可以在相同内存大小下生成更长的初始归并段,从而减少归并段数。

朴素方法:每个初始归并段长度 = M(内存大小) 置换-选择排序:平均每个初始归并段长度 = 2M

4.2 基本思想

  1. 从输入文件读入 M 个记录到内存工作区
  2. 最小堆(或类似结构)从工作区中选出关键字最小的记录输出到当前归并段
  3. 再从输入文件读入下一个记录,如果其关键字 ≥ 刚输出的记录,则加入当前归并段;否则标记为下一段的开始
  4. 重复直到工作区为空,当前归并段结束
  5. 开始新的归并段,重复上述过程

4.3 伪代码

c
void ReplacementSelection(ElementType input[], int N, int M) {
    // input: 输入文件,N 个记录
    // M: 内存工作区大小
    MinHeap heap;  // 最小堆,大小为 M
    int lastOutput = -∞;  // 上一次输出的关键字
    int segmentId = 0;    // 当前归并段编号

    // 初始化:读入 M 个记录建小根堆
    for (int i = 0; i < M && i < N; i++) {
        InsertHeap(heap, input[i]);
    }
    int nextIdx = M;  // 下一个要读入的记录下标

    while (!IsEmpty(heap)) {
        ElementType min = DeleteMin(heap);  // 取堆顶最小值
        if (min.key < lastOutput) {
            // 当前值比上一个输出小,属于下一个归并段
            segmentId++;
        }
        Output(min, segmentId);  // 输出到当前归并段
        lastOutput = min.key;

        if (nextIdx < N) {
            ElementType newRecord = input[nextIdx++];
            if (newRecord.key >= lastOutput) {
                // 可以加入当前归并段
                InsertHeap(heap, newRecord);
            } else {
                // 属于下一个归并段,暂不插入,等当前段结束
                // 实际实现中需要额外空间暂存
                InsertHeap(heap, newRecord);  // 仍插入堆但标记
            }
        }
    }
}

4.4 效果分析

  • 朴素方法:n = ⌈N/M⌉ 个归并段,每段长度 M
  • 置换-选择排序:n 更少,每段平均长度 ≈ 2M
  • 归并趟数从 ⌈log_k(⌈N/M⌉)⌉ 降到 ⌈log_k(⌈N/(2M)⌉)⌉

5. 最佳归并树

当各初始归并段长度不等时,归并的顺序会影响总的 I/O 次数。最佳归并树利用 Huffman 树的思想,使带权路径长度最小(即 I/O 次数最少)。

原则:短的归并段晚归并,长的归并段早归并。类似 Huffman 编码中频率低的编码长。

对于 k 路归并:

  1. 若 (n-1) mod (k-1) ≠ 0,则补充长度为 0 的虚段
  2. 每次取 k 个最短的归并段合并
  3. 重复直到只剩一个归并段

6. 外部排序关键技术对比表

技术作用效果
多路归并减少归并趟数趟数从 ⌈log₂n⌉ 降到 ⌈log_k(n)⌉
败者树减少每次选最小的比较次数从 O(k) 降到 O(log₂k)
置换-选择排序减少初始归并段数每段平均长度从 M 增到 2M
最佳归并树优化归并顺序减少总 I/O 次数

三、记忆辅助

  1. 外部排序 = 生成初始归并段 + 多路归并:两步走,先分段排序,再多路合并
  2. 瓶颈是 I/O:外部排序的时间主要花在磁盘读写上,不是 CPU 计算
  3. 败者树 = 高效选最小:从 k 个中选最小只需 log₂k 次比较,不是 k-1 次
  4. 置换-选择排序的意义:同样 M 大小的内存,生成的归并段更长(平均 2M),段数更少
  5. 最佳归并树 = Huffman 树:短段晚合并,长段早合并,最小化总 I/O

四、例题精解

例题 1:多路归并的归并趟数计算

题目:有 10000 个记录,内存可容纳 500 个记录。使用 4 路归并,需要几趟归并?如果改用 8 路归并呢?

第一步:审题 N=10000, M=500,分别计算 4 路和 8 路归并的趟数。

第二步:分析

初始归并段数 n = ⌈N/M⌉ = ⌈10000/500⌉ = 20

4 路归并:

  • 趟数 = ⌈log₄(20)⌉ = ⌈log(20)/log(4)⌉ = ⌈2.16⌉ = 3 趟

8 路归并:

  • 趟数 = ⌈log₈(20)⌉ = ⌈log(20)/log(8)⌉ = ⌈1.44⌉ = 2 趟

第三步:解答

  • 4 路归并需要 3 趟
  • 8 路归并需要 2 趟

第四步:总结 增加归并路数 k 可以减少归并趟数,但每趟的比较代价增加。使用败者树可以使每趟的比较代价从 O(k) 降到 O(log₂k),从而支持更大的 k。


例题 2:置换-选择排序的效果

题目:有 24 个记录,内存工作区大小 M=6。使用朴素方法和置换-选择排序分别生成初始归并段,对比段数。

第一步:审题 N=24, M=6,比较两种方法的初始归并段数。

第二步:分析

朴素方法:

  • 每段长度固定为 M = 6
  • 段数 = ⌈24/6⌉ = 4 段

置换-选择排序:

  • 每段平均长度 ≈ 2M = 12
  • 段数 ≈ ⌈24/12⌉ = 2 段

(实际段数取决于数据分布,但平均约为朴素方法的一半)

第三步:解答

  • 朴素方法:4 个归并段
  • 置换-选择排序:约 2 个归并段

归并趟数(2 路归并):

  • 朴素方法:⌈log₂4⌉ = 2 趟
  • 置换-选择排序:⌈log₂2⌉ = 1 趟

第四步:总结 置换-选择排序通过利用"记录可以属于不同归并段"的灵活性,在相同内存下生成更长的归并段,从而减少归并段数和归并趟数。


例题 3:败者树的建树与调整

题目:有 4 个归并段,当前元素分别为 (10, 25, 35, 15)。用败者树选出最小值,并写出调整过程。

第一步:审题 4 路归并,叶节点为各段当前元素,用败者树选最小。

第二步:分析

初始叶节点(归并段编号 0~3):

  • 段 0: 10
  • 段 1: 25
  • 段 2: 35
  • 段 3: 15

建败者树(内部节点记录败者编号):

        [1]          败者:段1(25)
       /    \
    [3]      [2]     败者:段3(15), 段2(35)
    /  \    /  \
   10  25  35  15    段0 段1 段2 段3

第 1 层:段0(10) vs 段1(25),段1 败 → 内部节点[3] = 1,胜者段0 第 2 层:段2(35) vs 段3(15),段2 败 → 内部节点[2] = 2,胜者段3 第 3 层:段0(10) vs 段3(15),段3 败 → 内部节点[1] = 3,胜者段0

最终胜者:段 0(值 10),输出 10

调整:段 0 读入下一个元素(假设为 20)

  • 段0(20) vs 段1(25) → 段1 败 → [3] = 1
  • 段0(20) vs 段3(15) → 段0 败 → [1] = 0
  • 胜者:段 3(值 15)

第三步:解答 第一次选出最小值 10(段 0),调整后选出次小值 15(段 3)。每次调整只需 ⌈log₂4⌉ = 2 次比较。

第四步:总结 败者树的每次调整只需沿树的高度向上比较,时间复杂度 O(log₂k)。这使得多路归并即使路数 k 很大,效率也很高。


五、考情分析

年份题型考点难度
中频计算题归并趟数的计算★★★
中频选择题败者树的原理★★☆
低频分析题置换-选择排序的过程★★★
低频计算题最佳归并树的构造★★★

命题趋势

  • 外部排序的考查频率低于内部排序,但属于大纲要求内容
  • 归并趟数计算是最常见的题型
  • 败者树的原理和调整过程偶尔考查
  • 置换-选择排序和最佳归并树考查频率较低

六、易错点

  1. 归并段数的计算:n = ⌈N/M⌉,不是 N/M(要向上取整)
  2. 归并趟数的底数:k 路归并的趟数是 ⌈log_k(n)⌉,不是 ⌈log₂(n)⌉
  3. 败者树 vs 胜者树:败者树的内部节点记录的是败者(较大值),不是胜者
  4. 置换-选择排序的平均段长:不是 2M(这是理想情况),实际取决于数据分布
  5. I/O 次数的计算:每趟读一次写一次,所以是 2 × 文件大小 × 趟数
  6. 最佳归并树的虚段:k 路归并时,若 (n-1) mod (k-1) ≠ 0,需要补充虚段

七、来源标注

  • 《数据结构(C语言版)》严蔚敏,第11章 外部排序
  • 《数据结构》王道考研,第7章 7.7节 外部排序
  • 408考试大纲:数据结构部分-外部排序
  • 《算法导论》Thomas H. Cormen,第23章 最小生成树(败者树相关)

考研全科复习资料 - 基于2026考研统考大纲