选择置换+败者树搞定外部排序
个人认为,外部排序是我们在学习过程中接触到的一个比较重要的算法,它既包含了基本的排序算法,又考察了对文件IO以及内存的理解,还展示了最基本的程序优化思想,可以说能够写好一个外部排序,就说明基本的编程能力已经过关了。本文将对整个外部排序的过程进行详细的分析,并介绍两个经典算法,最后附上完整的程序代码。
1. 问题描述
由于在现实环境中,有时候需要对一个非常大的文件进行排序,而计算机内存是有限的,当数据无法完全存入内存时,则无法使用正常的排序算法一次完成排序,而必须利用磁盘空间的辅助进行外部排序,即利用有限的内存每次读入部分数据排序后得到一个顺串后暂时放到磁盘,最后将多个顺串进行归并直到最终完成排序,因为在归并过程中,只需从每个顺串中取出最小的一个数据进行比较即可,而不需要整个顺串都在内存中,所以解决了内存空间不足的问题。那么,原问题就可以分解成两个子问题,一个是如何生成顺串,另一个是如何将顺串进行归并。
首先,从性能上考虑,由于磁盘IO的速度要比内存读取的速度慢上几十万倍,所以必须尽量减少磁盘IO次数。再考虑归并过程中,假设有8个顺串,每次归并两个,则第一轮归并后变成4个,第二轮变成2个,直到第三轮完成归并,在这个过程中对每个数据进行了3次IO,而如果一次可以归并8个顺串,则只需一轮即可完成,即对每个数据只进行了1次IO。所以,为了提高程序效率,则需要尽量减少归并过程中的轮数,要实现这点,可以从两个角度入手,一是减少顺串数量(即令每个顺串的长度尽可能长),二是使用多路归并,针对这两点,本文将通过选择置换算法和败者树来实现。
2. 选择置换
选择置换算法用于生成顺串,在有限的内存限制下,它可以生成大概两倍于内存大小的顺串,其算法步骤如下:
假设内存中只有一个能容纳N个整型的数组
(1)首先从输入文件中读取N个数字将数组填满
(2)使用数组中现有数据构建一个最小堆
(3)重复以下步骤直到堆的大小变为0:
a. 把根结点的数字A(即当前数组中的最小值)输出
b. 从输入文件中再读出一个数字B,若R比刚输出的数字A 大,则将B放到堆的根节点处,若B不比A大,则将堆的最后一个元素移到根结点,将B放到堆的最后一个位置,并把堆的大小缩减1(即新读入的数据没有进入堆中)
c. 在根结点处调用Siftdown重新维护堆
(4)换一个输出文件,重新回到步骤(2)
解释:在以上算法运行过程中,步骤(3)每从最小堆中输出一个最小值,就从输入文件中再读入一个数据,若新读入的数比刚输出的数大,则可以属于当前的顺串,将其放入堆中即可,否则只能属于下一个顺串,需将其放在堆外,在运行过程中,堆的大小逐渐缩减直到0,此时就输出了一个顺串,而数组中新的数则可以用于构造一个新的堆,如此循环即可将原先的一个大文件转化成一个大概2N的顺串。至于为什么是2N,有一个比较抽象的类比证明:
假设在一条环形跑道上有一辆铲雪车在铲雪,且空中还在均匀地下着雪,那么当铲雪车已经沿着跑道开过一圈后,只要车速和降雪速度恒定,则跑道上的积雪量S也恒定,且车后积雪量最少,车前积雪量最多,如下图a。在这种情况下,设铲雪车每开一圈的时间,降雪量为X,车铲雪量为Y,则X,Y满足S+X - Y =S,即X = Y,又因为在铲雪车开一圈的过程中,铲掉的雪为原有的积雪加上降雪的一半,所以Y = S + X/2, 所以Y = 2S,即铲雪车铲掉了2S的雪量。而在选择置换中,数组的大小就相当于S,铲雪量就相当于输出顺串的大小,即2倍数组大。这个证明虽然有点抽象,但实际中只要输入文件中的数字是随机分布的,得到的顺串大小的确大概是所用数组大小的两倍。
3. 败者树
在多路归并的过程中,如果有K个顺串,每次有K个候选值,要找出其中的最小值,普通的做法需要进行K-1次比较,而使用败者树,则只需要O(logK)次比较,其原理就像我们平常的分组比赛,一个参赛者在小组出线之后,只需要与其他小组出线的参赛者比赛即可决出最后的冠军(最值),而不需要和其他所有参赛者都比一遍。
下图为一个5路归并过程中构建的败者树,因为要按从小到大排序,所以在每次比较中,小的为胜,大的为败。数组B[0..4]存储从顺串中读入的数,L[0]存储最终的胜者(最小值)的位置,L[1..3]存储中间各比赛败者的位置。
当前最小值为5(B[4]), 将5输出后,若新读入的数据为11,则先与该组之前的败者B[3]比较,胜后再与B[0]比较,结果为败,则将下标4记录于L[2]处,令胜者B[0]继续向上与B[2]比较,胜出后将将下标记录到L[0],经过3次比较后得出新的最小值为10(B[0]),如下图所示
关于败者树的构建和每次读入新值后的调整步骤见下面代码。
1 void CreateLoserTree(Run **runs, int n) 2 { 3 for(int i = 0; i < n; i++) 4 { 5 ls[i] = -1; 6 } 7 for(int i = n-1; i >= 0; i--) 8 { 9 Adjust(runs, n, i); 10 } 11 } 12 void Adjust(Run **runs, int n, int s) 13 { 14 //首先根据s计算出对应的ls中哪一个下标 15 int t = (s + n) / 2; 16 int tmp; 17 18 while(t != 0) 19 { 20 if(s == -1) 21 break; 22 if(ls[t] == -1 || runs[s]->buffer[runs[s]->idx] > 23 runs[ls[t]]->buffer[runs[ls[t]]->idx]) 24 { 25 tmp = s; 26 s = ls[t]; 27 ls[t] = tmp; 28 } 29 t /= 2; 30 } 31 ls[0] = s; 32 }
4. 代码解释
附件中包含两个程序:
generate_data.cpp 用于生成10000000个不重复的随即数字
external_sort.cpp 用于完成外部排序
在外部排序的程序中,限制只能使用一个大小为1000000的数组作数据存储,用于生成顺串和多路归并的输入缓冲区。
程序运行结果如下图
程序源代码下载
external_sort.zip