本章介绍了一个贯穿本书的框架,后续章节的算法设计和分析都是在这个框架中进行的。 首先分析了一下如何用插入排序来解决排序问题,定义了一种“伪代码”来描述算法。在描述了算法后,再证明他能正确的完成任务,并对运行时间进行分析。引入一种记号,侧重于表达运行时间是如何随着待排序的数据项数而增加的。之后还要介绍算法设计中的“分治法”,并利用该方法来设计一个称为合并排序的算法,对合并算法的运行时间进行了分析。
插入排序算法
排序问题的定义如下:
输入:N个数{a1, a2,..., an }。
输出:输入序列的一个排列{a'1 ,a'1 ,...,a'n },使得a'n <=a' n<=...<=a' n。
插入排序算法的伪代码是一个以数组为参数的过程形式给出的。输入的各个数字是原地排序的(sorted in place),意即这些数字就是在数组A中进行重新排序的,在任何时候,至多只有常熟个数字是存储在数组之外的。
INSERTTION-SORT(A)
1 for j<--2 to length[A]
2 do key<--A[j]
3 i <-- j-1
4 while i>0 and A[i]>key
5 do A[i+1]<-A[i]
6 i<-- i-1
7 A[i+1] <-- key
循环不变式与插入算法的正确性
循环不变式主要用来帮助我们理解算法的正确性,对于循环不变式,必须具备三个性质:
初始化: 它在循环的第一轮迭代开始之前应该是正确的。
保持:如果在循环的某个一次迭代开始之前它是正确的,那么在下一次迭代开始之前,应该保持正确。
中止: 当循环结束时,不变式给出了一个有用的性质,它有助于表明算法是正确的。
PS: 对for语句来说,“第一轮迭代开始之前”指的是初始化赋值和条件检查之后,”下一次开始之前“指的是自增表达式和条件检查之后。一轮循环指的是条件检查(第一轮还包括初始化)之后,到下一次条件检查之间执行的代码。
循环不变式的原理类似于数学归纳法。
现通过第一重循的环不变式来证明排序算法的正确性,循环不变式为:A[1...j-1]是一个包含原数组第1到j-1元素并已排序的数组。
初始化:在第一轮循环体之前,j==2,那么A[j-1]只包含一个元素,且该元素没有被移动过,不变式成立;
保持:在循环的执行过程中,将A[j]插入到A[1...j-1]合适的位置,j增1,(这里我们咱不讨论第二重循环的不变式),此时不变式仍然成立。
中止:当循环中止的时候,j=length[A]+1,带入不变式,恰好证明了算法的正确性。
PS:循环中断的条件和不变式一起,可以证明算法的正确性。
不妨用循环不变式来证明一下第二重循环的正确性,第二重循环的目的是找出一个值-1<=i<=j-1,将key放入A[i+1]将使A[0...j]有序。这里我们可以认为当执行完"key<--A[j]"之后,A[j]为空,也即A[1..j]只包含j-1个元素。同样“A[i+1]<--A[i]”这句代码也会将A[i]置空。不变式为:(1)A[1...i]有序;(2)A[i+2,j]有序且所有元素不小于key,同时A[i+2...j]中所有元素不小于A[1...i]中的任意元素;(3)A[i+1]处是空闲位置。
初始:i = j-1,A[j]被置空,再加上外重循环的不变式,条件(1)成立,A[i+2..j]包含0个元素所以(2)也成立。(3)明显也成立。
保持:循环体将值A[i]转移到A[i+1]处,且i减小1。条件(1)显然成立;循环执行之前A[i]>key,A[i]是A[0...i]中最大元素,所以执行之后条件(2)仍成立;条件(3)显然成立。
中止:当循环中止时,假如i = -1,那么A[0]处时空值,A[1...j]有序(条件2),且A[1...j]所有元素都大于key,那么将key放入A[0]将使A[0...j]有序;如果A[i]<=key,那么由于A[0...i]有序,所以
将key大于A[0...i]中所有元素,同时由于A[i+2,j]有序且所有元素都大于key,将key放入A[i+1]会使A[0...j]有序。
PS:由于第二重循环只是一个辅助过程,所以它的不变式显得比较抽象晦涩。像这种简单明了的过程并不需要不变式来证明,这里为了练习不变式的使用故而尝试一下。
算法分析
算法分析就是对一个算法所需的资源进行预测,内存,通信带宽或计算机硬件资源偶尔是我们关系的,但通常是指我们希望测量的计算时间。算法的运行时间是指在特定输入时,所执行的基本操作数。
分析算法要建立有关实现技术的模型,包括描述所用资源及其代价的模型,本书采用一种通用的单处理器、随即存取机(random access machine,RAM)计算模型来作为实现技术。RAM模型包含了真实计算机中常见的指令,每条指令所需的时间都为常量。还假设RAM模型中数据的每一个字有着最大长度限制。指数运算2n 在N较小的情况下可以看做常数执行时间。RAM模型没有考虑存储器的层次,并不对高速缓存和虚拟内存进行建模。
一般来说,算法所需的时间与输入规模同步增长的,因而常常将一个程序的运行时间表示为其输入函数。输入规模的概念与具体问题有关,对许多问题来说,最自然的度量标准是输入中元素个数,对另一些问题,如两个整数相乘,其输入规模的最佳度量是输入数在二进制表示下的位数,有时用两个数表示输入规模更加合适,比如输入是一个图是,输入规模可以由图中顶点数和边数来表示。
假定每一行代码都要花常量的时间ci ,那么在统计出插入排序算法中每行代码的执行次数,就可以给出算法执行时间的一个表达式。(细节请参考原书Page14~15)。
插入排序算法的第二重循环的代码执行次数取决于输入的特性——“有序程度”,在最好的情况下(输入有序),第二重循环体根本不会被执行,算法的执行时间可以表示为 an+b。在最坏情况下(输入逆序),算法的执行时间可以表示为:an2 +b。
一般考差算法的”最坏情况下”的执行时间,这是因为:知道了最坏情况下的执行时间,我们就把握了算法执行时间的上限,不再担心算法在某些情形下会超出这个时间;对于某些算法最坏情况出现得还是比较频繁的,比如查询,当查询的对象不存在时就会出现最坏情况;“平均情况”往往与最坏情况一样差,在插入排序算法中,假设每次插入时,A[0...j-1]中有一半元素大于A[i],算法的执行时间还是n的一个二次函数。
为了简化分析,做进一步的抽像——运行时间的增长率或增长的量级,我们只考虑运行时间表达式中最高次项--n2 。在输入规模n比较小的时候,通过量级来判别算法的效率可能是不对的,但当n比较大时一个n2 的算法比n3 的算法运行要更快。
算法设计
算法设计有很多方法,插入排序使用的是增量法:在排序数组A[1...j-1]后,将A[j]插入,形成排好序的数组A[1...j]。本章这里要介绍“分治法”。
分治法
有很多算法在结构上是递归的,为了解决一个给定的问题,算法要一次或多次地递归调用其自身来解决相关的子问题,这些算法通常采用分治策略:将原问题划分成为n个规模更小而结构与原问题相似的子问题;递归地解决这些子问题,然后再合并其结果,就得到原问题的解。
分治法在每一层递归上都有三个步骤:
分解: 将原问题分解成一些列子问题;
解决:递归地姐各子问题,如果子问题足够小,则直接求值;
合并:将子问题的结果合并成原问题的解。
合并排序依据此模式,直观地操作如下:
分解:将n个元素分解成各含n/2个元素的子序列
解决:用合并排序法对两个子序列递归地排序
合并:合并两个已排序的子序列以得到排序结果
下面提供合并排序的伪代码,辅助过程MERGE(A,p,q,r)将数组A的已排序子数组A[p..q]和A[q+1...r]合并成有序子数组A[p...r]:
MERG(A,p,q,r)
n1 <-- q-p+1
n2 <-- r-q
create arrays L[1...n1+1] and R[1...n2+1]
for i<--1 to n1
do L[i] = A[p+i-1]
for i<--i to n2
do R[i] = A[q+i]
L[n1+1] = 极大值哨兵元素
R[n2+1] = 极大值哨兵元素
i<--1
j<--1
for k<-- p to r
do if L[i] <= R[j]
then A[k] = L[i]
i++
else A[k] = R[j]
j++
MERGE-SORT(A,p,r)
if p<r
then q<--(p+r)/2
MERGE-SORT(A,p,q)
MERGE-SORT(A,q+1,r)
MERGE(A,p,q,r)
PS: MERGE和MERGE-SORT的含义一目了然,不过权威书籍上的代码值得模仿,包括哨兵元素的使用
分治法分析
递归调用的算法的运行时间可以用一个递归方程来表示。分治算法中的递归是基于基本模式中的三个步骤。假设原问题的规模为n,把原问题分解成a个子问题,每一个子问题的规模是b分之一,注意a和b有时候相等,但很多情况下不相等。于是算法的运行时间可以表示如下:
T(n) = aT(n/b) + D(n) + C(n); T(n) 为常量当n足够小; D(n)为划分问题所需的时间; C(n)表示合并子问题结果地所需的时间。
对合并排序算法。 a=2,b=2, D(n)为常量, C(n)的量级为1。
上述公式可表示为 T(n) = 2T(n/2) + Θ(n)+ Θ(1)= 2T(n/2) +Θ(n) 。
第四章的主定理可以证明 T(n) = Θ(n㏒n)。 也可以通过递归树来证明,见原书Page21-22。
PS: 当n并不是偶数的时候分解的两个子问题规模并不完全相等,这里假定n为2的冥,这样每一层的分解都可以一致,第四章证明这种假设并不妨害分析。
习题
2-4 逆序对
设A[1...n]是一个包含n个不同数的数组。如果在i<j的情况下,有A[i]>A[j],则(i,j)就称为A中的一个逆序对。
(1)列出数组{2,3,8,6,1}的五个逆序。
(2)如果数组的元素取自{1,2...,n},那么,怎样的数组含有最多的逆序对?
(3)插入排序的时间与输入数组中逆序对的数量之间有怎样的关系?
(4)给出一个算法,能用Θ(n㏒n)的最坏运行时间,确定n个元素的任何排列中你逆序对的数目。(提示:修改合并排序)
解答:
(1) 略
(2)逆序数组
(3)插入排序的时间与输入数组中逆序对的数量呈线性正相关关系。通过观察插入排序的算法伪码可知,算法的运行步骤主要取决于内层循环中元素移动的次数,而每次移动就意味着数组的逆序数减一,当排序结束时逆序数为零。
(4)依据分治法,如果我们将数组分解成两个子序列,分别求出两个子序列的逆序数,再求出两个子序列之间元素的逆序数,就可以得出整个数组的逆序数了。可以做以下考虑:
分解:将问题分成前后两个规模为n/2的数组
解决:分别求解各自的逆序对数。如果子问题规模为2或1,可直接求解。
合并:此时虽然知道两个子序列各自的逆序对数,但两个子序列之间的逆序对数无法轻易获知,如果进行两两比较的话,合并操作的时间复杂度就是n2 ,分治法没有意义。
再考虑上述“合并”的问题,如果此时两个子序列都是有序的话,则通过修改合并排序的MERG过程就可以得出子序列之间的逆序数:在MERG对两个子序列的第一个元素之间进行选则时,如果前一个序列的首元素被选中,则逆序数不变——该元素不会和后一个序列中的剩下元素构成逆序对,如果第二个序列的首元素被选中,则逆序数增加“第一个序列剩下的元素数”——该元素和前一序列中剩下的每个元素构成逆序对,MERG后这些逆序对消除。按着这个思路分治算法重新设计如下:
分解:将问题分成前后两个规模为n/2的数组
解决:分别进行递归合并排序,并记录累加排序所消除的的逆序对数。如果子问题规模为2或1,可直接求解。
合并:通过合并排序的MERG进行合并,在MERG过程中按上述方法累加逆序数。
PS:在最初用分治法考虑问题(4)时,排序的作用在一开并不那么明显,但通过对“合并”的分析,要求对子问题的求解需要产生“排序”的副作用。这种”副作用“在分治法中是值得注意的。