第一部分 基础知识
将引导思考算法的设计和分析问题, 简单介绍算法的表达方法,
和将在本书中用到的一些设计策略, 以及算法分析中用到的许多基本思想;
本书后面的内容都是建立在这些基础知识上的;
第一章 算法在计算中的作用
对算法及其在现代计算系统中地位的一个综述;
本章将给出算法的定义和一些算法的例子;
还说明算法是一项技术, 就像硬件, 图形用户界面, 面向对象系统和网络一样;
什么是算法?
为什么算法值得研究?
相对于计算机中使用的其他技术来说算法的作用是什么?
本章将回答这些问题;
1.1 算法
是任何良定义的计算过程;
是把输入转换成输出的计算步骤的一个序列;
是用于求解良说明的计算问题的工具
例如: 排序问题
排序很常用, 所以相应的算法也很多;
哪个算法最好则依赖于:
将被排序的项数, 已排序的程度, 关于项值的可能限制,
计算机的体系结构, 以及将使用的存储设备的种类(主存, 磁盘或者磁带);
算法解决哪种问题
例如: 检索信息, 电子商务等;(关于算法与问题的举例可以看书)
总之都展示了有趣的算法问题共有的两个特征:
存在许多候选解, 但绝大多数候选解都没有解决手头的问题;
寻找一个真正的解或最优解可能是一个很大的挑战;
存在实际应用; 例如最短路径的应用;
算法解决的问题不是都有一个容易识别的候选解集;
数据结构
是一种存储和组织数据的方式, 旨在便于访问和修改;(本书包含几种数据结构)
没有一种单一的数据结构对所有用途均有效, 所以重要的是知道几种数据结构的优势和局限;
技术
本书将教你一些算法设计与分析的技术, 以便自行设计算法, 证明其正确性和理解其效率;
难题
有些问题目前还不知道有效的解法; 第34章会研究这些问题的一个有趣的子集,
其中的问题被称为NP完全的;(Non-deterministic Polynomial Complete : 多项式复杂程度的非确定性问题)
NP完全问题有趣性略(若能解其一则所有问题都能解决);
你应该了解它, 因为有些会在实际应用中冒出来; 若为此求解将做无用功;
若能证明这个问题是NP完全的, 那么就可以把时间花在可实现的但不是最优解上;
并行性
处理器时钟速度由于物理的限制可能难以增长, 为了每秒执行更多的计算,
芯片被设计成多"核"; 换而言之就是一类"并行计算机";
为了从多核计算机获得最佳的性能, 设计算法时必须考虑并行性;(第27章)
1.2 作为一种技术的算法
时间和空间都是有限资源, 算法将帮助你明智地使用资源;
效率
不同算法往往有不同的效率; 造成的影响可能远远大于硬件和软件;
算法与其他技术
算法没得说的重要, 其他技术都依赖于算法;
有好的算法背景才能完成更多的工作;
第二章 算法基础
本章介绍贯穿全书的框架;
该部分内容基本上是独立的
2.1 插入排序
我们希望排序的数也称为关键词
伪代码与真代码的区别在于, 我们将使用最清晰简洁的表示方法说明给定的算法;
另一个区别在于伪代码通常不关心软件工程的问题;
为了更简洁的表达算法的本质,常常忽略数据抽象、模块性和错误处理的问题。
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
上面存在循环不变式
习题
2.1-2 重写过程 INSERTION-SORT, 使之按非升序(而不是非降序)排序
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
2.1-3 考虑以下查找问题
输入:
n个数的一个序列A = < a 1 , a 2 , . . . , a n > 和 一 个 值 ν <a_1, a_2, ..., a_n>和一个值 \nu<a
1
,a
2
,...,a
n
和一个值ν
输出:
下标i使得ν = A [ i ] 或 者 当 ν 不 再 A 中 出 现 时 , ν 为 特 殊 值 N I L \nu = A[i]或者当\nu不再A中出现时,\nu为特殊值NILν=A[i]或者当ν不再A中出现时,ν为特殊值NIL
写出线性查找的伪代码,它扫描整个序列来查找ν \nuν。
使用一个循环不变式来证明你的算法是正确的。确保你的循环不变式满足三条必要的性质。
for i to n
if A[i] = v
return i
return nil
A[1, …, i-1] are equal to v
二进制加法
略
2.2 分析算法
RAM模型(random-access machine)
左移计算:当k是一个足够小的正整数时,我们把2^k的计算看成一个常量时间的操作。
该方法分析算法很困难;我们需要更简便的方法。
插入排序算法的分析
有最坏情况和平均情况分析
如最坏情况时间表示为a n 2 + b n + c an^2+bn+can
2
+bn+c, 其中常量abc依赖于语句代价c i c_ic
i
。如此,我们不但忽略实际的语句代价,而且也忽略抽象的代价
做更简化的抽象:即运行时间的增长率或增长量级
因此我们只需考虑a n 2 an^2an
2
, 因为n的值很大时,低阶项就不重要了
我们记最坏情况运行时间记为Θ ( n 2 ) \Theta(n^2)Θ(n
2
)
练习
2.2-2 对A中的n个数:首先找出A中最小的元素并将其与A[1]中的元素进行交换,然后次小与A[2]以此类推,对A中前n-1个元素按该方式继续;如此算法称为选择算法,写出其伪代码。该算法维持的循环不变式是什么?为什么它只需要对前n-1个元素,而不是对所有n个元素运行?用Theta记号给出选择排序的最好情况与最坏情况运行时间。
SELECTION-SORT(A)
for i=1 to n-1
j=FIND.MIN(A, i, n)
A[j] <-> A[i] // swap
Θ ( n 2 ) \Theta (n^2)
Θ(n
2
)
最好最坏都是这个;
再次考虑线性查找问题(见2.1-3 )。假定要查找的元素等可能地为数组中的任意元素,平均需要检查输入序列的多少元素?最坏情况又如何呢?用Theta记号给出线性查找的平均情况和最坏情况运行时间。证明你的答案
都是Θ ( n ) \Theta(n)Θ(n)
因为是平均可能性,每次都遍历一半的元素;
我们可以如何修改几乎任意算法来使之具有良好的最好情况运行时间?
用特殊情况的输入判断来输出预先设定好的输出值,最好运行情况通常不是好的分析;
2.3 设计算法
本节考虑“分治法”,第四章再更深入探究。
我们将用分治法设计排序算法,该算法最坏情况下运行时间比插入排序要少的多。
分治算法的优点之一是,通过第四章介绍的技术往往很容易确定其运行时间。
分治模式每次递归的三步骤
分解原问题为若干子问题,这些子问题是原问题的规模较小的示例。
解决这些子问题,递归地求解各子问题。若子问题规模足够小,则直接求解。
合并这些子问题解成原问题的解。
归并排序算法完全遵循分治模式。直观上其操作如下:
分解:分解待排序的n个元素的序列成各具n/2个元素的两个子序列。
解决:使用归并排序递归地排序两个子序列。
合并:合并两个已排序的子序列以产生已排序的答案。
归并排序算法的关键操作是MERGE, 即合并两个已排序好的数组;
回到扑克牌的想象:假设两堆排序好的牌,最上方都是最小的牌。将最上方两张牌比较,小的取出,重复该过程,直到一个输出堆为空,然后将剩余牌一一取出即可;
因为我们最多执行n个基本步骤,所以合并需要Θ ( n ) \Theta(n)Θ(n)的时间;
MERGE(A, p, q, r):p、q和r是数组下标,满足p < = q < r p <= q < rp<=q<r
假设A[p…q]和A[q+1…r]都已排序。合并这两个子数组形成单一的已排序好的子数组并代替当前的子数组A[p…r]
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-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的问题上的总运行时间。
然后我们可以使用数学工具来求解该递归式并给出算法性能的界。
若问题规模足够小,对某个常量c, n < = c n <= cn<=c则直接求解需要常量时间Θ ( 1 ) \Theta (1)Θ(1)
假设把原问题分解成a个子问题,每个子问题的规模是原问题的1/b。
求解一个n/b的子问题,需要T(n/b)的时间,分解成子问题需要的时间是D(n),合并子问题的解成原问题的解需要时间C(n),那么:
T ( n ) = { Θ ( 1 ) , 若 n < = c a T ( n b ) + D ( n ) + C ( n ) , 其 他 T(n)= \begin{cases} \Theta(1) , 若n<=c \ aT(\frac{n}{b}) + D(n) + C(n) , 其他 \end{cases}
T(n)={
Θ(1),若n<=c
aT(
b
n
)+D(n)+C(n),其他
在第四章将看到如何求解这类常见的递归式。
归并排序算法的分析
分析建立归并排序n个数的最坏情况运行时间T(n)的递归式。
分解:分解步骤仅仅计算子数组的中间位置,需要常量时间,因此,D(n)=Θ ( 1 ) \Theta(1)Θ(1)。
解决:我们递归地求解两个规模均为n/2的子问题,将贡献2T(n/2)的运行时间。
合并:我们已经注意到在一个具有n个元素的子数组上过程MERGE需要Θ ( n ) 的 时 间 \Theta (n)的时间Θ(n)的时间,所以C(n)=Θ ( n ) \Theta (n)Θ(n)。
T ( n ) = { Θ ( 1 ) , 若 n = 1 2 T ( n 2 ) + Θ ( n ) , 若 n > 1 T(n)=\begin{cases} \Theta(1) , 若n=1\ 2T(\frac{n}{2}) + \Theta(n) , 若n>1 \end{cases}
T(n)={
Θ(1),若n=1
2T(
2
n
)+Θ(n),若n>1
上式的解是T ( n ) = Θ ( n l g n ) T(n)=\Theta(nlgn)T(n)=Θ(nlgn)
证明:将上式重写为:
T ( n ) = { c , 若 n = 1 2 T ( n 2 ) + c n , 若 n > 1 T(n)=\begin{cases} c , 若n=1\ 2T(\frac{n}{2}) + cn , 若n>1 \end{cases}
T(n)={
c,若n=1
2T(
2
n
)+cn,若n>1
其中常量c代表求解规模为1的问题所需的时间以及在分解步骤与合并步骤处理每个数组元素所需的时间。
假设n是2的幂数
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-UVXlkSaK-1600073077378)(pic/RecursiveTree.png)]
对于递归式T ( n 2 ) + c n T(\frac{n}{2}) + cnT(
2
n
)+cn, 如何构造一棵递归树。
对于顶层之下第i层,总代价2 i c ( n 2 i ) = c n , ( 第 i 层 具 有 2 i 个 结 点 , 每 个 结 点 贡 献 代 价 c ( n 2 i ) ) 2^ic(\frac{n}{2^i})= cn , (第i层具有2^{i}个结点,每个结点贡献代价c(\frac{n}{2^{i}}))2
i
c(
2
i
n
)=cn,(第i层具有2
i
个结点,每个结点贡献代价c(
2
i
n
))
上图中递归树有lgn + 1 层,n是叶数,对应于输入规模。
练习
2.3-2 重写过程MERGE, 使之不使用哨兵,而是一旦数组L或R的所有元素均被复制回A就立即停止,然后把另一个数组的剩余部分复制回A。
void Merge(int *A,int p,int q,int r)
{
//构建左半部分和右半部分的辅助数组
int n1=q-p+1;
int n2=r-q;
int *L=new int[n1];
int *R=new int[n2];
for (int i=0;i<n1;i++)
{
L[i]=A[p+i-1];
}
for(int j=0;j<n2;j++) { R[j]=A[q+j]; } int i=0; int j=0; int k=p-1; while((i<=n1-1)&&(j<=n2-1)) { if(L[i]<=R[j]) { A[k]=L[i]; i++; } else { A[k]=R[j]; j++; } k++; } while(i<=n1-1) { A[k]=L[i]; i++; k++; } while(j<=n2-1) { A[k]=R[j]; j++; k++; } delete[]L; delete []R;
}
其实就是将while判断分出来减少
用数学归纳法证明:当n刚好是2的幂时,以下递归式的解是T(n)=nlgn
式子略,
当n=2时,符合条件。
给出假设:当n = 2 t 时 , T ( 2 t ) = 2 t l g 2 t 成 立 n= 2^t时,T(2^{t})= 2^{t}lg 2^{t}成立n=2
t
时,T(2
t
)=2
t
lg2
t
成立
那么:n = 2 t + 1 时 , . . . 也 成 立 即 可 n= 2^{t+ 1}时,...也成立即可n=2
t+1
时,...也成立即可
2.3-4 最坏情况下,A[n] 插入排序好的A[1..n-1]。写出这样的递归式
2.3-5 回顾查找问题2.1-3, 如果序列A已排序,就可以将该序列的中点与v进行比较。根据比较结果,原序列一半元素可以不做进一步考虑了。二分查找算法重复这个过程,每次都将序列规模减半。为二分查找写出迭代或递归的伪代码。证明:二分查找的最坏情况运行时间为 。
递归算法:
BINARY-SEARCH(A, v, p, r)
if p >= r and v != A[p]: # and 后不知道为什么加这个
return nil
j = A[(r-p)/2]
if v = A[j]:
return j
else:
if v < A[j]:
return BINARY-SEARCH(A, v, p, j)
else
return BINARY-SEARCH(A, v, j, r)
2.3-6 用二分查找(参见上题)来把插入排序的最坏情况总运行时间改进,能到Theta(nlgn)吗?
INSERTION-SORT(A)
for j = 2 to length[a]:
key = a[j]
# insert a[j] into the sorted sequence a[1..j-1]
high = j - 1
low = 1
while low < high:
mid = (low + high)/2
if key == A[mid]
break
if key < A[mid]:
high = mid - 1
if key > A[mid]:
low = mid + 1
for i = mid to j-1:
A[i+1] = a[i]
A[mid] = key
最坏情况下,二分查找时间复杂度是nlgn, 但插入时数组移动的时间复杂度仍是n^2
因此,总体运行时间不能改善为Θ ( n l g n ) \Theta(nlgn)Θ(nlgn)。(但若排序中采用链表的数据结构,可改善)
*2.3-7 描述一个运行时间为θ(nlgn)的算法,给定n个整数的集合S和另一个整数x,该算法能确定S中是否存在两个其和刚好为x的元素
Algorithm 4 CHECKSUMS(A, x)
Input: An array A and a value x.
Output: A boolean value indicating if there is two elements in A whose sum is x.
A = sort(A)
n = length(A)
for i = 0 to n:
if A[i] >= 0 and BINARY-SEARCH(A, A[i] - x, 1, n):
return true
return false