数据结构与算法系列——排序(15)_外部排序
核心部分
1. 实现外部排序的两个过程:
- 将整个初始文件分为多个初始归并段;
- 将初始归并段进行归并,直至得到一个有序的完整文件;
2. 时间组成:
- 内部排序所需要的时间
- 外存信息读写所需要的时间 (关键)
- 与归并的趟数有关
- k要大 —– 传统方法 会引起内部归并时间增大
- 赢者树
- 败者树(目的:提高在k个归并串中当前值中找到最小值的效率)
- m要小 —– 置换选择排序
- k要大 —– 传统方法 会引起内部归并时间增大
- Huffman(归并的顺序,对外存的I/O次数降到最低)
- 与归并的趟数有关
- 内部归并所需要的时间
3. 为了提高整个外部排序的效率,分别从以上两个方面对外部排序进行了优化:
- 在实现将初始文件分为 m 个初始归并段时,为了尽量减小 m 的值,采用置换-选择排序算法(内部使用败者树实现),可实现将整个初始文件分为数量较少的长度不等的初始归并段。
- 同时在将初始归并段归并为有序完整文件的过程中,为了尽量减少读写外存的次数,采用构建最佳归并树的方式(哈夫曼树实现),对初始归并段进行归并(败者树实现),而归并的具体实现方法是采用败者树的方式。
4. 优化递进顺序:
- 二路归并【因为硬盘的读写速度比内存要慢的多,按照以上这种方法,每个数据都从硬盘读了三次,写了三次,要花很多时间。考虑K路】
- 多路归并【K不是越大越好,因为K越大,在内部排序需要的时间越长,效率低。考虑减少初始顺串的数量M】
- 置换选择算法【可以用败者树和堆排序实现,得到多个长度不等的初始归并段,如何设置它们的归并顺序,可以使得对外存的访问次数降到最低? 考虑结合哈夫曼树】
- 最佳归并树(置换选择算法+哈夫曼树+多路归并+败者树)
5 胜者树 & 败者树 & 堆排序
发展历史
堆:其实一开始就是只有堆来完成多路归并的,但是人们发现堆每次取出最小值之后,把最后一个数放到堆顶,调整堆的时候,每次都要选出父节点的两个孩子节点的最小值,然后再用孩子节点的最小值和父节点进行比较,所以每调整一层需要比较两次。
胜者树:这时人们想能否简化比较过程,这时就有了胜者树,这样每次比较只用跟自己的兄弟节点进行比较就好,所以用胜者树可以比堆少一半的比较次数。 而胜者树在节点上升的时候首选需要获得父节点,然后再获得兄弟节点,然后再比较。
败者树:这时人们又想能否再次减少比较次数,于是就有了败者树。在使用败者树的时候,每个新元素上升时,只需要获得父节点并比较即可。
所以总的来说,减少了访存的时间。 现在程序的主要瓶颈在于访存了,计算倒几乎可以忽略不计了。
相同点
首先它们三个的相同点就是在于:空间和时间复杂度都是一样的O(N*logN)。调整一次的时间复杂度都是O(logN)的。
所以这道题用堆来做,跟用败者树来做并没有本质上的算法复杂度量级上的差别。
不同点
堆:所有的节点都是关键字; 每次调整一层需要比较两次(父亲 左孩子| 父亲 右孩子)。
胜者树:叶子节点是关键字,非叶子节点保存胜者索引;每次调整一层需要比较1次(自己 兄弟),读取两次(父亲| 兄弟)。
败者树:叶子节点是关键字,非叶子节点保存败者索引;每次调整一层需要比较1次(自己 父亲),读取一次(父亲),只需要和路径上的节点比较,不需要和兄弟节点比较,简化了重构的过程。; 新增B[0]记录比赛的胜者【在本例子中是ls[0]】
6. 涉及到的算法:
- 多路归并算法
- 败者树选择算法
- 置换选择算法
- 哈夫曼树
- 内部排序算法(堆排序)
一、定义
外部排序指的是大文件的排序,即待排序的记录存储在外存储器上,待排序的文件无法一次装入内存,需要在内存和外部存储器之间进行多次数据交换,以达到排序整个文件的目的。
二、步骤
外部排序算法由两个阶段构成:预处理和合并排序。
- 预处理产生有序的顺串:按照内存大小,将外存上含有 n 个纪录的大文件分成若干长度为 t 的子文件(t 应小于内存的可使用容量),然后将各个子文件依次读入内存,使用适当的内部排序算法对子文件的 t 个纪录进行排序(排好序的子文件统称为“归并段”或者“顺段”),将排好序的归并段重新写入外存,为下一个子文件排序腾出内存空间;这样在外存上就得到了m个顺串(m=[n/t])。
- 合并序列:对得到的顺段进行合并,直至得到整个有序的文件为止。
三、k-路平衡归并
3.1 2-路平衡归并
例子1
给你一个包含20亿个int类型整数的文件,计算机的内存只有2GB,怎么给它们排序?一个int数占4个字节,20个亿需要80亿字节,大概占用8GB的内存,而计算机只有2GB的内存,数据都装不下!可以把8GB分割成4个2GB的数据来排,然后在把他们拼凑回去。如下图:
在2G内存中排序的时候可以选择合适的内部排序,比如快速排序或归并排序等算法。为了方便,我们把排序好的2G有序数据称为有序子串。接着把两个小的有序子串合并成一个大的有序子串。
注意:读取的时候是每次读取一个int数,通过比较之后再输出。按照这个方法来回合并,总共经过三次合并之后就可以得到8G的有序子串。
我们假设需要排序的int数有12个,内存一次只能装下3个int数。
接下来把12个数据分成4份,然后排序成有序子串:
然后把子串进行两两合并:
输出哪个元素就在那个元素所在的有序子串再次读入一个元素:
继续
重复直到合并成一个包含6个int有序子串:
再把两个包含6个int的有序子串合并成一个包含12个int数据的最终有序子串:
因为硬盘的读写速度比内存要慢的多,按照以上这种方法,每个数据都从硬盘读了三次,写了三次,要花很多时间。
解释下:例如对于数据2,我们把无序的12个数据分成有序的4个子串需要读写各一次,把2份3个有序子串合并成6个有序子串读写各一次;把2份6个有序子串合并从12个有序子串读写各一次,一共需要读写各3次。
在进行有序子串合并的时候,不采取两两合并的方法,而是可以3个子串,或4个子串一起来合并。
例子2
例如,有一个含有 10000 个记录的文件,但是内存的可使用容量仅为 1000 个记录,毫无疑问需要使用外部排序算法,具体分为两步:
- 将整个文件其等分为 10 个临时文件(每个文件中含有 1000 个记录),然后将这 10 个文件依次进入内存,采取适当的内存排序算法(快排或者归并排序)对其中的记录进行排序,将得到的有序文件(初始归并段)移至外存。
- 对得到的 10 个初始归并段进行如图 1 的两两归并,直至得到一个完整的有序文件。
注意:此例中采用了将文件进行等分的操作,还有不等分的算法,后面会介绍。
如图 1 所示有 10 个初始归并段到一个有序文件,共进行了 4 次归并,每次都由 m 个归并段得到 ⌈m/2⌉ 个归并段,这种归并方式被称为 2-路平衡归并。
注意:在实际归并的过程中,由于内存容量的限制不能满足同时将 2 个归并段全部完整的读入内存进行归并,只能不断地取 2 个归并段中的每一小部分进行归并,通过不断地读数据和向外存写数据,直至 2 个归并段完成归并变为 1 个大的有序文件。
对于外部排序算法来说,影响整体排序效率的因素主要取决于读写外存的次数,即访问外存的次数越多,算法花费的时间就越多,效率就越低。
3.2 多路归并
对于同一个文件来说,对其进行外部排序时访问外存的次数同归并的次数成正比,即归并操作的次数越多,访问外存的次数就越多。3.1 中图 1 中使用的是 2-路平衡归并的方式,举一反三,还可以使用 3-路归并、4-路归并甚至是 10-路归并的方式。
例子1
我们假设内存一共可以装4个int型数据。
刚才我们是采取两两合并的方式,现在我们可以采取4个有序子串一起合并的方式,这样的话,每个数据从硬盘读写的次数各需要2次就可以了。如图:
4个有序子串的合并,叫4路归并。如果是n个有序子串的合并,就把它称为n路归并。n并非越大越好。N越大,内部排序所需要的时间越多。
例子2
图 2 为 5-路归并的方式:
对比3.1 中 图 1 和 3.2 中图 2可以看出,对于 k-路平衡归并中 k 值得选择,增加 k 可以减少归并的次数,从而减少外存读写的次数,最终达到提高算法效率的目的。除此之外,一般情况下对于具有 m 个初始归并段进行 k-路平衡归并时,归并的次数为:s=⌊logkm ⌋(其中 s 表示归并次数)。
从公式上可以判断出,想要达到减少归并次数从而提高算法效率的目的,可以从两个角度实现:
- 增加 k-路平衡归并中的 k 值;[但不能影响内部归并的效率]
- 尽量减少初始归并段的数量 m,即增加每个归并段的容量;
其增加 k 值的想法引申出了一种外部排序算法:多路平衡归并算法;增加数量 m 的想法引申出了另一种外部排序算法:置换-选择排序算法。
四、多路平衡归并排序(胜者树、败者树)算法
对于外部排序算法来说,其直接影响算法效率的因素为读写外存的次数,即次数越多,算法效率越低。若想提高算法的效率,即减少算法运行过程中读写外存的次数,可以增加 k –路平衡归并中的 k 值。但是经过计算得知,如果毫无限度地增加 k 值,虽然会减少读写外存数据的次数,但会增加内部归并的时间,得不偿失。(k越大,内部归并排序【比如选出最小值】需要花费更多的时间,所以k不是越大越好)
例如在上节中,对于 10 个临时文件,当采用 2-路平衡归并时,若每次从 2 个文件中想得到一个最小值时只需比较 1 次;而采用 5-路平衡归并时,若每次从 5 个文件中想得到一个最小值就需要比较 4 次。以上仅仅是得到一个最小值记录,如要得到整个临时文件,其耗费的时间就会相差很大。
为了避免在增加 k 值的过程中影响内部归并的效率,在进行 k-路归并时可以使用“败者树”来实现,该方法在增加 k 值时不会影响其内部归并的效率。
胜者树和败者树都是完全二叉树(非叶子节点存储的是索引),他们是树形选择排序的变形(非叶子节点存储的是具体的值)。
4.1 胜者树实现内部归并
- 胜者树是一颗完全二叉树
- 胜者树的叶子结点保存我们的一个输入缓冲区(一路归并顺序表); 叶节点L[ 1……n]
- 胜者树的非叶子节点保存当前比较的胜者的输入缓冲区的指针;非叶子节点B[1……n-1] //存储的是数组L的索引
- 胜者树的根节点保存我们的胜者树当前的的一次比较中的冠军(最优值) B[0]
当我们将我们的胜者树的最优值输入到我们的输出缓冲区(输出缓冲区从内存中额外开辟出来的一段,我们存储当前的归并的结果,缓冲区满写入磁盘)
之后,我们的根节点便出现了空的情况,我们需要从根节点对应的输入缓冲区中在读入一个数据来充当下一次比较的选手,然后从下到上进行维护,我们的每一次的维护都需要比较兄弟的胜者然后选出新一轮的胜者然后一直优化到我们的根的路径上(从低至上,贯穿整个树)之后我们不断地进行上述的操作,指导我们的所有的输入缓冲区已经为空为止。
例子:
胜者树的一个优点是,如果一个选手的值改变了,可以很容易地修改这棵胜者树。只需要沿着从该结点到根结点的路径修改这棵二叉树,而不必改变其他比赛的结果。
我们把胜者树分为两部分:
b[]:用来保存K路数组的首元素,叶节点存放在此处,即底下那七个数组
ls[]:用来保存胜者数组的下标,ls[1]是最终的胜者(即所求的数)。
胜者树的中间结点记录的是胜者的标号
胜者树的示例。规定数值小者胜。
b3 PK b4,b3胜b4负,内部结点ls[4]的值为3;
b3 PK b0,b3胜b0负,内部结点ls[2]的值为3;
b1 PK b2,b1胜b2负,内部结点ls[3]的值为1;
b3 PK b1,b3胜b1负,内部结点ls[1]的值为3。
叶子结点b3的值变为11时,重构的胜者树如图所示
1. b3 PK b4,b3胜b4负,内部结点ls[4]的值为3;
2. b3 PK b0,b0胜b3负,内部结点ls[2]的值为0;
3. b1 PK b2,b1胜b2负,内部结点ls[3]的值为1;
4. b0 PK b1,b1胜b0负,内部结点ls[1]的值为1。.
4.2 败者树实现内部归并
我们的胜者树维护的时候每次都需要去查找我们的根的兄弟节点的位置来进行比较,但是我们的每一次都要多一步查找兄弟的划,无论是对我们的程序的实现过程还是我们的时间效率上来看都还存在改进的余地。这里我们就要引入败者树,败者树与胜者树恰好相反,其双亲结点存储的是左右孩子比较之后的失败者,而胜利者则继续同其它的胜者去比较。
败者树的定义:
- 败者树是一颗完全二叉树(败者树是树形选择排序的一种变形)
- 败者树的叶子结点保存的是我们的输入缓冲区
- 败者树的非叶子结点保存我们的当前的比较中败者的对应的输入缓冲区的指针
- 败者树根保存我们的当前比较的亚军,根上面还有一个节点保存我们的冠军
比赛过程
- 将新入树的节点与其父亲进行比较
- 把败者存放在父亲节点
- 把胜者再与上一级的父亲进行比赛
- 比赛不断进行
- 把败者的索引存放在B[1]
- 把胜者的索引放到节点B[0]
例子1:
败者树是胜者树的一种变体。在败者树中,用父结点记录其左右子结点进行比赛的败者,而让胜者参加下一轮的比赛。败者树的根结点记录的是败者,需要加一个结点来记录整个比赛的胜利者。采用败者树可以简化重构的过程。
我们把败者树分为两部分:
b[]:用来保存K路数组的首元素,叶节点存放在此处,即底下那七个数组
ls[]:用来保存败者数组的下标,b[0]是最终的胜者(即所求的数),败者节点存放在中间节点。
败者树的中间结点记录的败者的标号
败者树示例,规定数大者败。
b3 PK b4,b3胜b4负,内部结点ls[4]的值为4;
b3 PK b0,b3胜b0负,内部结点ls[2]的值为0;
b1 PK b2,b1胜b2负,内部结点ls[3]的值为2;
b3 PK b1,b3胜b1负,内部结点ls[1]的值为1;
在根结点ls[1]上又加了一个结点ls[0]=3,记录的最后的胜者。
败者树重构过程如下:
将新进入选择树的结点与其父结点进行比赛:将败者存放在父结点中;而胜者再与上一级的父结点比较。
比赛沿着到根结点的路径不断进行,直到ls[1]处。把败者存放在结点ls[1]中,胜者存放在ls[0]中。
是当b3变为13时,败者树的重构图:
注意,败者树的重构跟胜者树是不一样的,败者树的重构只需要与其父结点比较。b3与结点ls[4]的原值比较,ls[4]中存放的原值是结点4,即b3与b4比较,b3负b4胜,则修改ls[4]的值为结点3。同理,以此类推,沿着根结点不断比赛,直至结束。
例子2:
例如还是图 1 中,叶子结点 49 和 38 比较,38 更小,所以 38 是胜利者,49 为失败者,但由于是败者树,所以其双亲结点存储的应该是 49;同样,叶子结点 65 和 97 比较,其双亲结点中存储的是 97 ,而 65 则用来同 38 进行比较,65 会存储到 97 和 49 的双亲结点的位置,38 继续做后续的胜者比较,依次类推。
胜者树和败者树的区别就是:胜者树中的非终端结点中存储的是胜利的一方;而败者树中的非终端结点存储的是失败的一方。而在比较过程中,都是拿胜者去比较。
如图 2 所示为一棵 5-路归并的败者树,其中 b0—b4 为树的叶子结点,分别为 5 个归并段中存储的记录的关键字。ls 为一维数组,表示的是非终端结点,其中存储的数值表示第几归并段(例如 b0 为第 0 个归并段)。ls[0] 中存储的为最终的胜者,表示当前第 3 归并段中的关键字最小。
当最终胜者判断完成后,只需要更新叶子结点 b3 的值,即导入关键字 15,然后让该结点不断同其双亲结点所表示的关键字进行比较,败者留在双亲结点中,胜者继续向上比较。
例如,叶子结点 15 先同其双亲结点 ls[4] 中表示的 b4 中的 12 进行比较,12 为胜利者,则 ls[4] 改为失败者 15 所在的归并段即 b3,然后 12 继续同 ls[2] 中表示的 10 做比较,10 为胜者,则 ls[2] 改为失败者 12 所在的归并段即 b4,然后 10 继续同其双亲结点 ls[1] 表示的 b1(关键字 9)作比较,最终 9 为胜者。整个过程如下图所示:
注意:为了防止在归并过程中某个归并段变为空,处理的办法为:可以在每个归并段最后附加一个关键字为最大值的记录。这样当某一时刻选出的冠军为最大值时,表明 5 个归并段已全部归并完成。(因为只要还有记录,最终的胜者就不可能是附加的最大值)
本节介绍了通过使用败者树来实现增加 k-路归并的规模来提高外部排序的整体效率。但是对于 k 值得选择也并不是一味地越大越好,而是需要综合考虑选择一个合适的 k 值。
4.3 胜者树 & 败者树 & 堆排序
发展历史
堆:其实一开始就是只有堆来完成多路归并的,但是人们发现堆每次取出最小值之后,把最后一个数放到堆顶,调整堆的时候,每次都要选出父节点的两个孩子节点的最小值,然后再用孩子节点的最小值和父节点进行比较,所以每调整一层需要比较两次。
胜者树:这时人们想能否简化比较过程,这时就有了胜者树,这样每次比较只用跟自己的兄弟节点进行比较就好,所以用胜者树可以比堆少一半的比较次数。 而胜者树在节点上升的时候首选需要获得父节点,然后再获得兄弟节点,然后再比较。
败者树:这时人们又想能否再次减少比较次数,于是就有了败者树。在使用败者树的时候,每个新元素上升时,只需要获得父节点并比较即可。
所以总的来说,减少了访存的时间。 现在程序的主要瓶颈在于访存了,计算倒几乎可以忽略不计了。
相同点
首先它们三个的相同点就是在于:空间和时间复杂度都是一样的O(N*logN)。调整一次的时间复杂度都是O(logN)的。
所以这道题用堆来做,跟用败者树来做并没有本质上的算法复杂度量级上的差别。
不同点
堆:所有的节点都是关键字; 每次调整一层需要比较两次(父亲 左孩子| 父亲 右孩子)。
胜者树:叶子节点是关键字,非叶子节点保存胜者索引;每次调整一层需要比较1次(自己 兄弟),读取两次(父亲| 兄弟)。
败者树:叶子节点是关键字,非叶子节点保存败者索引;每次调整一层需要比较1次(自己 父亲),读取一次(父亲),只需要和路径上的节点比较,不需要和兄弟节点比较,简化了重构的过程。; 新增B[0]记录比赛的胜者【在本例子中是ls[0]】
五、置换选择排序算法详解
k 不是越大越好,那么我们可以想办法减少有序子串的总个数 m。这样,也能减少数据从硬盘读写的次数。
上一节介绍了增加 k-路归并排序中的 k 值来提高外部排序效率的方法,而除此之外,还有另外一条路可走,即减少初始归并段的个数,也就是本章第一节中提到的减小 m 的值。
m 的求值方法为:m=⌈n/l⌉(n 表示为外部文件中的记录数,l 表示初始归并段中包含的记录数)
如果要想减小 m 的值,在外部文件总的记录数 n 值一定的情况下,只能增加每个归并段中所包含的记录数 l。而对于初始归并段的形成,就不能再采用上一章所介绍的内部排序的算法,因为所有的内部排序算法正常运行的前提是所有的记录都存在于内存中,而内存的可使用空间是一定的,如果增加 l 的值,内存是盛不下的。所以要另想它法,探索一种新的排序方法:置换—选择排序算法。
例如已知初始文件中总共有 24 个记录,假设内存工作区最多可容纳 6 个记录,按照之前的选择排序算法最少也只能分为 4 个初始归并段。而如果使用置换—选择排序,可以实现将 24 个记录分为 3 个初始归并段,如图 1 所示:
置换—选择排序算法的具体操作过程为:
- 首先从初始文件中输入 6 个记录到内存工作区中;
- 从内存工作区中选出关键字最小的记录,将其记为 MINIMAX 记录;
- 然后将 MINIMAX 记录输出到归并段文件中;
- 此时内存工作区中还剩余 5 个记录,若初始文件不为空,则从初始文件中输入下一个记录到内存工作区中;
- 从内存工作区中的所有比 MINIMAX 值大的记录中选出值最小的关键字的记录,作为新的 MINIMAX 记录;[使用败者树或者堆排序实现]
- 重复过程 3—5,直至在内存工作区中选不出新的 MINIMAX 记录为止,由此就得到了一个初始归并段;
- 重复 2—6,直至内存工作为空,由此就可以得到全部的初始归并段。
拿图 1 中的初始文件为例,首先输入前 6 个记录到内存工作区,其中关键字最小的为 29,所以选其为 MINIMAX 记录,同时将其输出到归并段文件中,如下图所示:
此时初始文件不为空,所以从中输入下一个记录 14 到内存工作区中,然后从内存工作区中的比 29 大的记录中,选择一个最小值作为新的 MINIMAX 值输出到 归并段文件中,如下图所示:
初始文件还不为空,所以继续输入 61 到内存工作区中,从内存工作区中的所有关键字比 38 大的记录中,选择一个最小值作为新的 MINIMAX 值输出到归并段文件中,如下图所示:
如此重复性进行,直至选不出 MINIMAX 值为止,如下图所示:
当选不出 MINIMAX 值时,表示一个归并段已经生成,则开始下一个归并段的创建,创建过程同第一个归并段一样,这里不再赘述。
5.1 堆排序实现
我们要如何从内存中选出这个目的数呢?难道每次都把内存中的数据进行排序,然后再逐个比较选择吗?其实我们可以构建一个最小堆来帮助我们选择目的数。具体如下:
12个无序的数
从12个数据中读取3个数据,构建一个最小堆,然后从堆顶选择一个数写入到p1中。之后再从剩余的9个数中读取一个数,如果这个数比刚才那个写入到p1中的数大,则把这个数插入到最小堆中,重新调整最小堆结构,然后在堆顶选一个数写入到p1中。否则,把这个数暂放在一边,暂时不处理。之后一样需要调整堆结构,从堆顶选择一个数写入到p1中。这里说明一下,那个被放在一边的数是不能在放入p1中的了,因为它一定比p1中的数都要小,所以它会放在下一个子串中。如下图所示:
从12个数据中读取3个数据:
构建最小堆,且选出目标数:
读入下一个数86:
读入下一个数3,比70小,暂放一边,不加入堆结构中:
读入下一个数据24,比81小,不加入堆结构:
读入下一个数据8,比86小,不加入堆结构。此时p1已经完成了,把那些刚才暂放一边的数重新构成一个堆,继续p2的存放:
以此类推…最后生成的p2如下:
这样子的话,最后只生成了2个有序子串,我们把这种方法称之为置换选择。按照这种方法,最好的情况下,所有数据只生成一个有序子串;最坏的情况下,和原来没采取置换选择算法一样,还是4个子串,那平均性能如何呢?
结论:如果内存可以容纳n个元素的话,那么平均每个子串的长度为2m,也就是说,使用置换选择算法我们可以减少一半的子串数。
5.2 败者树实现
在上述创建初始段文件的过程中,需要不断地在内存工作区中选择新的 MINIMAX 记录,即选择不小于旧的 MINIMAX 记录的最小值,此过程需要利用“败者树”来实现。
同上一节所用到的败者树不同的是,在不断选择新的 MINIMAX 记录时,为了防止新加入的关键字值小的的影响,每个叶子结点附加一个序号位,当进行关键字的比较时,先比较序号,序号小的为胜者;序号相同的关键字值小的为胜者。
在初期创建败者树时也可以通过不断调整败者树的方式,其中所有记录的序号均设为 0 ,然后从初始文件中逐个输入记录到内存工作区中,自下而上调整败者树。过程如下:
- 首先创建一个空的败者树,如下图所示:
提示:败者树根结点上方的方框内表示的为最终的胜者所处的位置。
- 从初始文件中读入关键字为 51 的记录,自下往上调整败者树,如下图所示:
提示:序号 1 为败者。 先比较序号,序号小的为胜者;序号相同的关键字值小的为胜者。
- 从初始文件中读入关键字为 49 的记录,调整败者树如下图所示:
- 从初始文件依次读入关键字为 39、46、38、29 的记录,调整败者树如下图所示:
- 首先创建一个空的败者树,如下图所示:
由败者树得知,其最终胜者为 29,设为 MINIMAX 值,将其输出到初始归并文件中,同时再读入下一个记录 14,调整败者树,如下图所示:
注意:当读入新的记录时,如果其值比 MINIMAX 大,其序号则仍为 1;反之则为 2 ,比较时先比较序号,序号小的为胜者;序号相同的关键字值小的为胜者。。
通过不断地向败者树中读入记录,会产生多个 MINIMAX,直到最终所有叶子结点中的序号都为 2,此时产生的新的 MINIMAX 值的序号 2,表明此归并段生成完成,而此新的 MINIMAX 值就是下一个归并段中的第一个记录。
通过置换选择排序算法得到的初始归并段,其长度并不会受内存容量的限制,且通过证明得知使用该方法所获得的归并段的平均长度为内存工作区大小的两倍。
通过对初始文件进行置换选择排序能够获得多个长度不等的初始归并段
证明此结论的方法是 E.F.Moore(人名)在 1961 年从置换—选择排序和扫雪机的类比中得出的,有兴趣的可以自己了解一下。
若不计输入输出的时间,通过置换选择排序生成初始归并段的所需时间为O(nlogw)
(其中 n 为记录数,w 为内存工作区的大小)。
六、最佳归并树详解
6.1 哈夫曼树
通过上一节对置换-选择排序算法的学习了解到,通过对初始文件进行置换选择排序能够获得多个长度不等的初始归并段,相比于按照内存容量大小对初始文件进行等分,大大减少了初始归并段的数量,从而提高了外部排序的整体效率。
本节带领大家思考一个问题:无论是通过等分还是置换-选择排序得到的归并段,如何设置它们的归并顺序,可以使得对外存的访问次数降到最低?
例如,现有通过置换选择排序算法所得到的 9 个初始归并段,其长度分别为:9,30,12,18,3,17,2,6,24
。在对其采用 3-路平衡归并的方式时可能出现如图 1 所示的情况:
图 1 3-路平衡归并
提示:图 1 中的叶子结点表示初始归并段,各自包含记录的长度用结点的权重来表示;非终端结点表示归并后的临时文件。
假设在进行平衡归并时,操作每个记录都需要单独进行一次对外存的读写,那么图 1 中的归并过程需要对外存进行读或者写的次数为:(9+30+12+18+3+17+2+6+24)*2*2=484(图 1 中涉及到了两次归并,对外存的读和写各进行 2 次)从计算结果上看,对于图 1 中的 3 叉树来讲,其操作外存的次数恰好是树的带权路径长度的 2 倍。所以,对于如何减少访问外存的次数的问题,就等同于考虑如何使 k-路归并所构成的 k 叉树的带权路径长度最短。若想使树的带权路径长度最短,就是构造赫夫曼树。
在学习赫夫曼树时,只是涉及到了带权路径长度最短的二叉树为赫夫曼树,其实扩展到一般情况,对于 k 叉树,只要其带权路径长度最短,亦可以称为赫夫曼树。
若对上述 9 个初始归并段构造一棵赫夫曼树作为归并树,如图 2 所示:
依照图 2 所示,其对外存的读写次数为:(2*3+3*3+6*3+9*2+12*2+17*2+18*2+24*2+30)*2=446
通过以构建赫夫曼树的方式构建归并树,使其对读写外存的次数降至最低(k-路平衡归并,需要选取合适的 k 值,构建赫夫曼树作为归并树)。所以称此归并树为最佳归并树。
6.2 附加“虚段”的归并树
上述图 2 中所构建的为一颗真正的 3叉树(树中各结点的度不是 3 就是 0),而若 9 个初始归并段改为 8 个,在做 3-路平衡归并的时候就需要有一个结点的度为 2。
对于具体设置哪个结点的度为 2,为了使总的带权路径长度最短,正确的选择方法是:附加一个权值为 0 的结点(称为“虚段”),然后再构建赫夫曼树。例如图 2 中若去掉权值为 30 的结点,其附加虚段的最佳归并树如图 3 所示:
注意:虚段的设置只是为了方便构建赫夫曼树,在构建完成后虚段自动去掉即可。
对于如何判断是否需要增加虚段,以及增加多少虚段的问题,有以下结论直接套用即可:在一般情况下,对于 k–路平衡归并来说,若 (m-1) MOD (k-1) = 0,则不需要增加虚段;否则需附加 k - (m-1)MOD(k-1) - 1 个虚段。
七、代码
八、总结
1. 实现外部排序的两个过程
- 将整个初始文件分为多个初始归并段;
- 将初始归并段进行归并,直至得到一个有序的完整文件;
2. 时间组成
- 内部排序所需要的时间
- 外存信息读写所需要的时间 (关键)
- 与归并的趟数有关
- k要大 —– 传统方法 会引起内部归并时间增大
- 赢者树
- 败者树
- m要小 —– 置换选择排序
- k要大 —– 传统方法 会引起内部归并时间增大
- Huffman(归并的顺序)
- 与归并的趟数有关
- 内部归并所需要的时间
3.外部排序优化
为了提高整个外部排序的效率,分别从以上两个方面对外部排序进行了优化:
- 在实现将初始文件分为 m 个初始归并段时,为了尽量减小 m 的值,采用置换-选择排序算法(内部使用败者树或者堆排序实现),可实现将整个初始文件分为数量较少的长度不等的初始归并段。
- 同时在将初始归并段归并为有序完整文件的过程中,为了尽量减少读写外存的次数,采用构建最佳归并树的方式(哈夫曼树实现),对初始归并段进行归并(败者树实现),而归并的具体实现方法是采用败者树的方式。
4. 优化递进顺序
- 二路归并【因为硬盘的读写速度比内存要慢的多,按照以上这种方法,每个数据都从硬盘读了三次,写了三次,要花很多时间。考虑K路】
- 多路归并【K不是越大越好,因为K越大,在内部排序需要的时间越长,效率低。考虑减少初始顺串的数量M】
- 置换选择算法【可以用败者树和堆排序实现,得到多个长度不等的初始归并段,如何设置它们的归并顺序,可以使得对外存的访问次数降到最低? 考虑结合哈夫曼树】
- 最佳归并树(置换选择算法+哈夫曼树+多路归并+败者树)
5 胜者树 & 败者树 & 堆排序
发展历史
堆:其实一开始就是只有堆来完成多路归并的,但是人们发现堆每次取出最小值之后,把最后一个数放到堆顶,调整堆的时候,每次都要选出父节点的两个孩子节点的最小值,然后再用孩子节点的最小值和父节点进行比较,所以每调整一层需要比较两次。
胜者树:这时人们想能否简化比较过程,这时就有了胜者树,这样每次比较只用跟自己的兄弟节点进行比较就好,所以用胜者树可以比堆少一半的比较次数。 而胜者树在节点上升的时候首选需要获得父节点,然后再获得兄弟节点,然后再比较。
败者树:这时人们又想能否再次减少比较次数,于是就有了败者树。在使用败者树的时候,每个新元素上升时,只需要获得父节点并比较即可。
所以总的来说,减少了访存的时间。 现在程序的主要瓶颈在于访存了,计算倒几乎可以忽略不计了。
相同点
首先它们三个的相同点就是在于:空间和时间复杂度都是一样的O(N*logN)。调整一次的时间复杂度都是O(logN)的。
所以这道题用堆来做,跟用败者树来做并没有本质上的算法复杂度量级上的差别。
不同点
堆:所有的节点都是关键字; 每次调整一层需要比较两次(父亲 左孩子| 父亲 右孩子)。
胜者树:叶子节点是关键字,非叶子节点保存胜者索引;每次调整一层需要比较1次(自己 兄弟),读取两次(父亲| 兄弟)。
败者树:叶子节点是关键字,非叶子节点保存败者索引;每次调整一层需要比较1次(自己 父亲),读取一次(父亲),只需要和路径上的节点比较,不需要和兄弟节点比较,简化了重构的过程。; 新增B[0]记录比赛的胜者【在本例子中是ls[0]】
6. 涉及到的算法
- 多路归并算法
- 败者树选择算法
- 置换选择算法
- 哈夫曼树
- 内部排序算法(堆排序)