算法导论(五):线性时间排序

麻省理工学院公开课:算法导论。B站地址,网易公开课也有对应的资源。
https://www.bilibili.com/video/av1149902/?p=5
这节课的主要内容是用决策树模型证明比较排序算法的时间复杂度不会快于nlgn。以及另外两个非比较类型的排序算法及其分析:计数排序、基数排序。

前面讲过的算法有:
1、快速排序 Θ(nlgn) 或者 Θ(n2)
2、归并排序 Θ(nlgn)
3、插入排序 Θ(n2)
4、堆排序【这个课程上没有讲过,可能是在练习课上讲过】 Θ(nlgn)

提问:排序算法最快可以有多快?
回答:看情况,取决于你使用的计算模型里,哪些操作是被允许的。我们需要关心元素的排列顺序,以及我们可以如何对元素操作?

前面的算法最快是nlgn,如何能使它比nlgn更好呢?
这些排序都属于比较排序的算法模型,这是解决排序问题的一个模型。也就是只能使用比较(如大于、小于、等于、大于等于、小于等于)来决定元素的相对顺序。

事实上,比较排序的算法运行速度不会快于nlgn。下面用决策树模型来证明

决策树模型

决策树模型:这是算法中另一个规定你可以进行哪些操作的模型,比比较模型适用范围更广。

算法导论(五):线性时间排序_第1张图片

图中数字表示数组中元素的索引

上面是对数组[a1, a2, a3]进行排序的决策树模型。每一个节点表示一个比较操作,如果a1 < a2则往左,a1 > a2则往右继续。

决策树模型的定义如下:
有数组(a1, a2, ... , an),每一个非叶节点都会有一个下标i:j,i,j∈{1,2, ... , n},表示比较ai和aj的大小。
每个非叶节点下会分出2棵子树,左边的子树表示ai≤aj的情况下,算法的后续操作。右边的子树表示ai>aj的情况下,算法的后续操作。
每个叶节点表示一个排序结果,如[π(1), π(2), ... , π(n)]表示:aπ(1)≤aπ(2)≤...≤aπ(n)

决策树模型是算法的一种图形表示,比较排序的算法都可以用决策树模型表示,为什么不用决策树模型来表示排序算法呢?因为决策树模型根据输入n的数值而有不同的大小。


把比较排序的一个算法,转换成决策树形式的算法表达:
1、为每一个n值绘制一棵决策树
2、进行比较时,把树分成两个分支,左子树和右子树。
3、决策树就是把算法中这些比较的所有可能的结果分别列出来。即指出了所有可能的路线。

根据输入n的数值,画出来的决策树会非常庞大。
决策树中叶节点的个数至少是n的阶乘,即n!(列出了所有可能的排序结果),而决策树的高度为lg(n!)。根据斯特林公式,n!的近似值为(n/e)n,即高度为lg(n/e)n = nlg(n/e) = n(lgn-lge),其中lge是常数,所以决策树的高度为Ω(nlgn)。
即所有比较排序算法对应的决策树的深度至少是nlgn。

线性时间的非比较类型的排序算法

计数排序 counting sort

输入为:数组A[1, ... , n],其中Ai∈{1, 2, ..., k}
输出为:数组B[1, ... , n],即已排序的A
其中,需要辅助存储的数组C

这里的数组索引是从1到n,不是平时代码写的0到n-1

计数排序算法的伪代码如下

for i <- 1 to k
    do C[i] <- 0
for j <- 1 to n
    do C[A[j]] <- C[A[j]]+1
    // C[i] = |{key = i}|
for i <- 2 to k
    do C[i] <- C[i] + C[i-1]
    // C[i] = |{key <= i}|
for j <- n downto 1
    do B[C[A[j]]] <- A[j]
    C[A[j]] <- C[A[j]] - 1

偷偷放一下js版本

for(var i=1;i<=k;i++){
    C[i] = 0;
}
// 遍历A,得到数组C,即A中各元素的个数
for(var j=1;j<=n;j++){
    C[A[j]]++;
}
for(i=2;i<=k;i++){
    C[i] = C[i] + C[i-1];
}
// 分配
for(j=n;j>=1;j--){
    B[C[A[j]]] = A[j];
    C[A[j]] = C[A[j]] - 1;
}

