K路归并排序与败者树

一、大文件的排序问题

在我们日常开发中有时候会遇到这样一个问题,有一个文件大小为10GB,现在要为里面的数据进行排序,而计算机的内存只有1GB,如何对这10GB的数据进行排序呢?

由于内存空间只有1GB我们无法一次性读取所有的文件来进行排序,因此需要借助外部排序来解决。外部排序的思路很简单,它采用了一种" 排序-归并 " 的策略。大概步骤如下:

  1. 把10GB文件大小分为10份,每一份1GB。
  2. 依次把每份文件读取到内存中进行排序,可采用快排、归并、堆排等,然后把排序后的数据写入到磁盘中,这样每一份的文件数据都是有序的。
  3. 对10个有序的文件,进行两两归并。既把每两个文件中的部分数据读取到内存中进行比较,然后把比较后的结果输出到临时文件中,最终得到的临时文件就是两个小文件整合在一起的有序文件 。然后把该临时文件和其他临时文件再进行两两归并,依次类推,最终输出的文件就是一个有序的文件。见下图的归并过程:
    K路归并排序与败者树_第1张图片

如上图 所示有 10 个初始归并段到一个有序文件,共进行了 4 次归并,每次都由 m 个归并段得到 ⌈m/2⌉ 个归并段,这种归并方式被称为 2路归并排序

对于外部排序算法来说,影响整体排序效率的因素主要取决于读写磁盘的次数,即访问磁盘的次数越多,算法花费的时间就越多,效率就越低。而对于同一个文件来说,对其进行外部排序时访问外存的次数同归并的次数成正比,即归并操作的次数越多,访问外存的次数就越多

为了提高外部排序的效率,降低归并次数,所以出现了4路排序、5路排序、10路排序等K路排序。比如下图所示的就是5路归并排序:
在这里插入图片描述
可见当有10个文件, 进行2路归并,需要进行4次归并操作,而进行5路归并,则只需要2次归并操作。因此对于 k路平归并排序中 k 值得选择,增加 k 可以减少归并的次数,从而减少外存读写的次数,最终达到提高算法效率的目的

根据上面的两个图,可以推到出一个公式,假设有m个文件采用K路进行归并时,那么归并的次数为:s =log­k(m)(其中s为归并的次数)。

从公式上可以判断出,想要达到减少归并次数s从而提高算法效率的目的,可以从两个角度实现:

  • 增加 K路平衡归并中的 K 值,可采用多路平衡归并排序算法解决。
  • 尽量减少初始归并段的数量 m,即增加每个归并段的容量,可采用置换-选择排序算法解决。

接下我们就想使用多路平衡归并排序的算法来解决K的问题。

二、多路平衡归并排序算法

上面我们提到,要想减少归并的次数,可以增加K的值。但是,如果毫无限度地增加K的值,虽然会减少归并次数,但是会增加内部归并的时间。以10 个文件为例子,当采用 2归并时,若每次从 2 个文件中想得到一个最小值时只需比较 1 次;而采用 5路归并时,若每次从 5 个文件中想得到一个最小值就需要比较 4 次。因此K值越大比较的次数就多

那么K要如何选择比较好呢?为了避免K 值的选择过程中影响内部归并的效率,在进行 K路归并时可以使用败者树来实现,该方法让K值的不会影响其内部归并的效率。

2.1、败者树

败者树是一颗完全二叉树,是树形选择排序的一种变型。每个叶子结点相当于一个选手,每个中间结点相当于一场比赛的结果,每一层相当于一轮比赛。

在败者树中,用父结点记录其左右子结点进行比赛的败者,而让胜者参加下一轮的比赛。败者树的根结点记录的是败者,需要加一个结点来记录整个比赛的胜利者。如下图代表的就是一颗败者树:
K路归并排序与败者树_第2张图片
数组b代表数据项 ,数组ls用于构建一颗败者树。

b3与b4比较,b3胜b4负,内部结点ls[4]的值为败者4索引,让胜者3参加下一轮比赛;
b3与b0比较,b3胜b0负,内部结点ls[2]的值为败者0索引,让胜者3参加下一轮比赛;
b1与b2比较,b1胜b2负,内部结点ls[3]的值为败者2索引,让胜者1参加下一轮比赛;
b3与b1比较,b3胜b1负,内部结点ls[1]的值为败则1索引,在根结点ls[1]上又加了一个结点ls[0]=3,记录的最后的胜者。

