0
点赞
收藏
分享

微信扫一扫

算法导论-上课笔记4:线性时间排序


文章目录

  • ​​1 排序算法的下界​​
  • ​​2 计数排序​​
  • ​​3 基数排序​​
  • ​​4 桶排序​​

1 排序算法的下界

在排序算法的最终结果中,若各元素的次序依赖于它们之间的比较,则把这类排序算法称为比较排序。快速排序、堆排序、归并排序、插入排序都属于比较排序。对包含n个元素的输入序列来说,任何比较排序算法在最坏情况下都要经过Ω(n·lgn)次比较,其中归并排序和堆排序是渐近最优的,并且任何已知的比较排序算法最多就是在常数因子上优于它们二者。

比较排序算法只使用元素间的比较来获得输入序列<a1,a2,…,an>中的元素间次序的信息,而不使用其他方法观察元素的值或者元素之间的次序信息。即给定两个元素ai和aj,可以执行ai<aj、ai≤aj、ai=aj、ai≥aj或者ai>aj中的一个比较操作来确定它们之间的相对次序。

不妨假设所有的输入元素都是互异的,于是不需要ai=aj这种比较了。而ai<aj、ai≤aj、ai≥aj或者ai>aj都是等价的,因为通过它们所得到的关于ai和aj的相对次序的信息是相同的。于是可以进一步假设所有比较采用的都是ai≤aj形式。

比较排序可以被抽象为一棵决策树,是一棵二叉树,它可以表示在给定输入规模情况下,某一特定排序算法对所有元素的比较操作,但是控制和数据移动等其他操作都被忽略了。下图显示了插入排序算法作用于包含三个元素的输入序列的决策树情况:

算法导论-上课笔记4:线性时间排序_计数排序


在上面的决策树中,每个非叶子结点都以i:j标记,其中1≤i,j≤n,n是输入序列中的元素个数。每个叶子结点上都标注某个序列<π(1),π(2),…,π(n)>。排序算法的执行对应于一条从树的根结点到叶子结点的路径。每一个非叶子结点表示对ai与aj的一次比较,左子树表示一旦确定ai≤aj之后的后续比较,右子树则表示在确定了ai>aj后的后续比较。当到达一个叶子结点时,表示排序算法已经确定了一个顺序aπ(1)≤aπ(2)≤…≤aπ(n)。因为任何正确的排序算法都能够生成输入的每一个正确排序好的排列,所以对一个正确的比较排序算法来说,n个元素的n!种可能的排列都应该出现在决策树的叶子结点上。而且,每一个叶子结点都必须是可以从根结点经由某条路径到达的,该路径对应于比较排序的一次实际执行过程,此时称这种叶子结点为“可达的”。后续内容将只考虑每一种排列都是一个可达的叶子结点的决策树。

在决策树中,从根结点到任意一个可达叶子结点之间最长简单路径的长度,表示的是对应的排序算法中最坏情况下的比较次数。因此,一个比较排序算法中的最坏情况比较次数就等于其决策树的高度。同时,当决策树中每种排列都是以可达的叶子结点的形式出现时,该决策树高度的下界也就是比较排序算法运行时间的下界。

定理:在最坏情况下,任何比较排序算法都需要做Ω(n·lgn)次比较。

证明:对于一棵每个排列都是一个可达的叶子结点的决策树来说,树的高度完全可以被确定。考虑一棵高度为h、具有m个可达叶子结点的决策树,它对应一个对n个元素所做的比较排序。因为输入数据的n!种可能的排列都是叶子结点,所以有n!≤m。由于在一棵高为h的二叉树中,叶子结点的数目不多于2h,得到:n!≤m<2h,两边取对数lg,又因为lg函数是单调递增的,有h≥lg(n!)。

又因为lg(n!)=Ω(n·lgn),这是书上的结论,我不会证明。

推论:堆排序和归并排序都是渐近最优的比较排序算法。

证明:堆排序和归并排序的运行时间上界为O(n·lgn),这与上述定理给出的最坏情况的下界Ω(n·lgn)是一致的。

2 计数排序

计数排序假设n个输入元素中的每一个都是在0到k区间内的一个整数,其中k为某个整数

计数排序的基本思想是:对每一个输入元素x,确定小于x的元素个数。利用这一信息,就可以直接把x放到它在输出数组中的位置上了。例如,如果有17个元素小于x,则x就应该在第18个输出位置上。当有几个元素相同时,这一方案要略做修改。因为不能把它们放在同一个输出位置上。

在计数排序算法的代码中,假设输入是一个数组A[1…n],A.length=n。还需要两个数组:B[1…n]存放排序的输出,C[0…k]提供临时存储空间。

