外部排序

有时,待排序的文件很大,计算机内存不能容纳整个文件,这时候对文件就不能使用内部排序了(这里做一下说明,其实所有的排序都是在内存中做的,这里说的内部排序是指待排序的内容在内存中就可以完成,而外部排序是指待排序的内容不能在内存中一下子完成,它需要做内外存的内容交换),外部排序常采用的排序方法也是归并排序

这种归并方法由两个不同的阶段组成

1、采用适当的内部排序方法对输入文件的每个片段进行排序,将排好序的片段(成为归并段)写到外部存储器中(通常由一个可用的磁盘作为临时缓冲区),这样临时缓冲区中的每个归并段的内容是有序的。

2、利用归并算法,归并第一阶段生成的归并段,直到只剩下一个归并段为止。

例如要对外存中4500个记录进行归并,而内存大小只能容纳750个记录,在第一阶段,我们可以每次读取750个记录进行排序,这样可以分六次读取,进行排序,可以得到六个有序的归并段,如下图:

每个归并段的大小是750个记录,记住,这些归并段已经全部写到临时缓冲区(由一个可用的磁盘充当)内了,这是第一步的排序结果。

完成第二步该怎么做呢?这时候归并算法就有用处了,算法描述如下:

1、将内存空间划分为三份,每份大小250个记录,其中两个用作输入缓冲区,另外一个用作输出缓冲区。首先对Segment_1和Segment_2进行归并,先从每个归并段中读取250个记录到输入缓冲区,对其归并,归并结果放到输出缓冲区,当输出缓冲区满后,将其写道临时缓冲区内,如果某个输入缓冲区空了,则从相应的归并段中再读取250个记录进行继续归并,反复以上步骤,直至Segment_1和Segment_2全都排好序,形成一个大小为1500的记录,然后对Segment_3和Segment_4、Segment_5和Segment_6进行同样的操作。

2、对归并好的大小为1500的记录进行如同步骤1一样的操作,进行继续排序,直至最后形成大小为4500的归并段,至此,排序结束。

以上对外部排序如何使用归并算法进行排序进行了简要总结,提高外部排序需要考虑以下问题:

1、如何减少排序所需的归并趟数。

2、如果高效利用程序缓冲区,使得输入、输出和CPU运行尽可能地重叠。

3、如何生成初始归并段(Segment)和如何对归并段进行归并。

 

举个例子

   我们假设需要排序的int数有12个,内存一次只能装下3个int数。

接下来把12个数据分成4份,然后排序成有序子串:

然后把子串进行两两合并:

优化策略

         因为硬盘的读写速度比内存要慢的多,按照以上这种方法,每个数据都从硬盘读了三次,写了三次,要花很多时间。

         解释下:例如对于数据2,我们把无序的12个数据分成有序的4个子串需要读写各一次,把2份3个有序子串合并成6个有序子串读写各一次;把2份6个有序子串合并从12个有序子串读写各一次,一共需要读写各3次。

         在进行有序子串合并的时候,不采取两两合并的方法,而是可以3个子串,或4个子串一起来合并。

多路归并排序

外部排序最常用的算法是归并排序,而多路归并排序的流程与思想也比较简单,在此不再赘言。
但值得注意的是其重要的子算法

  1. 置换-选择排序
    n个记录分为m个规模较小的记录段,如果被划分的每个小记录段规模不够小,仍然无法完全读入内存,则无法用内排序得到初始归并段,因此需要一种适用于初始归并段规模的,高效的且不受内存空间限制的排序算法,即置换-选择排序。
  2. 最佳归并树
    将当前的m组(每组含有k个有序记录段)记录归并为m个有序记录段的过程称为一趟归并,可见每个记录在每趟归并中需要两次I/O操作(读写操作各一次)。读写操作是非常耗时的,可见减少归并次数可以提高效率。为了使得归并次数最少,需用到最佳归并树。
  3. 败者树
    归并排序算法中有一个多次出现的步骤是从当前k个值中用某种算法选出最值,可见提高选最值的效率也是整个归并排序算法效率提高的关键。这就需要用到败者树。

置换-选择排序

