排序(二)键索引、桶排序、位示图、败者树等(图文详解--败者树)

本文深入探讨了排序算法的不同类型,包括比较排序与非比较排序。详细介绍了键索引计数法、基数排序、桶排序等非比较排序算法的工作原理及其应用场景,并对比了它们与快速排序等比较排序算法的优缺点。

排序(二)



以上排序算法都有一个性质:在排序的最终结果中,各元素的次序依赖于它们之间的比较。我们把这类排序算法称为比较排序

任何比较排序的时间复杂度的下界是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<N; 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<R; 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<N; i++)

   aux[count[a[i].key()]++] = a[i] ;

 

4. 回写:


因此我们在将元素移动到辅助数组的过程中完成了排序,所以最后一步就是将排序的结果复制回原数组中。

for (int i=0; i<N; 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],调整的结果如下



void CreateLoserTree(LoserTree &ls)
{
    b[k].key = MINKEY ;

    //设置ls中“败者”的初值
    for (i=0; i<k; ++i)
        ls[i] = k ;
        
    //依次从b[k-1]...b[0]出发调整败者
    for (i=k-1; i>=0; --i)
        Adjust(ls, i) ;
}

void Adjust(LoserTree &ls, int m)
{
    //沿从叶节点b[m]到根结点ls[0]的路径 调整败者树
    for (i = (m + k)/2; i>0; i=i/2)  //ls[i]是b[m]的双亲结点
    {
        if (b[m].key > b[ls[i]].key) 
            exch(m, ls[i]) ;         //m保存新的胜者的索引
    }
    ls[0] = m ;
}

【后记】

从n个数中选出最小的,我们为什么要用败者树?

首先,我们想到用优先队列,但其应对这种多路归并的情况,效率并不高。

 

堆结构:其待处理的元素都在树结点中(在叶节点和非叶子节点中)

败者树:其待处理的元素都在树的叶子结点上,其非叶子结点上记录上次其子结点比较的结果。

这样的话,堆结构的某个叶子结点不是对应固定的某个待归并序列。一次选出最值之后,还得取出各归并序列的首元素,重建堆再调整,不能利用之前比较的结果。

 

而败者树,一个叶结点固定地对应一个归并序列,这样,若其序列的首元素被选出,则序列的下个元素可以直接增补进入结点,然后沿树的路径向上比较。

总结:堆结构适用于插入是无规则的,选出最值。

败者树适用于多路序列的插入,选出最值。

 

 

 


