外部排序

5388 字
14 分钟

每日一言

Look around you, and all you will see are people the world would be better off without. — Light Yagami from Death Note

外存信息的存取

外部排序的基本方法——归并排序法

步骤

  1. 生成若干初始归并段(初始归并串/顺串),即对文件进行预处理

    • 把含有n个记录的文件,按内存缓冲区大小分成若干长度为L的可一次完整装入内存的子文件/段;
    • 将每个段,依次调入内存用有效的内排序方法排序后送回外存,形成若干个“初始归并段”;

    1734247385732

  2. 对初始归并段进行多路合并(多路归并)

    初始方法:对初始归并段进行多次2路归并排序,直至最后在外存上得到整个有序文件为止(原理同内排序的2路归并排序)

示例:

假设一次I/O的物理块大小为150,每次可对750 个记录进行内部排序,那么对含有150000个记录的磁盘文件进行4-路平衡归并排序时,共需进行多少次I/O?写出计算过程和结果。

1.初始归并段的生成

  • 每次内部排序可以处理750个记录。
  • 总共有150000个记录。
  • 因此,初始归并段的数量为:150000 / 750 = 200 段。
  1. I/O次数的计算
  • 每次I/O只能读取或写150个记录。
  • 每段有750个记录,因此每段需要(750 / 150)*2 = 10次I/O来读写。
  1. 第一次归并:(200 / 4 = 50)

    读200 段写200段,则读写10 * 200 = 2000次

  2. 第二次归并:(48/4+2/2 = 13)

    读200 段写200段,读写10 * 200 = 2000次

  3. 第三次归并:(12/4 +1 = 4)

    未进行全部的归并,按条数多的一段16*750 = 12000条未参加计算读写:10 * 200 - 160 = 1840 次

  4. 第四次归并:(4 /4 =1)

    读200 段写200段,读写10 * 200 = 2000次

总读写次数:2000*4- 160 = 9840次

大多数情况下,我们可以通过公式:logkm\lceil \log_k m \rceil 直接计算k路归并要归并的次数,再乘以每次归并需要读写的次数算出大致I/O次数:

log4200\lceil\log_4 200 \rceil = 4

则I/0次数为:4 * 200 * (750/150) *2 = 10000次

多路(k路)平衡归并

设,记录总数为n,初始归并段的个数为m,进行k路归并合并,共进行s=logkms = \lceil \log_k m \rceil 趟归并。

胜者树

胜者树是一棵 完全二叉树 ,其每个内节点存储的是其两个子节点中的“胜者”(即较优值,如较小值或较大值)的索引,而叶子节点存储的是参赛者(归并段中的元素)的索引或值。

特点:

  1. 比较过程:
    • 从叶子节点开始,依次将相邻两个参赛者进行比较,将“胜者”传递到父节点。
    • 比赛从下到上,直到根节点,此时根节点存储的是整体的胜者(全局最优值)。
  2. 快速更新:
    • 当一个叶子节点(归并段中的一个元素)发生变化时,只需重新比较该叶子节点所在路径的节点,大约需要 O(log k) 的时间。
  3. 应用场景:
    • 用于多路归并中快速找到最小值(或最大值),并更新状态。
    • 胜者树的时间复杂度通常为 O(n log k) ,其中 n 是数据总量,k 是参与归并的路数。

胜者树示例:

假设有 4 个归并段,当前数据如下:

  • 归并段 1:[3, 7, 9]
  • 归并段 2:[2, 8, 10]
  • 归并段 3:[5, 6, 12]
  • 归并段 4:[1, 4, 11]

构建胜者树:

  • 每个叶子节点表示 4 个段的当前最小值:[3, 2, 5, 1]
  • 比较相邻叶子,产生胜者:
    • 第一层比较:3 vs 2 -> 25 vs 1 -> 1
    • 第二层比较:2 vs 1 -> 1(最终胜者)
  • 最终,根节点为 1,即当前全局最小值。

更新过程:

  • 如果叶子节点 1 被取走,下一个值为 4,更新路径上的父节点并重新比较。
  • 更新只需 O(log k) 的时间。

1734333713368

败者树

败者树与胜者树类似,但其每个内节点存储的是比赛中的 败者 (即较差值,如较大值或较小值)的索引,而非胜者。最终的胜者存储在树的根节点之外。

特点:

  1. 比较过程:
    • 与胜者树相同,从叶子节点开始比较,但每个父节点存储的是比赛中较差的一方(败者)。
    • 根节点外部存储了全局的胜者。
  2. 快速更新:
    • 更新时同样沿着路径重新比较,更新时间复杂度为 O(log k)
  3. 应用场景:
    • 用于多路归并或多路选择问题,效果与胜者树相同,但实现中可以简化根节点的处理逻辑。

败者树示例:

同样的数据 [[3, 2, 5, 1]]

构建败者树:

  • 每个叶子节点表示 4 个段的当前最小值:[3, 2, 5, 1]
  • 比较相邻叶子,产生败者:
    • 第一层比较:3 vs 2 -> 3(败者存父节点,2 继续比赛),5 vs 1 -> 5
    • 第二层比较:2 vs 1 -> 2(败者存父节点,1 存为胜者)
  • 根节点外存储全局胜者 1