/**
*置换-选择排序的简单描述
*/
typedef struct{
    RcdType    rec;     //记录
    KeyType    key;    //从记录中抽取的关键字
    int    rnum;    //所属归并段的段号
}RcdNode,  WorkArea[w];    //内存工作区,容量为w

void  Replace_Selection(LoserTree  &ls,WorkArea  &wa,  FILE  *fi,  FILE  *fo){
    //  在败者树ls和内存工作区wa上用置换-选择排序求初始归并段,fi为输入文件
    //(只读文件)指针,fo为输出文件(只写文件)指针,两个文件均已打开
    Construct_Loser(ls,wa);    //初建败者树
    rc = rmax = 1;      //rc指示当前生产的初始归并段的段号
                             //rmax指示wa中关键字所属初始归并段的最大段号
    while(rc <= rmax){    //"rc = rmax+1" 标志输入文件的置换-选择排序已完成
      get_run(ls,wa);    //求得一个初始归并段
      fwrite(&RUNEND_SYMBOL,sizeof(struct RcdType),1,fo);  //将段结束标志输入输出文件
      rc = wa[ls[0]].rnum;    //设置下一段的段号
    }
}

void get_run (LoserTree &ls,WorkArea &wa){
    //求得一个初始归并段,fi为输入文件指针,fo为输出文件指针
    while(wa[ls[0]].rnum == rc){    //选得的MINIMAX记录属当前段时
        q = ls[0];    //q知识MINIMAX记录在wa中的位置
        minimax = wa[q].key;
    }
    fwrite(&wa[q].rec,sizeof(RcdType),1,fo);    //将刚选好的MINIMAX记录写入输出文件
    if(feof(fi))    {wa[q].rnum = rmax +1; wa[q].key = MAXKEY;}
    //输入文件结束,虚设记录(属"rmax+1"段)
    //输入文件非空时
    else{
        fread(&wa[q].rec,sizeof(RcdType),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;    //新读入的记录属当前段
        }
          Select_MiniMax(ls,wa,q);    选择新的MINIMAX记录
    }
    
void Select_MiniMax(LoserTree &ls,WorkArea wa,int q){
        //从wa[q]起到败者树的根比较选择MINIMAX记录,并由q指示它所在的归并段
        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){
                q <-->ls[t]; //q指示新的胜利者
            }
        }
        ls[0] = q;
    }
}

void Construct_Loser(LoserTree &ls,WorkArea &wa){
    //输入w个记录到内存工作区wa,建得败者树ls,选出关键字最小的记录并由s指示
    //其在wa中的位置
    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(RcdType),1,fi);    //输入一个记录
        wa[i].key = wa[i].rec.key;    // 提取关键字
        wa[i].rnum = 1;    //其段号为"1"
        Select_MiniMax(ls,wa,i);    //调整败者
    }
}


最佳归并树

归并的过程可以用一棵树来形象地描述,这棵树称为归并树,
为了优化归并树的带权路径长度,可以运用哈弗曼树。

 败者树

为了突出如何利用败者树进行归并,在算法中避开了外存信息存取的细节,可以认为归并段已在内存。

typedef int LoserTree[k];  //败者树是完全二叉树且不含叶子,可采用顺序存储结构
typedef struct{
    KeyType key;
}ExNode,External[k+1];    //外结点,只存放待归并记录的关键字

void K_Merge(LoserTree &ls,External &b){
      /*利用败者树ls将编号从0到k-1的k个输入归并段中的记录归并到输出归并段。
b[0]至b[k-1]为败者树上的k个叶子结点,分别存放k个输入归并段中当前记录的关键字。
*/
    for(i = 0;i<k; ++ i) input(b[i].key);       //分别从k个输入归并段读入该段当前第一个记录的关键字到外结点
    CreateLoserTree(ls);    //建败者树ls,选得最小关键字为b[ls[0]].key
    while(b[ls[0]].key != MAXKEY){
      q = ls[0];    //q指示当前最小关键字所在归并段
      output(q);  //将编号为q的归并段中当前(关键字为b[q].key)的记录写至输出归  并段
      input(b[q].key,q);// 从编号为q的输入归并段中读取下一个记录的关键字
      Adjust(ls,q);    //调整败者树,选择性的最小关键字
    }//while    
    output(ls[0]);    //将含最大关键字MAXKEY的记录写至输出归并段
}