看伪代码一共有4个for循环,举个例子来看下代码是如何运行的:
A=[4, 1, 3, 4, 3]

  1. 第一个for循环对数组C进行初始化,其中k=4(数组A中的最大值),得到C=[0, 0, 0, 0]
  2. 第二个for循环遍历A中的元素,对A中的元素进行计数,并把每个元素的数量按顺序存储在C中,得到C=[1, 0, 2, 2]
  3. 第三个for循环遍历C中的元素,进行累加。得到C=[1, 1, 3, 5]
  4. 第四个for循环对A中的元素进行分配,放到数组B中对应的位置,可得到B=[1, 3, 3, 4, 4]

运行时间分析
四个for循环的运行时间一次为k+n+k+n,即k+n。
如果k=O(n),那么运行时间为O(n),那这个算法是合适的。如果k=2n之类的值,那么运行时间就不太好了。。
而且这里需要保证数组中的元素均为正整数,范围够小。

基数排序算法

基数排序是用来在线性时间内处理大范围数据的。这个算法有一个非常重要的性质,即稳定性。算法的整体思想为:按位排序

这里一段小插曲,这个算法是一个非常老的算法,最早起源于1890年,是MIT的一位讲师发明的。这位讲师因此赚了很多钱【笑

下面用一个例子来说明一下基数排序。
有下列数字[329, 457, 657, 839, 436, 720, 355],进行排序:

329      720      720      329
457      355      329      355
657      436      436      436
839      457      839      457
436      657      355      657
720      329      457      720
355      839      657      839

排序过程:

  1. 第一列数字为数组内元素的初始排序,即[329, 457, 657, 839, 436, 720, 355]
  2. 针对这些元素的最后一位进行排序,得到第二列数字[720, 355, 436, 457, 657, 329, 839]
  3. 针对倒数第二位元素进行排序,得到第三列数字[720, 329, 436, 839, 355, 457, 657]
  4. 针对第一位元素进行排序,得到最后一列数字[329, 355, 436, 457, 657, 720,
    839],即最终的结果。

在每一轮排序中,相同大小的元素保持原有顺序不变。

分析
1、 如果每一轮排序都用计数排序,其时间复杂度为O(k+n)。
2、 用二进制来表示数组中的元素。一共有n个整数,每一个整数均为b比特的长度,那么数组中元素的取值范围为(0, 2b-1)。
一共需要进行b轮排序,每一轮排序花费的时间为n,那么总时间为b·n。
假如b很小,为lgn,那么数字的取值范围为(0, n-1)。前面的计数排序已经可以做到在线性时间内对(0, n-1)范围内的数进行排序,这里却需要nlgn的时间,显然不太好。。。
可以把连续的比特看作是一位数来进行比较,看看用计数排序可以一次处理的最多比特为多少。


算法:把每个整数拆分成b/r位数字,每个数字都是r比特的长度。也就是将数组中的元素用2r进制来表示。
这里一共需要进行b/r轮运算,其中每一轮比较中,最大的数字为2r,即每位数字的取值范围为(0, 2r),也就是k=2r,所以每一轮使用计数排序需要(2r+n)的时间。
所以算法的总时长为(b/r)·(2r+n),即O((b/r)·(2r+n))。

这里要选择合适的r,使运行时间最小。
(b/r)·(2r+n),可以拆分为 (b/r)·n + (b/r)·2r 两项
对于(b/r)·n,r越大,其值越小。
对于(b/r)·2r,r越小,其值越小。

当n≥2r,r = lgn
如果r=lgn,总时长为(b/r)·(2r+n) = (b/lgn)·(n+n),即为O(bn/(lgn))。
待排序数列是某个范围内的整数,即(0, 2b-1),b是数的比特位数,对应于数的取值范围。
将取值范围写成(0, nd-1),d = b/r = b/lgn,d是一个常数。
对于n∈{0, ..., nd-1},总时长为O(dn)

计数排序 vs 基数排序

计数排序可以用线性时间处理0到某个常数乘以d范围内的数。
基数排序可以在线性时间内处理0到n的某个常数次方范围内的数。

假设处理32比特的数,即取值范围为(0, 232),使用基数排序分四轮进行计算,每轮处理8比特的数,需要28=256的存储空间进行计数排序,时间复杂度为4*(8+n),时间复杂度不高。但在实际情况中,计数排序因为需要比较多的存储空间,所以并没有理想中那么快。

你可能感兴趣的:(算法导论(五):线性时间排序)