更新过程:

  • 如果叶子节点 1 被取走,下一个值为 4,重新比较并更新路径。

1734333673404

但是在实际实现时,也可以选择不保存败者的实际数据,而是保存败者的归并段号,便于补充记录值。

胜者树 vs 败者树

特性胜者树败者树
内节点存储胜者(较优值)败者(较差值)
根节点存储全局胜者根节点外部存储全局胜者
构建复杂度O(k log k)O(k log k)
更新复杂度O(log k)O(log k)
实现复杂度相对简单稍复杂,根节点逻辑更清晰
适用场景多路归并、快速找最优解多路归并、快速找最优解

多路(k路)平衡归并

多路平衡归并是一种扩展的归并排序算法,它将数据划分为多个归并段(runs),在每一轮归并时, 同时处理k个归并段并将它们合并成一个有序段 。通过利用多路归并,可以减少归并轮数,从而提高外存排序的效率。

核心思想

  1. 初始归并段生成 : 将原始数据划分为若干个初始有序段(runs),每个段可以通过内存内排序(例如置换选择排序、快速排序等)生成。
  2. 多路归并 : 每次从k个归并段中选出最小(或最大)值,并将其输出到结果段中。重复此过程,直至所有段处理完毕。
  3. 递归归并 : 如果归并后的段数仍大于k,则需要进行多轮归并,直到所有数据归并为一个有序段。

关键步骤

1. 初始归并段的生成

  • 数据分块 : 从外存中读取固定大小(等于内存容量)的数据块,使用内存排序算法将其排序成一个有序段(run)。
  • 输出到外存 : 每处理一个块,将排序后的有序段存储到外存中,形成初始归并段。

2. 多路归并过程

多路归并的核心是 合并k个有序段 ,通常使用最小堆优先队列优化归并效率。步骤如下:

  1. 初始化:从每个归并段中读取第一个记录,构建一个最小堆(按关键字排序)。
  2. 取堆顶元素(当前最小值)输出到结果段,并从对应段中读取下一条记录,更新堆。
  3. 重复以上过程,直到所有段的数据都输出完毕。

3. 递归归并

  • 如果一次归并无法处理所有段,则归并k个段生成新的段,递归进行。
  • 最终所有段被归并为一个整体有序段。

多路平衡归并的特点

  1. 减少归并轮数 : 如果是两路归并,每轮会将段数减少一半;而k路归并每轮可以将段数减少到1/k,显著减少轮数。
  2. 外存与内存交互 : 每次只读取固定大小的数据到内存,避免将所有数据加载到内存中。
  3. 时间复杂度
  • 总时间复杂度为 O(n logₖ m) ,其中:
    • n 是数据总量;
    • m 是初始归并段的数量;
    • k 是归并的路数。
  1. 适用场景
  • 常用于外存排序、大规模数据处理(例如数据库的排序操作)。
  • 特别适合内存有限、但可以利用外存的场景。

举例说明

假设有 16 个数需要排序,内存一次只能存放 4 个数,采用 4 路归并的过程如下:

1. 初始归并段的生成

将数据分为 4 段,分别排序成初始归并段:

  • 原始数据:[20, 10, 15, 5, 30, 25, 35, 40, 50, 45, 60, 55, 70, 65, 75, 80]
  • 初始段:[5, 10, 15, 20][25, 30, 35, 40][45, 50, 55, 60][65, 70, 75, 80]

2. 4 路归并

将 4 段归并为一个段:

  • 从 4 个段中分别取第一个数,形成一个最小堆:
    • 堆中初始值为 [5, 25, 45, 65],堆顶为 5。
  • 输出堆顶(5)到结果段,并从第一个段中读取下一个数(10),更新堆。
  • 重复该过程,直至所有段合并为一个有序段。

结果:[5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70, 75, 80]

置换——选择排序

置换选择排序的基本步骤

  1. 初始化
    • 将内存大小设为k,加载k个记录到内存。
    • 选择k个记录中的最小关键字记录,记为 minimax,并将其输出到外存,形成初始归并段的一部分。
  2. 记录替换
    • 当内存中空出一个位置时,从外存中补充一个新记录进入内存,保持内存中有k个记录。
    • 在内存的k个记录中,寻找 大于 minimax的最小关键字
      • 如果找到:将其输出到外存,更新 minimax为此记录的关键字。
      • 如果找不到(内存中的所有记录都小于 minimax):生成一个完整的初始归并段。
  3. 新归并段生成
    • 当一个归并段完成时,记录当前归并段,并重新开始:
      • 将当前内存中的所有记录清空并填充新的k个记录。
      • 重复上述步骤,继续生成归并段。
  4. 终止条件
    • 当外存中的所有待处理记录都被读取并处理后,算法结束。

示例

假设有以下数据集合需要排序:[15, 21, 10, 8, 35, 19, 25, 5],内存容量k=3。

