外排序
第九章 排序 | ||
9.7 外排序
当待排序的对象数目特别多时,在内存中不能一次处理。必须把它们以文件的形式存放于外存,排序时再把它们一部分一部分调入内存进行处理。这样,在排序过程中必须不断地在内存与外存之间传送数据。这种基于外部存储设备(或文件)的排序技术就是外排序。
9.7.1 外排序的基本过程
n 当对象以文件形式存放于磁盘上的时候,通常是按物理块存储的。 n 物理块也叫做页块,是磁盘存取的基本单位。
n 每个页块可以存放几个对象。操作系统按页块对磁盘上的信息进行读写。 n 本节所指的磁盘是由若干片磁盘组成的磁盘组,各个盘片安装在同一主轴上高速旋转。各个盘面上半径相同的磁道构成了柱面。各盘面设置一个读写磁头,它们装在同一动臂上,可以径向从一个柱面移到另一个柱面上。 n 为了访问某一页块,先寻找柱面,移动臂使读写磁头移到指定柱面上:寻查(seek)。 再根据磁道号(盘面号)选择相应读写磁头,等待指定页块转到读写磁头下:等待(latency)。因此, 在磁盘组上存取一个页块的时间:
tio=tseek+tlatency+trw
n基于磁盘进行的排序多使用归并排序方法。其排序过程主要分为两个阶段: u 第一个阶段建立用于外排序的内存缓冲区。根据它们的大小将输入文件划分为若干段,用某种内排序方法对各段进行排序。这些经过排序的段叫做初始归并段或初始顺串 (Run)。当它们生成后就被写到外存中去。 u 第二个阶段仿照内排序中所介绍过的归并树模式,把第一阶段生成的初始归并段加以归并,一趟趟地扩大归并段和减少归并段个数,直到最后归并成一个大归并段(有序文件)为止。
n示例:设有一个包含4500个对象的输入文件。现用一台其内存至多可容纳750个对象的计算机对该文件进行排序。输入文件放在磁盘上,磁盘每个页块可容纳250个对象,这样全部对象可存储在 4500 / 250=18 个页块中。输出文件也放在磁盘上,用以存放归并结果。 n 由于内存中可用于排序的存储区域能容纳750 个对象, 因此内存中恰好能存3个页块的对象。 n 在外归并排序一开始,把18块对象,每3块一组,读入内存。利用某种内排序方法进行内排序, 形成初始归并段, 再写回外存。总共可得到6个初始归并段。然后一趟一趟进行归并排序。
n 若把内存区域等份地分为 3 个缓冲区。其中的两个为输入缓冲区,一个为输出缓冲区,可以在内存中利用简单 2 路归并函数 merge 实现 2 路归并。 n首先, 从参加归并排序的两个输入归并段 R1 和 R2 中分别读入一块,放在输入缓冲区1 和输入缓冲区2 中。然后,在内存中进行2路归并,归并出来的对象顺序存放到输出缓冲区中。
n 一般地,若总对象个数为 n,磁盘上每个页块可容纳 b 个对象,内存缓冲区可容纳 i 个页块,则每个初始归并段长度为 len = i * b,可生成 m = én / lenù 个等长的初始归并段。 n 在做2路归并排序时,第一趟从 m 个初始归并段得到 ém/2ù 个归并段,以后各趟将从 l (l >1) 个归并段得到 él/2ù 个归并段。总归并趟数等于归并树的高度 élog2mù。 n 根据 2 路归并树, 估计 2 路归并排序时间 tES 的上界为:
tES = m*tIS + d*tIO + S*u*tmg
n对4500个对象进行排序的例子,各种操作的计算时间如下: u 读18个输入块, 内部排序6段, 写18个输出块 =6 tIS+36 tIO u 成对归并初始归并段 R1~R6 =36 tIO+4500 tmg u 归并两个具有1500个对象的归并段R12和R34 =24 tIO+3000 tmg u 最后将 R1234 和 R56 归并成一个归并段 = 36 tIO+4500 tmg n 合计 tES=6 tIS+132 tIO+12000 tmg
n 由于 tIO = tseek + tlatency +trw, 其中,tseek和tlatency是机械动作,而trw、tIS、tmg是电子线路的动作,所以 tIO >> tIS,tIO >> tmg。想要提高外排序的速度,应着眼于减少 d。 n 若对相同数目的对象,在同样页块大小的情况下做 3 路归并或做 6 路归并(当然, 内存缓冲区的数目也要变化),则可做大致比较:
归并路数 k 总读写磁盘次数 d 归并趟数 S 2 132 3 3 108 2 6 72 1
n因此,增大归并路数,可减少归并趟数,从而减少总读写磁盘次数d。
n 一般, 对 m 个初始归并段, 做 k 路平衡归并, 归并树可用正则 k 叉树(即只有度为 k 与度为0的结点的 k 叉树)来表示。 n 第一趟可将 m 个初始归并段归并为 l = ém/kù 个归并段,以后每一趟归并将 l 个归并段归并成 l = él / kù 个归并段,直到最后形成一个大的归并段为止。树的高度= élogkmù = 归并趟数S。 n 只要增大归并路数 k,或减少初始归并段个数 m,都能减少归并趟数 S,以减少读写磁盘次数 d,达到提高外排序速度的目的。 n采用输入缓冲区、内部归并和输出缓冲区并行处理的方法,也能有效地提高外排序的速度。
9.7.2 k路平衡归并 (k-way Balanced merging)
n 做 k 路平衡归并时,如果有 m 个初始归并段,则相应的归并树有 élogkmù +1 层,需要归并élogkmù 趟。下图给出对有36个初始归并段的文件做6路平衡归并时的归并树。
n 做内部 k 路归并时,在 k 个对象中选择最小者,需要顺序比较 k-1 次。每趟归并 u 个对象需要做(u-1)*(k-1)次比较,S 趟归并总共需要的比较次数为: S*(u-1)*(k-1) = élogkmù * (u-1) * (k-1) = élog2mù * (u-1) * (k-1) / élog2kù n 在初始归并段个数 m 与对象个数 u 一定时, élog2mù*(u-1) = const,而 (k-1) / élog2kù 在 k 增大时趋于无穷大。因此,增大归并路数 k,会使得内部归并的时间增大。 n 使用“败者树”从 k 个归并段中选最小者,当 k 较大时 (k >= 6),选出关键码最小的对象只需比较 élog2kù 次。
S*(u-1)*élog2kù = élogkmù * (u-1) * élog2kù = élog2mù * (u-1) * élog2kù / élog2kù = élog2mù * (u-1) n 关键码比较次数与 k 无关,总的内部归并时间不会随 k 的增大而增大。 n因此,只要内存空间允许, 增大归并路数 k, 将有效地减少归并树深度, 从而减少读写磁盘次数 d, 提高外排序的速度。 n 下面讨论利用败者树在 k 个输入归并段中选择最小者,实现归并排序的方法。
n败者树是一棵正则的完全二叉树。其中 u 每个叶结点存放各归并段在归并过程中当前参加比较的对象; u 每个非叶结点记忆它两个子女结点中对象关键码小的结点(即败者); n 因此,根结点中记忆树中当前对象关键码最小的结点 (最小对象)。 n 败者树与胜者树的区别在于一个选择了败者(关键码大者),一个选择了胜者(关键码小者)。
n示例:设有5个初始归并段,它们中各对象的关键码分别是:
n 败者树的高度为 [log2],在每次调整,找下一 个具有最小关键码对象时, 最多做 [log2k]次关键码比较。 n 在内存中应为每一个归并段分配一个输入缓冲区,其大小应能容纳一个页块的对象,编号与归并段号一致。每个输入缓冲区应有一个指针,指示当前参加归并的对象。 n在内存中还应设立一个输出缓冲区,其大小相当于一个页块大小。它也有一个缓冲区指针,指示当前可存放结果对象的位置。每当一个对象 i 被选出,就执行OutputRecord(i)操作,将对象按输出缓冲区指针所指位置存放到输出缓冲区中。
n 在实现利用败者树进行多路平衡归并算法时,把败者树的叶结点和非叶结点分开定义。 n 败者树叶结点key[k]有k+1个,key[0]到key[k-1]存放各归并段当前参加归并的对象的关键码,key[k]是辅助工作单元,在初始建立败者树时使用,存放一个最小的在各归并段中不可能出现的关键码:-MaxNum。 n 败者树非叶结点loser[k-1]有 k 个,其中loser[1]到loser[k-1]存放各次比较的败者的归并段号,loser[0]中是最后胜者所在的归并段号。另外还有一个对象数组 r[k],存放各归并段当前参加归并的对象。
k 路平衡归并排序算法 void kwaymerge ( Element *r ) { r = new Element[k]; //创建对象数组 int *key = new int[k+1]; //创建外结点数组 int *loser = new int[k]; //创建败者树数组 for ( int i = 0; i < k; i++ ) //传送参选关键码 { InputRecord ( r[i] ); key[i] = r[i].key; } for ( i = 0; i < k; i++) loser[i] = k; key[k] = -MaxNum; //初始化 for ( i = k-1; i; i-- ) //调整形成败者树 adjust ( key, loser, k, i );
while ( key[loser[0]] != MaxNum ) { //选归并段 q = loser[0]; //最小对象的段号 OutputRecord ( r[q] ); //输出 InputRecord ( r[q] ); //从该段补入对象 key[q] = r[q].key; adjust ( key, loser, k, q ); //调整 } Output end of run marker; //输出段结束标志 delete [ ] r; delete [ ] key; delete [ ] loser; }
自某叶结点key[q]到败者树根结点的调整算法 void adjust ( int key[ ]; int loser[ ]; const int k; const int q ) { //q指示败者树的某外结点key[q], 从该结点起到根 //结点进行比较, 将最小 key 对象所在归并段的段 //号记入loser[0]。k是外结点key[0..k-1]的个数。 for ( int t = (k+q) / 2; t > 0; t /= 2 ) // t是q的双亲 if ( key[loser[t]] < key[q]) { //败者记入loser[t], 胜者记入q int temp = q; q = loser[t]; loser[t] = temp; } //q与loser[t]交换 loser[0] = q; }
n 以后每选出一个当前关键码最小的对象,就需要在将它送入输出缓冲区之后,从相应归并段的输入缓冲区中取出下一个参加归并的对象,替换已经取走的最小对象,再从叶结点到根结点,沿某一特定路径进行调整,将下一个关键码最小对象的归并段号调整到loser[0]中。 n 最后,段结束标志MaxNum升入loser[0],排序完成,输出一个段结束标志。 n归并路数 k 的选择不是越大越好。归并路数 k增大时,相应需增加输入缓冲区个数。如果可供使用的内存空间不变,势必要减少每个输入缓冲区的容量,使内外存交换数据的次数增大。
9.7.3初始归并段的生成 (Run Generation)
为了减少读写磁盘次数,除增加归并路数 k 外,还可减少初始归并段个数 m。在总对象数n 一定时,要减少 m,必须增大初始归并段长度。 如果规定每个初始归并段等长,则此长度应根据生成它的内存工作区空间大小而定,因而m的减少也就受到了限制。 为了突破这个限制,可采用败者树来生成初始归并段。在使用同样大的内存工作区的情况下,可以生成平均比原来等长情况下大一倍的初始归并段,从而减少参加多路平衡归并排序的初始归并段个数,降低归并趟数。 图解举例说明如何利用败者树产生较长的初始归并段。设输入文件FI中各对象的关键码序列为 { 17, 21, 05, 44, 10, 12, 56, 32, 29 }。 选择和置换过程的步骤如下:
1、从输入文件FI中把 k 个对象读入内存中,并构造败者树。(内存中存放对象的数组r可容纳的对象个数为 k ) 2、利用败者树在 r 中选择一个关键码最小的对象r[q],其关键码存入LastKey作为门槛 。以后再选出的关键码比它大的对象归入本归并段,比它小的归入下一归并段。 3、将此r[q]对象写到输出文件FO中。(q是叶结点序号) 4、 若FI未读完, 则从FI读入下一个对象, 置换r[q]及败者树中的key[q]。 5、 调整败者树,从所有关键码比LastKey大的对象中选择一个关键码最小的对象r[q]作为门槛,其关键码存入LastKey。 6、重复3~5, 直到在败者树中选不出关键码比LastKey大的对象为止。此时, 在输出文件FO中得到一个初始归并段, 在它最后加一个归并段结束标志。 7、重复2~6, 重新开始选择和置换,产生新的初始归并段,直到输入文件FI中所有对象选完为止。
n 若按在 k 路平衡归并排序中所讲的,每个初始归并段的长度与内存工作区的长度一致, 则上述9个对象可分成3个初始归并段: Run0 { 05, 17, 21 } Run1 { 10, 12, 44 } Run2 { 29, 32, 56 } n 但采用上述选择与置换的方法, 可生成2个长度不等的初始归并段: Run0 { 05, 17, 21, 44, 56 } Run1 { 10, 12, 29, 32 } n
n 在利用败者树生成不等长初始归并段的算法和调整败者树并选出最小对象的算法中,用两个条件来决定谁为败者,谁为胜者。
u 首先比较两个对象所在归并段的段号,段号小者为胜者,段号大者为败者; u 在归并段的段号相同时,关键码小者为胜者,关键码大者为败者。 n 比较后把败者对象在对象数组 r 中的序号记入它的双亲结点中,把胜者对象在对象数组 r 中的序号记入工作单元 s 中,向更上一层进行比较,最后的胜者记入 loser[0]中。
利用败者树生成初始归并段
void generateRuns ( Element *r ) { r = new Element[k]; int *key = new int[k]; //参选对象关键码数组 int *rn = new int[k]; //参选对象段号数组 int *loser = new int[k]; //败者树 for ( int i = 0; i < k; i++ ){ loser[i] = 0; rn[i] = 0;} for ( i = k-1; i > 0; i-- ) { //输入首批对象 if ( end of input ) rn[i] = 2; //中途结束 else { InputRecord ( r[i] ); //从缓冲区输入 key[i] = r[i].key; rn[i] = 1; }
SelectMin ( key, rn, loser, k, i, rq ); //调整 } q = loser[0]; // q是最小对象在 r 中的序号 int rq = 1; // r[q]的归并段段号 int rc = 1; //当前归并段段号 int rmax = 1; //下次将要产生的归并段段号 int LastKey = MaxNum; //门槛 while (1) { //生成一个初始归并段 if ( rq != rc ) { //当前最小对象归并段大 Output end of run marker; //加段结束符 if ( rq > rmax ) return; //处理结束 else rc = rq; //否则置当前段号等于rq }
OutputRecord ( r[q] ); // rc==rq,输出 LastKey = Key[q]; //置新的门槛 if ( end of input ) rn[q] = rmax + 1; //虚设对象 else { //输入文件未读完 InputRecord ( r[q] ); //读入到刚才位置 key[i] = r[i].key; if ( key[q] < LastKey ) //小于门槛 rn[q] = rmax = rq + 1; //应为下一段对象 else rn[q] = rc; //否则在当前段 } rq = rn[q]; SelectMin ( key, rn, loser, k, q, rq ); q = loser[0]; //新的最小对象
} // end of while delete [ ] r; delete [ ] key; delete [ ] rn; delete [ ] loser; }
在败者树中选择最小对象的算法
void SelectMin ( int key[ ]; int rn[ ]; int loser[ ]; const int k; const int q; int &rq ) { //q指示败者树的某外结点key[q], 从该结点向上到//根结点loser[0]进行比较, 选择出LastKey对象。k //是外结点key[0..k-1]的个数。
for ( int t = (k+q)/2; t > 0; t /= 2 ) if ( rn[loser[t]] < rq || rn[loser[t]] == rq && key[loser[t]] < key[q] ) { //先比较段号再比较关键码, 小者为胜者 int temp = q; q = loser[t]; loser[t] = temp; //败者记入loser[t], 胜者记入q rq = rn[q]; } loser[0] = q; //最后的胜者 }
9.7.4 并行操作的缓冲区处理
n 如果采用 k 路归并对 k 个归并段进行归并,至少需要 k 个输入缓冲区和 1 个输出缓冲区。每个缓冲区存放一个页块的信息。 n 但要同时进行输入、内部归并、输出操作,这些缓冲区就不够了。例如, u
n 由于内外存信息传输的时间与CPU的运行时间相比要长得多,所以使得内部归并经常处于等待状态。 n 为了改变这种状态,希望使输入、内部归并、输出并行进行。对于 k 路归并,必须设置 2k 个输入缓冲区和 2 个输出缓冲区。 n示例:给每一个归并段固定分配 2 个输入缓冲区,做 2 路归并。假设存在 2 个归并段: u Run0:对象的关键码是 1, 3, 7, 8, 9 u Run1:对象的关键码是 2, 4, 15, 20, 25 n 假设每个缓冲区可容纳 2 个对象。需要设置 4 个输入缓冲区IB[i], 1£ i £ 4,2 个输出缓冲区OB[0]和OB[1]。 n 因此,不应为各归并段分别分配固定的两个缓冲区,缓冲区的分配应当是动态的,可根据需要为某一归并段分配缓冲区。但不论何时,每个归并段至少需要一个包含来自该归并段的对象的输入缓冲区。
k 路归并时动态分配缓冲区的实施步骤
¶ 1、为 k 个初始归并段各建立一个缓冲区的链式队列,开始时为每个队列先分配一个输入缓冲区。另外建立空闲缓冲区的链式栈,把其余 k 个空闲的缓冲区送入此栈中。输出缓冲区OB定位于0号输出缓冲区。
· 2、用 LastKey[i] 存放第 i 个归并段最后输入的关键码,用 NextRun 存放 LastKey[i] 最小的归并段段号;若有几个 LastKey[i] 都 是最小时,将序号最小的 i 存放到 NextRun 中。如果LastKey [NextRun] ¹ ¥,则从空闲缓冲区栈中取一个空闲缓冲区,预先链入段号为 NextRun 的归并段的缓冲区队列中。 ¸ 3、使用函数 kwaymerge 对 k 个输入缓冲区队列中的对象进行 k 路归并,结果送入输出缓冲区OB 中。归并一直持续到输出缓冲区 OB 变满或者有一个关键码为 ¥ 的对象被归并到OB 中 为止。
如果一个输入缓冲区变空,则 kwaymerge 进到该输入缓冲区队列中的下一个缓冲区,同时将变空的位于队头的缓冲区从队列中退出,加入到空闲缓冲区栈中。 但如果在输出缓冲区变满或关键码为 ¥ 的对象被归并到输出缓冲区OB的同时一个输入缓冲区变空,则 kwaymerge 不进到该输入缓冲区队列中的下一个缓冲区,变空的缓冲区也不从队列中退出,归并暂停。 ¹ 4、一直等着,直到磁盘输入或磁盘输出完成为止,继续归并。
º 5、如果一个输入缓冲区读入完成,将它链入适当归并段的缓冲区队列中。然后确定满足LastKey [NextRun] 的最小的 NextRun,确定下一步将读入哪一个归并段的对象。 » 6、如果 LastKey[NextRun] ¹ ¥,则从空闲缓冲区栈中取一个空闲缓冲区,从段号为 NextRun 的归并段中读入下一块,存放到这个空闲缓冲区中。 ¼ 7、开始写出输出缓冲区OB的对象,再将输出缓冲区定位于1号输出缓冲区。 ½ 8、如果关键码为 ¥ 的对象尚未被归并到输出缓冲区OB中,转到¸继续操作;否则,一直等待,直到写出完成,然后算法结束。
n示例:假设对如下三个归并段进行 3 路归并。每个归并段由3块组成, 每块有2个对象。各归并段最后一个对象关键码 为∞。 u 归并段1 { 20, 25 } { 26, 28 } { 36, ∞} u 归并段2 { 23, 29 } { 34, 38 } { 70, ∞} u 归并段3 { 24, 28 } { 31, 34 } { 50, ∞} n 设立6个输入缓冲区,2个输出缓冲区。利用动态缓冲算法,各归并段输入缓冲区队列及输出缓冲区状态的变化如图所示。
n 对于较大的k, 为确定哪一个归并段的输入缓冲区最先变空,可对 LastKey[i], 0 <= i <= k-1,建立一棵败者树。通过 log2k 次比较就可确定哪一 个归并段的缓冲区队列最先变空。 n 对于较大的k,函数 kwaymerge 使用了败者树进行归并。 n 除最初的 k 个输入页块的读入和最后一个输出页块的写出外,其它所有输入,输出和内部归并都是并行执行的。此外,也有可能在 k 个归并段归并完后,需要立即开始对另外 k 个归并段执行归并。所以,在对 k 个归并段进行归并的最后阶段,就开始下一批 k 个归并段的输入。 n 此算法假定所有的页块大小相同。
9.7.5 最佳归并树
n 归并树是描述归并过程的 m 叉树。因为每一次做 m 路归并都需要有 m 个归并段参加,因此,归并树是只有度为0和度为 m 的结点的正则 m 叉树。 n示例:设有13个长度不等的初始归并段,其长度(对象个数)分别为 0, 0, 1, 3, 5, 7, 9, 13, 16, 20, 24, 30, 38 n其中长度为 0 的是空归并段。对它们进行 3 路归并时的归并树如图所示。 n在归并树中 u 各叶结点代表参加归并的各初始归并段 u 叶结点上的权值即为该初始归并段中的对象个 数 u 根结点代表最终生成的归并段 u 叶结点到根结点的路径长度表示在归并过程中的读对象次数 u 各非叶结点代表归并出来的新归并段 u 归并树的带权路径长度 WPL 即为归并过程中的总读对象数。因而,在归并过程中总的读写对象次数为 2*WPL = 754。
n 不同的归并方案所对应的归并树的带权路径长度各不相同。
n 为了使得总的读写次数达到最少,需要改变归并方案,重新组织归并树。 n 可将哈夫曼树的思想扩充到 m 叉树的情形。在归并树中,让对象个数少的初始归并段最先归并,对象个数多的初始归并段最晚归并,就可以建立总的读写次数达到最少的最佳归并树。 n例如,假设有11个初始归并段, 其长度(对象个数)分别为 1, 3, 5, 7, 9, 13, 16, 20, 24, 30, 38 做3路归并。
n为使归并树成为一棵正则三叉树,可能需要补入空归并段。补空归并段的原则为: u
n
n如果做5路归并,让 m = 5,则有 (11-1)/(5-1) = 2 n表示有2个度为5的内结点;但是, u = (11-1) mod (5-1) =2 ¹ 0 n需要加一个内结点,它在归并树中代替了一个叶结点的位置,故一个叶结点参加这个内结点下的归并,需要增加的空初始归并段数为 m-u-1=5-2-1 = 2 n应当补充2个空归并段。则归并树如图所示。 |