COUNTING-SORT(A,B,k)
let C[0..k] be a new array
for i=0 to k
c[i]=0 //初始化
for j=1 to A.length
C[A[j]]+=1
//C[i]现在存储的是值=i的元素个数
for i=1 to k
C[i]+=C[i-1]
//C[i]现在存储的是值≤i的元素个数
for j=A.length downto 1
B[C[A[j]]]=A[j]
C[A[j]]-=1

在上述代码中:

1、在第3-4行for循环的初始化操作之后,数组C的值全被置为0。

2、第5-6行的for循环遍历每一个输入元素。如果一个输入元素的值为i,就将C[i]值加1。于是,在第6行执行完后,C[i]中保存的就是等于i的元素的个数,其中i=0,1,…,k。

3、第8-9行通过加运算确定对每一个i=0,1,…,k,有多少输入元素是小于或等于i的。

4、在第11-13行的for循环部分,把每个元素A[j]放到它在输出数组B中的正确位置上。如果所有n个元素都是互异的,那么当第一次执行第11行时,对每一个A[j]值来说,C[A[j]]就是A[j]在输出数组中的最终正确位置。这是因为共有C[A[j]]个元素小于或等于A[i]。因为所有的元素可能并不都是互异的,所以每次将一个值A[i]放入数组B中以后,都要将C[A[i]]的值减1。这样,当遇到下一个值等于A[i]的输入元素(如果存在)时,该元素可以直接被放到输出数组中A[j]的前一个位置上。

计数排序的时间代价是多少呢?第3-4行的for循环所花时间为Θ(k),第5-6行的for循环所花时间为Θ(n),第8-9行的for循环所花时间为Θ(k),第11-13行的for循环所花时间为Θ(n)。这样,总的时间代价就是Θ(k+n)。在实际工作中,当k=O(n)时,一般会采用计数排序,这时的运行时间为Θ(n)。

计数排序的下界优于比较排序算法的Ω(n·lgn),因为它并不是一个比较排序算法。

事实上,它的代码中完全没有输入元素之间的比较操作。相反,计数排序是使用输入元素的实际值来确定其在数组中的位置。当脱离了比较排序模型的时候,Ω(n·lgn)这一下界就不再适用了。计数排序的一个重要性质就是它是稳定的:具有相同值的元素在输出数组中的相对次序与它们在输入数组中的相对次序相同。也就是说,对两个相同的数来说,在输入数组中先出现的数,在输出数组中也位于前面。

下图是计数排序的一个运行过程示例:

算法导论-上课笔记4:线性时间排序_算法_02


上图是COUNTING-SORT在输入数组A[1…8]上的处理过程,其中A中的每一个元素都是不大于k=5的非负整数:

1、a:第6行执行后的数组A和辅助数组C的情况。

2、b:第9行执行后,数组C的情况。

3、c-e:分别显示了第11-13行的循环体迭代了一次、两次和三次之后,输出数组B和辅助数组C的情况。其中,数组B中只有浅色阴影部分有元素值填充。

4、f:最终排好序的输出数组B。

3 基数排序

基数排序(radix sort)是一种用在卡片排序机上的算法,一张卡片有80列,在每一列上机器可以选择在12个位置中的任一处穿孔。通过机械操作,可以对排序机“编程”来检查每个卡片中的给定列,然后根据穿孔的位置将它们分别放入12个容器中。操作员就可以逐个容器地来收集卡片,其中第一个位置穿孔的卡片在最上面,其次是第二个位置穿孔的卡片,依此类推。

对十进制数字来说,每列只会用到10个位置(另两个位置用于编码非数值字符)。一个d位数将占用d列。因为卡片排序机一次只能查看一列,所以要对n张卡片上的d位数进行排序,就需要设计一个排序算法。

从直观上来看,应该按最高有效位进行排序,然后对得到的每个容器递归地进行排序,最后再把所有结果合并起来。遗憾的是,为了排序一个容器中的卡片,10个容器中的9个都必须先放在一边。

基数排序是先按最低有效位进行排序来解决卡片排序问题的。然后算法将所有卡片合并成一叠,其中0号容器中的卡片都在1号容器中的卡片之前,而1号容器中的卡片又在2号容器中的卡片前面,依此类推。之后,用同样的方法按次低有效位对所有的卡片进行排序,并把排好的卡片再次合并成一叠。重复这一过程,直到对所有的d位数字都进行了排序。此时,所有卡片已按d位数完全排好序。所以,对这一叠卡片的排序仅需要进行d轮。下图是一叠7张3位数卡片的基数排序过程:

算法导论-上课笔记4:线性时间排序_桶排序_03


上图最左边的一列是输入数据,其余各列显示了由低位到高位连续进行排序后列表的情况,阴影指出了进行排序的位置。

为了确保基数排序的正确性,在每一位数上进行排序的排序算法必须是稳定的。

在一台典型的串行随机存取计算机上,有时会用基数排序来对具有多关键字域的记录进行排序。例如,用三个关键字(年、月和日)来对日期进行排序。对这个问题,可以使用基于特殊比较函数的排序算法:给定两个日期,先比较年,如果相同,再比较月,如果还是相同,就比较日。也可以采用另一种方法,用一种稳定排序算法对这些信息进行三次排序:先日,再月,最后是年。

基数排序的代码是非常直观的。在下面的伪代码中,假设n个d位的元素存放在数组A中,其中第1位是最低位,第d位是最高位:

RADIX-SORT(A,d)
for i=1 to d //从低位到高位进行排序
use a stable sort to sort array A on digit i

引理:给定n个d位数,其中每一个数位有k个可能的取值。如果RADIX-SORT使用的稳定排序方法耗时Θ(n+k),那么它就可以在Θ(d(n+k))时间内将这些数排好序。

证明:基数排序的正确性可以通过对被排序的列进行归纳而加以证明,这里不会证明这个。对算法时间代价的分析依赖于所使用的稳定的排序算法。当每位数字都在0到k-1区间内,这样它就有k个可能的取值,且k的值不太大的时候,计数排序是一个好的选择。对n个d位数来说,每一轮排序耗时Θ(n+k)。共有d轮,因此基数排序的总时间为Θ(d(n+k))。

当d为常数且k=O(n)时,基数排序具有线性的时间代价。在更一般的情况中,可以灵活地决定如何将每个关键字分解成若干位。

引理:给定一个b位数和任何正整数r,且r≤b,如果RADIX-SORT使用的稳定排序算法对数据取值区间是0到k的输入进行排序耗时Θ(n+k),那么它就可以在Θ((b/r)(n+2r))时间内将这些数排好序。

证明:对于一个值r,其中r≤b,每个关键字可以看做d=⌈b/r⌉个r位数。每个数都是在0到2r-1区间内的一个整数,这样就可以采用计数排序,其中k=2r-1。例如可以将一个32位的字看做是4个8位的数,于是有b=32,r=8,k=2r-1=255和d=⌈b/r⌉=b/r=4。每一轮排序花费时间为Θ(n+k)=Θ(n+2r),计数排序花费的总时间代价为Θ(d(n+2r))=Θ((b/r)(n+2r))。

对于给定的n和b,希望所选择的r(r≤b)值能够最小化表达式(b/r)(n+2r)。如果b<⌊lgn⌋,则对于任何满足r≤b的r,都有(n+2r)=Θ(n)。显然,选择r=b得到的时间代价为(b/b)(n+2r)=Θ(n),这一结果是渐近意义上最优的。如果b≥⌊lgn⌋,选择r=⌊lgn⌋可以得到偏差不超过常数系数范围内的最优时间代价。下面详细说明这一点:选择r=⌊lgn⌋,得到的运行时间为Θ(bn/lgn)。随着将r的值逐步增大到大于⌊lgn⌋后,分子中的2r项比分母中的r项增加得快。因此,将r增大到大于⌊lgn⌋后,得到的时间代价为Ω(bn/lgn)。反之,如果将r减小到⌊lgn⌋之下,则b/r项会变大,而n+2r项仍保持为Θ(n)。

基数排序是否比基于比较的排序算法(如快速排序)更好呢?

通常情况,如果b=O(lgn),而且选择r≈lgn,则基数排序的运行时间为Θ(n)。这一结果看上去要比快速排序的期望运行时间代价Θ(n·lgn)更好一些。但是,在这两个表达式中,隐含在Θ符号背后的常数项因子是不同的。在处理n个关键字时,尽管基数排序执行的循环轮数会比快速排序要少,但每一轮它所耗费的时间要长得多。快速排序通常可以比基数排序更有效地使用硬件的缓存(我并不知道为什么)。此外,利用计数排序作为中间稳定排序的基数排序不是就地排序,而很多Θ(n·lgn)时间的比较排序是就地排序。因此,当主存的容量比较宝贵时,可能会更倾向于像快速排序这样的就地排序算法。

4 桶排序