当数组b中的数据项发生了改变,就需要重构败者树,重构的方式是将新进入选择树的结点与其父结点进行比赛:将败者存放在父结点中;而胜者再与上一级的父结点比较。 比赛沿着到根结点的路径不断进行,直到ls[1]处。把败者存放在结点ls[1]中,胜者存放在ls[0]中。 下图是当b3变为13时,败者树的重构图:
K路归并排序与败者树_第3张图片

2.2、败者树的实现

由于败者树是一棵完全二叉树,所以可以由数组来实现。

首先我们需要两个数组: 数组b用于存储原始数据,数组ls用于生成败者树,里面记录着数组b的索引。

由于完全二叉树的叶子节点个数比度为2的节点个数多1,因此对于n个数据,败者树的数组长度也为n,其中多余的一个用于存储最终胜者

败者树的实现很简单,我直接贴代码了 ,看注释就懂:

#include 
#include 

#define MINKEY -1  //所有数据的可能最小值
#define SWAP(a,b) {a=a^b;b=b^a;a=a^b;} 

//结点s与父亲结点进行比较,重构败者树
void Adjust(int* b,int* ls,int s,int k)
{
    //t为b[s]在败者树中的父结点
    int t = (s + k) / 2;
    
    while (t > 0) {//若没有到达树根,则继续
    
        if (b[s] > b[ls[t]]){ //与父结点指示的数据进行比较
            //ls[t]记录败者所在的索引,s指示新的胜者,胜者将去参加更上一层的比较
            SWAP(s,ls[t]);
        }
        t/= 2;//获取下一个父节点
    }
    //最终的胜者记录于ls[0]
    ls[0] = s;
}


int* createLoserTree(int *arry,int k)
{

    //创建临时数组,用户存放数据
    //第K元素用于存放默认的最小值
    int b[k+1];
    
    for (int i = 0; i < k; ++i){
        b[i] = arry[i];
    }
    //最后一个元素用于存放默认的最小值,用于刚开始的对比
    b[k] = MINKEY;
    
    //创建败者树
    int* ls = malloc( k* sizeof(arry[0]));

    //设置ls数组中败者的初始值,既b[k]最小值
    for (int i=0; i < k; i++) {
        ls[i] = k;
    }
    
    //有k个叶子节点
    //从最后一个叶子节点开始,沿着从叶子节点到根节点的路径调整
    for (int i = k - 1; i >= 0; --i){
        Adjust(b, ls, i, k);
    }
    
    return ls;
}

int main()
{
    int b[] = {10,9,20,6,12};
    int len = sizeof(b)/sizeof(b[0]);
    int * ls = createLoserTree(b, len);

    for (int i =0 ; i < len; i++) {
        printf("%d ",ls[i]);
    }
    free(ls);
}

我们用数据 {10,9,20,6,12} 构建了一颗败者树,遍历败者树输出的结果为:3 1 0 2 4 ,对比下下图所示与构建的结果是一致的。
K路归并排序与败者树_第4张图片

2.3、败者树实现多路平衡归并

了解败者树后,我们就可以用它来实现多路平衡归并算法了。实现方式是让K路归并中的每一路第一个元素构建成一颗败者树,如下图所示是一棵 5路归并的败者树,ls[0] 中存储的为最终的胜者,我们把最小值ls[0]输出后,只需要更新叶子结点 b3 的值,即导入关键字 15,让该结点不断同其双亲结点所表示的关键字进行比较,败者留在双亲结点中,胜者继续向上比较。依次类推,最终完成K路的归并操作。
K路归并排序与败者树_第5张图片

一、使用败者树的优点

条件:总数据量为n、采用K路归并、初始归并段为m。

  • 对于K路平衡归并中,若不使用败者树,则对每个数据需要比较K-1次,总共n个数据,每一 趟归并共需要(n-1)(K-1)次比较。若有m个归并初始段,归并趟数为 logk(m) ,总共比较次数为log­k(m) (n-1) (k-1)
  • 引入败者树后每个数据的比较次数为log­2(k) (二叉树只要需要跟父节点比较),总共的比较次数为log­k(m) (n-1)(log­2k) ,简化后为log­2(m)(n-1)

可以见引入败者树后比较次数与K无关,减少了归并和比较的次数,提高归并算法的效率

二、使用败者树实现多路平衡归并

为了简化操作,通过5个数组来模拟5个已经排序好的片段,然后通过败者树来实现这5个片段的多路平衡归并,代码如下:

#include 

