第九章 外排序
进行外排序的原因:
- 内存是有限的
- 数据是海量的,需要外存的支持
- 外排序本质借助内排序完成,进行许多次内外存之间的数据交换
9.1 主存储器和外存储器
9.2 文件的组织与管理
9.3 外排序
- 把外存中的数据划分为若干段,每次读入内存一段并按照 内排序 方法进行排序,已排序的段或子文件叫做 顺串或归并段
外排序的基本过程
- 置换选择排序:把外存文件初始化为尽可能长的有序顺串集
- 归并排序:把顺串集合逐趟归并排序,形成全局有序的外存文件
外排序的时间组成
- 产生初始顺串的内排序所需时间
- 初始化顺串和归并过程所需的读写(I/O)时间
- 内部归并所需要的时间
- 关键:减少外存的 I/O 次数
9.3.1 置换选择排序
输入文件 -> 输入缓冲区 -> RAM -> 输出缓冲区 -> 输出顺串文件
- 将文件生成若干尽可能少、尽可能长的顺串,借助在 RAM 中的堆来完成
置换选择算法
- 初始化最小堆:目的是提高 RAM 中排序的效率
- (a)从缓存区读 M 个记录放到数组 RAM 中
- (b)设置初始堆尾标志:LAST = M - 1(读入 RAM 的最后一个记录)
- (c)建立一个最小值堆
- 重复以下步骤,直至堆空(结束条件:LAST < 0)
- (a) 把具有最小关键码的记录(根节点)送到输出缓冲区
- (b) 设 R 是输入缓冲区中的下一条记录
- 如果(R 的关键值码 不小于 已输出的关键值码) 把 R 放到根节点
- 否则 把 LAST 位置的记录放到根节点处,把 R 放到 LAST 位置,LAST = LAST - 1(减小堆的大小,刚存入的 R 不在现有堆中,由下一顺串处理)
- (c) 重新排列堆,筛出根节点
算法分析
- 一次顺串结束后,RAM 中填满了不能处理的数据,建成堆可以下次顺串处理
- 大小是 M 的堆,生成顺串最小长度是 M,平均长度是 2M
算法实现
// 初始化输入输出文件
initFiles(inputFile, outputFile, in, out);
// 初始化堆的数据,读入n个数据
initMinHeapArry(inputFile, n, A);
// 建立最小值堆
MinHeap<Elem> H(A, n, n);
// 初始化inputbuffer,读入部分数据
initInputBuffer(input, inputFile);
for (int last = n - 1; last >= 0; ) {
Elem mval = H.heapArray[0]; // 堆的最小值
sendToOutputBuffer(input, output, iptF, optF, mval);
Elem r;
input.read(r); // 从输入缓冲区读入一个记录
if (!less(r, mval)) {
H.heapArray[0] = r;
} else { // 否则用 last 位置记录代替根结点,把 r 放到 last
H.heapArray[0] = H.heapArray[last];
H.heapArray[last] = r;
last--;
H.setSize(last + 1);
}
if (last >= 0)
H.SiftDown(0); // 堆调整
}
endUp(output, inputFile, outputFile);
9.3.2 归并排序
产生顺串 -> 归并排序
算法分析
- 所需读写外存次数与归并趟数有关
- 假设 m 个初始顺串,每次对 k 个顺串进行归并,则归并趟数为 ceil(logk m)
- 为了减少归并趟数,两个方面入手:
- 减少初始顺串的个数 m
- 增加每次归并的顺串个数 k
- 实现方式:使用最佳归并树
- 最佳归并树:一个 Huffman 树,每个叶子结点表示一个顺串,结点的权值为该顺串的长度
- 归并时,每次选择 k 个最小权值的结点进行归并
9.3.3 多路归并树
- 赢者树
- 败者树
- 目的:减少比较次数,提高归并效率
赢者树的归并实现
- 用完全二叉树作为存储结构
- 叶子结点存放 k 个顺串的当前元素,非叶子结点存放其子结点中关键字较小者
- 叶子结点用数组 L[0..n] 表示,非叶子结点用数组 B[0..n-1] 表示
- 数组 B 中存储的是叶子结点在数组 L 中的下标
- 赢者树的过程
- 从树的最底层开始,比较每对叶子结点的关键字,将较小者上升到其父结点
- 重复此过程,直到比较到根结点,根结点存放的是 k 个叶子结点中关键字最小的元素
- 在过程中 B 数组存放的是每次比较的赢者的下标(也就是相应的顺串)
- 每次输出根结点的元素后,从相应顺串中读入下一个元素,重新调整赢者树
败者树的归并实现
- 败者树与赢者树类似,但非叶子结点存放的是其子结点中的败者,获胜者则去参加下一轮比较
- 败者树比赢者树多了一个结点,在赢者树的根节点前增加一个结点,存放最终的获胜者
- 败者树的调整过程
- 每次输出根结点的获胜者后,从相应顺串中读入下一个元素
- 从该叶子结点开始,沿路径向上比较,将败者存放在相应的非叶子结点中,直到到达根结点,更新最终的获胜者
- 优点:每次调整只需 log(k) 次比较,只需要和内部结点比较,只需更改该路径上的结点
败者树的初始化实现
template<class T>
void LoserTree<T>::Initialize(T A[], int size,
int (*winner)(T A[], int b, int c),
int (*loser)(T A[], int b, int c)) {
int i, s;
n = size;
L = A;
// 初始化成员变量
for (s = 1; 2 * s <= n - 1; s += s); // 计算倒数第2层节点数
LowExt = 2 * (n - s);
offset = 2 * s - 1;
// 最底层外部结点比赛
for (i = 2; i <= LowExt; i += 2)
Play((offset + i) / 2, i - 1, i, winner, loser);
// 处理其余外部结点
if (n % 2) { // n 为奇数,内部结点和外部结点比一次
// 暂存在父中的左胜者与外部右子结点比
Play(n / 2, B[(n - 1)], LowExt + 1, winner, loser);
i = LowExt + 3;
} else
i = LowExt + 2;
// 剩余外部结点的比赛
for (; i <= n; i += 2)
Play((i - LowExt + n - 1) / 2, i - 1, i, winner, loser);
}
败者树的生成
沿叶节点(由小到大)所在子树的右分支向上比赛,右分支路径节点编号为奇
template<class T>
void LoserTree<T>::PLAY(int p, int lc, int rc, int(*winner)(T A[], int b, int c),
int(*loser)(T A[], int b, int c)) {
B[p] = loser(L, lc, rc);
// 败者索引放在 B[p] 中
int temp1, temp2;
temp1 = winner(L, lc, rc);
while (p > 1 && p % 2) {
// p 为奇数向上比赛
temp2 = winner(L, temp1, B[p / 2]); // 胜者与父比较
B[p / 2] = loser(L, temp1, B[p / 2]);
// 败者留下
temp1 = temp2;
p /= 2;
} // while
B[p / 2] = temp1;
}
败者树的调整
void LoserTree<T>::RePlay(int i, int (*winner)(T A[], int b, int c),
int (*loser)(T A[], int b, int c)) {
int p;
if (i <= LowExt)
// 用于计算父结点索引的临时变量
// 确定父结点的位置
p = (i + offset) / 2;
else
p = (i - LowExt + n - 1) / 2;
B[0] = winner(L, i, B[p]); // B[0]中保存胜者的索引
B[p] = loser(L, i, B[p]); // B[p]中保存败者的索引
for (; (p / 2) >= 1; p /= 2) { // 沿路径向上比赛
int temp;
// 临时存放赢者的索引
temp = winner(L, B[p / 2], B[0]);
B[p / 2] = loser(L, B[p / 2], B[0]);
B[0] = temp;
}
}
9.3.4 多路归并的效率
假设对 k 个顺串进行归并:
- 原始方法(每次线性查找最小值)
- 每次找到最小值需要 Θ(k)
- 生成一个大小为 n 的顺串总时间为 Θ(k · n)
- 败者树方法(Loser Tree)
- 初始化包含 k 个选手的败者树需要 Θ(k) 时间
- 读入一个新值并重构败者树需要 Θ(log k) 时间
- 因此产生一个大小为 n 的顺串总时间为 Θ(k + n · log k)
总结:败者树将每次选择最小值的复杂度从 Θ(k) 降为 Θ(log k),当 n 很大时能显著提高归并效率。