桶排序(bucket sort)假设输入数据服从均匀分布,平均情况下它的时间代价为O(n)。与计数排序类似,因为对输入数据作了某种假设,桶排序的速度也很快。具体来说,计数排序假设输入数据都是属于一个小区间内的整数,而桶排序则假设输入是由一个随机过程产生,该过程将元素均匀、独立地分布在[0,1)区间上

桶排序将[0,1)区间划分为n个相同大小的子区间,称子区间为桶。然后,将n个输入数据分别放到各个桶中。因为输入数据是均匀、独立地分布在[0,1)区间上,所以一般不会出现很多数落在同一个桶中的情况。为了得到输出结果,先对每个桶中的数进行排序,然后遍历每个桶,按照次序把各个桶中的元素列出来即可。

在下面的桶排序的伪代码中,假设输入是一个包含n个元素的数组A,且每个元素A[i]满足0≤A[i]<1。此外,算法还需要一个临时数组B[0…n-1]来存放链表(即桶),并假设存在一种用于维护这些链表的机制。

BUCKET-SORT(A)
n=A.length
let B[0..n-1] be a new array
for i=0 to n-1
make B[i] an empty list
for i=1 to n
insert A[i] into list B[⌊nA[i]⌋]
for i=0 to n-1
sort list B[i] with insertion sort
concatenate the lists B[0],B[1],...,B[n-1] together in order//遍历每个桶,按照次序把各个桶中的元素列出来

下图显示了在一个包含n=10个元素的输入数组上的BUCKET-SORT的过程:

算法导论-上课笔记4:线性时间排序_数据结构_04


1、a:输入数组A[1…10]。

2、b:在算法的第9行之后,B[0…9]中的已排序链表(桶)的情况。第i个桶中存放的是半开区间[i/10,(i+1)/10]中的值。排好序的输出是由链表B[0],B[1],…,B[9]依次连接而成的。

为了验证BUCKET-SORT算法的正确性,先来看看两个元素A[i]和A[j]。不失一般性,不妨假设A[i]≤A[j]。由于⌊nA[i]⌋≤⌊nA[j]⌋,元素A[i]或者与A[j]被放入同一个桶中,或者被放入一个下标更小的桶中。如果A[i]和A[j]在同一个桶中,则第8-9行中的for循环会将它们按适当的顺序排列。如果A[i]和A[j]落入了不同的桶中,则第10行也会将它们按适当的顺序排列。因此,桶排序算法是正确的。

现在来分析桶排序的运行时间。在最坏情况下,除第9行以外,所有其他各行时间代价都是O(n)。接下来需要分析第9行中n次插入排序调用所花费的总时间。

假设ni是表示桶B[i]中元素个数的随机变量,因为插入排序的时间代价是平方阶的,所以桶排序的时间代价为:

算法导论-上课笔记4:线性时间排序_计数排序_05


现在来分析桶排序在平均情况下的运行时间。通过对输入数据取期望,可以计算出期望的运行时间。对上式两边取期望,并利用期望的线性性质,有:

算法导论-上课笔记4:线性时间排序_算法_06


上式记为公式A。有:

算法导论-上课笔记4:线性时间排序_基数排序_07


上式记为公式B。公式B对所有i=0,1,…,n-1成立——因为输入数组A的每一个元素是等概率地落入任意一个桶中,所以每一个桶i具有相同的期望值E[ni2]。为了证明公式B,定义指示器随机变量:对所有i=0,1,…,n-1和j=1,2,…,n,Xij=Ⅰ{A[j]落入桶i}。因此,有:

算法导论-上课笔记4:线性时间排序_基数排序_08


为了计算E[ni2],现在展开平方项,并重新组合各项:

算法导论-上课笔记4:线性时间排序_计数排序_09


上式记为公式C。其中,最后一行是根据数学期望的线性性质得出的。分别计算这两项累加和,指示器随机变量Xij为1的概率是1/n,其他情况下是0。于是有:

算法导论-上课笔记4:线性时间排序_数据结构_10


当k≠j时,随机变量Xij和Xik是独立的,因此有:

算法导论-上课笔记4:线性时间排序_数据结构_11


将这两个期望值带入公式C,得到:

算法导论-上课笔记4:线性时间排序_算法_12


到此,公式B得证。

利用公式A中的期望值,可以得出结论:桶排序的期望运行时间为Θ(n)+n·O(2-1/n)=Θ(n)。

即使输入数据不服从均匀分布,桶排序也仍然可以线性时间内完成。只要输入数据满足性质:所有桶的大小的平方和与总的元素个数呈线性关系,那么通过公式A,就可以知道:桶排序仍然能在线性时间完成。

END


举报

相关推荐

算法导论2.3归并排序

0 条评论