本章将要介绍一个贯穿本书的框架,后续的算法设计与分析都是在这个框架中进行的。
2.1 插入排序
输入:n个数的一个序列< a1, a2, … , an >。
输出:输入序列的一个排列< a1’, a2’, … , an’>,满足a1’ <= a2’ <=…<=an’。
我们希望排序的数也称为关键词。
本书中通常将算法描述为用一种伪代码书写的程序。将伪代码过程命名为INSERTION-SORT,其中的参数是一个数组A[1…n],包含长度为n的要排序的一个序列。(在代码中,A中元素的数目n用A.length来表示)
INSERTION-SORT(A)
for j = 2 to A.length
key = A[j]
//Insert A[j]into the sorted sequence A[1..j-1].
i = j - 1
while i > 0 and A[i] > key
A[i+1] = A[i]
i = i - 1
A[i+1] = key
循环不变式与插入排序的正确性
循环不变式主要用来帮助我们理解算法的正确性,循环不变式必须证明的三条性质:
初始化:循环的第一次迭代之前,它为真。
保持:如果循环的某次迭代之前它为真,那么下次迭代之前它扔为真。
终止:在循环终止时,不变式为我们提供了一个有用的性质,该性质有助于证明算法是正确的。
伪代码中的一些约定
- 缩进表示块结构。可以大大提高代码的清晰性。
- while、for与repeat-until等循环结构以及if-else等条件结构与C、C++中的那些结构具有类似的解释。不同在于,退出循环后,循环计数器保持其值。当一个for循环每次迭代增加其循环计数器时,使用关键词to;减少时,使用downto。当循环计数器以大于 1 的一个量改变时,该改变量跟在可选关键词by之后。
- 符号‘//’表示该行后面部分是个注释。
- 形如i=j=e的多重赋值表达式将表达式e的值赋给变量i和j;它应该被处理成等价于赋值j=e后面跟着i=j。
- 变量是局部于给定过程的。若无显式说明,我们不适用全局变量。
- 数组元素通过“数组名[下标]”这样的形式来访问。记号“..”用于表示数组中值的一个范围,这样,A[1..j]表示A的一个子数组,它包含j个元素A[1],A[2],…,A[j]。
- 复合数据通常被组织成对象,对象又由属性组成。对象后跟一个点再跟属性名。例如,数组可以看成是一个对象,它具有属性length,表示数组包含多少元素,如A.length就表示数组A中的元素数目。
我们把表示一个数组或对象的变量看做指向表示数组或对象的数据的一个指针。对于某个对象x的所有属性f,在赋值y=x后,x和y指向相同的对象。
我们的属性记号可以“串联”。例如:假设属性 f 本身是指向某种类型的具有属性 g 的对象的一个指针。那么记号想x.f.g被隐含地加括号(x.f).g。欢聚话说,如果已经赋值y=x.f,那么x.f.g与y.g相同。
有时一个指针根本不指向任何对象。这时,我们赋给它特殊值NULL。 - 我们按值把参数传递给过程:被调用过程接受其参数自身的副本。如果它对某个参数赋值,调用过程看不到这种改变。当对象被传递时,指向表示对象数据的指针被复制,而对象的属性却未被复制。例如,如果x是某个被调用过程的参数,在被调用过程中的复制x=y对调用过程是不可见的。然而,赋值x.f=3却是可见的。类似地,数组通过指针来传递,结果指向数组的一个指针被传递,而不是整个数组,单个数组元素的改变对调用过程是可见的。
- 一个return语句立即将控制返回到调用过程的调用点。大多数return语句也将一个值传递会调用者。我们的伪代码与许多编程语言不同,因为我们允许在单一的return语句中返回多个值。
- 布尔运算符“and”和“or”都是短路的。也就是说,当求值表达式“x and y”时,首先求值x。如果x值为FALSE,那么整个表达式不可能求值为TRUE,所以不再求值y。另外,如果x求值为TRUE,那么就必须求值y以确定整个表达式的值。类似地,对表达式“x or y”,仅当x求值为FALSE时,才求值表达式y。
- 关键词error表示因为已被调用的过程情况不对而出现了一个错误。调用过程负责处理该错误,所以我们不用说明将采取什么行动。
2.2 分析算法
在能够分析一个算法之前,我们必须有一个要使用的实现技术的模型,包括描述所用的资源及其代价的模型。对本书的大多数章节中,我们假定一种通用的单处理器计算模型——随机访问机(random-access machine,RAM)来作为我们的实现技术,算法可以用计算机程序来实现。在RAM模型中,指令一条一条地执行,没有并发操作。
我们的指导性意见是真实的计算机如果设计,RAM就如何设计。RAM模型包含真实计算机中常见的指令:算数指令(加法、减法、乘法、除法、取余、向下取整、向上取整)、数据移动指令(装入、存储、复制)和控制指令(条件与无条件转移、子程序调用与返回)。每条这样的指令所需时间都为常量。RAM模型中的数据类型有整数型和浮点数类型。
插入排序算法的分析
一般来说,算法需要的时间与输入规模同步增长,所以通常把一个程序的运行时间描述其输入规模的函数。
输入规模的最佳概念依赖于研究的问题。对许多问题,如排序或计算离散傅里叶变换,最自然的量度是输入中的项数,例如:待排序的数组的规模n。有时,用两个数而不是一个数来描述输入规模更合适。例如:若某个算法的输入是一个图,则输入规模可以用改图中的定点数和边数来描述。对于研究的每个问题,我们将指出所使用的输入规模量度。
一个算法在特定输入上的运行时间是指执行的基本操作数或步数。定义“步”的概念以便尽量独立于机器是方便的。目前,让我们采纳以下观点:执行每行伪代码需要常量时间。
我们首先给出过程INSERT_SORT中,每条语句的执行时间和执行次数。对j=2,3,…,n。其中n=A.length,假设 tj 表示对那个值 j 第五行执行while循环测试的次数。当一个for或while循环按通常的方式(即由于循环头中的测试)退出时,执行测试的次数比执行循环体的次数多 1。我们假定注释是不可执行的语句,所以它们不需要时间。
INSERTION-SORT(A) 代价 次数
for j = 2 to A.length c1 n
key = A[j] c2 n-1
//Insert A[j]into the sorted sequence A[1..j-1].0 n-1
i = j - 1 c4 n-1
while i > 0 and A[i] > key c5
A[i+1] = A[i] c6
i = i - 1 c7
A[i+1] = key c8 n-1
其中c5=
∑nj=2tj
,c6=
∑nj=2(tj−1)
,c7=
∑nj=2(tj−1)
.
该算法的运行时间是执行每条语句的运行时间之和。为计算在具有n个值的输入上INSERTION-SORT的运行时间T[n],我们将代价与次数列对应元素之积求和,得:
T(n)=c1n + c2(n-1) + c4(n-1) + c5
∑nj=2tj
+c6
∑nj=2(tj−1)
+c7
∑nj=2(tj−1)
+c8(n-1)
即使对给定规模的输入,一个算法的运行时间也可能依赖于给定的是该规模下的哪个输入。例如,在INSERTION-SORT中,若输入数组已排好序,则出现最佳情况。这时,对每个j=2,3,…,n,有tj=1,该最佳情况的运行时间为:
T(n)=c1n + c2(n-1) + c4(n-1)+c5(n-1)+c8(n-1)
=(c1+c2+c4+c5+c8)n-(c2+c4+c5+c8)
我们把该运行的时间表示为an+b。因此,它是 n 的 线性函数。若输入函数已反相排序,则导致最坏情况。我们必须将每个元素A[j] 与整个已排序子数组A[1..j-1]中的每个元素进行比较,所以,对j=2,3,…,n,有tj=j。注意到:
我们发现在最坏情况下,INSETION-SORT的运行时间为:
T(n)=c1n + c2(n-1) + c4(n-1) + c5 (n(n+1)2−1) +c6 (n(n−1)2) +c7 (n(n−1)2) +c8(n-1)
=( c52 + c62 + c72 ) n2 + (c1+c2 + c4 + c52 - c62 - c72 + c8)n - (c2+c4+c5+c8)
也可以把最坏情况运行时间表示为 an2+bn+c ,因此,它是n的二次函数。
最欢情况与平均情况分析
在本书的余下部分中,往往集中于只求 最坏情况运行时间,即对规模为n的任何输入,算法的最长运行时间。
增长量级
我们做出一种更简化的抽象:即我们真正感兴趣的运行时间的 增长率或 增长量级。所以我们只考虑公式中最重要的项,因为当n的值很大时,低价项相对来说不太重要。我们也忽略最重要项的常系数,因为对大的输入,在确定计算效率时常量因子不如增长率重要。对于插入排序,我们记插入排序具有最坏情况运行时间Θ( n2 )。
如果一个算法的最坏情况运行时间具有比另一个算法更低的增长量级,通常认为前者比后者更有效。
2.3 设计算法
我们可以选择使用的算法设计技术有很多。插入排序使用了增量方法:在排序子数组A[1..j-1]后,将单个元素A[j]插入子数组的适当位置,产生排序好的子数组A[1..j]。
本节我们考查另一种“分治发”的设计方法。我们将用分治法来设计一个排序算法,该算法的最坏情况运行时间比插入排序要少得多。分治算法的优点之一是,通过使用第 4 章介绍的技术往往很容易确定其运行时间。
2.3.1 分治法
许多有用的算法在结构上是递归的:为了解决一个给定的问题,算法一次或多次递归地调用其自身以解决紧密相关的若干子问题。这些算法典型地遵循分治法的思想:将原问题分解为几个规模较小但类似原问题的子问题,递归地求解这些问题,然后在合并这些子问题的解来建立原问题的解。
分治模式在每层递归时都有三个步骤:
分解原问题为若干子问题,这些子问题是原问题的规模较小的实例。
解决这些子问题,递归地解决各子问题。然而,若干子问题的规模足够小,则直接求解。
合并这些子问题的解成原问题的解。
归并排序算法完全遵循分治模式。直观上其操作如下:
分解:分解待排序的n个元素的序列成个具n/2个元素的两个子序列。
解决:使用归并排序递归地排序两个子序列。
合并:合并两个已排序的子序列以产生已排序的答案。
归并排序算法的关键操作是“合并”步骤中两个已排序序列的合并。通过调用一个辅助过程MERGE(A,p,q,r)来完成合并,其中A是一个数组,p、q和r是数组下标,满足p<=q
n1 = q - p + 1
n2 = r - q
let L[1..n1 + 1] and R[1..n2 + 1] be new arrays
for i = 1 to n1
L[i] = A[p + i -1]
for j = 1 to n2
R[j] = A[q + j]
L[n1 + 1] = ∞
R[n2 + 1] = ∞
i = 1
j = 1
for k = p to r
if L[i] <= R[j]
A[k] = L[i]
i = i + 1
else A[k] = R[j]
j = j + 1
现在我们可以把过程MERGE作为归并排序算法中的一个子程序来用。下面的过程MERGE-SORT(A,p,r)排序子数组A[p..r]中的元素。若p>=r,则该数组最多有一个元素,所以已经排好序。否则,分解步骤简单地计算一个下标q,将A[p..r]分成两个子数组A[p..q]和A[q+1..r],前者包含[n/2]个元素,后者包含[n/2]个元素。
MERGE-SORT(A,p,r)
if p < r
q = [(p+r)/2]
MERGE-SORT(A,p,q)
MERGE-SORT(A,q+1,r)
MERGE(A, p, q, r)
2.3.2 分析分治算法
当一个算法包含对其自身的递归调用时,我们往往可以用递归方程或递归式来描述其运行时间,该方程根据在较小输入上的运行时间来描述在规模为n的问题上的总运行时间。然后,我们可以使用数学工具来求解该递归式并给出算法性能的界。
分治算法运行时间的递归式来自基本模式的三个步骤。如前所述,我们假设T(n)是规模为n的一个问题的运行时间。若问题规模足够小,如对某个常量 c,n <= c,则直接求解需要常量时间,我们将其写作Θ(1)。假设把原问题分解成 a 个子问题,每个子问题的规模是原问题的 1/b。(对归并排序,a和b都为2,然而,我们将看到在许多分治算法中,a ≠b)为了求解一个规模为n/b的子问题,需要T(n/b)的时间,所以需要aT(n/b)的时间来求解a 个子问题。如果分解问题成子问题需要时间D(n),合并子问题的解成原问题的解需要时间C(n),那么得到递归式:
归并排序算法的分析
下面我们分析建立归并排序 n 个数的最坏情况运行时间T(n) 的递归式。归并排序一个元素需要常量时间。当有n > 1 个元素时,我们分解运行时间如下:
分解:分解步骤仅仅计算子数组的中间位置,需要常量时间,因此,D(n)= Θ(1)
解决:递归地求解两个规模均为n/2的子问题,将贡献2T(n/2)的运行时间。
合并:我们已经注意到在一个具有n 个元素的子数组上过程MERGER需要Θ(n)的时间,所以C(n)=Θ(n)。
当为了分析归并排序而把函数D(n)与C(n)相加时,我们是在一个Θ(n)函数与另一个Θ(1)函数相加。相加的和是n 的一个线性函数,即Θ(n)。把它与来自“解决”步骤项的2T(n/2)相加,将给出归并排序的最坏情况运行时间T(n)的递归式:
在第 4 章中,我们将看到“主定理”,可以用该定理来证明T(n) 为Θ(nlgn),其中,lgn代表。因为对数函数比任何线性函数增长要慢,所以对足够大的输入,在最坏情况下,运行时间为Θ(nlgn)的归并排序将优于运行时间为Θ( n2 )的插入排序。