败者树与外部多路归并排序
在处理大数据量的排序时,由于数据无法全部加载到内存,内部排序无法对整个数据集进行排序,需要到外部排序。
外部排序有一些特点,跟内存容量和读写文件有关:
1. 读写文件,需要考虑 IO 时间
2. 从无序到逐步有序的过程中,需要多个中间文件
外部排序有多种,常见的归并排序的如下:
输入为大文件 F ,排序过程分为分割和归并
分割:
1. 从 F 中读入内存能容纳的 k 个数
2. 对 k 个数排序
3. 排序结果输出的到文件 Gi
4. 重复 1-3 直到 F 结束
分割后得到 ceil(F/k) 个已排序的小文件,合并过程将多个小文件合并
归并:
1. 打开 m 个小文件(或后续中间结果文件)
2. 从 m 小文件中读当前数字,选最小的数输出到中间结果文件 Ri (假设为按从小到大排序)
3. 重复 2 直到 m 个小文件结束
4. 重复 1-3 直到所有文件(包间中间文件)已合并到最终文件
归并的过程中,以什么样的算法选数呢?
假如 m=2 (二路外部归并排序) ,那么没有什么好说的,直接比较两个数的大小就行了。但从整体上来说,由于每次只操作两个小文件,需要的中间结果文件数量多,IO 占用时间长。
如果提升 m ,进行多路排序,那么可减少中间文件的数量,但是对数的比较运算就复杂,每次取走一个数后,都要对 m 个文件流第一个数进行一次排序。
败者树是处理 m 路归并时,优化排序的数据结构,它是对排序树的一种变型,可减少因取走数调整的工作量。
败者树的基本思想是:左右孩子结点进行比较时,败者保存到父节点,而胜者则继续向上比较;根节点保存冠军。
L[0]:冠军b2
|
L[1]:败者b1
/ \
L[2]:败者b0 L[3]:败者b3
/ \ / \
L[4]:b[0]8 L[5]:b[1]7 L[6]:b[2]5 L[7]:b[3]9
对于 m 路归并排序,需要使用 m 个元素记录胜败情况,m 个叶子保存 m 个文件流第一个数。败者树的节点可用 union 表示为:
typedef union _Node {
int pos; // 胜者或败者位置
InfoType *info; // 文件流和其第一个数
}
InfoType 中需要记录文件流和对应的第一个数,需要支持的操作:
top() 取当前文件偏移的数,偏移位置不变
de() 取当前文件偏移的数,偏移位置 + 1
它使用数组存储,L[0]
为冠军,L[m]...L[2m-1]
为叶子节点,每个节点 i 的分节点为 i/2 (有些写法会把败者树数组 L 和数据数组 B 分开,也是可以的) 。每次取走冠军L[0]
,需要从叶子结点向上更新。
调整的算法为:
// 调整败者树 l ,冠军为位置 s ,取出冠军后调整
adjust(LoserTree l, int s):
p = s / 2 // 从叶子节点 l[s] 向根节点 l[0] 调整
while p>0:
// l[s] 失败,调整父节点l[p],l[p]向上比较
if l[s].info->top() > l[l[p]].info->top():
tmp = l[p] // 暂存 l[p]
l[p] = s // 父节点为败者
s = tmp // 存入 s 继续向上
p = p / 2 // 继续向上调整
l[0] = s // 冠军
那么败者树的构建过程为:
create_lt(LoserTree l, int m, int data[]):
for i in rang(m):
l[i] = m+i; // 胜者败者初始化
for i in range(m, 2m-1):
l[i] = InfoType(data[i-m]) // 数据初始化
for i in range(2m-1, m, step=-1):
adjust(l, i); // 初始化调整,从 data[m-1]...data[0]
合并的算法为:
m_merge(LoserTree l, int m, int data[]):
create_lt(l, m, data);
// 一直取冠军直到文件结束
while l[l[0]]->top() != EOF:
output(l[l[0]]->de()); // 输出冠军,同时该文件流到下一位置
adjust(l, l[0])
败者树将调整的过程的比较数量减少到 log(n) ,有效地加速外部排序的过程。
除了在外部归并排序中使用,还可以在置换选择排序中使用败者树,以在内存中获取当前小于已输出的最大的数的最小数,不过比较的时有双关键字:当前已输出的最大数、和同时比较数字。