主要分为两类:比较排序和运算排序
排序(一)
思想:首先,找到数组中最小的那个元素。其次,将它和数组的第一个元素交换位置。再次,在剩下的元素中找到最小的元素,将它与数组的第二个元素交换位置。如此往复,直到将整个数组排序。
【图例】
图中,x轴方向为数组的索引,y轴方向为待排序元素的值。
选择排序有两个很鲜明的特点:
运行时间和输入无关。为了找出最小的元素而扫描一遍数组并不能为下一遍扫描提供什么信息。这种性质在某些情况下是缺点。(无论数组的初始状态是什么,此算法效率都一样低效)
数据移动是最少的。每次交换都会改变两个数组元素的值,因此选择排序用了N次交换——交换次数和数组的大小是线性关系。(我们将研究的其他任何算法都不具备这个特征)
【对于长度为N的数组,选择排序需要大约N2/2次比较和N次交换】
思想:它重复地走访过要排序的数列,一次比较两个元素,如果他们的顺序错误就把他们交换过来。走访数列的工作是重复地进行直到没有再需要交换,也就是说该数列已经排序完成。这个算法的名字由来是因为越小的元素会经由交换慢慢“浮”到数列的顶端。(较大的元素也会慢慢沉到底部。)
冒泡排序算法的运作如下:
1、比较相邻的元素。如果第一个比第二个大,就交换他们两个。
2、对每一对相邻元素作同样的工作,从开始第一对到结尾的最后一对。在这一点,最后的元素应该会是最大的数。
3、针对所有的元素重复以上的步骤,除了最后一个。
4、持续每次对越来越少的元素重复上面的步骤,直到没有任何一对数字需要比较。
【图例】
图中,x轴方向为数组的索引,y轴方向为待排序元素的值。
由图中可看出,冒泡排序是从后到前,逐步有序的,最大的元素先沉到底部,接着是次大的……
特点:
冒泡排序是与插入排序拥有相等的执行时间,但是两种法在需要的交换次数却很大地不同。在最坏的情况,冒泡排序需要O(n2)次交换,而插入排序只要最多O(n)交换。冒泡排序的实现(类似下面)通常会对已经排序好的数列拙劣地执行O(n2),而插入排序在这个例子只需要O(n)个运算。
因此很多实现中避免使用冒泡排序,而用插入排序取代之。冒泡排序如果能在内部循环第一次执行时,使用一个旗标来表示有无需要交换的可能,也有可能把最好的复杂度降低到O(n)。在这个情况,在已经排序好的数列就无交换的需要。(例如上面代码)
思想:将第i个元素与其左边的已经有序的元素一一比较,找到合适的位置,插入其中。为了给要插入的元素腾出空间,我们需要将其余所有元素在插入之前都向右移动一位。
具体算法描述如下:
1、从第一个元素开始,该元素可以认为已经被排序
2、取出下一个元素,在已经排序的元素序列中从后向前扫描
3、如果该元素(已排序)大于新元素,将该元素移到下一位置
4、重复步骤3,直到找到已排序的元素小于或者等于新元素的位置
5、将新元素插入到该位置后
6、重复步骤2~5
与选择排序一样,当前索引左边的所有元素都是有序的。但它们的最终位置还不确定,为了给更小的元素腾出空间,它们可能会被移动。但是当索引到达数组的右端时,数组排序就完成了。插入排序不会访问索引右侧的元素,而选择排序不会访问索引左侧的元素。
和选择排序不同的是,插入排序所需的时间取决于输入中元素的初始顺序。对一个其中的元素已经有序(或接近有序)的数组进行排序,将会比对随机顺序的数组或是逆序数组进行排序要快得多。
【图例】
使用插入排序为一列数字进行排序的过程。
从前到后逐步有序。
//在索引i由左向右变化的过程中,它左侧的元素总是有序的,所以当i到达数组的右端时排序就完成了。
改进:要大幅提高插入排序的速度并不难,只需要在内循环中将较大的元素都向右移动而不总是交换两个元素(这样访问数组的次数就能减半)。
【平均情况下插入排序需要~N2/4次比较以及~N2/4次交换。】
【当倒置(两元素颠倒)的数量很少时,插入排序很可能比其他任何排序算法都要快!】
【插入排序对于部分有序的数组十分高效,也很适合小规模数组。它也是高级排序算法的中间过程。】
【插曲】
Knuth高爷爷说:“尽管第一个二分查找程序于1946年就已经公布了,但是第一个没有bug的二分查找程序在1962年才出现。”
显然,我们上面的二分查找代码是有bug的。 (今年的阿里巴巴实习生笔试 就出了一个二分查找的改错题~)
至于二分查找,以后会再起一篇博文详细讨论。
希尔排序,也称递减增量排序算法,是插入排序的一种更高效的改进版本。
希尔排序是基于插入排序的以下两点性质而提出改进方法的:
1、插入排序在对几乎已经排好序的数据操作时,效率高, 即可以达到线性排序的效率
2、对于大规模乱序数组插入排序很慢,因为它只会交换相邻的元素,因此元素只能一点一点地从数组的一端移动到另一端。
希尔排序简单地改进了插入排序,交换不相邻的元素以对数组的局部进行排序,并最终用插入排序将局部有序的数组排序。(先将整个大数组基本有序,再对大数组来一次插入排序)
思想:使数组中任意间隔为h的元素都是有序的。这样的数组被称为h有序数组。在进行排序时,如果h很大,我们就能将元素移动到很远的地方,为实现更小的h有序创造方便。
我们只需要在插入排序的代码中将移动元素的距离改为h即可。这样,希尔排序的实现就转化为了一个类似于插入排序但使用不同增量的过程。
【图例】
希尔排序更高效的原因是它权衡了子数组的规模和有序性。排序之初,各个子数组都很短,排序之后子数组都是部分有序的,这两种情况都很适合插入排序。
希尔排序的算法性能不仅取决于h,还取决于h之间的数学性质。在实际应用中,使用3*h+1的递增序列基本就足够了。
【在最坏情况下希尔排序的比较次数和N1.5成正比】
希尔排序的代码量很小,且不需要使用额外的内存空间。如果你需要解决一个排序问题而又没有系统排序函数可用(例如运行于嵌入式中的代码),可以先用希尔排序,然后再考虑是否值得将它替换为更加复杂的排序算法。
思想:要将一个数组排序,可以先(递归地)将它分成两半分别排序,然后将结果归并起来。
【图例】
一个归并排序的例子:对一个随机点的链表进行排序。
改进:用不同的方法处理小规模问题能改进大多数递归算法的性能,因为递归会使小规模问题中方法的调用过于频繁,所以改进对它们的处理方法就能改进整个算法。(即增大递归的粒度,使递归在达到小范围时停止,而不是到一个元素时停止递归)
对排序来说,插入排序非常简单,因此很可能在小数组上比归并排序更快。使用插入排序处理小规模的子数组一般可以将归并排序的运行时间缩短10%~15%。
自底向上的归并排序
递归实现的归并排序是算法设计中分治思想的典型应用。我们可以把递归方式写成迭代的——先归并那些微型数组,然后再成对归并得到的子数组。
首先我们进行的是两两归并,然后是四四归并,然后是八八归并,一直下去。自底向上的归并排序比较适合用链表组织的数据。这种方法只需要重新组织链表链接就能将链表原地排序(不需要创建任何新的链表结点)
【归并排序是一种渐进最优的基于比较排序的算法】
(即:归并排序在最坏情况下的比较次数和任意基于比较的排序算法所需的最少比较次数都是~NlgN)
归并排序的缺点:它所需的额外空间和N成正比。
思想:快速排序是一种分治的排序算法。它将一个数组分成两个子数组,将两部分独立地排序。
一般策略是先随意地取a[lo]作为切分元素,即那个将会被排定的元素,然后我们从数组的左端开始向右端扫描直到找到一个大于等于它的元素,再从数组的右端开始向左扫描直到找到一个小于等于它的元素。
这两个元素显然是没有排定的,因此我们交换它们的位置。如此继续,我们就可以保证左指针i的左侧元素都不大于切分元素,右指针j的右侧元素都不小于切分元素。当两个指针相遇时,我们只需要将切分元素a[lo]和左子数组最右侧的元素(a[j])交换然后返回j即可。
快速排序的特点是原地排序(只需要一个很小的辅助栈),且将长度为N的数组排序所需的时间和NlgN成正比。
缺点:在切分不平衡时这个程序可能会极为低效。
改进:切换到插入排序
和大多数数组递归排序算法一样,改进快速排序性能的一个简单办法基于以下两点:
对于小数组,快速排序比插入排序慢。
因为递归,快速排序的sort()方法在小数组中也会调用自己。
因此,在排序小数组时应该切换到插入排序。
将 sort()中的语句 if (hi <= lo) return ;
替换成: if (hi <= lo + M) { Insertion.sort(a, lo, hi); return; }
●三向切分的快排
在实际应用中经常会出现含有大量重复元素的数组。
在有大量重复元素的情况下,快速排序的递归性会使元素全部重复的子数组经常出现,这就有很大的改进潜力,将当前实现的线性对数级的性能提高到线性级别。
一个简单的想法是将数组切分为三部分,分别对应小于、等于和大于切分元素的数组元素。
这段排序代码的切分能够将和切分元素相等的元素归位,这样它们就不会被包含在递归调用处理的子数组之中。对于存在大量重复元素的数组,这种方法比标准的快速排序的效率高的多。
许多应用程序都需要处理有序的元素,但不一定要求它们全部有序,或是不一定要一次将它们排序。很多情况下我们会收集一些元素,处理当前键值最大的元素,然后再收集更多的元素,再处理当前键值最大的元素,如此这般。
在这种情况下,一个合适的数据结构应该支持两种操作:删除最大元素和插入元素。这种数据类型叫做优先队列。
数据结构二叉堆能够很好地实现优先队列的基本操作。
当一颗二叉树的每个结点都大于等于它的两个子结点时,它被称为堆有序。(大顶堆)
我们使用完全二叉树来表达二叉堆,会变得特别方便。完全二叉树只用数组而不需要指针就可以表示。具体方法就是将二叉树的结点按照层级顺序放入数组中,根结点在位置1,它的子结点在位置2和3,而子结点的子结点则分别在位置4、5、6和7,以此类推。
堆的算法
我们用长度为N+1的私有数组pq[]来表示一个大小为N的堆,我们不会使用pq[0],堆元素放在pq[1]至pq[N]中。
在有序化的过程中我们会遇到两种情况:
当某个结点的优先级上升(或是在堆底加入一个新的元素)时,我们需要由下至上恢复堆的顺序。
当某个结点的优先级下降(例如,将根结点替换为一个较小的元素)时,我们需要由上至下恢复堆的顺序。
·由下至上的堆有序化(上浮)
如果堆的有序状态因为某个结点变得比它的父结点更大而被打破,那么我们就需要通过交换它和它的父结点来修复堆。
交换后,这个结点比它的两个子结点都大,但这个结点仍然可能比它现在的父结点更大。我们可以一遍遍地用同样的办法恢复秩序,将这个结点不断向上移动直到我们遇到了一个更大的父结点。(溯流而上)
·由上至下的堆有序化(下沉)
如果堆得有序状态因为某个结点变得比它的某子结点更小而被打破了,那么我们可以通过将它和它的两个子结点中的较大者交换来恢复堆。
交换可能会在子结点处继续打破堆的有序状态,因此我们需要不断地用相同的方式将其修复,将结点向下移动直到它的子结点都比它更小或是到达了堆的底部。(顺流而下)
堆排序
我们可以把任意优先队列变成一种排序方法。将所有元素插入一个查找最小元素的优先队列,然后再重复调用删除最小元素的操作来将它们按顺序删去。
1.堆的构造:
由N个给定的元素构造一个堆,从右至左用sink()下沉函数构造子堆。开始时我们只需要扫描数组中的一半元素,因为我们可以跳过大小为1的子堆,由此向前对每个结点sink(),直到我们在位置1上调用sink()方法,扫描结束。
(用下沉操作由N个元素构造堆只需要少于2N次比较以及少于N次交换)
(如果我们从左至右用swim()上浮操作遍历数组,则需要用NlogN成正比的时间完成)
for (int k = N/2; k>= 1; k--)
sink(a, k, N) ;
2.下沉排序:
堆排序的主要工作都是在第二阶段完成的。这里我们将堆中的最大元素删除,然后放入堆缩小后数组中空出的位置。
这个过程和选择排序有些类似(一步一步选出最值),但所需的比较要少的多,因为堆提供了一种从未排序部分找到最大元素的有效方法。
while (N > 1)
{
exch(a, 1, N--) ; //把堆尾结点与堆顶最大元素交换
sink(a, 1, N) ; //对改变后的堆顶结点下沉操作
}
堆排序总代码:(仅使用下沉操作)
特点:堆排序是我们所知的唯一能够同时最优地利用空间和时间的方法——在最坏的情况下它也能保证使用~2NlgN次比较和恒定的额外空间。
当空间十分紧张的时候(例如在嵌入式系统)它很流行,因为它只用几行就能实现较好的性能。但现代系统的许多应用很少使用它,因为它无法利用缓存。其数组元素很少和相邻的其他元素进行比较,因此其缓存未命中的次数要远远高于大多数比较都在相邻元素间进行的算法,如快速排序、归并排序,甚至是希尔排序。
应用:
TopM问题:
在某些数据处理的例子里,总数据量太大,无法排序(甚至无法全部装进内存)。如果你需要从10亿个元素中选出最大的十个,你真的想把一个10亿规模的数组排序吗?但有了优先队列,你就只用一个能存储十个元素的队列即可。
【例】
100w个数中找出最大的100个数。
答:
先把这100W个数分别放在100个文件中(每个文件存放1W个数)。
再用优先队列:在每个文件中求出TOP100,可以采用包含100个元素的堆完成(TOP100小,用最大堆,TOP100大,用最小堆,比如求TOP100大,我们首先取前100个元素调整成最小堆,如果发现,然后扫描后面的数据,并与堆顶元素比较,如果比堆顶元素大,那么用该元素替换堆顶,然后再调整为最小堆。最后堆中的元素就是TOP100大)。
求出每个文件中的TOP100后,然后把这100个文件的TOP100组合起来,共1W个数据,再利用上面类似的方法求出TOP100就可以了。
[注]大家都说那动态图NB,那动态图是从维基百科Copy下来的 :-)
其他部分的图文皆为自己整合总结而来。
Oh,My God! 竟然有人转了此文,而我反倒被举报抄袭,楼主世界观尽毁。
http://www.2cto.com/kf/201405/303623.html 红黑联盟真无耻。 (大家仔细看图片中的水印即知,版主勿要轻信于人)
初入CSDN的菜鸟需要大家的爱护~
====================
(PS:这篇文章很一般,真不知道有什么好的,可能是它发的时间点对了)
其他有几篇本博主花费了很多精力总结的文章反而甚少有人关注: 打个广告 希望有兴趣的网友能临幸它们:-) 指出其中的不足。
http://blog.csdn.net/yang_yulei/article/details/26066409 (这个讲红黑树的,绝对浅显易懂,自己画的很多图例,是读《算法》一书,总结整理而来)
http://blog.csdn.net/yang_yulei/article/details/8086934 (关于C语言的一些隐晦的 易忽略的地方, 若是此文中的题目对你都毫无压力,那你对C是有足够的理解了)
http://blog.csdn.net/yang_yulei/article/details/22529437 (此文是对处理器体系结构原理的一个简单介绍, 作为程序员多了解一些底层硬件的原理,呃,其实用处不大,纯属好奇)
http://blog.csdn.net/yang_yulei/article/details/24142743 (操作系统内存管理的, 其是之后两篇文章的总领,那三篇文章,把OS内存管理基本上讲的比较细致了, 但其中有一些个人的理解,比如Linux 0.12和Linux 2.x 的内存管理确实是那样的差异么? 希望有网友指正。)
排序(二)--运算排序
以上排序算法都有一个性质:在排序的最终结果中,各元素的次序依赖于它们之间的比较。我们把这类排序算法称为比较排序。
任何比较排序的时间复杂度的下界是nlgn。
以下排序算法是用运算而不是比较来确定排序顺序的。因此下界nlgn对它们是不适用的。
计数排序假设n个输入元素中的每一个都是在0到k区间的一个整数,其中k为某个整数。
思想:对每一个输入元素x,确定小于x的元素个数。利用这一信息,就可以直接把x放到它在输出数组中的位置了。
例如:
学生被分为若干组,标号为1,、2、3、4等,在某些情况下我们希望将全班同学按组序号排序分类。
1.频率统计:
第一步就是使用int数组cout[]计算每个键出现的频率。
对于数组中的每个元素,都使用它的键访问count[]中的相应元素并将其加1。(即把键值作为cout[]的索引)如果键值为r,则将count[r+1]加1.(为什么需要加1?稍后解释)
for (i=0; i
count[a[i].key()+1]++ ;
count[0~5]:0 0 3 5 6 6
2.将频率转换为索引:
接下来,我们会使用count[]来计算每个键在排序结果中的起始索引位置。在这个示例中,因为第一组中有3个人,第二组中有5个人,因此第三组中的同学在排序结果数组中的起始位置为8。
对于每个键值r,小于r+1的键的频率之和为小于r的键的频率之和加上count[r],因此从左向右将count[]转化为一张用于排序的索引表是很容易的。
for (int r=0; r
count[r+1] += count[r] ;
count[0~5]:0 0 3 8 14 20
3. 数据分类:
在将count[]数组转换为一张索引表之后,将所有元素(学生)移动到一个辅助数组aux[]中以进行排序。每个元素在aux[]中的位置是由它的键(组别)对应的count[]值决定的,在移动之后将count[]中对应元素的值加1,以保证count[r]总是下一个键为r的元素在aux[]中的索引位置。这个过程只需遍历一遍数据即可产生排序结果。
(这种实现方式的稳定性是很关键的——键相同的元素在排序后会被聚集到一起,但相对顺序没有变化。)
for (int i=0; i
aux[count[a[i].key()]++] = a[i] ;
4. 回写:
因此我们在将元素移动到辅助数组的过程中完成了排序,所以最后一步就是将排序的结果复制回原数组中。
for (int i=0; i
a[i] = aux[i] ;
特点:键索引计数法是一种对于小整数键排序非常有效却常常被忽略的排序方法。
键索引计数法不需要比较,只要当范围R在N的一个常数因子范围之内,它都是一个线性时间级别的排序方法。
有时候,我们需要对长度都相同的字符串进行排序。这种情况在排序应用中很常见——比如电话号码、银行账号、IP地址等都是典型的定长字符串。
将此类字符串排序可以通过低位优先的字符串排序来完成。如果字符串的长度均为W,那就从右向左以每个位置的字符作为键,用键索引计数法(或插入排序)将字符串排序W遍。
(为了确保基数排序的正确性,一位数排序算法必须是稳定的。例如:计数排序、插入排序)
特点:基数排序是否比基于比较的排序算法(如快速排序)更好呢?
基数排序的时间复杂度为线性级(n),这一结果看上去要比快速排序的期望运行时间代价(nlgn)更好一些。但是,在这两个表达式中隐含在背后的常数项因子是不同的。
在处理的n个关键字时,尽管基数排序执行的循环轮数会比快速排序要少,但每一轮它所耗费的时间要长得多。且快速排序通常可以比基数排序更有效地使用硬件的缓存。
此外,利用计数排序作为中间稳定排序的基数排序不是原址排序,而很多nlgn时间的比较排序是原址排序。因此,当主存的容量比较宝贵时,我们可能会更倾向于像快速排序这样的原址排序。
桶排序(bucket sort)假设输入数据服从均匀分布,其独立分布在[0,M)区间上。平均情况下它的时间代价为O(n)。
思想:桶排序将[0,M)区间划分为n个相同大小的子区间,或称为桶。
然后,将n个输入数分别放到各个桶中。因为输入数据时均匀、独立地分布在[0,M)区间上,所以一般不会出现很多数落在同一个桶中的情况。为了得到输出结果,我们先对每个桶中的数进行排序,然后遍历每个桶,按照次序把各个桶中的元素列出来即可。
(桶平排序算法还需要一个临时数组B[0..n-1]来存放链表(即桶),并假设存在一种用于维护这些链表的机制)
(有点像哈希表的拉链法的处理方式。)
思想:用比特位的相对位置(索引)来表示一个数值。
即就像用数组的下标来表示一个数值那样,只不过为了节省内存我们用一个bit的位置来标记一个数。
例如:我们可以将集合{1, 2, 3, 5,8, 13}存储在下面这个字符串中:0 1 1 1 0 10 0 1 0 0 0 0 1 0 0 0 0 0 0 集合中代表数字的各个位设置为1,而其他的位全部都设为0。
特点:位示图法适用的问题是(该情况在排序问题中不太常见):
输入的范围相对要小些,并且还不包含重复数据,且没有数据与记录相关联。
【应用举例】
考虑这样一个问题:给一个磁盘文件排序。(具体描述如下)
输入:
所输入的是一个文件,至多包含n个不重复的正整数,每个正整数都要小于n,这里n=10^7. 这些整数没有与之对应的记录相关联。(即仅对这些整数排序)
输出:
以增序形式输出经过排序的整数列表。
约束:
至多只有1MB的可用主存,但是可用磁盘空间非常充足。10秒钟是最适宜的运行时间。
看到磁盘文件排序,我们首先想到经典的多路归并排序。(后面会讲到)
一个整数为32位,我们可以在1MB空间中存储250000个数。因此,我们将使用一个在输入文件中带有40个通道的程序。在第一个通道中它将249999之间的任意整数读到内存中,并(至多)对250000个整数进行排序,然后将它们写到输出文件中。第二个通道对250000到499999之间的整数进行排序,依此类推,直到第40个通道,它将排序9750000到9999999之间的整数。在内存中,我们用快速排序,然后把排序的有序序列进行归并,最终得到整体有序。
但是,此方式的效率较低,光是读取输入文件就需要40次,还有外部归并的IO开销。
怎样降低IO操作的次数,来提高程序的效率?一次把这一千万个数字全部读入内存?
用位图的方式,我们将使用一个具有一千万个bit位来表示该文件,在该bit位串中,当且仅当整数i在该文件中时,第i位才打开(设为1)。
给定了表示文件中整数集合的位图数据结构后,我们可以将编写该程序的过程分为三个自然阶段,第一个阶段关闭所有的位,将集合初始化为空集。第二个阶段读取文件中的每个整数,并打开相应的位,建立该集合。第三个阶段检查每个位,如果某个位是1,就写出相应的整数,从而创建已排序的输出文件。
内部排序方法总结
稳定性
如果一个排序算法能够保留数组中重复元素的相对位置则可以被称为是稳定的。
这个性质在许多情况下很重要。
(例如:
考虑一个需要处理大量含有地理位置和时间戳的事件的互联网商业程序。
首先,我们在事件发生时将它们挨个存储在一个数组中,这样在数组中它们已经是按时间排序好了的。现在再按照地理位置切分,如果排序算法不是稳定的,排序后的每个城市的交易可能不会再是按照时间顺序排序的了。
)
算法 是否稳定
选择排序 否
插入排序 是
希尔排序 否
快速排序 否
三向快速排序 否
归并排序 是
堆排序 否
键索引计数 是
基数排序 是
快速排序是最快的通用排序算法。
快速排序之所以快是因为它的内循环中的指令很少(而且它还能利用缓存,因为它总是顺序地访问数据),所以它的运行时间的增长数量级为~cNlgN,而这里的c比其他线性对数级别的排序算法的相应常数都要小。
且,在使用三向切分之后,快速排序对于实际应用中可能出现的某些分布的输入变成线性级别的了,而其他的排序算法仍然需要线性对数时间。
如果稳定性很重要而空间又不是问题,归并排序可能是最好的。
----------------------------------------------------------------------外部排序---------------------------------------------------------------------
(我们为什么要进行外部排序?为什么不在插入数据时就按照某种数据结构组织,方便查找且有序。这就像静态查找树那样,没什么实用功能)
外部排序基本上由两个相对独立的阶段组成。
首先,按可用内存大小,将外存上含有n个记录的文件分成若干长度为l的子文件,依次读入内存并利用有效的内部排序方法对它们进行排序,并将排序后得到的有序子文件重新写入外存,通常称这些有序子文件为归并段。
然后,对这些归并段进行逐趟归并,使归并段逐渐由小至大,直到得到整个有序文件为止。
【例】假设有一个含有10000个记录的文件,首先通过10次内部排序得到10个初始归并段R1~R10,其中每一段都含有1000个记录。然后对它们作两两归并,直至得到一个有序文件为止。
每一趟归并从m个归并段得到m/2个归并段。这种归并方法称为2-路平衡归并。
若对上例中所得的10个初始归并段进行5-路平衡归并,则从下图可见,仅需进行二趟归并,外排时总的IO读/写次数显著减少。
一般情况下,对m个初始归并段进行k-路平衡归并时,归并的趟数s = logkm
可见,若增加k或减少m便能减少s。
一般的归并merge,每得到归并后的有序段中的一个记录,都要进行k-1次比较。显然,为得到含u个记录的归并段需进行(u-1)(k-1)次比较。
内部归并过程中总的比较次数为:
logkm (k-1) (u-1)tmg =( log2m/ log2k)(k-1) (u-1)tmg
所以,要单纯地增加k将导致内部归并的时间,这将抵消由于增大k而减少外存信息读写时间所得效益。
然而,若在进行k-路归并时利用败者树(Tree of Loser),则可使在k个记录中选出关键字最小的记录时仅需进行log2k次比较。则总的归并时间变为log2m (u-1)tmg此式与k无关,它不再随k的增长而增长。
它是树形选择排序的一种变型。每个非终端结点均表示其左、右孩子结点中的“败者”。而让胜者去参加更高一层的比赛,便可得到一颗“败者树”(所谓“胜者”就是你想选出来的元素)。
以一颗实现5-路(k=5)归并的败者树为例:
数组ls[0…k-1]表示败者树中的非终端结点。败者树中根结点ls[1]的双亲结点ls[0]为“冠军”,其他结点记录的是其左右子树中的“败者”的索引值。b[0…k-1]是待比较的各路归并序列的首元素。
ls[]中除首元素外,其他元素表示为完全二叉树。
那表示叶子结点的b[]该如何与之对应?
叶结点b[x]的父结点是ls[(x+k)/2]。
败者树的建立:
1、 初始化败者树:把ls[0..k-1]中全设置为MINKEY(可能的最小值,即“绝对的胜者”)
//我们设一个b[k]= MINKEY,ls[]中记录的是b数组中的索引值。故初始为5.
2、从各叶子结点溯流而上,调整败者树中的值。
拿胜者s(初始为叶结点值)与其父结点中值比较,谁败(较大的)谁上位(留着父结点中),胜者被记录在s中。(决出胜者,记录败者,胜者向上走)
//对于叶结点b[4],调整的结果如下:
//对于叶结点b[3],调整的结果如下
//同理,对于叶结点b[2],调整的结果如下
//同理,对于叶结点b[1],调整的结果如下
//同理,对于叶结点b[0],调整的结果如下
【后记】
从n个数中选出最小的,我们为什么要用败者树?
首先,我们想到用优先队列,但其应对这种多路归并的情况,效率并不高。
堆结构:其待处理的元素都在树结点中(在叶节点和非叶子节点中)
败者树:其待处理的元素都在树的叶子结点上,其非叶子结点上记录上次其子结点比较的结果。
这样的话,堆结构的某个叶子结点不是对应固定的某个待归并序列。一次选出最值之后,还得取出各归并序列的首元素,重建堆再调整,不能利用之前比较的结果。
而败者树,一个叶结点固定地对应一个归并序列,这样,若其序列的首元素被选出,则序列的下个元素可以直接增补进入结点,然后沿树的路径向上比较。
总结:堆结构适用于插入是无规则的,选出最值。
败者树适用于多路序列的插入,选出最值。