#define MINKEY -1  //假设所有数据都大于与该值
#define MAXKEY 999 //假设所有数据都小与该值
#define SWAP(a,b) {a=a^b;b=b^a;a=a^b;}


void adjust(int* ,int* ,int,int );
void createLoserTree(int *[], int* ,int* ,int );
void kMerge(int* [], int* , int , int* , int*);


int main()
{
    //总共有5个片段需要归并
    int arr0[] = { 6, 15, 25 };
    int arr1[] = { 12, 37, 48, 50 };
    int arr2[] = { 10, 15, 16 };
    int arr3[] = { 9, 18, 20 };
    int arr4[] = { 10, 11, 40 };
    int* arr[] = { arr0, arr1, arr2, arr3, arr4 };
    
    //总共K路
    int k = sizeof(arr) / sizeof(arr[0]);
    //记录每一路的个数,用于判断结束的标记
    int arrayCount[k];
    arrayCount[0] = sizeof(arr0) / sizeof(arr0[0]);
    arrayCount[1] = sizeof(arr1) / sizeof(arr2[0]);
    arrayCount[2] = sizeof(arr2) / sizeof(arr2[0]);
    arrayCount[3] = sizeof(arr3) / sizeof(arr3[0]);
    arrayCount[4] = sizeof(arr4) / sizeof(arr4[0]);
    
    
    //存放败者树
    int ls[k];
    //存放每一路的首元素
    int b[k+1];
    //创建败者树
    createLoserTree(arr,b,ls,k);
    //进行多路归并
    kMerge(arr, arrayCount, k, ls, b);
    
}

/**
 * 调整败者树,得出最终胜者
 */
void adjust(int* b,int* ls,int s,int k)
{
    //t为b[s]在败者树中的父结点
    int t = (s + k) / 2;
    while (t > 0) {//若没有到达树根,则继续
        if (b[s] > b[ls[t]]){ //与父结点指示的数据进行比较
            //ls[t]记录败者所在的索引,s指示新的胜者,胜者将去参加更上一层的比较
            SWAP(s,ls[t]);
        }
        t/= 2;//获取下一个父节点
    }
    //最终的胜者记录于ls[0]
    ls[0] = s;
}



/**
 * arry:多路归并的所有数据
 * b:存放多路归并的首地址数组
 * ls:败者树数组
 * k:几路归并
 */
void createLoserTree(int *arry[], int* b,int* ls,int k)
{
    for (int i = 0; i < k; ++i){
        b[i] = arry[i][0];//每一路的首元素进行赋予
    }
    //最后一个元素用于存放默认的最小值,用于刚开始的对比
    b[k] = MINKEY;
  

    //设置ls数组中败者的初始值,既b[k]最小值
    for (int i=0; i < k; i++) {
        ls[i] = k;
    }
    
    //有k个叶子节点
    //从最后一个叶子节点开始,沿着从叶子节点到根节点的路径调整
    for (int i = k - 1; i >= 0; --i){
        adjust(b, ls, i, k);
    }
    
}

/**
 *多路归并操作
 */
void kMerge(int* arr[], int* arrayCount, int k, int* ls, int* b)
{
    
    //index数组记录每一路读取的索引,
    int index[k];
    for (int i = 0; i < k; i++){
        index[i] = 0;
    }
    //最终的胜者存储在 is[0]中,当其值为 MAXKEY时,证明5个临时文件归并结束
    while (b[ls[0]]!=MAXKEY) {
        
        //获取胜者索引
        int s = ls[0];
        //输出过程模拟向外存写的操作
        printf("%d ",b[s]);
        //对应的索引路进行++记录
        ++index[s];
    
        //判断是否已经读完
        if (index[s] < arrayCount[s]){
            //没有读完,从第s路中读取数据
             b[s] = arr[s][index[s]];
        }else{
            //已经读完用最大值来表示该路已经结束
            b[s] = MAXKEY;
        }
        //进行调整,让最终胜者的索引存放到ls[0]中 
        adjust(b, ls, s, k);
        
    }
    
}

输出结果如下:

6 9 10 10 11 12 15 15 16 18 20 25 37 40 48 50

最终的输出结果模拟了向外存写的操作。

三、总结

败者树是多路归并算法中其中一种优化方案,其他的优化方案还有通过最小堆来实现置换选择排序算法,减少m的分片个数,进而提高归并的效率,以及还有最佳归并树等方案,由于篇幅的原因这里就不再细讲。

你可能感兴趣的:(算法)