第1章 算法在计算中的作用
算法的定义:算法就是一系列的计算步骤,用来将输入数据转换成输出结果。
算法可以解决哪些类型的问题?
1、生物问题,如DNA基因序列的分析;
2、因特网,路由数据传输路径的查找、网页搜索引擎;
3、电子商务,银行卡等信息的公共密钥加密技术和数字签名技术;
4、制造业和其它商业应用,资源分配与人员高度;
……
由于计算机的计算时间和存储空间是有限的资源,所以好的算法在保证结果正确的同时还要兼顾好的效率。
第2章 算法入门
2.1 插入排序法
首先来分析一下插入排序算法,它解决的是一个排序问题:
输入:n个数
输出:输入序列的一个排列(即重新排序),使得a'1 ≤ a'2 ≤ ... ≤ a'n。
插入算法类似于摸扑克牌:在摸第一张牌前,左手是空的。之后右手每次从牌堆上摸入一张牌,放入左手。在放入时,把摸起的牌与左手的牌从右向左(点数从大到小)进行比较,一但发现一张点数不大于摸起的牌的点数时,把摸起的牌放在这张牌的右边。当右手无牌可摸时,左手的牌就是一个点数由小到大排列的序列,从而完成了排序。下面给出伪代码(数组索引从1开始,而非0):
INSERTION-SORT(A) {
for j = 2 to A.length {
key = A[j];
i = j - 1;
while i > 0 and A[i] > key {
A[i+1] = A[i];
i = i - 1;
}
A[i+1] = key;
}
}
我们以
循环不变式(loop invariant)来形式化地表达我们的算法:每一轮迭代的开始,子数组A[1...j-1]包含了最初位于A[1...j-1]、但目前已排好序的各个元素。
循环不变式主要用来帮助我们理解算法的正确性。对于循环不变式,必须证明它的三个性质:
初始化:它在循环的每一轮迭代开始之前,应该是正确的。
保持:如果在循环的某一次迭代开始之前它是正确的,那么在下一次迭代开始之前,它也应该保持正确。
终止:当循环结束时,根据前两个性质,归纳出此循环不变式在结束时是正确的。
可以看到循环不变式与数学归纳法类似,不同的是归纳法没有终止。
下面我们就来证明插入排序算法这这些性质:
初始化:第一轮迭代开始前,j=2,即子数组A[1...j-1]只包含一个元素的A[1],它是已排序的。
保持:在某轮迭代之后,子数组A[1...j-1]为排好序的,下一次迭代时将把A[j]插入到这样一个位置:它的前面的元素不大于它而它后面的元素大于它。所在在这次迭代后,A[1...j]仍为已排序的。
终止:当j大于数组长度n时,即j=n+1,根据前两个性质,A[1...n]是已排序的,所以这个算法是正确的。
2.2 算法分析
算法分析即对一个算法所需要的资源进行预测。我们偶尔会关心内存、通信带宽或计算机硬件等资源,但通常我们更关心计算时间。
在分析一个算法前,要建立有关实现技术的模型,包括描述所用资源的及代价的模型。有一种通用的单处理器、随机存取机计算模型作为实现技术,算法可以用计算机程序实现。在RAM模型中,指令一条接一条执行,没有并发操作。
下面来分析一下插入排序算法。可以直观地看到,算法所需的时间开销与输入(数组的大小)有关。一般来说,算法的运行时间是与输入规模同步增长的。
输入规模的概念与具体问题有关。对许多问题来说(比如排序),最自然的度量标准是输入中的元素个数。而对另些问题(比如两个整数相乘),其输入规模的最佳度量是输入数的二进制表示下的位数。有时,用多个数来表示规模更合适,比如输入的是一个图,可以把图的顶点数和边数作为输入的规模。
运行时间是指在特定输入时,所执行的基本操作数或步数。(即计算机指令执行的时间。)
分析插入排序算法的运行时间:
假定每行程序执行一次的时间为某一个常数,c1~c7。我们并不关心执行一次它真正运行了多少时间,而且对于不同的机器它的执行时间也不一样。我们关心的是每行程序被执行的次数。下面对每行进行分析:
1、第一行外循环j从2开始递增,最后一次增加到n+1,比A.length大,从而结束循环,所以这行实际执行了n次;
2、第二行在j=2到j=n时执行,所以实际执行了n-1次;
3、与第二行一样,执行了n-1次;
4、外循环的每一轮迭代里,内循环执行的次数都不一样(与数组元素的顺序有关,它会影响迭代次数)。假定在值为j时内循环的迭代次数为tj,那么第四行执行的总数为(t2 + t3 + ... + tn)。我们之后再来计算这个tj;
5、循环体内的程序要比循环判断次数少一次,所以第五行执行的次数为为[(t2-1) + (t3-1) + ... + (tn-1)];
6、与第五行执行次数一样;
7、与每二、三行执行次数一样。
现在回过头来看第四行的tj。在最好情况下(即整个数组是已排序的),每次迭代内循环只做一次比较而不进入循环体,所以这时第四行执行的总次数为n-1次(与第二、三行相同)。每五行和每六行总执行次数为0。所以整个插入排序算法的总运行时间为:
T(n) = c1*n + c2*(n-1) + c3*(n-1) + c4*(n-1)+c7*(n-1) = (c1+c2+c3+c4+c7)*n - (c2+c3+c4+c7)。它可以表示为a*n+b,这是n的一个线性函数。
当最坏情况发生时(即整个数组都是逆序的),外循环的每次迭代,内循环的i都要从j-1到0进行比较,即比较j次。所以在最坏情况下第四行执行的总次数为(2 + 3 + ... n) = n(n+1)/2-1,而每五行和第六行执行的总次数为[1 + 2 + ... + (n-1)] = n(n-1)/2。所以整个插入排序算法的运行时间为:
T(n) = c1*n + c2*(n-1) + c3*(n-1) + c4*(n(n+1)/2-1) + c5*(n(n-1)/2) + c6*(n(n-1)/2) + c7*(n-1)
= (c5 / 2 + c6 / 2 + c7 / 2) * n^2 + (c1 + c2 + c3 + c4 / 2 + c5 / 2 - c6 / 2 + c7) * n - (c2 + c3 + c4 + c7)
所以在最坏情况下,插入排序算法总的运行时间是一个二次多项式,可以表示为a*n^2 + b*n + c。(n^2表示n的平方)。
前面我们既考察了最佳情况与最坏情况,但通常我们一般只考虑最坏情况的运行时间,这样做的理由有三点:
一、最坏情况是任何输入下运行时间的上界,不会有比最坏情况运行时间更长的情况发生;
二、对于某些算法来说,最坏情况出现得还是相当频繁的;
三、大致上看来,“平均情况”通常和最坏情况一样差。
--增长的量级--
前面我们已经得出插入排序算法在最坏情况下运行时间可以抽象为一个二次多项式a*n^2+b*n+c。我们可以进行更进一步的抽象,只关心运行时间的增长率(rate of growth)或称增长的量级(order of growth)。当n很大时,我们可以忽略掉低阶项,因为它们的影响已经很小了。同时因为只考虑增长率,最高次项的系数也是可以忽略掉的,这样插入排序的最坏情况时间代价可以表示为Ө(n^2)。(在第三章会给出Ө记号的准确定义。)如果一个算法在最坏情况下运行时间比另一个算法要少,我们通常认为它要更高效。对于规模足够大的输入,Ө(n^2)的算法要比Ө(n^3)的算法要高效。
2.3 算法设计
算法设计有很多方法。插入排序使用的是增量(incremental)方法:在排好子数组A[1...j-1]后,将元素A[j]插入,形成排好序的子数组A[1...j]。
还有一种分治法:将原问题划分为规模较小而与问题相似的子问题,递归地解决这些子问题,然后再合并其结果,就能得到原问题的解。分治模式在每一层递归上都有三个步骤:
分解(Divide):将原问题分解成一系列的子问题;
解决(Conquer):递归地解各个子问题。若子问题足够小,则直接求解;
合并(Combine):将子问题的结果合并成原问题的解。
合并排序(merge sort)算法完全依照了上述模式,它的每次迭代包含:
分解(Divide):将n个元素分成各含n/2个元素的子序列;
解决(Conquer):用合并排序法对两个子序列递归地排序;
合并(Combine):合并两个已排序的子序列以得到排序结果。
MERGE(A, p, q, r) {
1 n1 = q-p+1;
2 n2 = r-q;
3 create arrays L[1...n1+1] and R[1...n2+1];
4 for i = 1 to n1 {
5 L[i] = A[p+i-1];
6 }
7 for j = 1 to n2 {
8 R[j] = A[q+j];
9 }
10 L[n1+1] = ∞;
11 R[n2+1] = ∞;
12 i = 1;
13 j = 1;
14 for k = p to r {
15 if L[i] <= R[j] {
16 A[k] = L[i];
17 i = i + 1;
18 } else {
19 A[k] = R[j];
20 j = j + 1;
21 }
22 }
}
MERGE的过程概括说来,就是比较两个子序列中最前面(最小)的元素,把更小的那个放到合并后的序列的最后面。数组L和R的最后都有一个额外的哨兵元素∞,这样可以免去判断L数组或R数组下标越界的情况。第14行到第22行的for循环可以用一个
循环不变式来表达:
下面证明这个循环不变式是成立的:
初始化:在第一次迭代前,k=p,所以A[p...k-1]是空的。这个空的子数组包含了L和R中k-p=0个最小元素。此外,因为i=j=1,L[i]和R[j]都是各自所在数组中,尚未被复制回数组A的最小元素。
保持:假设L[i] ≤ R[j],那么L[i]就是未被复制到数组A的最小元素。由于A[p...k-1]包含k-p个最小元素,因此,在16行L[i]复制到A[k]后,子数组A[p...k]包含k-p+1个最小元素,L[i+1]和R[j]分别是各自数组中尚未被复制回数组A的最小元素,不变式保持成立。同样,如果L[i] > R[j],不变式同样保持成立。
终止:终止时,k=r+1,子数组A[p...k-1](即A[p...r])包含了L[1...n1+1]和L[1...n2+1]中k-p = r-p+1个最小元素,并且是已排好序的。数组L和R除最后的两个最大(哨兵)元素外,其余的所有元素都被复制回数组A中。
不难理解MERGE的运行时间是Ө(n),因为第4行和第7行的循环体分别执行了n1与n2次(n1+n2=n次),而第14行的循环体执行了r-p+1次,也即n次。
接下来就是通过已有的MERGE来实现MERGE-SORT算法:
MERGE-SORT(A, p, r)
1 if p < r {
2 q = floor((p+r)/2);
3 MERGE-SORT(A, p, q);
4 MERGE-SORT(A, q+1, r);
5 MERGE(A, p, q, r);
6 }
}
第2行的floor是向下取整的意思。可以看到MERGE-SORT把数组分为两部分,并递归地对它们排序,最后再把排好序的两个子数组合并。如果p=r,即子数组里只一个元素时,就不再递归,而是直接返回。
分治法的分析
如前所述分治法分为分解、解决和合并三个步骤。如果问题足够小,如n≤c(c为一个常量),则直接求解;否则分解这个问题。假设分治法里的每次迭代把一个问题分解成a个子问题,每个子问题的大小是原问题的1/b,(合并排序算法里a=b=2,但在许多分治法中a≠b),如果分解该问题和合并该问题的时间分别为D(n)和C(n),则可以得到解决问题所需时间T(n)的递归式:
T(n) = Ө(1) 当 n ≤ c
或 = aT(n/b) + D(n) + C(n) 否则
来分析一下合并排序算法的递归式形式在最坏情况下的运行时间:
分解:这一步仅仅计算出子数组的中间位置,需要常量时间,所以D(n) = Ө(1)。
解决:递归地解两个规模为n/2的子问题,时间为2T(n/2)。
合并:前面已经分析过,MERGE操作的运行时间为Ө(n)。
对于2T(n/2)的值,我们可以这样求解:它等同于构造了一棵二叉树,根结点为规模为n的merge-sort,第二层为规模为n/2的merge-sort,以此类推,最后的叶结点是n≤c的情况,即直接求值不再分解。我们分析一下这棵二叉树每层的代价:第一层做了一次规模为n的合并,所以运行时间为c*n;第二层做了两次规模为n/2的合并,所以运行时间为2 * c * (n/2) = c*n;同理每层的运行时间都是c*n,而这棵树的层数为lg(n)+1(lg表示底数为2的对数,待会用归纳法证明),而总的运行时间等于这棵树的层数乘以每层的运行时间,所以是lg(n) * c * n。也即整个合并排序算法的运行时间为Ө(n*lg(n))。
至于这棵树的层数为什么是lg(n)+1,现在给予证明:当n=1时,层数为1;假设输入的n都为2的整数幂,当n=2^i时,层数为lg(2^i)+1=i+1,对于n=2^(i+1)时,它要比n=2^i的树多一层,所以它的层数为(i+1)+1=lg(2^(i+1)) + 1,命题成立。(对于所以介于2^i与2^(i+1)的值都作为2^i处理)。
第3章 函数的增长
前面看到用于表示运行时间的Ө记号,现在给出它的定义:
Ө(g(n)) = { f(n):存在正常数c1,c2和n0,使对所有的n≥n0,有0≤c1*g(n)≤f(n)≤c2*g(n) }。
从定义上看,Ө(g(n))是一个集合,g(n)是一个函数,而f(n)为Ө(g(n))集合里的一个元素,可以写成“ f(n)∈Ө(g(n))”,但通常我们直接写成“f(n)=Ө(g(n))”。比如我们某个算法的运行时间为f(n) = 2n^2+3n+5,我们要证明f(n)属于Ө(n^2),即g(n)=n^2:当n大于某值时,应该满足条件 c1*n^2 ≤ 2n^2 + 3n + 5 ≤ c2*n^2。我们选择c1=1,c2=3,当n≥5时,不等式成立。所以我们可以说f(n) = 2n^2+3n+5 = Ө(n^2)。
对于任何一个多项式p(n) = ∑ai*n^i (i=0→d),当ad大于0(即最高次项的系数大于0)时,p(n) = Ө(n^d)。换而言之,二次项系数为正的二次多项式属于Ө(n^2),三次项系数为正的三次多项式属于Ө(n^3)。
Ө记号渐近给出了一个函数的上界和下界。当只有渐近上界时,使用O记号。它的定义为:
O(g(n)) = { f(n):存在正常数c和n0,使对所有的n≥n0,有0 ≤ f(n) ≤ c*g(n) }。
相应的,Ω记号给出了渐近下界:
Ω(g(n)) = { f(n):存在正常数c和n0,使对所有的n≥n0,有0 ≤ c*g(n) ≤ f(n) }。
O记号在提供的渐近上界可能是也可能不是渐近紧确的。例如2n^2 = O(n^2)是渐近紧确的,而2n=O(n^2)就不是渐近紧确的。o记号(小写字母o)表示非渐近紧确的上界:
o(g(n)) = { f(n):对任意正常数c,存在常数n0>0,使对所有的n≥n0,有0 ≤ f(n) ≤ c*g(n) }。
注意到这里是任意正常数c,而不是存在正常数c。这是它与O记号的区别。
当n趋于无穷时,函数f(n)相对于g(n)就不重要了,即 lim(f(n)/g(n)) = 0 (n→∞)。
渐近记号的一些性质:
传递性:
f(n) = Ө(g(n)) 和 g(n) = Ө(h(n)) => f(n) = Ө(h(n))
f(n) = O(g(n)) 和 g(n) = O(h(n)) => f(n) = O(h(n))
f(n) = Ω(g(n)) 和 g(n) = Ω(h(n)) => f(n) = Ω(h(n))
f(n) = o(g(n)) 和 g(n) = o(h(n)) => f(n) = o(h(n))
f(n) = ω(g(n)) 和 g(n) = ω(h(n)) => f(n) = ω(h(n))
自反性:
f(n) = Ө(f(n))
f(n) = O(f(n))
f(n) = Ω(f(n))
对称性:
f(n) = Ө(g(n)) 当且仅当 g(n) = Ө(f(n))
转置对称性:
f(n) = O(g(n)) 当且仅当 g(n) = Ω(f(n))
f(n) = o(g(n)) 当且仅当 g(n) = ω(f(n))
因为这些性质,我们可以把两个函数f与g的渐近比较与实数a和b的比较类比起来:
f(n) = O(g(n)) ≈ a ≤ b
f(n) = Ω(g(n)) ≈ a ≥ b
f(n) = Ө(g(n)) ≈ a = b
f(n) = o(g(n)) ≈ a < b
f(n) = ω(g(n)) ≈ a > b
第4章 递归式
第2章我们分析说合并排序法的运行时间是一个递归式:T(n) = 2*T(n/2) + n。递归式(recurrence)是一组等式或不等式,它所描述的函数是用在更小的输入下该函数的值来定义的。
求解递归式有三种方法:代换法、递归树法和主方法。
代换法
代换法解递归式有两个步骤:
1、猜测解的形式;
2、用数学归纳法找出使解真正有效的常数。
下面举例子来求解一个递归式T(n) = 2T(floor(n/2)) + n:
我们猜测它的解为O(nlgn)。所以我们要证明T(n) ≤ c*n*lgn,其中c为某正常数,当n足够大时,不等式成立。根据数学归纳法,先假设T(n/2) ≤ c*(n/2)*lg(n/2)成立,接下来证明T(n)也符合:
T(n) = 2*T(floor(n/2)) + n ≤ 2*c*floor(n/2)*lg(floor(n/2)) + n ≤ c*n*lg(n/2) + n = c*n*lgn - c*n*lg2 + n = c*n*lgn - cn + n。
最后为了T(n) ≤ c*n*lgn,直需要上式中最后得出的c*n*lgn - cn + n ≤ c*n*lgn,只要c ≥ 1就成立。于是我们的猜测被证明是正确的。
有时候我们在归纳时会出现问题,比如下例:T(n) = T(floor(n/2)) + T(ceiling(n/2)) + 1,我们猜它的解为O(n),所以想证明它T(n) ≤ c*n,其中c为某正常数。但是在推导时会出现问题:
T(n) ≤ c*floor(n/2) + c*ceiling(n/2) + 1 = c*n + 1,无法推出T(n) ≤ c*n。
为了解决这样的问题,我们可以作出更强的假设,证明T(n) ≤ c*n - b,其中c,b均为正常数。所以推导过程就变为:
T(n) ≤ c*floor(n/2) - b + c*ceiling(n/2) - b + 1 = c*n - 2*b+1 ≤ c*n - b。 上式中,只要b≥1就可以令不等式成立。
在推导过程中,一定要小心,比如下例就是错误的:
证明T(n) = 2T(floor(n/2)) + n的解为O(n):T(n) ≤ 2*c*(floor(n/2)) + n ≤ c*n + n = O(n) 错误!!
上式错在,它没有严格的证明T(n) ≤ c*n。
对于一些复杂递归式,可以通过改变变量来求解。比如递归式 T(n) = 2T(floor(√n)) + lgn。
上面的式子里有一个根号,看上去很复杂,但如果设m=lgn,(为方便我们省去向下取整的操作),则上式用变量m代换后,就成为T(2^m) = 2T(2^(m/2)) + m,接着我们再设函数S(m) = T(2^m),则得到新的递归式:
S(m) = 2S(m/2) + m。这个递归式我们已经知道它的解为O(mlgm),再将n代为这个解,可得到T(n)的解为O(lgn*lg(lgn)))。
递归树法
代换法的猜解步骤有时比较困难,我们可以用递归树法得到一个猜测解,再用代换法证明该解。递归树法通过构造递归树,算出它所有节点运行时间的和,从而算出一个递归式的解。
如果在求节点和时很精确的话,递归树本身就能证明递归式的解,而不需要再用代换法证明。
考虑递归式T(n) = 3T(n/4) + c*n^2。可以把它分解成一个递归树,如下图。可以看到深度为i的节点,节点总数3^i个,而每个节点(子问题)的大小是n/(4^i),所以每层的总和是3^i * c* (n/4^i)^2 = (3/16)^i * c*n^2。递归树的深度为log_4(n)(总层数为log_4(n)+1),所以最底层(其深度为log_4(n) )有3^(log_4(n)) = n^log_4(3)个节点,每个节点的代价为T(1),所以最后一层的代价为 n^log_4(3) * T(1),即Ө(n^log_4(3))。
上棵树的总代价
T(n) = c*n^2 + (3/16)^2 * c*n^2 + ... + (3/16)^(log_4(n) - 1) + Ө(n^log_4(3))
= [(3/16)^log_4(n) - 1] / [(3/16) - 1] * c * n^2 + Ө(n^log_4(3)) (等比数列求和)
当n趋于无穷大时,T(n)有最大值(16/13)*c*n^2 + Ө(n^log_4(3)) = O(n^2)。(无限递减等比数列求和)。
由于当n=1时,T(n)有最小值c*n^2 = Ω(n^2),所以T(n) = Ө(n^2)。
下面考虑另一个递归式:T(n) = T(n/3) + T(2*n/3) + O(n)。
同样可以把它分解成一棵递归树(图略),可以发现它是一棵不完整二叉树,从根部到叶子的最长路径是一直沿着T(2*n/3)这棵子树到叶节点,所以树的深度为log_(3/2)(n)(以3/2为底数,n的对数)。我们可以假设它是一棵完整二叉树,每层的代价都为c*n。而最底层,共有2^(log_(3/2)(n))个 = n ^ ((log_(3/2)(2))个叶子,而每个叶子的代价都是常量,所以所有叶子的总和为 Ө(n ^ ((log_(3/2)(2)))。而除叶子外其它层的节点代价总和为c*n*lgn,所以这棵树的总代价为:c*n*lgn + Ө(n ^ ((log_(3/2)(2)))。但实际上,这棵树并不是完整树,越往底层代价越小,所以我们可以猜测T(n)的代价为O(n*lgn),之后就可以用代换法来证明了。
主方法
主方法(master method)给出了求解如下形式的递归式的公式:
T(n) = a*T(n/b) + f(n) 其中a≥1和b>1是常数,f(n)是一个渐近正的函数。n为非负整数,n/b指floor(n/b)或ceiling(n/b)。那么T(n)可能有如下的渐近界:
1、若对于某常数ε>0,有f(n) = O(n^(log_b(a)-ε)),则T(n)=Ө(n^(log_b(a)));
2、若f(n)=Ө(n^(log_b(a))),则T(n) = Ө(n^(log_b(a)) * lgn);
3、若对某常数ε>0,有f(n)=Ω(n^(log_b(a) + ε)),且对常数c<1与足够大的n,有a*f(n/b) ≤ c*f(n),则T(n) = Ө(f(n))。
对上面定理简单的理解就是:比较函数f(n)与n^(log_b(a)),如果f(n)渐近较小则取n^(log_b(a));如果两者渐近相等,则取n^(log_b(a))与lgn的乘积;如果f(n)渐近较大,且有a*f(n/b) ≤ c*f(n),则取f(n)。注意第三种条件更加苛刻。
下面给出例子:T(n) = 9*T(n/3) + n。a=9,b=3,所以log_b(a) = log_3(9) = 2。f(n) = n = n^1 = Ө(n^(log_b(a) - 1)),其中ε=1,所以满足条件1,T(n) = Ө(n^2)。
另一个例子:T(n) = T(2*n/3) + 1。a=1,b=3/2,所以log_b(a) = log_(3/2)(1) = 0。f(n) = 1 = n^0 = Ө(log_b(a)),满足条件2,所以T(n) = Ө(1*lgn) = Ө(lgn)。
再一个例子:T(n) = 3*T(n/4) + n*lgn。a=3,b=4,所以log_b(a) = log_4(3) < 1。f(n) = n*lgn = Ω(n) = Ω(n^(log_4(3) + ε)),其中ε=1-log_4(3) > 0。疑似满足条件3,但还要核查另一个条件a*f(n/b) ≤ c*f(n),即3*f(n/4) = 3*(n/4 * lg(n/4)) = 3/4*n*(lgn - lg4) = 3/4*n*lgn - 3/2*n ≤ 3/4*n*lgn ≤ c*n*lgn = c*f(n)。当c ≥ 3/4时,不等式成立。所以符合条件3,T(n) = Ө(f(n)) = Ө(n*lgn)。
最后一个例子:T(n) = 2*T(n/2) + n*lgn。a=2,b=2,所以log_b(a) = log_2(2) = 1。f(n) = n*lgn。f(n)渐近大于log_b(a),疑似满足条件3。再核查条件a*f(n/b) ≤ c*f(n):2*f(n/2) = 2*(n/2)*lg(n/2) = n*(lgn - 1) = n*lgn - n ≤ c*n*lgn = f(n)。解得c ≥ 1 - 1/lgn。当n趋于无穷时,不等式右侧值为1,可见无法取得c < 1的值使得不等式成立,所以不满足条件3,不能用主方法。
主方法的证明:这里不作深入研究。简而言之,先用递归树解出T(n)的值,之后根据该值针对三种不同的条件进行证明。有兴趣可以查阅原书4.4节。
第5章 概率分析和随机算法
考虑一个雇佣问题:你是一个老板,向猎头公司委托寻找一个秘书职位,猎头每天为你推荐一个应聘者,而你对他进行面试。你的目标是,任用所有应骋者中资质最好的。但由于秘书职位不能空缺,在每次面试完后,都要立即给面试者结果,所以只要当天的面试者资质比现任秘书好,你就解雇现任的秘书,而重新雇佣当天的应骋者。下面给出面试n个人的伪代码:
HIRE_ASSISTANT(n) {
1 best = 0; // candidate 0 is a least-qualified dummy candidate
2 for i = 1 to n {
3 interview candidate i;
4 if candidate i is better than candidate best {
5 best = i;
6 hire candidate i;
7 }
8 }
}
现在来分析一下面试过程中的花费。这里我们不是分析运行时间,而是花费,但本质是一样的——分析代码执行的代价。设每次进行面试的花费为c_i,而雇佣一个新秘书的的花费为c_h。c_i的花费比较少,而c_h的花费很高,因为雇佣新的秘书要给猎头一笔佣金,同时解雇现任秘书也需要花费。假设期间我们雇佣过m个人,则上面算法的总花费为O(c_i*n + c_h*m)。进行面试的花费是固定的,为c_i * n,所以我们关注于雇佣的花费,而雇佣的花费取决于雇佣的次数。在
最坏情况下,每天到来的面试者资质都比前一天的好,则每天都要雇佣新的秘书,总花费为O((c_i+c_h)*n)。我们的算法依赖于面试者到来的顺序,但我们不能预期也不能改变这个顺序,所以我们预期一个
一般或平均情况,这就需要对面试者的到来顺序进行
概率分析,但在分析前,我们先来介绍下
指示器随机变量。
给定一个样本空间S和事件A,那么事件A对应的指示器变量I{A}的定义为:
I{A} = 1 如果A发生的话
or = 0 如果A不发生的话
比如抛一枚均匀硬币,样本空间为S={H, T} (H为正面朝上,T为背面朝上),正反面朝上的概率都分别为1/2,即Pr{H} = Pr{T} = 1/2。我们用指示器随机变量X_H来对应正面朝上的情况,则:
X_H = I{H} = 1 如果H发生,即正面朝上
or = 0 如果T发生,即背面朝上
我们可以计算抛一次硬币时指示器随机变量X_H的期望值:
E[X_H] = E[I{H}] = 1*Pr{H} + 0*pr{T} = 1*1/2 + 0*1/2 = 1/2
不难发现,指示器随机变量的期望值等于对应事件发生的概率。
现在连续抛硬币n次,假设随机变量X_i对应第i次抛硬币时正面朝上的事件:X_i = I{第i次抛硬币的正面朝上}。
我们用随机变量X来对应n次抛硬币中正面朝上的总次数:X = ∑X_i。则正面朝上的期望次数为:
E[X] = E[∑ X_i] = ∑ E[X_i] = ∑1/2 = n/2
现在回到刚才的雇佣问题,我们用指示器随机变量来分析花费:假设X_i对应事件A“第i个应骋者被雇佣”:
X_i = I{A} = 1 如果应骋者i被雇佣
or = 0 如果应骋者i没有被雇佣
事件A的概率为:应骋者是1...i中最好的概率 = 1/i,所以X_i值的期望值也为1/i。用随机变量X表示雇佣的总次数:
X = X_1 + X_2 + ... + X_n
则X的期望值为:
E[X] = E[∑ X_i] = ∑ E[X_i] = ∑ 1/i = ln(n) + O(1) (调合级数的求和)
综上所述,在应骋者以随机的次序出现时,面试n个人后平均雇佣的人数为ln(n) + O(1),而HIRE_ASSISTANCE总的雇佣费用为O(c_h*ln(n))。期望的雇佣费用比最坏情况下的雇佣费用O(c_h*n)有了显著改善。
随机算法
前面我们的概率分析是基于前提:应骋者到来的顺序是随机分布的。可以看到输入的随机化可以保证我们算法的一个期望值,所以随机算法(先将输入序列随机排列再进行计算)有比较好的平均效率。比如用随机算法重新考虑雇佣问题:
RANDOMIZED_HIRE_ASSISTANT(n) {
1 randomly permute the list of candidates
2 HIRE_ASSISTANT(n)
}
它就只多了一步将应骋者序列随机排序的操作,这样可以保证哪怕是猎头刻意造成最坏情况,算法也能有一个很好的期望值。
产生随机排列的一个比较好的方法是原地产生随机序列:
RANDOMIZE_IN_PLACE(A) {
1 n = A.length;
2 for i = 1 to n
3 swap(A[i], A[RANDOM(i, n)]);
}
堆是一个数据结构,它是一棵完全二叉树。(完全树除最后一层外每层都是填满的,而最后一层从左往右开始填直至到最后一个结点。)在这棵完全树中,每个父结点的值都不小于它的两个子结点的值(最大堆,也叫大根堆。如果小根堆则每个父结点的值不小于它的两个子结点)。通常我们用数组表示堆,并用一个数值heapSize来表示数组最前面heapSize个元素构成了一个堆:
对于数组A,A[1]是堆的根。对于某个下标为i的结点,它的父结点、左儿子和右儿子的下标都可以简单计算出来:
PARENT(i) {
return floor(i/2);
}
LEFT(i) {
return 2*i;
}
RIGHT(i) {
return 2*i + 1;
}
保持堆的性质
有时一个结点的左子树和右子树都是堆,但该结点本身却小于它的某个子结点,于是违反了堆的性质。我们可以通过下面的过程来使它变为一个堆:把违反堆性质的结点与值更大的子结点交换,再递归保持交换后的产生的新子树的堆性质。
相应的保持堆的伪代码:
MAX_HEAPIFY(A, i) {
1 l = LEFT(i);
2 r = RIGHT(i);
3 if l ≤ A.heaSize and A[l] > A[i]
4 largest = l;
5 else
6 largest = i;
7 if r ≤ A.heapSize and A[r] > A[largest]
8 largest = r;
9 if largest ≠ i {
10 swap(A[i], A[largest]);
11 MAX_HEAPIFY(A, largest);
12 }
}
MAX_HEAPIFY的效率分析:一棵有n个元素,根结点为i的堆,其中每次交换父结点与子结点的开销为Ө(1)。它子树大小至多为2/3*n(在最坏情况发生在最底层恰好半满的时候),所以它的运行时间可以表示为:
建堆
对于一个数组A[1...n],我们可把它看成一个违反堆性质的完全二叉树。在建堆时,从最下层的子树开始,使用前面所述的保持堆的算法,使得所有的子树都是一个堆,直至整棵二叉树A[1...n]就成为一个堆。
建(最大)堆的伪代码如下:
BUILD_MAX_HEAP(A) {
1 A.heapSize = A.length;
2 for i = floor(A.length/2) downto 1
3 MAX_HEAPIFY(A, i);
}
这个算法直观上看上去的运行时间为O(n*lg(n)),因为MAX_HEAPIFY的运行时间为O(lg(n)),而且MAX_HEAPIFY运行了O(n)次。但是这个上界不够紧确,下面更深入地分析建堆的效率:
堆排序算法
在基于数组A[1...n]建堆后,就可以用这个堆进行排序了。我们把数组A看成两部份,左侧为堆结构,右侧为排好序的序列。因为一个(最大)堆的根结点是最大元素,所以我们每次把堆的根结点提取出来放入到数组A右侧的有序数列的最左边。每次提取根后,堆的大小减一,而右侧有序数列的大小加一。当整个堆都被转移到有序数列的部分时,整个数组也就完成排序了。在每次转移堆的根结点时,需要用堆的最右元素来充当新的根,但这样可能会违反堆的性质,所以需要保持堆的操作。下面是伪代码:
HEAPSORT(A) {
1 BUILD_MAX_HEAP(A);
2 for i = A.length downto 2 {
3 swap(A[1], A[i]);
4 A.heapSize = A.heapSize - 1;
5 MAX_HEAPIFY(A, 1);
6 }
}
堆排序算法的运行时间为O(n*lg(n))。
优先级队列
虽然堆排序算法很漂亮,但在实际中它往往比不上快速排序算法。仅管如此,堆数据结构仍然有很大的作用,比如实现优先级队列(priority queue)。与堆一样,优先级队列也有两种:最小优先级队列和最大优先级队列。
(最大)优先级队列每次取元素时都返回优先级最高的元素,它包含以下操作:
INSERT(S, x):把元素x插入集合S。
MAXIMUM(S):返回S中的最大元素。
EXTRACT_MAX(S):去掉并返回S中的最大元素。
INCREASE_KEY(S, x, k):将元素x的关键字的值增加到k,这里k值不能小于x的原关健字的值。
下面给出以上操作的伪代码:
HEAP_MAXIMUM(A) {
1 return A[1];
}
HEAP_MAXIMUM的运行时间为O(1)。
HEAP_EXTRACT_MAX(A) {1 if A.heapSize < 1
2 error("heap underflow");
3 max = A[1];
4 A[1] = A[A.heapSize];
5 A.heapSize = A.heapSize - 1;
6 MAX_HEAPIFY(A, 1);
7 return max;
}
HEAP_EXTRACT_MAX的运行时间为O(lg(n)),主要消耗在MAX_HEAPIFY的操作上。
HEAP_INCREASE_KEY(A, i, key) {
1 if key < A[i]
2 error("new key is smaller than current key");
3 while i>1 and A[PARENT(i)]
HEAP_INCREASE_KEY的运行时间为O(lg(n)),因为要执行lg(n)(堆的高度)次交换操作
MAX_HEAP_INSERT(A, key) {
1 A.heapSize = A.heapSize + 1;
2 A[A.heapSize] = -∞;
3 HEAP_INCREASE_KEY(A, A.heapSize, key);
}
MAX_HEAP_INSERT的运行时间为O(lg(n)),主要消耗在HEAP_INCREASE_KEY的操作上。
第7章 快速排序
快速排序算法和合并排序算法一样,也是基于分治模式。对子数组A[p...r]快速排序的分治过程的三个步骤为:
分解:把数组A[p...r]分为A[p...q-1]与A[q+1...r]两部分,其中A[p...q-1]中的每个元素都小于等于A[q]而A[q+1...r]中的每个元素都大于等于A[q];
解决:通过递归调用快速排序,对子数组A[p...q-1]和A[q+1...r]进行排序;
合并:因为两个子数组是就地排序的,所以不需要额外的操作。
快速排序算法的伪代码:
QUICKSORT(A, p, r) {
1 if p < r {
2 q = PARTITION(A, p, r);
3 QUICKSORT(a, p, q-1);
4 QUICKSORT(a, q+1, r);
5 }
}
这个算法的关键在于数组的划分,即PARTITION:
PARTITION(A, p, r) {
1 x = A[r];
2 i = p;
3 while j = p to r-1 {
4 if A[j] ≤ x {
5 i = i + 1;
6 swap(A[i], A[j]);
7 }
8 }
9 swap(A[i+1], A[r]);
10 return i+1;
}
PARTITION直观上的看是做如下的操作:
我们给出PARTITION代码中第3行至第8行迭代的循环不变式:
每一轮迭代的开始,对于任何数组下标k,有:
1) 如果p≤k≤i,则A[k]≤x。
2) 如果i+1≤k≤j-1,则A[k]>x。
3) 如果k=r,则A[k]=x。
下面便是证明这个循环不变式:
初始化:循环开始前,有i=p-1和j=p。不存在k使得p≤k≤i或i+1≤k≤j-1,所以1)和2)成立。程序第一行代码里的x=A[r]使得条件3)成立。
保持:根据第4行代码的比较结果,有两种情况:
一、A[j] > x时,仅做一个j增加1的操作,所以条件1)和3)不受影响。j增加后A[j-1]>x,又因为A[1...j-2]在迭代前同样都大于x,所以条件2)成立;
二、A[j] ≤ x时,i增加1,因为A[p...i-1]都小于等于x,而A[i]也小于等于x,所以A[p...i]都小于等于x,条件1)成立。j也增加1,与情况1一样,条件2)和条件3)也都成立。
终止:循环结束时j=r,根据条件1) A[p...i]都会小于等于x,而A[i+1...r-1]都大于x。
快速排序的性能
快速排序算法的性能与数组如何被切分有关。在最坏情况下,n个元素的数组被切分为n-1个元素和0个元素的两部分,PARTITION因为要经历n-1次迭代,所以运行代价为Ө(n)。即:
T(n) = T(n-1) + T(0) + Ө(n) = T(n-1) + Ө(n) (元素数为0时,QUICKSORT直接返回,所以运行代价为Ө(1))
利用代换法,可以得到最坏情况下快速排序算法的运行时间为Ө(n^2)。
在最好情况下,每次PARTITION都得到两个元素数分别为floor(n/2)和ceiling(n/2)-1的子数组,这种情况下:
T(n) ≤ 2*T(n/2) + Ө(n)
所以最佳情况下快速排序算法的运行时间为Ө(n*lg(n))。
考虑平均情况,假设每次都以9:1的例划分数组,则得到:
T(n) ≤ T(9*n/10) + T(n/10) + Ө(n)
它的递归树如下:
树的到最近的叶结点的路径长度为log_10(n),在这层之前这棵树每层都是满的,所以运行时间为cn,而越往下直至最底层log_(10/9) (n),每层的代价都会小于cn。所以以9:1划分情况下,总的运行时间
T(n) ≤ log_(10/9) (n) = O(lg(n))
事实上只要以常数比例划分数组的情况,哪怕是99:1,运行时间也仍然为O(lg(n)),只不过O记号中隐含的常量因子要大些。而一般情况下,平均下来的划分情况不应该比9:1差,直观上看来平均情况下快速算法的运行时间为O(lg(n))。
稍后会对快速排序的性能做更深入的分析。
快速排序的随机化版本
因为在平均情况下,快速排序的运行时间为O(n*lg(n)),还是比较快的,所以使所有的输入都能获得较好的平均情况性能,可以使快速排序随机化,即随机选择作为分割点的元素,而不总是数组尾部的元素:
RANDOMIZED_PARTITION(A, p, r) {
1 i = RANDOM(p, r);
2 swap(A[i], A[r]);
3 return PARTITION(A, p, r);
}
RANDOMIZED_QUICKSORT(A, p, r) {
1 if p < r {
2 q = RANDOMIZED_PARTITION(A, p, r);
3 RANDOMIZED_QUICKSORT(A, q-1, p);
4 RANDOMIZED_QUICKSORT(A, q+1, r);
5 }
}
快速排序分析
前面我们从直觉上获得最坏情况下快速排序的运行时间为O(n^2),下面来证明:
设T(n)为最坏情况下规模为n的QUICKSORT的运行时间,则有:
T(n) = max<0≤q≤n-1> (T(q) + T(n-q-1) - 1) + Ө(n) (max(...)表示即所有的划分情况下,运行时间最长的情况所花费的时间)
我们猜测T(n) ≤ c*n^2成立,于是:
T(n) ≤ max<0≤q≤n-1> (c*q^2 + c*(n-q-1)^2) + Ө(n) = c * max<0≤q≤n-1> (q^2 + (n-q-1)^2) + Ө(n)
表达式q^2 + (n-q-1)^2 在取值空间0≤q≤n-1的某个端点取得最大值,因为该表达式关于q的二阶导数是正的(即关于q的二次函数是凹的),所以max<0≤q≤n-1>(q^2 + (n-q-1)^2) ≤ (n-1)^2 = n^2 - 2*n + 1,所以对于T(n)有:
T(n) ≤ c*n^2 - c*(2n-1) + Ө(n) ≤ n^2
因为我们可以选择足够大的常数c,使得项c*(2n-1)可以支配Ө(n),使得不等式成立,所以T(n) = O(n^2)。
同样,我们也可以证明T(n) = Ω(n^2),所以在最坏情况下快速排序的运行时间为Ө(n^2)。
下面来分析一下平均情况。快速排序主要在递归地调用PARTITION过程。我们先看下PARTITION调用的总次数,因为每次划分时,都会选出一个主元元素(作为基准、将数组分隔成两部分的那个元素),它将不会参与后续的QUICKSORT和PATITION调用里,所以PATITION最多只能执行n次。在PARTITON过程里,有一段循环代码(第3至第8行,将各元素与主元元素比较,并根据需要将元素调换)。我们把这段循环代码单独提出来考虑,这样在每次PATITIOIN调用里,除循环代码外的其它代码的运行时间为O(1),所以在整个排序过程中,除循环代码外的其它代码的总运行时间为O(n*1) = O(n)。
接下来分析整个排序过程中,上述循环代码的总运行时间(注意:不是某次PATITION调用里的循环代码的运行时间)。可以看到在循环代码里,数组中的各个元素之间进行比较。设总的比较次数为X,因为一次比较操作本身消耗常量时间,所以比较的总时间为O(X)。如此整个排序过程的运行时间为O(n+X)。
为了得到算法总运行时间,我们需要确定总的比较次数X的值。为了便于分析,我们将数组A中的元素重新命名为z_1,z_2,z_3,...,z_n。其中z_i是数组A中的第i小的元素。此外,我们还定义Z_i_j = {z_i, z_(i+1), ..., z_j}为z_i和z_j之间(包含这两个元素)的元素集合。
我们用指示器随机变量X_i_j = I{z_i与z_j进行比较}。这样总的比较次数:
X = ∑ ∑
求期望得:
E[X] = E[∑ ∑
注意两个元素一旦被划分到两个不同的区域后,则不可能相互进行比较。它们能进行比较的条件只能为:z_i和z_j在同一个区域,且z_i或z_j被选为主元元素,这样:
Pr{z_i与z_j进行比较} = Pr{z_i或z_j是从Z_i_j中选出的主元元素} = Pr{z_i是从Z_i_j中选出的主元元素} + Pr{z_j是从Z_i_j中选出的主元元素}
= 1/(j-i+1) + 1/(j-i+1) = 2/(j-i+1) (因为两事件互斥,所以概率可以直接相加)
得到x_i_j的概率后,就可以得到总的比较次数:
E[X] = ∑ ∑
设变量k = j - 1,则上式变为:
E[X] = ∑ ∑
< ∑ ∑
= ∑ O(lg(n)) (调合级数求和)
= O(n*lg(n))
所以在平均情况下快速排序的运行时间为O(n*lg(n))。
第8章 线性时间排序
前面介绍过的排序算法都是通过比较各个元素来确定最后的排列顺序,这类都属于比较排序算法。比较排序可以被抽象地视为决策树。(书上说它是一棵满二叉树,但它明显不是)。比如含有对三个元素排序的决策树如下:
可以看到,根据不同的比较结果,每个叶结点都是n个元素的一个排列。设比较排序的决策树高为h,可达叶结点数为l。因为n个元素共有n!种排列,而树的叶子树至多为2^h,所以n! ≤ l ≤ 2^h。
对上式取对数,则有h ≥ lg(n!) = Ω(n*lg(n)) (根据斯特林近式公式可以推出 lg(n!)=θ(n*lg(n)),理解不了就强记好了)。
综上所述,任意一个比较排序算法的运行时间为Ω(n*lg(n)),所以运行时间为O(n*lg(n))的堆排序和合并排序都是渐近最优的比较排序算法。
计数排序
不同于比较排序,计数排序不需要通过比较来确定元素的位置。当n个输入的每一元素都为介于0~k的整数,此处k为整数,可以使用计数排序。
简单来说,计数排序记录在n个输入中,值为0, 1, ..., k分别有多少个,之后根据记录好的个数来决定各元素应该摆放的位置。
下面是伪代码,A为输入的数组,B为排好序的数组,k为输入数组中元素可能的最大值,C数组用来计数:
COUNTING-SORT(A, B, k) {
1 for i = 0 to k
2 C[i] = 0;
3 for j = 1 to A.length
4 C[A[j]] += 1;
// 此时C[i]为值等于i的元素的个数
5 for i = 1 to k
6 C[i] += C[i-1];
// 此时C[i]为值小于等于i的元素的个数
7 for j = A.length downto 1 {
8 B[C[A[j]]] = A[j];
9 C[A[j]] -= 1;
10 }
}
下面是某个具体的计数排序的过程:
代码第1~2行运行时间为θ(k),第3~4行运行时间为θ(n),第5~6行运行时间为θ(k),第7~10行运行时间为θ(n),所以计数排序总的运行时间为θ(n+k)。当k=O(n)时,它的运行时间为θ(n)。
计数排序是稳定的:值相同的元素的相对次序在排序后保持不变。这归功于代码第7行中A数组中的元素从后开始复制到B中,否则相对次序就被打乱了。
基数排序
如果待排序的每个元素都为一个d位数,每个数位可以取k种可能的值,(比如十进制里的246是一个3位数,每个数位可以取0~9共10种可能的值。)第1位为最低位,而第d位为最高位,我们便可以依次对每一个位进行排序,当所有的位数都排好序时后,所有的元素也都排好序了。下面演示一个对三位十进制数的排序过程:
注意到每次排序的结果必须是稳定的,否则会出错。比如上例中,在排好第二位后,329在355的前面,之后在对最高位排序时,必须保持这个次序。利用不稳定的排序算法对最高位排序时,329可能会排到355的后面,因为最高位是相等的,都是3。
所以基数排序的伪代码为:
RADIX_SORT(A, d) {
1 for i = 1 to d
2 use a stable sort to sort array A on digit i
}
前面我们已经介绍过计数排序,它是稳定的,所以我们可以用它来实现基数排序。这样上面代码中每次迭代的代价为Ө(n+k),基数排序总的运行时间可达到Ө(d*(n+k)),其中d为元素的位素,k为每位可取的值。当d为常数,k为O(n)时,基数排序有线性时间。
接下来,我们针对计算机特例,把所有的数都视为二进制数。若待排序的每个元素都为b位(二进制)数,我们把它分成大小为r个二进制位的各个部份,这样共用b/r个部分,每部分的取值范围为2^r-1。(比如一个32位数,分成4部份,每部份都有8位,取值范围为0~2^8-1。)这样,我们便可以使用基数排序的方法,对每部份分别排序。前面分析基数排序的运行时间为Ө(d*(n+k)),这里d=b/r,k=2^r-1,所以总的运行时间为Ө((b/r)*(n+2^r))。
如果b
基数排序是否比基于比较的排序(如快速排序)更好呢?看上去基数排序在b=O(lg(n)),r≈lg(n)时运行时间为Ө(n),渐近优于快速排序的平均情况Ө(n*lg(n)),但是基数排序的Ө(n)里隐含的常数因子很大,所以基数排序并不一定快于快速排序。而且基数排序所使用的计数排序不是原地排序,所以需要额外的内存。
桶排序
A为待排序的数组,所有的元素都均匀而独立地落在[0,1)的区间里。一个辅助数组B,分成与A中元素个数相同的区间,每个区间都称为一个桶,它是一个链表。我们将A中的元素A[i]放入到B[floor(n*A[i])]里,比如A中共10个元素,值为0.1的元素就放到B[floor(10*0.1)],即B[1]里的桶里。可以看到,B中每个桶里的元素都要大于其前一个桶里的元素,比如B[6]里的元素都要比B[5]里的元素大。如果我们再对每个桶里的链表里的元素进行排序的话,我们便可以在B中得到一个有序数列。
下面是桶排序的伪代码:
BUCKET_SORT(A) {
1 n = A.length;
2 for i = 1 to n
3 insert A[i] into list B[floor(n*A[i])];
4 for i = 0 to n-1;
5 sort list B[i] with insertion sort;
6 concatenate the lists B[0], B[1], ..., B[n-1] together in order
}
从上面代码里可以看到第2~3行最坏情况下(所有的元素都放入同一个桶里)的运行时间为Ө(n),第6行的运行时间也为Ө(n)。设n_i为表示桶B[i]里元素个数的随机变量,而对链表进行插入排序的运行时间为Ө(n_i^2),总的运行时间为:
T(n) = Ө(n) + ∑O(n_i^2)
对两边取期望,得:
E(Tn) = E[Ө(n) + ∑O(n_i^2)]
= Ө(n) + ∑E[O(n_i^2)]
= Ө(n) + ∑O(E[n_i^2])
只要我们能求出E[n_i^2],上式便能得解。为此,我们定义指示器随机变量
X_i_j = I{A[j]落在桶i中}
于是有n_i = ∑
E[n_i^2] = E[(∑
= E[∑
= ∑
X_i_j为1的概率为1/n,于是有:
E[X_i_j^2] = 1^2 * 1/n = 1/n
当k≠j时,变量X_i_j和X_i_k是独立的,所以有:
E[X_i_j * X_i_k] = E[X_i_j] * E[X_i_k] = 1/n * 1/n = 1/n^2
把上面两式代入求n_i^2期望的式子里得:
E[n_i^2] = ∑
= n * 1/n + n*(n-1)*1/n^2 = 1 + (n-1)/n = 2 - 1/n
所以对桶内的所有链表的排序在平均情况下运行时间为Ө(2 - 1/n),而桶排序的总运行时间为Ө(n) + Ө(2 - 1/n) = Ө(n)
第9章 中位数和顺序统计学
在一个由元素组成的集合里,第i个顺序统计量(order statistic)是该集合第i小的元素。例如,最小值是第1个顺序统计量(i=1),最大值是第n个顺序统计量(i=n)。一个中位数(median)是它所在集合的“中点元素”。当n为奇数时,中位数是唯一的,i=(n+1)/2;当n为偶数时,中位数有两个,一个是i=floor((n+1)/2)(下中位数),另一个是i=ceiling((n+1)/2)(上中位数)。在不考虑n的奇偶性的情况下,中位数总是出现在i=floor((n+1)/2)处和i=ceiling((n+1)/2)处。
最小值和最大值
在一个有n个元素的集合里,要找到最小元素,可以用下面的代码:
MINIMUM(A) {
1 min = A[1];
2 for i = 2 to A.length
3 if (min > A[i])
4 min = A[i];
5 return min;
}
为了找到最小元素,每个元素都必须参与比较,所以n-1次比较是必须的。从比较次数来看,算法MINIMUM是最优的。
如果要同时找出最大值和最小值,我们很容易想到让每一个元素分别和min和max比较,这样每个元素要进行2次比较,而总的比较数就为2*n-2。事实上只需3*floor(n/2)次比较就可以同时找到最小值和最大值。我们并不让每个元素同时与min和max比较,而是取两个元素先比较,较大的元素与max比较,而较小的元素与min比较,这样每两个元素要做3次比较,总的比较次数最多为3*floor(n/2)。
以期望线性时间做选择
如果我们不选最大值或最小值,而是选一个第i小的值。我们可以用一种分治算法——RAMDOMIZED_SELECT,它以快速排序为模型:把数组随机划分为两部分,A[p...q-1]的元素比A[q]小,A[q+1...r]的元素比A[q]大。与快速排序不同,如果i=q,则A[q]就是要找的第i小的元素,返回这个值;如果i < q,则说明第i小的元素在A[p...q-1]里;如果i > q,则说明第i小的元素在A[q+1...r]里。
下面是在A[p...r]中找到第i小元素的代码:
RANDOMIZED_SELECT(A, p, r, i) {
1 if p == r
2 return A[p];
3 q = RANDOMIZED_PARTITION(A, p, r);
4 k = q-p+1;
5 if i == k
6 return A[q];
7 elseif i < k
8 return RANDOMIZED_SELECT(A, p, q-1, i);
9 else return RANDOMIZED_SELECT(A, q+1, r, i-k);
}
在最坏情况下,数组被划分为n-1和0两部分,而第i个元素总是落在n-1的那部分里,运行时间为Ө(n^2)。但随机算法更关心的是平均情况:
设指示器随机变量X_k = I{子数组A[p...q]中恰有k个元素}
这样,元素数量为n的数组在每次划分时,就会被划分为1...k-1, k, k+1...n三部分。下次递归时,就可以作用在1...k-1(共k-1个元素)区间或k+1...n(共n-k个元素)区间上。考虑X_k所有可能的取值,则可以得到递归式:
T(n) ≤ ∑
= ∑
由于X_k为1的概率为1/n,取期望值,得到:
E(T(n)) ≤ E[∑
= ∑
= ∑
= ∑
考虑max(k-1, n-k),如果k > ceiling(n/2),其值为k-1;若k ≤ ceiling(n/2),其值为n-k。所以
∑
E(T(n)) ≤ 2/n * ∑
用代换法解上式,可以得到E(T(n))=O(n)。也就是说在平均情况下,任何顺序统计量(特别是中位数)都可以在线性时间内得到。
最坏情况线性时间的选择
相比于上面的随机选择,我们有另一种类似的算法,它在最坏情况下也能达到O(n)。它也是基于数组的划分操作,而且利用特殊的手段保证每次划分两边的子数组都比较平衡。
首先我们需要稍微修改一下PARTITION算法(不是RANDOMIZED_PARTITION),它接收一个数组和一个值x,并把它划分为小于x和大于x的两部分(x为A中某个元素的值):
PARTITION_X(A, p, r, x) {
1 for i = 1 to n
2 if A[i] == x {
3 swap(A[i], A[n-1]);
4 break;
5 }
6 return PARTITION(A, p, r);
}
在修改划分算法后,我们通过以下步骤来实现在n个元素的数组中找第i小元素的SELECT:
1、把数组A分成ceiling(n/5)个组,除最后一组外,每组都有5个元素,最后一组有n mod 5个元素;
2、对每组(的五个元素)用插入法进行排序,然后选出该组的中位数,即排好序的第三个元素;
3、对于步骤2中得到的所有的中位数,通过递归调用SELECT来找到它们的(下)中位数x,(也就是找到第2步得到的所有中位数中第floor(ceiling(n/5) / 2)小个元素);
4、利用修改后的划分算法把元素分成小于x和大于x的两个子数组。如果设k为划分低区的元素个数加一,则x就是A中第k小的元素;
5、如果i = k,那我们就返回x,它便是我们要找的值。如果i < k,我们就在第4步里的划分低区继续递归调用SELECT来找到第i小的元素;如果i > k,我们就在划分高区递归调用SELECT找第i-k小的数。
下面是伪代码:
SELECT(A, p, r, i) {
// 步骤1、2
1 count = ceiling(n/5);
2 for i = 1 to count-1
3 insertion sort A[(i-1)*5+1...i*5+1];
4 insertion sort A[(count-1)*5+1...n];
5 if count ==1
6 return A[floor(n/2)];
// 步骤3
7 create array B;
9 for i = 1 to count-1
10 B[i] = A[(i-1)*5+3];
11 B[count] = A[(count-1)*5 + floor((n - (count-1)*5)/2)];
12 x = SELECT(B, 1, count, floor(count/2));
// 步骤4
13 q = PARTITION_X(A, p, r, i);
14 k = q-p+1;
// 步骤5
15 if i == k
16 return x;
17 elseif i < k
18 return SELECT(A, p, q-1, i);
19 else
20 return SELECT(A, q+1, r, i-k);
}
(上面的伪代码原书没有,博主根据书中描述得到的。)
下面分析下SELECT算法的性能。第2~4行中,共执行了ceiling(n/5)次排序操作,每次插入排序的代价都为O(1),所以这几行总的代价为O(n);第9~11行的创建数组B的代价为O(n);第12行里,SELECT的输入规模为ceiling(n/5),所以运行时间为T(ceiling(n/5));第13行划分数组的代价为O(n);第18行,因为x是所有组里中位数的中位数,这样除最后组与包括x本身的组,至少有一半的组里有3个元素大于x,为3*(1/2 * floor(n/5) - 2) ≥ 3*n/10 -6,这样也意味着最多有n - (3*n/10-6) = 7*n/10 + 6个元素小于x,所以第18行低区元素的规模不会超过7*n/10+6;同理,第20行SELECT的输入规模也不会超过7*n/10+6。
综上所述,可以得到递归式:
T(n) ≤ T(floor(n/5)) + T(7*n/10+6) + O(n)
用代换法来解上面的递归式,设c, a为正常数得:
T(n) ≤ c*(floor(n/5)) + c*(7*n/10+6) + a*n
≤ c*(n/5) + c*(7*n/10+6) + a*n
= 9*c*n/10 + 7*c + a*n
= c*n + (-c*n/10 + 7*c + a*n)
为使T(n) ≤ c*n成立,-c*n/10+7*c+a*n ≤ 0必须成立。当n>70时,只需c ≥ 10*a*(n/(n-70)) 不等式成立。假设n > 140,有n/(n-70) ≤ 2,所以选c ≥ 20*a就可以。所以SELECT算法在最坏情况下运行时间为O(n)。
算法所操作的集合可以随时间改变而增大、缩小或产生其它变化,我们称这种集合是动态的。动态集合上的操作可分为两类:查询操作,返回有关集合的信息;修改操作,对集合进行修改。下面是一些典型的操作:
SEARCH(S, k):给定一个集合S和一个关键字k,返回指向S中一个元素的指针x,使得x.key = k。
INSERT(S, x):一个修改操作,将由x指向的元素添加到S中去。
DELETE(S, x):修改操作,将指向S中某元素的指针x从S中删除。
MINIMUM(S):返回指向S中具有最小关键字的元素的指针。
MAXIMUM(S):返回指向S中具有最大关键字的元素的指针。
SUCCESSOR(S, x):返回S中比x大的下一个元素的指针。当x为最大元素时,返回NIL。
PREDECESSOR(S, x):返回S中比x小的下一个元素的指针。当x为最小元素时,返回NIL。
第10章 基本数据结构
栈和队列
栈是后进先出(last-in, first-out,LIFO)的结构,它的INSERT操作称为压入(PUSH),而无参数的DELETE操作称为弹出(POP)。栈有一属性top,指向最近插入的元素。
下面是相关操作的伪代码(用数组实现):
STACK_EMPTY(S) {
1 if S.top == 0
2 return TRUE;
3 else return FALSE;
}
PUSH(S, x) {
1 S.top += 1;
2 S[S.top] = x;
}
POP(S) {
1 if STACK_EMPTY(S)
2 error "underflow";
3 else {
4 x = S[S.top];
5 S.top -= 1;
6 return x;
7 }
}
上述三种操作运行时间都为O(1)。
用数组实现的队列看起来是这样:
各操作的伪代码如下,(不考虑上溢与下溢的情况):
ENQUEUE(Q, x) {
1 Q[Q.tail] = x;
2 if Q.tail == Q.length
3 Q.tail = 1;
4 else Q.tail += 1;
}
DEQUEUE(Q) {
1 x = Q[Q.head];
2 if Q.head == Q.length
3 Q.head = 1
4 else Q.head += 1;
5 return x;
}
上述两种操作运行时间都为O(1)。
一个典型的(双向)链表看起来会像这样:
它的相关操作为:
LIST_SEARCH(L, k) {
1 x = L.head;
2 while x ≠ NIL and x.key ≠ k
3 x = x.next;
4 return x;
}
(在链表头)插入:
LIST_INSERT(L, x) {
1 x.next = L.head;
2 if L.head ≠ NIL
3 L.head.prev = x;
4 L.head = x;
5 x.prev = NIL;
}
删除:
LIST_DELETE(L, x) {
1 if x.prev ≠ NIL
2 x.prev.next = x.next;
3 else
4 L.head = x.next;
5 if x.next ≠ NIL
6 x.next.prev = x.prev;
}
链表的插入和删除运行时间为O(1),查找的运行时间为O(n)。
它的相关操作为:
LIST_DELETE'(L, x) {
1 x.prev.next = x.next;
2 x.next.prev = x.prev;
}
LIST_SEARCH'(L, k) {
1 x = L.nil.next;
2 while x ≠ L.nil and x.key ≠ k
3 x = x.next;
4 return x;
}
LIST_INSERT'(L, x) {
1 x.next = L.nil.next;
2 L.nil.next.prev = x;
3 L.nil.next = x;
4 x.prev = L.nil;
}
我们可以用三个数组key,next和prev来实现:
也可以用一个数组实现,这样的好处是比较灵活,允许同一数组存放不同长度的对象:
可以看到数组中有些是未使用的空间(可以在此空间创建新的对象),其它的都是已经存放了对象的空间。我们需要一个机制来管理这些空闲的空间,它便是自由表。
下图的数组里维护了两个(双向)链表,同时用一个自由表(单向链表)来管理空闲的空间:
分配和去配(释放)对象的伪代码如下:
ALLOCATE_OBJECT() {
1 if free == NIL
2 error "out of space";
3 else {
4 x = free;
5 free = free.next;
6 return x;
7 }
}
FREE_OBJECT(x) {
1 x.next = free;
2 free = x;
}
上面管理空闲空间的两个过程运行时间都为O(1)。
有根树的表示
二叉树可以这样表示:每个结点维护三个域,分别存放指向父亲、左儿子和右儿子的指针。看下图:
而对于有任意数量子女的有根树来说,用以上方法,维护child1、child2、...、childn指针分别指向各子女是不切实际的,因为首先这些指针的总数是不确定的。如果每个结点都维护很大数量的子结点指针的话,对于一些并没有这么多子女的结点来说无疑是一种浪费。我们可以用二叉树来表示这样的有根树:它有三个域,left-child指向它的最左孩子,right-sibling指向它的右兄弟,parent指向它的父亲:
第11章 散列表
直接寻址表
假设一个集合里关键字的全域U比较小时,可以使用直接寻址。比如全域为0~9的整数集,可以用一个大小为10的数组来存放该集合的子集:
它的相关操作比较直接:
DIRECT_ADDRESS_SEARCH(T, k) {
1 return T[k];
}
DIRECT_ADDRESS_INSERT(T, x) {
1 T[x.key] = x;
}
DIRECT_ADDRESS_DELETE(T, x) {
1 T[x.key] = NIL;
}
以上操作运行时间都为O(1)。
散列表
大多数情况下,关键字的全域都是比较大的,我们很难用一个如此大的数组来存放数据,而且实际使用到的关键字可能会比全域的数量小很多,所以我们可以用一个较小的数组,利用散列函数h,根据关键字k计算出数组中的位置。
下图把一个全域为U的集合通过散列函数h映射到大小为m的数组中:
在选择散列函数时,我们会尽可能的把全域中的关键字均匀地散列到数组中,但有时我们仍不能避免出现碰撞(collision),即不同的关键字被散列到同一个槽里。链接法就是解决碰撞的一种方式:
在碰撞情况出现时,用一个链表维护被映射到同一个槽里的元素。它的相关操作的伪代码如下:
CHAINED_HASH_INSERT(T, x) {
1 insert x at the head of list T[h(x.hey)];
}
CHAINED_HASH_SEARCH(T, k) {
1 search for an element with key k in list T[h(k)];
}
CHAINDED_HASH_DELETE(T, x) {
1 delete x from the list T[h(x.key)];
}
接下来分析查找特定元素的性能。对于一个存放了n个元素,具有m个槽的散列表T,定义它的装载因子(load factor)a为n/m,即每个槽里链表的平均长度。a可以小于、等于或大于1。
在最坏情况下,所有的元素都被散列到同一个槽里,这样查找的运行时间为Ө(n)。
为了分析平均情况,我们假设所有元素被散列到m个槽中的每一个的可能性是相同的,这个假设为简单一致散列(simple uniform hashing)。
在查找不成功的情况下,(即关键字k不在散列表中,)我们会遍历某个槽的链表的所有元素,而该链表的元素数量的平均值为a,所以这种情况下运行时间为Ө(1+a)。
在查找成功的情况下,遍历的次数是由x在其所在链表中位置决定的。因为每次插入都是插在链表头上,所以x前的元素都是在x之后放入散列表的。
设k_i为第i个插入到表中的元素,i = 1, 2, ..., n。而k_j为在k_i后插入到表中的元素,j = i+1, ..., n。定义指示器变量X_i_j = I{h(k_i) = h(k_j)},即两个元素被散列到同一个槽。在k_i已经存在于散列表的情况下,k_j和k_i被散列到到同一个槽的概率为1/m,即E[X_i_j] = 1/m。我们就是要求在x_i之后插入到表且与x_i被散列到同一个槽的元素个数(包括x_i本身)的期望值:
E[1/n * ∑(1 + ∑
= 1/n * E[∑(1 + ∑
= 1/n * E[∑(1 + ∑
= 1 + 1/(m*n) * ∑(n-i)
= 1 + 1/(m*n) * ∑ n - ∑ i
= 1 + 1/(m*n) * (n^2 - n*(n+1) / 2)
= 1 + (n-1) / (2*m) = 1 + a/2 + a/(2*n)
上面求出便是为成功找出某特定元素,要遍历的元素的平均个数。所以在平均情况下,一次成功的查找所需的时间为Ө(1+a/2+a/(2*n)) = Ө(1+a)。所以综上所述,平均情况下散列表查找的运行时间为Ө(1+a)。于是得到运行时间与散列表的装载因子有关。当装载因子与元素数目n成正比时,散列表插入、删除与查找的运行时间都为O(1)。
散列函数
一个好的散列函数应该(近似地)满足简单一致散列的假设:元素被等可能地散列到各槽中。散列函数都假定关键字域为自然数集N={0, 1, 2, ...}。如果关键字不是自然数,必须有一种方法来将它们解释为自然数。比如字符串pt所对应的ASCII码为(112, 116),我们以128为基数,pt可以表示为(112*128)+116 = 14452。
除法散列法通过取k除以m的余数,来将关键字k映射到m个槽的某一个中去。亦即,散列函数为:
h(k) = k mod n
m的值不应为2的幂,因为如果m = 2^p,则h(k)就是k的最低p位所表示的数字。我们常常选与2的整数幂不太接近的质数。比如,有n=2000个元素,用链接法解决碰撞,我们可以忍受不成功的查找平均要检查3个元素,所以我们选择接近2000/3,但不接近2的任何次幂的m = 701。
乘法散列法包含两个步骤:1、用关键字乘上常数A(0 h(k) = floor(m*(k*A mod 1) )
mod 1的意思就是取小数部分。k*A mod 1即为k*A - floor(k*A)。
乘法方法的一个优点是对m的选择没什么特别的要求。一般选m为2的某次幂(2^p,p为某个整数),因为在计算机上比较容易实现:假设计算机字长为w,我们限制A值为形如s/(2^w)的分数,其中0 1、将k与s相乘,它将得到一个2*w位的乘积,值形如R1*2^w + R0;
2、对低w位的R0,取高位的p位,便是散列后的结果。
操作如下图:
对上面的操作解释下,k * A = k*s/(2^w) = (R1*2^w + R0) / (2^w)。因为R1*2^w+R0是一个整数(因为s和k都为整数),所以除以2^w后,R1便是小数点左部的整数部分,而R0成了小数点右侧的小数部分。于是k*A mod 1 = .R0。而m = 2^p,所以m * (k*A mod 1) = 2^p * .R0,等同于.R0左移p位,所以R0中高的p位成了整数部分,floor(m*(k*A mod 1))只取R0的高p位,这便是h(k)的值。
尽管上述方法对任意A值都适用,但对某些值效果更好。Knuth[185]认为
A ≈ (√5 - 1) / 2 = 0.618 033 988 7...
是比较理想的值。举个例子,在32位机上,字长w=32,取A为形如s/(2^32)的分数,为使它与(√5 - 1) / 2最接近,于是取s为2 654 435 769。
全域散列
为了尽可能地避免最坏情况的发生,我们不使用某个特定的散列函数,而是准备好一系列的散列函数,在执行开始时随机选择一个作为之后的散列函数。这种方法称作全域散列(universal hashing)。
设H为有限的一组散列函数,它将给定的关键字域U映射到{0, 1, ..., m-1}中,这样的一个函数组称为是全域的(universal),如果它满足以下条件:
对每一对不同的关键字k,l ∈ U,满足h(k) = h(l)的散列函数h∈H的个数至多为|H| / m。换言之,如果从H中随机选择一个散列函数,当k≠l时,两者发生碰撞的概率不大于1/m。
对使用全域散列函数的散列表,其用链接法处理碰撞的,包含某关键字k的链表的期望长度至多为1+a,其中a为装载因子。
下面是一个全域散列函数类:
选择足够大的质数p,使每一个可能的关键字k都落到[0, p-1]的范围内。设Z_p = {0, 1, ..., p-1},设Z_p* = {1, 2, ..., p-1}。对于任何a∈Z_p*,和任何b∈Z_p,定义散列函数:
h_a_b (k) = ((a*k+b) mod p) mod m
所有这样的散列函数构成的函数簇为:
H_p_m = {h_a_b:a∈Z_p*和b∈Z_p}
证明略。有兴趣可以参考原书11.3节。
开放寻址法
在开放寻址法(open addressing)中,所有元素都存放在散列表里,每个表项或包含一个元素,或包含NIL,但不会包含链表或其它的处于散列表外的辅助结构。
当插入一个元素时,如果映射的位置已经被其它元素占用,则通过散列函数再产生另一个映射值(称为探查),直到找到空槽或发现表中没有空槽为止。同样,散列函数也需要相应的变化:
h:U X {0, 1, ..., m-1} → {0, 1, ..., m-1} (即散列函数多了另一个参数——散列的次数}
对开放寻址法来说,要求对每一个关键字k,探查序列{h(k, 0), h(k, 1), ..., h(k, m-1)}必须是{0, 1, ..., m-1}的一个排列,即散列函数h在连续对同一个关键字k进行散列时,每次得到的都是不一样的值。
下面是插入元素的伪代码:
HASH_INSERT(T, k) {
1 i = 0
2 do {
3 j = h(k, i);
4 if T[j] == NIL {
5 T[j] = k;
6 return j;
7 }
8 else
9 i += 1;
10 } while i≠m
11 error "hash table overflow"
}
再插入过程,如果有碰撞发生,就增加h的第二个参数的值,直到找到空槽或上溢为止。
下面是查找关键字k的伪代码:
HASH_SEARCH(T, k) {
1 i = 0;
2 do {
3 j = h(k, i);
4 if (T[j] == k)
5 return j;
6 else
7 i += 1;
8 } while i ≠ m and T[j] ≠ NIL
9 return NIL;
}
删除操作执行起来比较困难,当我们从槽i中删除关键字时,不能简单地让T[i]=NIL,因为这样会破坏查找的过程。假设关键字k在i之后插入到散列表中,如果T[i]被设为NIL,那么查找过程就再也找不到k了。解决这个问题的方法是引入一个新的状态DELETED,而不是NIL,这样在插入过程中,一旦发现DELETED的槽,便可以在该槽中放置数据,而查找过程不需要任何改动。但如此一来,查找时间就不再依赖于装载因子了,所以在必须删除关键字的应用中,往往采用链接法来解决碰撞。
有三种技术常用来计算开放寻址法中的探查序列:线性探查、二次探查和双重探查。
给定一个普通的散列函数h':U→ {0, 1, ..., m-1}(称为辅助散列函数),线性探查(linear probing)方法采用的散列函数为:
h(k, i) = (h'(k) + i) mod m, i = 0, 1, ..., m-1
它在碰撞发生后,便依次探查当前槽的后一个槽,到T[m-1]后绕回到T[0]继续探查,直到最开始发生碰撞的槽的前一个槽。
线性探查方法比较容易实现,但它存在一个问题,称作一次群集(primary clustering)。随着时间的推移,连续被占用的槽不断增加,平均查找的时间也随着不断增加。
二次探查(quadratic probing)采用如下形式的散列函数:
h(k, i) = (h'(k) + c1*i + c2*i^2) mod m
c1和c2为常量。这种探查方法的效果比线性探查好很多,但c1, c2, m的取值受到限制。此外,如果两个关键字的初始探查位置相同,那么它们的探查序列也是相同的,即h(k1, 0) = h(k2, 0)意味着h(k1, i) = h(k2, i),这一性质可导致一种程度较轻的群集现象,称为二次群集(secondary clustering)。
双重散列是用于开放寻址法的最好方法之一,因为它产生的排列近似于随机选择的排列。它采用如下形式的散列函数:
h(k, i) = (h1(k) + i*h2(k)) mod m
为了能查找整个散列表,值h2(k)要与表的大小m互质。有两种方法:1、m为2的幂,而h2总产生奇数;2、取m为质数,h2则总是产生比m小的正整数。
线性探查和二次探查都只能产生m种不同的序列,而双重散列可以产生m^2种,这样已经与“理想的”一致散列的性能很接近了。
开放寻址法的性能分析。
给定一个装载因子a = n/m < 1的开放寻址散列表,定义随机变量X为在一次不成功的查找中所做的探查数,定义事件A_i(i = 1, 2, ...)为进行了第i次探查,且探查到的是一个已被占用的槽的事件,那么事件{X≥i}即为事件A_1∩A_2∩...∩A_(i-1)的交集。因为有n个元素,m个槽,所以Pr{A_1} = n/m,在第一次碰撞发生后,我们便在其它的槽中寻找空槽,此时再遇碰撞概率Pr{A_2|A_1}为(n-1)/(m-1),因为剩下n-1个元素要分布到余下的m-1个槽中。同理,在第j次尝试时,遇碰撞的概率Pr{A_j | A_1∩A_2∩...A_(j-1)}为(n-j-1)/(m-j-1)。于是:
Pr{X≥i} = n/m * (n-1)/(m-1) * (n-2)/(m-2) * ... * (n-i-2)/(m-i-2) ≤ (n/m)^(i-1) = a^(i-1)
所以对X求期望来确定探查数的平均值:
E[X] = ∑ Pr{X≥i} ≤ ∑ a^(i-1) = ∑ a^i = 1/(1-a) (无限等比数列求和)
根据上面的结论,假设采用的是一致散列,平均情况下,向一个装载因子为a的开放寻址散列表插入一个元素时,至多需要做1/(1-a)次探查。
对于散列表中的任何关键字k,设它为第i+1个插入到表中的元素,那么要找到它,则至多需要做1/(1-a)=1/(1-i/m)=m/(m-i)次探查,所以我们对所有的关键字求探查数的平均:
1/n*∑ m/(m-i) = m/n * ∑ 1/(m-i)
= m/n * (∑
= 1/a * (∑
≤ 1/a * (ln(m) - ln(m-n)) (调和级数求和)
= 1/a * ln(m/(m-n))
= 1/a * ln(1/(1-a))
所以一次成功的查找平均需要探查的次数为1/a * ln(1/(1-a))。
完全散列
如果某种散列技术可以在查找时,最坏情况内存访问次数为O(1)的话,则称其为完全散列(perfect hashing)。当关键字集合是静态的时,这种最坏情况的性能是可以达到的。所谓静态就是指一旦各关键字存入表中后,关键字集合就不再变化了。
我们可以用一种两级的散列方案来实现完全散列,其中每级上采用的都是全域散列。如下图:
首先第一级使用全域散列把元素散列到各个槽中,这与其它的散列表没什么不一样。但在处理碰撞时,并不像链接法一样使用链表,而是对在同一个槽中的元素再进行一次散列操作。也就是说,每一个(有元素的)槽里都维护着一张散列表,该表的大小为槽中元素数的平方,例如,有3个元素在同一个槽的话,该槽的二级散列表大小为9。不仅如此,每个槽都使用不同的散列函数,在全域散列函数簇h(k) = ((a*k+b) mod p) mod m中选择不同的a值和b值,但所有槽共用一个p值如101。每个槽中的(二级)散列函数可以保证不发生碰撞情况。
可以证明,当二级散列表的大小为槽内元素数的平方时,从全域散列函数簇中随机选择一个散列函数,会产生碰撞的概率小于1/2。(证明略,详细参考原书11.5节。)所以每个槽随机选择散列函数后,如果产生了碰撞,可以再次尝试选择其它散列函数,但这种尝试的次数是非常少的。
虽然二级散列表的大小要求是槽内元素数的平方,看起来很大,但可以证明,当散列表的槽的数量和元素数量相同时(m=n),所有的二级散列表的大小的总量的期望值会小于2*n,即Ө(n)。(证明过程参考有所原书11.5节。)
第12章 二叉查找树
查找树(search tree)是一种数据结构,它支持多态集合操作,包括SEARCH,MINIMUM,MAXIMUM,PREDECESSOR,SUCCESSOR,INSERT和DELETE。
二叉查找树是用二叉树结构来组织的查找树。它满足这样的性质:每个结点的左子树中所有的元素都小于等于该结点的值,而该结点同时小于等于它的右子树中的所有元素的值。基于这个性质,只需一个中序遍历便可以按由小到大的顺序遍历树内的所有的元素:
INORDER_TREE_WALK(x) {
1 if x ≠ NIL {
2 INORDER_TREE_WALK(x.left);
3 print x.key;
4 INORDER_TREE_WALK(x.right);
5 }
}
其实从直观上,可以发现上面的代码就是遍历树中的所有的元素,所以它的运行时间为Ө(n)。但我们还是稍微证明下:
查询二叉查找树
下面的代码在二叉查找树中查找特定的关键字:
TREE_SEARCH(x, k) {
1 if x == NIL or x.key == k
2 return x;
3 if k < x.key
4 return TREE_SEARCH(x.left, k);
5 else
6 return TREE_SEARCH(x.right, k);
}
很明显,查找时会从根结点一直沿着某路径到达所要查找的元素或者叶结点,所以该算法运行时间为O(h),其中h为二叉树的高度。
下面的代码是查找的非递归版本:
ITERATIVE_TREE_SEARCH(x, k) {
1 while x ≠ NULL and k ≠ x.key {
2 if (k < x.key)
3 x = x.left;
4 else
5 x = x.right
6 }
7 return x;
}
下面的代码找出二叉查找树的最小元素:
TREE_MINIMUM(x) {
1 while x.left != NIL
2 x = x.left
3 return x;
}
其实最小元素就是树的最左结点,这是一个从根到叶结点的一条路径,所以它的运行时间为O(h)。
下面找出最大元素:
TREE_MAXIMUM(x) {
1 while x.right != NIL
2 x = x.right;
3 return x;
}
它找出树的最右结点,运行时间也为O(h)。
我们同样可以找到某结点的前趋(predecessor)和后继(successor)(分别为刚好比该结点小和刚好比该结点大的两个结点)。
查找后继:
TREE_SUCCESSOR(x) {
1 if x.right ≠ NIL {
2 return TREE_MINIMUM(x.right);
3 y = x.parent;
4 while y ≠ NULL and x ≠ y.left {
5 x = y;
6 y = y.parent;
7 }
9 return y;
}
如果一个结点有右子树,它的后继便是它右子树中的最小元素;如果它没有右子树,它的后继便是离它最近的并把它当成左子树部分的祖先。可以发现它不是从上往下查找便是从下往上查找,所以它的运行时间也为O(h)。
插入与删除
插入时,从根开始向下查找,直到发现某结点,使得插入的元素可以作为叶结点直接成为该结点的子女,而不改变现有的其它元素的结构。
下面是插入的代码:
TREE_INSERT(T, z) {
1 x = NIL;
2 y = T.root;
3 while y ≠ NIL {
4 x = y;
5 if z.key ≤ y.key
6 y = y.left;
7 else
8 y = y.right;
9 }
10 if x == NIL // tree was empty
11 T.root = z;
12 else
13 if z.key ≤ x.key
14 x.left = z;
15 else
16 x.right = z;
}
插入时主要的操作也是查找,所以它的运行时间为O(h)。
删除一个元素要更复杂一些,因为我们在删除一个结点后,还要维护整棵树的二叉查找的性质。我们可以从三种情况考虑:
1、如果要删除的结点没有子女,那么可以直接把这个结点从树中删除;
2、如果要删除的结点只有一个孩子,则不论它是左孩子还是右孩子,用这个孩子代替被删掉的结点,(例如,要删除的结点是其父结点的右孩子,该结点只有一个左孩子,则使它的左孩子成为它的父结点的新的右孩子,再把要删除的结点从树中移除);
3、如果要删除的结点有两个子女,则找到它的后继(右子树的最左结点)来代替这个被删除结点。(之所以这么做,因为它的后继比它的左子树的所有元素都大,又比它的右子树中的所有其它元素小。)
下面是删除的代码:
TREE_DELETE(T, z) {
1 if z.left == NIL or z.right == NIL
2 y = z;
3 else
4 y = TREE_SUCCESSOR(z);
5 if y.left ≠ NIL
6 x = y.left;
7 else
8 x = y.right;
9 if x ≠ NIL
10 x.parent = y.parent;
11 if y.parent == NIL
12 T.root = x;
13 else if y == y.parent.left
14 y.parent.left = x;
15 else
16 y.parent.right = x;
17 if y ≠ z {
18 z.key = y.key;
19 copy y's satellite data into z;
20 }
21 return y;
}
上面代码中,在第一种和第二种情况下(删除的结点没有子女或只有一个子女),y为要删除的结点,x为要删除的结点的孩子(可能为NIL),再把x代替y。在第三种情况下(删除的结点有两个子女),y为要删除的结点的后继,x只可能为该后继的右孩子,将后继的右孩子x代替该后继y,接着在第17行,如果y≠z,则意味着情况三,需要用后继代替被删除的元素,但这里并不是通过改变父亲指针、子女的指针来代替,而是直接把后继的值拷贝到要删除的元素中。
虽然要复杂些,但删除操作中结点替代的操作为常数,主要的性能消耗在对后继的查找中,所以删除操作的运行时间也为O(h)。
综上所述,二叉寻找树的所有操作,包括查找特定关键字、查找最大元素、查找最小元素、查找前趋、查找后继、插入和删除的运行时间都为O(h),其中h为二叉寻找村的高度。
随机构造的二叉寻找树
通过前面的分析可以发现,二叉查找树的性能与树的高度密切相关。我们可以用随机选择插入元素的方式来获得一个好的期望性能:对于n个要插入的元素,每次随机选取其中一个插入,这种方式构造出来的二叉寻找树的期望高度为lg(n)。证明参考原书12.4节。
第13章 红黑树
由前一章我们知道,二叉查找树的性能与树的高度密切相关,所以让树中的元素尽量地平衡在树的两侧,使得树的高度尽量地低,便可提高二叉查找树的性能。红黑树(red-black tree)是许多“平衡的”查找树中的一种。
红黑树的性质
1、每个结点或是红的,或是黑的。
2、根结点是黑的。
3、每个叶结点(NIL)是黑的。
4、如果一个结点是红的,则它的两个儿子都是黑的。
5、对每个结点,从该结点到其子孙的所有路径上包含相同数目的黑结点。
红黑树的结点比普通的二叉查找树的结点多了一个颜色属性。下面就是一棵红黑树:
可以看到所有的叶结点都是NIL,且都是黑的。这些叶结点被称为外结点,除了外结点的其它结点便被称为内结点。所有内结点旁标注的数字是该结点的黑高度,即从该结点出发到达一个叶结点的任意一条路径上的黑色结点的个数(根据性质5所有路径上黑结点个数一样)。
因为所有的叶结点都是一样的,所以我们可以用一个哨兵元素来表示它:
根结点的父亲也可以使用这个哨兵元素。
下面来分析下红黑树的高度。
我们用bh(x)来表示结点x的黑高度,先来用归纳法来证明以x为根的子树至少包含2^bh(x) - 1个内结点:
1)如果x的黑高度为0,则x必为叶结点(T.nil),所以该树包含2^0 - 1 = 0个结点。
2)若x的孩子是红色的,则该孩子结点的黑高度与x一样,为bh(x);如果x是黑色的,则该孩子结点的黑高度比x少1,为bh(x)-1。所以x的孩子结点的黑高度至少为bh(x)-1。根据归纳假设,x的两个子树里的元素个数至少为(2^(bh(x)-1) - 1) + (2^(bh(x)-1) - 1) = 2^bh(x) - 2,加上x结点本身,则以x为根的红黑树至少有2^bh(x)-1个内结点。归纳成立。
根据红黑树性质4,任何路径上,黑结点的个数不会少于红结点的个数,所以根的黑高度至少是h/2(h为树的高度)。同时根据上面的结论,可得:
n ≥ 2^bh(x) - 1 ≥ 2^(h/2) - 1
可求出h ≤ 2*lg(n+1),所以红黑树的高度为O(lg(n))。
旋转
我们可以通过旋转来改变某些结点在树中的位置而不破坏二叉查找树的性质。
可是看到子树b是所有的元素值都介于结点A和B之间,在旋转操作后b仍然处在两结点之间,二叉查找树的性质得以保持。
下面是左旋(把x结点左移,使得其右孩子y代替x的位置)的代码:
LEFT_ROTATE(T, x) {
1 y = x.right;
2 x.right = y.left;
3 if y.left ≠ T.nil
4 y.left.parent = x;
5 y.parent = x.parent;
6 if x.parent == T.nil
7 T.root = y;
8 else if x == x.parent.left
9 x.parent.left = y;
10 else
11 x.parent.right = y;
12 y.left = x;
13 x.parent = y;
}
右旋的代码则刚好与左旋对称,把left和right对换就可以,这里不再赘述。旋转的操作的运行时间为O(1)。
插入
与二叉查找树相同,每次插入时元素都会被放到叶结点处。
RB_INSERT(T, z) {
1 y = T.NIL;
2 x = T.root;
3 while x != T.NIL {
4 y = x;
5 if z.key < x.key
6 x = x.left;
7 else
8 x = x.right
9 }
10 z.parent = y;
11 if y == T.nil
12 T.root = z;
13 else if z.key < y.key
14 y.left = z;
15 else
16 y.right = z;
17 z.left = T.nil;
18 z.right = T.nil;
19 z.color = RED;
20 RB_INSERT_FIXUP(T, z);
}
红黑树的插入代码与二叉查找树的插入代码大致相同,区别在于最后z的左右孩子都设为哨兵元素(黑色地NIL),而且z的颜色属性设为红色。新元素插入可能会破坏红黑性质,所以我们要做额外的操作RB_INSERT_FIXUP来保持。
我们先看一下这个新增的红结点可能会破坏哪些性质。如果它是根结点,则它破坏了性质2:根必须是黑色的。如果它的父亲是红色的,则它破坏了性质4:红结点必须有两个黑色的孩子。对于性质1、3、5,它并不会破坏:它是红色的,满足性质1;它的两个孩子是黑色的T.nil,满足性质3;它是红色的,不会增加黑高度,满足性质5。
对于性质2的破坏,我们只需要把根结点设为黑色即可。下面描述如何应对性质4的破坏,它分为三种情况:
其实一共是六种情况,即父结点是左孩子的三种情况加上父结点是右孩子的三种情况。但其它三种情况与前三种情况对称,所以不再赘述。
下面是相应的代码:
RB_INSERT_FIXUP(T, z) {
1 while z.parent.color == RED {
2 if z.parent == z.parent.parent.left {
3 y = z.parent.parent.right;
// CASE 1
4 if y.color == RED {
5 z.parent.color = BLACK;
6 y.color = BLACK;
7 z.parent.parent.color = RED;
8 z = z.parent.parent;
9 }
// CASE 2
10 else if z == z.parent.right {
11 z = z.parent;
12 LEFT_ROTATE(T, z);
13 }
// CASE 3
14 z.parent.color = BLACK;
15 z.parent.parent.color = RED;
16 RIGHT_ROTATE(T, z.parent.parent);
17 }
// z's parent is a right child
18 else
19 same as the previous "if" clause with "right" and "left" extranged.
20 }
21 T.root.color = BLACK;
}
最后一行保证了性质2。RB_INSERT_FIXUP操作可能会从叶部一直到根部,所以它的运行时间为O(h) = O(lg(n))。
删除
红黑树的删除同样也是基于普通二叉查找树的删除,并同时维护其红黑性质。下面是删除的代码:
RB_DELETE(T, z) {
1 if z.left == T.nil or z.right == T.nil
2 y = z;
3 else
4 y = SUCCESSOR(z);
5 if y.left != T.nil
6 x = y.left;
7 else
8 y = y.right;
9 x.parent = y.parent;
10 if y.parent == T.NIL
11 T.root = x;
12 else if y == y.parent.left
13 y.parent.left = x;
14 else
15 y.parent.right = x;
16 if y != z {
17 z.key = y.key;
18 copy y's satellite data into x;
19 }
20 if y.color == BLACK
21 RB_DELETE_FIXUP(T, x);
22 return y;
}
注意到,真正从树中移除的结点(代码中的y,不一定是z)最多只有一个孩子(如果删除的不是叶节点的话,会找到它的后继,把后继的覆盖要删除的元素,再把后继移除),所以被移除的元素会被它的孩子(或T.nil)代替。
当移除的元素是黑色的时,经由这个结点的路径的黑高度便少了1。为了(暂时)让红黑树的性质得到保持,我们赋予这个结点的替代结点额外一个黑色属性:在计算黑高度时,树中的所有路径经由这个替代结点时,多计算一次(即加1)。
由于这个替代结点有一个额外的黑色属性,所以我们便要想办法把这个额外的黑色属性去掉。如果替代结点是红色的话,那么我们只要简单的把它变为黑色即可。但如果它是黑色的话,则必须想办法把这个黑色属性转移出去。一共有四种情况需要考虑:
考虑替代结点是右孩子的情况,其实一共是八种情况。下面是RB_DELETE_FIXUP的代码:
RB_DELETE_FIXUP(T, x) {
1 while x != root and x.color == BLACK {
2 if x == x.parent.left {
// CASE 1
3 w = x.parent.right; // brother
4 if w.color == RED {
5 w.color = BLACK;
6 x.parent.color = RED;
7 LEFT_ROTATE (T, p[x]);
8 w = x.parent.right;
9 }
// CASE 2
10 if w.left.color == BLACK and w.right.right == BLACK {
11 w.color = RED;
12 x = x.parent;
13 }
// CASE 3
14 else if w.right.color == BLACK {
15 w.left.color = BLACK;
16 w.color = RED;
17 RIGHT_ROTATE(T, w);
18 w = x.parent.right;
// CASE 4
19 w.color = x.parent.color;
20 x.parent.color = BLACK;
21 w.right.color = BLACK;
22 LEFT_ROTATE(T, x.parent);
23 x = T.root;
24 }
25 }
26 else
27 same as the previous "if" clause with "right" and "left" exchanged;
28 }
29 x.color = BLACK;
}
RB_DELETE_FIXUP中,情况一运行O(1)进行情况二、三、四,情况三运行O(1)时间进入情况四,而情况四运行O(1)时间终止循环。只由每次迭代都进入情况二直到根结点的话,运行时间为O(h) = O(lg(n))。
第14章 数据结构的扩张
有些时候,我们需要在一些标准的数据结构(比如双链表、散列表或二叉查找树)上增加一些信息,以便编入新的操作。下面给出一个红黑树进行扩充的例子。
动态顺序统计
在红黑树的每个结点,除了基本的color, left, right , parent域,还增加一个size域,来记录以该结点为根的子树的结点数。一个结点的size可以由它的两个子结点得到:
x.size = x.left.size + x.right.size + 1 (T.nil.size为0。)
基于这个新加的域,我们便可增加新的操作,比如选择第i小的元素:
OS_SELECT(x, i) {
1 r = x.left.size + 1;
2 if i == r
3 return x;
4 else if i < r
5 return OS_SELECT(x.left, i);
6 else
7 return OS_SELECT(x.right, i-r);
}
它其实与第9章里的基本快速排序思想的选择算法差不多。
OS_RANK(T, x) {
1 r = x.left.size + 1;
2 y = x;
3 while y != T.root {
4 if y == y.parent.right
5 r = y.parent.left.size + 1;
6 y = y.parent;
7 }
8 return r;
}
接下来讨论如何维护该数据结构中的size域。
在插入元素时,分两阶段,阶段一:从根开始向下遍历,直到元素找到可以插入的位置;阶段二:通过旋转来维护红黑性质。在阶段一,我们只需在遍历时经由的所有结点的size增加1便可,时间为O(lg(n)),在阶段二最多会有O(lg(n))次旋转,每次旋转只需O(1)的时间:重新计算被旋转的元素的size,看下图:
在LEFT_ROTATE里加入下列两行代码以维护size信息:
y.size = x.size;
x.size = x.left.size + x.right.size + 1
综上所述,插入元素的两个阶段里,维护size息共需O(lg(n))的时间。
同样,在删除元素时,同样分为两个阶段,阶段一:从树中删除元素,阶段二,通过旋转维护红黑信息。对于阶段一,我们可以沿着被删除的元素一直向根遍历,经由的每个结点的size域都减1;在阶段二至多有O(lg(n))次旋转。所以删除操作时维护size域的运行时间同样为O(lg(n))。
如何扩张数据结构
对一种数据结构的扩张过程可分为四个步骤:
1、选择基础数据结构; (选择红黑树)
2、确定要在基础数据结构中添加哪些信息; (加入size域)
3、验证可用基础数据结构上的基本修改操作来维护这些新添加的信息; (插入和删除可以维护size域)
4、设计新的操作。 (OS_SELECT和OS_RANK)
以上给出的是一般模式,不必生硬地遵循。
对于红黑树的扩张,我们可以给出以下概括:
设域f对含n个结点的红黑树进行扩张的域,且假设某结点x的域f的内容可以仅用结点x,x.left和x.right中的信息计算,包括x.left.f和x.right.f。这样,在插入和删除操作中,我们可以在不影响这两个操作O(lg(n))渐近性能的情况下,对T的所有结点的f值进行维护。
下面再介绍另一个红黑树的扩张:区间树。
区间树中,每个结点的关键字不是简单的整数,而是一个区间[low, high],域名key同样也更名为interval。在进行关键字比较时,low更小的值作为更小的值放在树的左侧。同时,每个结点还维护一个max域,它表示以该结点为根的子树里,所有元素里的区间[low, high]的high值中的最大值。
我们这样定义两个区间重叠(overlap):[low, high]和[low', high']只有在high < low'或high' < low时才不重叠。
基于这个数据结构,我们可以定义一个新操作:给定一个区间i,查找区间树中与i重叠的区间:
INTERVAL_SEARCH(T, i) {
1 x = T.root;
2 while x != T.nil and i does not overlap x.interval {
3 if left != T.nil and x.left.max >= i.low
4 x = x.left;
5 else
6 x = x.right;
7 }
8 return x;
}