过程:

  1. 初始内存加载 :加载 [15, 21, 10],找到最小值 10,输出到外存,更新 minimax = 10
  2. 从外存补充新数据 8,内存为 [15, 21, 8]
    • 在内存中找到 大于 10的最小值15,输出到外存,更新 minimax = 15
  3. 补充新数据 35,内存为 [35, 21, 8]
    • 找到大于 15的最小值:21,输出到外存,更新 minimax = 21
  4. 补充新数据 19,内存为 [35, 19, 8]
    • 找到大于 21的最小值:35,输出到外存,更新 minimax = 35
  5. 补充新数据 25,内存为 [25, 19, 8]
    • 此时内存中所有值均小于 35,形成一个初始归并段 [10, 15, 21, 35]
  6. 继续加载数据生成下一个归并段,直至所有记录处理完毕。

最终生成归并段:[10, 15, 21, 35][8, 19, 25, 5]

采用败者树来实现置换-选择排序

败者树与置换-选择排序的关系

  1. 置换-选择排序的过程
  • 内存中维护一个大小为 k 的缓冲区存储记录。
  • 每次从缓冲区中选出最小的记录输出到外存,然后用新的数据填充缓冲区。
  • 当缓冲区中所有剩余记录的值都小于刚输出的记录时,当前归并段结束,开启新的一段。
  1. 败者树的作用
  • 快速找到缓冲区中的 最小值 (用于输出)。
  • 高效更新缓冲区中的记录(用新值替代被选出的最小值)。
  • 减少比较次数,将记录的维护复杂度从 O(k) 降低到 O(log k)。

置换-选择排序的实现步骤

1. 初始化

  • 将内存大小设为 k,从输入数据中读入前 k 个记录构建初始缓冲区。
  • 构造败者树,用于维护缓冲区中记录的最小值。

2. 输出最小值并更新

  • 每次通过败者树找到当前缓冲区中的最小记录(根节点)。
  • 将最小记录输出到外存,填充新数据到被替换的位置。
  • 如果新数据的值小于当前输出记录,则将其标记为“不可参与本段”(设置为“哨兵值”),直到开启新段。

3. 新段生成

  • 当缓冲区中的所有值都比输出的最小值小(即标记为“哨兵值”),当前归并段结束,记录段的边界。
  • 继续处理剩余数据,重复以上过程,直到所有输入数据被处理完。

4. 输出初始归并段

  • 最终生成多个初始归并段,每段可以直接用于外部排序的归并阶段。

最佳归并树

k路最佳归并树是为了解决k路归并排序中最小化比较次数的问题,它是最佳归并树的扩展版本,但区别在于一次可以归并k个序列,而不仅仅是两两归并。

构造思想

设初始归并段为m个,进行k-路归并

  1. (m1)mod(k1)0(m-1) \mod (k-1) \neq 0: 则需附加 (k1)(m1)mod(k1)(k-1)-(m-1) \mod (k-1)个长度为0的虚段
  2. 按照哈夫曼树的构造原则(权值越小的结点离根结点越远)构造最佳归并树,权值为归并段长度。

k路最佳归并树的示例

示例:

有6个序列,长度分别为:10, 20, 30, 40, 50, 60,我们希望使用 3路归并 来最小化比较代价。

构造过程:

给定数据

初始序列长度为: 10, 20, 30, 40, 50, 60 (6个序列, m=6m = 6)

使用3路归并 (k=3k = 3):

第一步: 检查是否需要补充虚段

计算 (m1)mod(k1)(m - 1) \mod (k - 1):

(61)mod(31)=5mod2=1(6 - 1) \mod (3 - 1) = 5 \mod 2 = 1

因为结果不为0, 需要补充:

(k1)(m1)mod(k1)=21=1(k - 1) - (m - 1) \mod (k - 1) = 2 - 1 = 1

补充一个长度为0的虚段。新的序列为: 10, 20, 30, 40, 50, 60, 0.

第二步: 按哈夫曼树规则进行归并

  1. 第一轮合并

选择最小的三个序列 0, 10, 20, 合并后的长度为:

0+10+20=300 + 10 + 20 = 30

剩余序列为: 30, 30, 40, 50, 60

  1. 第二轮合并

选择最小的三个序列 30, 30, 40, 合并后的长度为:

30+30+40=10030 + 30 + 40 = 100

剩余序列为: 50, 60, 100

  1. 第三轮合并

选择最小的三个序列 50, 60, 100, 合并后的长度为:

50+60+100=21050 + 60 + 100 = 210

剩余序列为: 210

k路最佳归并树的图示


               (210)

              /  |  \

           (100) 60  50

          / | \ 

      (30) 30  40

     / | \

   10 20 0

与其他树的区别

特性最佳归并树胜者树败者树
用途最小化归并代价快速找到多路最优元素快速找到多路最优元素
构造过程基于哈夫曼树思想二叉树逐层比较二叉树逐层比较
代价最小化最小化归并比较总次数不关注总次数,仅找最优值不关注总次数,仅找最优值
适用场景多路归并排序、文件合并归并排序、优先级队列归并排序、优先级队列