在我们日常开发中有时候会遇到这样一个问题,有一个文件大小为10GB,现在要为里面的数据进行排序,而计算机的内存只有1GB,如何对这10GB的数据进行排序呢?
由于内存空间只有1GB我们无法一次性读取所有的文件来进行排序,因此需要借助外部排序来解决。外部排序的思路很简单,它采用了一种" 排序-归并 " 的策略。大概步骤如下:
如上图 所示有 10 个初始归并段到一个有序文件,共进行了 4 次归并,每次都由 m 个归并段得到 ⌈m/2⌉ 个归并段,这种归并方式被称为 2路归并排序。
对于外部排序算法来说,影响整体排序效率的因素主要取决于读写磁盘的次数,即访问磁盘的次数越多,算法花费的时间就越多,效率就越低。而对于同一个文件来说,对其进行外部排序时访问外存的次数同归并的次数成正比,即归并操作的次数越多,访问外存的次数就越多。
为了提高外部排序的效率,降低归并次数,所以出现了4路排序、5路排序、10路排序等K路排序。比如下图所示的就是5路归并排序:
可见当有10个文件, 进行2路归并,需要进行4次归并操作,而进行5路归并,则只需要2次归并操作。因此对于 k路平归并排序中 k 值得选择,增加 k 可以减少归并的次数,从而减少外存读写的次数,最终达到提高算法效率的目的。
根据上面的两个图,可以推到出一个公式,假设有m个文件采用K路进行归并时,那么归并的次数为:s =logk(m)(其中s为归并的次数)。
从公式上可以判断出,想要达到减少归并次数s从而提高算法效率的目的,可以从两个角度实现:
接下我们就想使用多路平衡归并排序的算法来解决K的问题。
上面我们提到,要想减少归并的次数,可以增加K的值。但是,如果毫无限度地增加K的值,虽然会减少归并次数,但是会增加内部归并的时间。以10 个文件为例子,当采用 2归并时,若每次从 2 个文件中想得到一个最小值时只需比较 1 次;而采用 5路归并时,若每次从 5 个文件中想得到一个最小值就需要比较 4 次。因此K值越大比较的次数就多!
那么K要如何选择比较好呢?为了避免K 值的选择过程中影响内部归并的效率,在进行 K路归并时可以使用败者树来实现,该方法让K值的不会影响其内部归并的效率。
败者树是一颗完全二叉树,是树形选择排序的一种变型。每个叶子结点相当于一个选手,每个中间结点相当于一场比赛的结果,每一层相当于一轮比赛。
在败者树中,用父结点记录其左右子结点进行比赛的败者,而让胜者参加下一轮的比赛。败者树的根结点记录的是败者,需要加一个结点来记录整个比赛的胜利者。如下图代表的就是一颗败者树:
数组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时,败者树的重构图:
由于败者树是一棵完全二叉树,所以可以由数组来实现。
首先我们需要两个数组: 数组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路归并中的每一路第一个元素构建成一颗败者树,如下图所示是一棵 5路归并的败者树,ls[0] 中存储的为最终的胜者,我们把最小值ls[0]输出后,只需要更新叶子结点 b3 的值,即导入关键字 15,让该结点不断同其双亲结点所表示的关键字进行比较,败者留在双亲结点中,胜者继续向上比较。依次类推,最终完成K路的归并操作。
条件:总数据量为n、采用K路归并、初始归并段为m。
可以见引入败者树后比较次数与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的分片个数,进而提高归并的效率,以及还有最佳归并树等方案,由于篇幅的原因这里就不再细讲。