待排序文件很大时,计算机内存无法容纳整个文件,此时不能对文件使用内部排序,而要用外部排序,需要做内外存的内容交换,但是排序工作仍是在内存中完成。
外部排序也常采用归并排序,有两个阶段:
1)采用适当的内部排序方法对输入文件的每个片段进行排序,将排好序的片段(成为归并段)写到外部存储器中(通常由一个可用的磁盘作为临时缓冲区),这样临时缓冲区中的每个归并段的内容是有序的。
2)利用归并算法,归并第一阶段生成的归并段,直到只剩下一个归并段为止。、
要对外存中的4500个记录进行归并,内存大小只够容纳750个记录。
1)每次读取750个记录进行排序,则共读取6次,可得到6个有序的归并段,写入临时缓冲区。
2)将内存空间划分为三份,每份为250个记录的大小,其中两个作输入缓冲,一个作输出缓冲。
首先对前两个750个记录进行归并,先从每个归并段中读取250个记录到输入缓冲区,进行归并,结果放到输出缓冲区。
输出缓冲区满后,将其写入临时缓冲区。
某个输入缓冲区空后,从相应的归并段中再读取250个记录进行归并。
重复以上步骤,至前两个750个记录排好序,形成1500个有序记录,后继续下两个750个记录。
3)归并好的两个1500个记录进行归并,后与另一个1500个记录进行归并,最后形成大小为4500的归并段。
1)将序列分为左右两部分,再将每部分继续分为左右两部分,至不可再分,此为递归过程
2)分为8个部分后,每个部分都有序,因为都只含1个元素
3)合并L11和L22,使L1有序,同理使L2、R1、R2有序
4)合并L1、L2使L有序,同理使R有序
5)合并L和R,使整个数组有序
空间复杂度:O(N),需要一个与原数组长度相同的辅助数组
在败者树中,父结点记录其左右子结点比较的败者,index记入父结点,但让胜者进行下一轮比较,败者树的根结点记录的是败者的index,在其上方再加一个结点记录胜者的index。
如图是一棵规定数大为败的败者树:
1)b3 PK b4,b3胜b4负,内部结点ls[4]的值为4;
2)b3 PK b0,b3胜b0负,内部结点ls[2]的值为0;
3)b1 PK b2,b1胜b2负,内部结点ls[3]的值为2;
4)b3 PK b1,b3胜b1负,内部结点ls[1]的值为1;
5)在根结点ls[1]上又加了一个结点ls[0]=3,记录的最后的胜者。
败者树的重构:
1)将新进入选择树的结点与其父结点进行比赛:将败者存放在父结点中;而胜者再与上一级的父结点比较。
2)比赛沿着到根结点的路径不断进行,直到ls[1]处。把败者存放在结点ls[1]中,胜者存放在ls[0]中。
败者树常常用于多路外部排序,对于K个已经排好序的文件,将其归并为一个有序文件。
败者树的叶子节点是数据节点,两两分组,内部节点记录左右子树中的“败者”,优胜者往上传递一直到根节点,若以大为败,则根结点记录的是第二小的数(的index),在其上的结点记录最小的数。
把最小值输出以后,用一个新的值替换最小值节点的值(在文件归并的时候,如果文件已经读完,可以用一个无穷大的数来替换),接下来维护败者树,从更新的节点往上,一次与父节点比较,将败者更新,胜者继续比较。
维护一个叶子节点个数为k的败者树,数字较小者取胜,则最顶层保存的是值最小的叶子节点,每来一个数和最小值比较,如果比最小值还小,直接舍弃,否则替换最小值的节点值,从下往上维护败者树,最后的k个叶子节点中保存的就是所有数中值最大的k的,时间复杂度为O(nlogk)。
//测试数组,假设内存只能放入3组数据,并且内存已经将这些数据排好序了。
//现在需要归并这些数据
static int testArray[K][MEM_SIZE] = {
{10,15,16,INT_MAX},
{9,18,20,INT_MAX},
{20,22,40,INT_MAX},
{6,15,25,INT_MAX},
{12,37,48,INT_MAX},
};
//调整函数,和 所有 祖先比较,替换败者 和 最终胜利者 t[0]
void adjust(LoseTree t,External ex,int i){
int f = (i + K) / 2;
while (f > 0){
if (ex[i].key < ex[t[f]].key){ //以大为败
int temp = i;
i = t[f];//i 保存 胜利者,继续 比较
t[f] = temp;//有新的败者了.
}
f = f / 2;
}
t[0] = i;//最终胜利者
}
//创建败者树..
void createTree(LoseTree tree,External ex){
for (int i = 0; i < K; i++){//初始化叶子节点
ex[i].key = testArray[i][0];
}
ex[K].key = INT_MIN;//为了让第一次 所有 都是 失败者
for (int i = 0; i < K; i++){//初始化非叶子节点,
tree[i] = K;
}
for (int i = K-1; i >= 0; i--){//调整叶子节点,顺序不能反
adjust(tree,ex,i);
}
}
void inputNewKey(External ex,int winIndex){
ex[winIndex].key = testArray[winIndex][1];
//前移
for (int i = 0; i < MEM_SIZE -1; i++){
testArray[winIndex][i] = testArray[winIndex][i+1];
}
}
//归并函数
void K_Merge(){
LoseTree t;//非叶子节点
External ex;//叶子节点
createTree(t,ex);
int winIndex = t[0];//胜利者 坐标
while (ex[winIndex].key != INT_MAX){
printf("%d\t",ex[winIndex].key);
inputNewKey(ex,winIndex);
adjust(t,ex,winIndex);
winIndex = t[0];
}
}
int _tmain(int argc, _TCHAR* argv[])
{
K_Merge();
return 0;
}
1)首先从初始文件中输入 l 个记录到内存工作区中;
2)从内存工作区中选出关键字最小的记录,将其记为 MINIMAX (极小值)记录;
3)将 MINIMAX 记录输出到归并段文件中;
4)此时内存工作区中还剩余 l-1 个记录,若初始文件不为空,则从初始文件中输入下一个记录到内存工作区中;
5)从内存工作区中的所有比 MINIMAX 值大的记录中选出值最小的关键字的记录,作为新的 MINIMAX 记录;
6)重复过程 3—5,直至在内存工作区中选不出新的 MINIMAX 记录为止,由此就得到了一个初始归并段;
7)重复 2—6,直至内存工作为空,由此就可以得到全部的初始归并段。
举例:
1)首先输入前 6 个记录到内存工作区,其中关键字最小的为 29,所以选其为 MINIMAX 记录,同时将其输出到归并段文件中,如下图所示:
2)此时初始文件不为空,所以从中输入下一个记录 14 到内存工作区中,然后从内存工作区中的比 29 大的记录中,选择一个最小值作为新的 MINIMAX 值输出到归并段文件中
3)初始文件还不为空,所以继续输入 61 到内存工作区中,从内存工作区中的所有关键字比 38 大的记录中,选择一个最小值作为新的 MINIMAX 值输出到归并段文件中
4)如此重复性进行,直至选不出 MINIMAX 值为止
5)当选不出 MINIMAX 值时,表示一个归并段已经生成,则开始下一个归并段的创建.
C实现
#define MAXKEY 10000
#define RUNEND_SYMBOL 10000 // 归并段结束标志
#define w 6 // 内存工作区可容纳的记录个数
#define N 24 // 设文件中含有的记录的数量
typedef int KeyType; // 定义关键字类型为整型
// 记录类型
typedef struct{
KeyType key; // 关键字项
}RedType;
typedef int LoserTree[w];// 败者树是完全二叉树且不含叶子,可采用顺序存储结构
typedef struct
{
RedType rec; /* 记录 */
KeyType key; /* 从记录中抽取的关键字 */
int rnum; /* 所属归并段的段号 */
}RedNode, WorkArea[w];
// 从wa[q]起到败者树的根比较选择MINIMAX记录,并由q指示它所在的归并段
void Select_MiniMax(LoserTree ls,WorkArea wa,int q){
int p, s, t;
// ls[t]为q的双亲节点,p作为中介
for(t = (w+q)/2,p = ls[t]; t > 0;t = t/2,p = ls[t]){
// 段号小者 或者 段号相等且关键字更小的为胜者
if(wa[p].rnum < wa[q].rnum || (wa[p].rnum == wa[q].rnum && wa[p].key < wa[q].key)){
s=q;
q=ls[t]; //q指示新的胜利者
ls[t]=s;
}
}
ls[0] = q; // 最后的冠军
}
//输入w个记录到内存工作区wa,建得败者树ls,选出关键字最小的记录,并由s指示其在wa中的位置。
void Construct_Loser(LoserTree ls, WorkArea wa, FILE *fi){
int i;
for(i = 0; i < w; ++i){
wa[i].rnum = wa[i].key = ls[i] = 0;
}
for(i = w - 1; i >= 0; --i){
fread(&wa[i].rec, sizeof(RedType), 1, fi);// 输入一个记录
wa[i].key = wa[i].rec.key; // 提取关键字
wa[i].rnum = 1; // 其段号为"1"
Select_MiniMax(ls,wa,i); // 调整败者树
}
}
// 求得一个初始归并段,fi为输入文件指针,fo为输出文件指针。
void get_run(LoserTree ls,WorkArea wa,int rc,int *rmax,FILE *fi,FILE *fo){
int q;
KeyType minimax;
// 选得的MINIMAX记录属当前段时
while(wa[ls[0]].rnum == rc){
q = ls[0];// q指示MINIMAX记录在wa中的位置
minimax = wa[q].key;
// 将刚选得的MINIMAX记录写入输出文件
fwrite(&wa[q].rec, sizeof(RedType), 1, fo);
// 如果输入文件结束,则虚设一条记录(属"rmax+1"段)
if(feof(fi)){
wa[q].rnum = *rmax+1;
wa[q].key = MAXKEY;
}else{ // 输入文件非空时
// 从输入文件读入下一记录
fread(&wa[q].rec,sizeof(RedType),1,fi);
wa[q].key = wa[q].rec.key;// 提取关键字
if(wa[q].key < minimax){
// 新读入的记录比上一轮的最小关键字还小,则它属下一段
*rmax = rc+1;
wa[q].rnum = *rmax;
}else{
// 新读入的记录大则属当前段
wa[q].rnum = rc;
}
}
// 选择新的MINIMAX记录
Select_MiniMax(ls, wa, q);
}
}
//在败者树ls和内存工作区wa上用置换-选择排序求初始归并段
void Replace_Selection(LoserTree ls, WorkArea wa, FILE *fi, FILE *fo){
int rc, rmax;
RedType j;
j.key = RUNEND_SYMBOL;
// 初建败者树
Construct_Loser(ls, wa, fi);
rc = rmax =1;//rc指示当前生成的初始归并段的段号,rmax指示wa中关键字所属初始归并段的最大段号
while(rc <= rmax){// "rc=rmax+1"标志输入文件的置换-选择排序已完成
// 求得一个初始归并段
get_run(ls, wa, rc, &rmax, fi, fo);
fwrite(&j,sizeof(RedType),1,fo);//将段结束标志写入输出文件
rc = wa[ls[0]].rnum;//设置下一段的段号
}
}
void print(RedType t){
printf("%d ",t.key);
}
int main(){
RedType a[N]={51,49,39,46,38,29,14,61,15,30,1,48,52,3,63,27,4,13,89,24,46,58,33,76};
RedType b;
FILE *fi,*fo; //输入输出文件
LoserTree ls; // 败者树
WorkArea wa; // 内存工作区
int i, k;
fo = fopen("ori","wb"); //准备对 ori 文本文件进行写操作
//将数组 a 写入大文件ori
fwrite(a, sizeof(RedType), N, fo);
fclose(fo); //关闭指针 fo 表示的文件
fi = fopen("ori","rb");//准备对 ori 文本文件进行读操作
printf("文件中的待排序记录为:\n");
for(i = 1; i <= N; i++){
// 依次将文件ori的数据读入并赋值给b
fread(&b,sizeof(RedType),1,fi);
print(b);
}
printf("\n");
rewind(fi);// 使fi的指针重新返回大文件ori的起始位置,以便重新读入内存,产生有序的子文件。
fo = fopen("out","wb");
// 用置换-选择排序求初始归并段
Replace_Selection(ls, wa, fi, fo);
fclose(fo);
fclose(fi);
fi = fopen("out","rb");
printf("初始归并段各为:\n");
do{
k = fread(&b, sizeof(RedType), 1, fi); //读 fi 指针指向的文件,并将读的记录赋值给 b,整个操作成功与否的结果赋值给 k
if(k == 1){
if(b.key ==MAXKEY){//当其值等于最大值时,表明当前初始归并段已经完成
printf("\n\n");
continue;
}
print(b);
}
}while(k == 1);
return 0;
}
如果一开始就归并很长的段,由于该段还会在以后的归并中出现,那么消耗的时间就很长了。所以我们应该先归并段长较短的段。对于如何减少访问外存的次数的问题,就等同于考虑如何使 k-路归并所构成的 k 叉树的带权路径长度最短。
对于采取3-路平衡归并的方式,在进行平衡归并时,操作每个记录都需要单独进行一次对外存的读写,那么图中的归并过程需要对外存进行读或者写的次数为:(9+30+12+18+3+17+2+6+24)*2*2=484。其操作外存的次数恰好是树的带权路径长度的2倍。
若想使树的带权路径长度最短,就是构造Huffman树。
通过以构建Huffman树的方式构建归并树,使其对读写外存的次数降至最低(k-路平衡归并,需要选取合适的 k 值,构建Huffman树作为归并树)。所以称此归并树为最佳归并树。
1)根据给定的 n 个权值 {w1,w2,w3,...,wn} 构成 n 棵二叉树的集合 F={T1,T2,T3,...,Tn},其中每棵二叉树 Ti 中只有一个带权为 wi 的根节点,其左右子树均为空。
2)在 F 中选取两棵根节点的权值最小的树作为左右子树构造一棵新的二叉树,且置新的二叉树的根节点的权值为其左右子树上根节点的权值之和。
3)在 F 中删除这两棵树,同时将新得到的二叉树加入 F 中。
4)重复 2)和 3),直到 F 只含一棵树为止。
//构建Huffman树
#define SIZE 10
typedef struct
{
unsigned int weight;
unsigned int parent, lchild, rchild;
}HTNode, *HuffmanTree;
void Select(HuffmanTree &P, int m, int &s1, int &s2)
//该函数的功能是选出结构数组中权重最小的两个节点
//其中已经选择过一次的结点剔除在外
{
int wmin, j;
wmin = P[s1].weight>=P[s2].weight? P[s1].weight: P[s2].weight; //wmin=结点s1,s2中的最小值
for(int i=0; i=(m+1)/2)) continue;
if(P[i].weight<=P[s1].weight) s1=i;
}
for(j=0; j=(m+1)/2)
) continue;
if(P[j].weight<=P[s2].weight) s2 = j;
}
}
void HuffmanCreate(HuffmanTree &HT, int *w, int n)
//HT is a struct array
//w is the weight array of every nodes
//n is the number of nodes
{
if(n<=1) exit(0);
int i;
int w_tmp;
int s1 = n, s2 = n+1;
HuffmanTree P;
int m = 2*n-1; //一棵含有n个叶子节点的赫夫曼树,总共含有2*n-1个结点
HT = (HuffmanTree)malloc((m+1)*sizeof(HTNode)); //动态分配一块内存空间,用来存储完整的赫夫曼树
for(i=0,P=HT;iweight = *w;
P->parent = 0;
P->rchild = 0;
P->lchild = 0;
}
for(;iweight = 0;
P->parent = 0;
P->lchild = 0;
P->rchild = 0;
}
for(i=n;i
选择-置换排序:在实现将初始文件分为 m 个初始归并段时,尽量减小 m 的值,可实现将整个初始文件分为数量较少的长度不等的初始归并段。
最佳归并树:在将初始归并段归并为有序完整文件的过程中,尽量减少读写外存的次数,对初始归并段进行归并使用的是败者树的方式。