void CreateLoserTree(LoserTree &ls){
      //已知b[0]到b[k-1]为完全二叉树ls的叶子结点存在k个关键字
//,沿从叶子到根的k条路径将ls调整为败者树
    
    b[k].key  = MINKEY;    //设MINKEY为关键字可能的最小值
    for(i = 0;i < k;++i){
          ls[i] = k;    //设置ls中"败者"的初值
    }
    for(i = k-1; i>=0; - - i) Adjust(ls,i);   
 //依次从b[k-1],b[k-2],……,b[0]出发调整败者
   
}

while Adjust(LoserTree &ls,int s){
    //沿从叶子结点b[s]到根结点ls[0]的路径调整败者树
    t = (s+k)/2;
    while(t >0){
      if(b[s].key > b[ls[t]].key)  s<-->ls[t];    //s指示新的胜者
      t=t/2;
    }
    ls[0] = s;
}

由序号构造结点的好处是,不仅可以找到记录值,还可以找到其所在的归并段,以便于下一个记录读入内存取代刚选出的最值。

为什么得用败者树,而不是用堆?:

时间空间复杂度分析 

外部排序的时间复杂度涉及很多方面,且分析较为复杂,一般考试不会让考试分析整个流程中与
时间复杂度相关的每一个细节,因此只需要注意以下几点即可:

1 m个初始归并段进行k路归并,归并的趟数为[logkm]
2 每一次归并,所有记录都要进行两次I/O操作。
3 置换-选择排序这一步中,所有记录都要进行两次I/O操作
4 置换-选择排序中,选最值那一步的时间复杂度要根据考试要求的选择算法而定
5 k路归并的败者树的高度为[log2k]+1,因此利用败者树从k个记录中选出最值需要进行[log2k]此比较,即 
  时间复杂度O(log2k)
6 k路归并的败者树的建树时间复杂度为O(klog2k)
注意k路归并败者树,不是k叉败者树,而是一颗二叉树

 空间复杂度
显然所有步骤中的空间复杂度都是常量,因此是O(1)

 

多路归并示例

 

  为了方便讲解,我们假设内存一共可以装4个int型数据。

         刚才我们是采取两两合并的方式,现在我们可以采取4个有序子串一起合并的方式,这样的话,每个数据从硬盘读写的次数各需要2次就可以了。如图:

 4个有序子串的合并,叫4路归并。如果是n个有序子串的合并,就把它称为n路归并。n并非越大越好。

置换选择

         n不是越大越好,那么我们可以想办法减少有序子串的总个数。这样,也能减少数据从硬盘读写的次数。

         以前面的12个无序数据为例:

例如我们可以从12个数据读取3个存到内存中,然后从内存中选出最小的那个数放进子串p1里;之后再从剩余的9个数据读取一个放到内存中,然后再从内存中选出一个数放进子串p1里,这个数必须满足比p1中的其他数大,且在内存中尽量小。这样一直重复,直到内存中的数都比p1中的数小,这时p1子串存放结束,继续来p2子串的存放,例如(这时假设内存只能存放3个int型数据):
 

读入3个到内存中,且选出一个最小的到子串p1:

  这个时候已经没有符合要求的数了,且内存已满,进而用p2子串来存放,以此类推。

         通过这种方法,p1子串存放了4个数据,而原来的那种方法p1子串只能存放3个数据。

 

 这样子的话,最后只生成了2个有序子串,我们把这种方法称之为置换选择。按照这种方法,最好的情况下,所有数据只生成一个有序子串;最坏的情况下,和原来没采取置换选择算法一样,还是4个子串,那平均性能如何呢?

         结论:如果内存可以容纳n个元素的话,那么平均每个子串的长度为2m,也就是说,使用置换选择算法我们可以减少一半的子串数。

         这种方法适合要排序的数据太多,以至于内存一次性装载不下。只能通过把数据分几次的方式来排序,把这种方法称为外部排序。