<think>好的,我现在需要回答用户关于多路归并外排序败者的实现与原理的问题。首先,我得回顾一下用户的问题和系统给的引用内容。用户想知道败者的使用方法和实现细节,而系统提供的引用提到了败者的结构、初始化以及更新过程。首先,我应该明确多路归并外排序的基本概念。外排序通常用于处理大数据,当数据无法全部装入内存时,需要分块排序后再归并。多路归并相比路归并能减少归并次数,提高效率。而败者是优化多路归并的关数据结构。 接下来,败者的原理。根据引用内容,败者是完全,每个节点记录败者的信息。胜者晋级,最终根节点是冠军,也就是当前最小的元素。这需要解释清楚败者如何减少比较次数,尤其是在多路情况下的优势。 然后是结构实现。引用提到败者使用一维数组,分为叶子节点、比较节点和冠军节点。需要详细说明每个部分的作用,比如叶子节点对应各个归并段当前元素,比较节点存储败者的归并段索引。初始化时,每个叶子节点对应的元素需要进行比较,填充败者。初始化过程可能比较复杂,需要一步步构建。比如,从叶子节点开始两两比较,父节点记录败者,胜者向上继续比较,直到根节点。初始时,可能需要一个辅助空间存储最小值,如引用中的MINKEY。 然后是调整过程。当冠军被取出后,需要从对应的归并段中取出下一个元素,替换叶子节点中的值,并重新调整败者。调整时只需要沿着该叶子节点到根的路径进行比较,不需要处理其他分支,这减少了时间复杂度。引用中提到只需要更新相关路径,其他节点不影响,这很重要。 还要比较败者和胜者的区别。胜者每次比较需要知道胜者和败者,而败者只记录败者,胜者继续向上比较。这可能减少数据移动的次数,提高效率,特别是在多路情况下。 在实现细节上,代码结构可能涉及数组索引管理,比如ls数组的大小是2k,其中k是归并路数。叶子节点从ls[k]到ls[2k-1],而比较节点是ls[1]到ls[k-1]。冠军节点是ls[0]。这需要清晰的数组结构说明。可能还需要举例说明,比如当k=5时,数组的结构如何,如何初始化,如何调整。但用户可能更关注逻辑而不是具体代码,不过提供伪代码或示例能帮助理解。另外,引用中的内容提到附加空间bk,用于存储当前各归并段的元素,这个辅助空间的作用需要解释清楚,比如在调整时临时存储新取出的元素,然后更新到败者中。 最后,要确保回答符合系统指令,使用正确的中文,数学公式用$...$,独立公式用$$...$$,并生成相关问题。同时引用用户提供的引用内容,但用户提供的引用是关于快速排序的示例,可能需要注意。不过用户给出的引用是关于归并排序败者的,所以需要正确引用[^1]和[^2]。 可能还需要检查是否有遗漏的关点,比如败者的时间复杂度,或者实际应用中的注意事项。例如,多路归并的路数选择对性能的影响,或者如何处理归并段耗尽的情况。总结,我需要分步骤解释败者的原理、结构、初始化、调整过程,对比胜者,并说明其优势。同时确保引用正确,结构清晰,最后生成相关问题。</think>### 多路归并外排序败者实现原理 #### 一、败者核心作用 败者是多路归并外排序中减少比较次数的关数据结构。通过记录每轮比较的“败者”信息,可将每次插入新元素后的调整复杂度从$O(k)$降低到$O(\log k)$,其中$k$为归并路数[^1]。 #### 、数据结构设计 1. **完全结构**:使用一维数组存储,总节点数为$2k$: - 叶子节点:$k$个,存储各归并段当前元素(对应数组后半部分$ls[k..2k-1]$) - 内部节点:$k-1$个,记录败者的归并段编号(对应数组$ls[1..k-1]$) - 冠军节点:$ls[0]$存储最终胜者编号[^1] $$ \text{示例结构(k=3): } ls = [\text{冠军}, \text{败者1}, \text{败者2}, \text{叶子1}, \text{叶子2}, \text{叶子3}] $$ 2. **辅助空间**:数组$b[0..k-1]$存储各归并段当前元素值,初始化时设置$b[k] = -\infty$作为哨兵[^2]。 #### 三、初始化过程(以5路归并为例) 1. 从各归并段读取首个元素到$b[0..4]$ 2. 自底向上构建: ```python def init_loser_tree(): for i in range(k-1, -1, -1): # 从最后一个叶子节点开始 parent = (i + k) // 2 # 计算父节点位置 while parent > 0: if b[i] > b[ls[parent]]: # 新元素是败者 ls[parent], i = i, ls[parent] # 交换胜者与败者 parent = parent // 2 ls[0] = i # 最终冠军 ``` #### 四、动态调整过程 当取出冠军元素$b[ls[0]]$后: 1. 从对应归并段读取新元素到$b[s]$($s$为胜者段编号) 2. **局部更新路径**: ```python def adjust(s): parent = (s + k) // 2 while parent > 0: if b[s] > b[ls[parent]]: # 新元素是败者 ls[parent], s = s, ls[parent] # 更新败者并传递胜者 parent = parent // 2 ls[0] = s # 更新冠军 ``` #### 五、关优化特性 1. **减少冗余比较**:仅需更新从叶子到根的路径(时间复杂度$O(\log k)$) 2. **稳定性处理**:当某归并段耗尽时,将其$b[i]$设为$+\infty$,自动被淘汰出局 3. **对比胜者**:胜者需要维护所有子节点关系,而败者通过记录败者减少数据移动量[^2] #### 六、应用场景 1. 数据库大规模排序(如MySQL外部排序) 2. 搜索引擎倒排索引合并 3. 分布式计算中MapReduce的shuffle阶段
评论 3
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值