0
点赞
收藏
分享

微信扫一扫

算法导论-上课笔记1:算法基础/递归/分治法/渐近符号表示/程序运行时间


文章目录

  • ​​1 什么是算法​​
  • ​​2 程序运行时间​​
  • ​​3 递归与分治法​​
  • ​​4 渐近符号表示​​
  • ​​5 递归方法​​
  • ​​5.1 忽略技术细节​​
  • ​​5.2 置换法​​
  • ​​5.3 迭代法​​
  • ​​5.4 主方法​​
  • ​​6 常用函数​​
  • ​​7 补充知识​​
  • ​​7.1 P问题​​
  • ​​7.2 NP问题​​
  • ​​7.3 NPC问题​​
  • ​​7.4 NP hard问题​​

1 什么是算法

算法是一个定义明确的可计算过程,其中“定义明确”是指每一个步骤要做什么都是明确的,而且总可以在找到正确答案后停止算法。算法也是解决定义明确的、可计算问题的一种工具。

算法导论-上课笔记1:算法基础/递归/分治法/渐近符号表示/程序运行时间_算法


算法被称为是正确的,如果对于每个输入实例,在得到正确的输出后算法都可以停止。

相对地,算法被称为是不正确的,如果:

(1)对于某些输入实例,算法可能根本不会停止,而会进入死循环;

(2)虽然算法不会进入死循环,但是得到的是一个与期望答案不同的答案;

(3)算法有时会很有用,有时候不靠谱;

(4)算法的错误率可以被人为操纵。

循环不变量是与程序变量有关的一个语句,它在程序循环开始前,以及在循环的每次迭代执行后都为真,特别是在循环结束后,仍然为真。举个栗子,插入排序算法的循环不变量——在for循环第j个迭代执行前,子数组A[1,j-1]由最初A[1,j-1]中的元素构成,不过现在是有序的。

数据结构是一种存储和组织数据的方式,以便于访问和修改。注意,没有哪一个单一的数据结构可以适用于所有的实际情况。相对而言,数据结构是静态的,算法是动态的。在同样的硬件设备的性能提升中,高效的算法可以比低效的算法获得更多的效率提升。

伪代码类似于C或Java代码。伪代码和代码的区别:伪代码清晰简洁地指定了给定的算法,并且伪代码通常不关心软件工程的问题,对数据会进行一定的抽象化和模块化,除此之外,伪代码不考虑太多技术细节,最后,用伪代码可以体现算法的本质,而且伪代码永远不会过时。

2 程序运行时间

通常,程序的运行时间是输入规模n的函数T(n)。运行时间是指,在特定的输入上,执行的基本操作的数量,即基本操作数。运行时间满足如下一致性原则:每条基本操作都独立于机器,每行指令的执行时间均为常数级。

一般会专注于寻找最坏情况下的运行时间,即对于大小为n的任何输入的最长运行时间。算法的最坏情况运行时间是任何输入的运行时间的上限。因为最坏的情况经常发生,所以通常平均情况和最坏情况一样糟糕。估计算法的平均运行时间或预期运行时间需要使用概率分析和随机化算法。

一般仅考虑公式的前导项,例如a·n2+b·n+c的a·n2。这是因为低阶项在n趋近于无穷时相对来说无关紧要。除此之外,忽略前导项的常数系数,例如a·n2+b·n+c的a,因为在考察大的输入的计算效率时,常数系数不如增长率重要。

如果一个算法的最坏情况运行时间的增长率比另一个算法的增长率较低,则该算法比另一个算法更有效。当然小输入除外,例如10·n·log2n与2n2在n比较小时,前者的值较大,但是当n越来越大时就会发现后者的值都比前者大,且后者的增长率比前者大的多。

3 递归与分治法

递归是指函数调用自身一次或多次的一种行为或者方式。递归用来处理原问题与小规模的子问题密切相关的一类问题。

分治法的核心流程:

(1)分:把原问题分成若干个小规模的子问题;

(2)治:递归求解子问题,从而攻克子问题。如果子问题的规模足够小,就直接解决子问题;

(3)合:将子问题的解方案合并到原问题的解方案中。

当算法包含对自身的递归调用时,通常用递归方程来根据较小输入的运行时间,去描述规模为n的问题的总体运行时间,如下:

算法导论-上课笔记1:算法基础/递归/分治法/渐近符号表示/程序运行时间_分治法_02


上图中,D(n)是将问题分解(divide)成子问题的时间,C(n)是将子问题的解组合(compose)成原问题的解的时间。

首先引入排序问题

(1)输入:n个数的一个序列<a1,a2,…,an>。在排序一个序列时,输入是以n个元素的数组的形式出现的。

(2)输出:输入序列的一个排列<a’1,a’2,…,a’n>,满足a’1≤a’2≤…≤a’n

(3)排序的数也称为关键词。

现在使用归并排序算法作为示例:如何求解在n个数上归并排序的最坏运行时间T(n)?

归并排序算法完全遵循分治模式,其操作如下:

(1)分:分解待排序的n个元素的序列成各具n/2个元素的两个子序列。

(2)治:使用归并排序递归地排序两个子序列。

(3)合:合并两个已排序的子序列以产生已排序的答案。

下面是归并排序的伪代码:

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); //将排序好的两部分进行合并
}
}

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。主程序MERGE-SORT通过MERGE(A,p,q,r)来完成合并,其中A是一个数组,p、q和r是数组下标,满足p≤q<r。该过程假设子数组A[p…q]和A[q+1…r]都已排好序,合并这两个子数组形成单一的已排好序的子数组并代替当前的子数组A[p…r]。

举个扑克牌的例子进行介绍:假设桌上有两堆牌面朝上的牌,每堆都已排序,最小的牌在顶上。现在希望把这两堆牌合并成单一的排好序的输出堆,牌面朝下地放在桌上。基本步骤包括在牌面朝上的两堆牌的顶上两张牌中选取较小的一张,将该牌从其堆中移开(该堆的顶上将显露一张新牌)并牌面朝下地将该牌放置到输出堆。不断重复上述步骤,直到一个输入堆为空,这时,只需要拿起剩余的输入堆并牌面朝下地将该堆放置到输出堆。因为只是比较顶上的两张牌,所以计算上每个基本步骤需要常量时间。因为最多执行n个基本步骤,所以合并需要Θ(n)的时间。

下面是MERGE的伪代码,实现了上面的思想:

MERGE(A,p,q,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++]
else
A[k]=R[j++]

上述伪代码中,在每个堆的底部放置一张哨兵牌,使用∞作为哨兵值,其作用是:当某个堆出现了一张值为∞的牌时,代表该堆的所有非哨兵牌都已被放置到输出堆,只需将另一堆的牌按顺序放到输出堆上即可。

MERGE的运行时间是Θ(n),其中n=r-p+1。MERGE的工作过程描述如下:

(1)第2行计算子数组A[p…q]的长度n1;

(2)第3行计算子数组A[q+1…r]的长度n2;

(3)在第4行,创建长度分别为n1+1和n2+1的数组L和R,每个数组中额外的位置将保存哨兵∞;

(4)第5-6行的for循环将子数组A[p…q]复制到L[1…n1];

(5)第7-8行的for循环将子数组A[q+1…r]复制到R[1…n2];

(6)第9-10行将哨兵放在数组L和R的末尾;

(7)第11-18行通过维持以下循环不变量,执行r-p+1个基本步骤:

在开始第13-17行for循环的每次迭代时,子数组A[p…k-1]按从小到大的顺序包含L[1…n1+1]和R[1…n2+1]中的k-p个最小元素。进而,L[i]和R[j]是各自所在数组中未被复制回数组A的最小元素。

举个栗子,如下图:

算法导论-上课笔记1:算法基础/递归/分治法/渐近符号表示/程序运行时间_递归_03


上图为,当子数组A[9…16]包含序列<2,4,5,7,1,2,3,6>时,调用MERGE(A,9,12,16)的第11-17行的操作。在复制并插入哨兵后,数组L包含<2,4,5,7,∞>,数组R包含<1,2,3,6,∞>。A中的浅阴影位置包含它们的最终值,L和R中的浅阴影位置包含还未被复制回A的值。若将全部浅阴影元素合在一起,就(总)是包含原来在A[9…16]中的值和两个哨兵。A中的深阴影位置包含将被覆盖的值,L和R中的深阴影位置包含已被复制回A的值。其中图(a)-(h)是在第13-17行循环的每次迭代之前,数组A、L和R以及它们各自的下标k、i和j。图(i)是终止时的数组与下标。这时,A[9…16]中的子数组已排好序,L和R中的两个哨兵是这两个数组中仅有的两个未被复制回A的元素。

下面证明第13-17行for循环的第一次迭代之前该循环不变量成立,该循环的每次迭代保持该不变量,并且循环终止时,该不变量提供了一种有用的性质来证明算法的正确性。

(1)初始化:循环的第一次迭代之前,有k=p,所以子数组A[p…k-1]为空。这个空的子数组包含L和R的k-p=0个最小元素。又因为i=j=1,所以L[i]和R[j]都是各自所在数组中未被复制回数组A的最小元素。

(2)保持:为了理解每次迭代都维持循环不变量,首先假设L[i]≤R[i]。这时,L[i]是未被复制回数组A的最小元素。因为A[p…k-1]包含k-p个最小元素,所以在第15行将L[i]复制到A[k]之后,子数组A[p…k]将包含k-p+1个最小元素。增加k的值(在for循环中更新)和i的值(在第15行中)后,为下次迭代重新建立了该循环不变量。反之,若L[i]>R[j],则第17行执行适当的操作来维持该循环不变量。

(3)终止:终止时k=r+1。根据循环不变量,子数组A[p…k-1]就是A[p…r]且按从小到大的顺序包含L[1…n1+1]和R[1…n2+1]中的k-p=r-p+1个最小元素。数组L和R一起包含n1+n2+2=r-p+3个元素。除两个最大的元素以外,其他所有元素都已被复制回数组A,这两个最大的元素就是哨兵。

归并排序算法的T(n)为:

(1)如果元素个数n=1,则需要常数级的运行时间;

(2)当n>1时:

  • 分解:这一步只是计算子序列的中间索引值的大小,只需要一步基本操作,因此D(n)=Θ(1);
  • 处理子问题:递归求解两个子问题,每个子问题大小为n/2,运行总时间为2T(n/2);
  • 合并:第6行的MERGE函数的运行时间C(n)=Θ(n)。

最后有:

算法导论-上课笔记1:算法基础/递归/分治法/渐近符号表示/程序运行时间_分治法_04


改写为:

算法导论-上课笔记1:算法基础/递归/分治法/渐近符号表示/程序运行时间_运行时间_05

4 渐近符号表示

下面讨论函数的渐近效率,即极限情况下的函数行为,用于描述函数的增长率,通过去掉低阶项常量系数,主要关注影响函数增长趋势的重要项,同时在一定程度上指示算法的运行时间。

渐近运行时间是根据定义域是自然数集N的函数来定义的,其中N={0,1,2,…}。这样的泛用只是为了方便后续的讨论。当符号被泛用时,需要理解其确切含义。下面将讨论5种渐近符号表示:Θ、O、Ω、o、ω。

Θ(g(n))的定义要求每个成员f(n)∈Θ(g(n))均渐近非负,即当n足够大时,f(n)非负。渐近正函数就是对所有足够大的n均为正的函数。因此函数g(n)本身必为渐近非负,否则集合Θ(g(n))为空。所以假设用在Θ、O、Ω、o、ω记号中的每个函数均渐近非负。下面详细介绍这五种记法符号:

(1)Θ-记法,称为渐近紧界。对于给定的函数g(n),用Θ(g(n))表示这样一类函数的集合,其中Θ(g(n))={f(n)|存在正常数c1、c2和n0,使得当n≥n0时,有0≤c1g(n)≤f(n)≤c2g(n)}。通常使用f(n)=Θ(g(n))来表达f(n)是Θ(g(n))的其中一个函数,称g(n)为f(n)的一个渐近紧界。举个栗子:如何说明n2/2-3n=Θ(n2)?只要能确定存在一组正常数c1,c2,n0使得:c1n2≤n2/2-3n≤c2n2成立即可,可以选择c1=1/14,c2=1/2,n0=7,则可以验证n2/2-3n=Θ(n2)。常数组c1,c2,n0可能存在其他选择,一般不唯一,关键是可选的常数组存在即可。举个反例,如何验证6n3≠Θ(n2)?假设c2和n0存在,使得对于所有n≥n0,有6n3≤c2n2,则n≤c2/6,这时候是不可能找到合适的c2的,因为n是任意大的数,而c2是常数,故6n3≠Θ(n2)。低阶项和最高阶项的系数可以忽略。例如f(n)=an2+bn+c,其中a>0,b、c均为常数。在去掉低阶项,并且忽略常数后,得到f(n)=Θ(n2)。可以把任何常数函数表示为Θ(n0)或Θ(1),Θ(1)是指某个常量或关于某个变量的一个常量函数。

(2)O-记法,称为渐近上界。对于给定的函数g(n),用O(g(n))表示这样的一类函数,其中O(g(n))={f(n)|存在正的常数c和n0,使得对于所有n≥n0,都有0≤f(n)≤cg(n)}。若有f(n)=Θ(g(n)),则f(n)=O(g(n)),即Θ(g(n))⊆O(g(n))。示例:2n2=O(n3),取c=1,n0=2即可。n2、n2+2000n、n、n/10、n1.99都是O(n2)中的函数示例。说运行时间为O(n2)时,指存在一个O(n2)的函数f(n),使得对n的任意值,不管选择什么特定的规模为n的输入,其运行时间的上界都是f(n),也就是说最坏情况运行时间为O(n2)。

(3)Ω-记法,称为渐近下界。对于给定的函数g(n),用Ω(g(n))表示这样的一类函数,其中Ω(g(n))={f(n)|存在正的常数c、n0,对于所有n≥n0,都有0≤cg(n)≤f(n)}。n2、n2+2000n、n3、n2.01都是Ω(n2)中的函数示例。对于任意两个函数f(n)和g(n),有:f(n)=Θ(g(n))⇔f(n)=O(g(n))和f(n)=Ω(g(n))。证明过程如下:

算法导论-上课笔记1:算法基础/递归/分治法/渐近符号表示/程序运行时间_递归_06


实际中,通常根据渐近上界和渐近下界来证明渐近紧界,而不是根据渐近紧界来得到渐近上界和渐近下界。插入排序的运行时间介于Ω(n)和O(n2)之间,但插入排序的运行时间不能说是Ω(n2),而且说插入排序的最坏情况下运行时间是Ω(n2),这两个描述并不矛盾,为什么?一个算法的运行时间是Ω(g(n))的意思是——对于足够大的n,无论输入的n的大小是多少,针对该输入的算法的运行时间至少是g(n)的常数倍。(4)o-记法,称为非渐近紧上界。对于给定的函数g(n),用o(g(n))表示这样的一类函数,其中o(g(n))={f(n)|对于任意的正常数c,存在一个正常数n0,使得0≤f(n)<cg(n)对于所有n≥n0都成立}。O记法提供的界限可能是渐近紧的,也可能不是。如2n2=O(n2)是渐近紧的,但是2n=O(n2)是非渐近紧的,2n=o(n2),但2n2≠o(n2)。O-记法和o-记法的定义是类似的,二者的主要区别:① 在f(n)=O(g(n))中,对于某些常数c>0,0≤f(n)≤cg(n)成立;② 在f(n)=o(g(n))中,0≤f(n)<cg(n)适用于所有常数c>0。直观上看,在f(n)=o(g(n))中,随着n趋近于无穷大,函数f(n)相对于g(n)变得无关紧要,即:

算法导论-上课笔记1:算法基础/递归/分治法/渐近符号表示/程序运行时间_渐近符号_07


(5)ω-记法,称为非渐近紧下界。对于给定的函数g(n),用ω(g(n))表示这样的一类函数,其中ω(g(n))={f(n)|对于任意的正常数c,存在一个正常数n0,使得0≤cg(n)<f(n)对于所有n≥n0都成立}。有:f(n)∈ω(g(n))当且仅当g(n)∈o(f(n))。例如n2/2=ω(n),但n2/2≠ω(n2)。f(n)=ω(g(n))意味着:

算法导论-上课笔记1:算法基础/递归/分治法/渐近符号表示/程序运行时间_渐近符号_08


实数的许多关系性质适用于渐近性比较。对于下面的f(n)和g(n)均假设为渐近正函数:

(1)若f(n)=Θ(g(n)),且g(n)=Θ(h(n)),则f(n)=Θ(h(n));

(2)若f(n)=O(g(n)),且g(n)=O(h(n)),则f(n)=O(h(n));

(3)若f(n)=Ω(g(n)),且g(n)=Ω(h(n)),则f(n)=Ω(h(n));

(4)若f(n)=o(g(n)),且g(n)=o(h(n)),则f(n)=o(h(n));

(5)若f(n)=ω(g(n)),且g(n)=ω(h(n)),则f(n)=ω(h(n))。

除此之外,有:

(1)自反性:f(n)=Θ(f(n)),f(n)=O(f(n)),f(n)=Ω(f(n));

(2)对称性:f(n)=Θ(g(n))当且仅当g(n)=Θ(f(n));

(3)反对称性:f(n)=O(g(n))当且仅当g(n)=Ω(f(n)),f(n)=o(g(n))当且仅当g(n)=ω(f(n))。

总之,函数渐近性比较可以类比实数之间的大小比较符号:

(1)o ≈ <:f(n)=o(g(n))⇔f(n)<g(n)⇔a<b;

(2)O ≈ ≤:f(n)=O(g(n))⇔f(n)≤g(n)⇔a≤b;

(3)Θ ≈ =:f(n)=Θ(g(n))⇔f(n)=g(n)⇔a=b;

(4)Ω ≈ ≥:f(n)=Ω(g(n))⇔f(n)≥g(n)⇔a≥b;

(5)ω ≈ >:f(n)=ω(g(n))⇔f(n)>g(n)⇔a>b。

如何解释【n=O(n2)】、【2n2+3n+1=2n2+Θ(n)】?

(1)当渐近符号仅在右手边时,意味着集合关系。如n=O(n2),则n∈O(n2);

(2)当渐近符号在一个公式中时,代表一些不愿意命名的匿名函数。例如,2n2+3n+1=2n2+Θ(n)表示2n2+3n+1=2n2+f(n),其中f(n)是集合Θ(n)中的某个匿名函数,即代表存在某个匿名函数使得方程成立。按这种方式使用渐近记号可以帮助消除一个等式中无关紧要的细节与混乱。例如,归并排序的最坏情况运行时间表示为递归式T(n)=2T(n/2)+Θ(n),如果只对T(n)的渐近行为感兴趣,那么没有必要准确说明所有低阶项,它们都被理解为包含在由项Θ(n)表示的匿名函数中;

(3)当渐近符号在左手边时,如2n2+Θ(n)=Θ(n2)中,可以解释为:无论等号左边的匿名函数是如何选择的,都有办法选择一个等号右边的匿名函数,使方程有效。即任给f(n)∈Θ(n),存在g(n)∈Θ(n2),使得2n2+f(n)=g(n)成立。

由上面的(2)和(3),有2n2+3n+1=2n2+Θ(n)=Θ(n2)。可以用上述规则分别解释每个等式:

1、第一个等式表明存在某个函数f(n)∈Θ(n),使得对所有的n,有2n2+3n+1=2n2+f(n);

2、第二个等式表明对任意函数g(n)∈Θ(n)(如刚刚提到的f(n)),存在某个函数h(n)∈Θ(n2),使得对所有的n,有2n2+g(n)=h(n)。

上述解释蕴涵着2n2+3n+1=Θ(n2)。

5 递归方法

递归也是分析算法的一种基本方法。迭代函数:用符号f(i)(n)来表示将初始值为n的函数f(n)迭代i次,其中i是非负整数,定义f(i)(n)为:

算法导论-上课笔记1:算法基础/递归/分治法/渐近符号表示/程序运行时间_分治法_09


举个栗子:如果f(n)=2n,那么f(2)(n)=f(f(n))= f(2n)=2(2n)= 22n,f(i)(n)=2i·n。

递归式是根据一个或多个基本情况及其本身的等式或不等式。如何获得递归式的渐近Θ或O界呢?有三种方法:

(1)置换法:猜测一个界限,然后用数学归纳法证明猜测是正确的;

(2)迭代法:将循环转换为求和,然后依靠边界求和技巧化简递归,同时还可以利用递归树;

(3)主方法:也称为母函数法,此方法只能解决T(n)=aT(n/b)+f(n)形式的递归,其中a≥1,b>1,f(n)是给定的一个函数。

5.1 忽略技术细节

1、假设函数的参数为整数:通常,T(n)仅在n为整数时定义。例如,归并排序的最坏情况运行时间为:

算法导论-上课笔记1:算法基础/递归/分治法/渐近符号表示/程序运行时间_运行时间_10


2、忽略边界条件:省略递归边界条件的陈述,并且假设当n为较小的整数时,T(n)是常数,即对于足够小的n,有T(n)=Θ(1)。例如对于:

算法导论-上课笔记1:算法基础/递归/分治法/渐近符号表示/程序运行时间_渐近符号_11


将上式记为T(n)=2T(n/2)+Θ(n),直接省略T(1)=Θ(1)而不明确给出当n很小时的函数值,这样做的原因是改变边界值只可能影响递归式的精确解,但不会改变解的函数增长率;

3、省略向下取整​​⌊⌋​​​、向上取整​​⌈⌉​​符号:这样做不会影响算法分析中遇到的许多递归式的渐近界限分析。

5.2 置换法

置换法的要点:

1)猜测解的形式;

2)用数学归纳法来说明猜测的解是有效的。

举个栗子,如下:

算法导论-上课笔记1:算法基础/递归/分治法/渐近符号表示/程序运行时间_运行时间_12


解决过程如下:

1)猜测T(n)=n·lg n+n;

2)数学归纳法证明过程如下:

算法导论-上课笔记1:算法基础/递归/分治法/渐近符号表示/程序运行时间_运行时间_13


这种方法很有效,但只能在某些情况下使用,尤其是当很容易猜出答案的形式时。置换法可以用来确定递归式的O界或Ω界。例如,确定下面递归式的一个上界O:

算法导论-上课笔记1:算法基础/递归/分治法/渐近符号表示/程序运行时间_算法_14


解决过程如下:

1)猜测T(n)=O(n·lg n);

2)数学归纳法证明过程如下:

算法导论-上课笔记1:算法基础/递归/分治法/渐近符号表示/程序运行时间_算法_15


数学归纳法要求证明猜测的解必须适用于边界条件,通常边界条件适合作为归纳证明的基本条件。简而言之,如果猜测的解不满足边界条件,那就不需要再进行下一步归纳法的证明,而应该重新进行猜测。这种要求有时会导致某些问题。假设T(1)=1是递归式的唯一的边界条件,那就不能选择足够大的c,因为T(1)≤c·1·lg1=0,这与T(1)=1是相悖的,这时候在归纳证明的情况不成立。那递归结果与初始情况矛盾,就只能说递归证明失败了吗?那如何克服递归结果与边界条件不一致的问题呢?

渐近符号记法只要求证明n≥n0时的T(n)≤cnlg n,其中n0是常数,这里不考虑边界条件T(1)=1,而是将T(2)和T(3)作为归纳的边界条件。由递归关系式T(n)= 2T(⌊n/2⌋)+n得:T(2)=4和T(3)=5。T(n)≤c·n·lg n的归纳证明现在可以通过选择任意常数c,只要c≥2,就能使得T(2)≤c·2·lg2和T(3)≤c·3·lg3。

不幸的是,没有通用的方法来猜测递归式的正确解,即猜想其实不是一种方法,因为猜测解往往需要的是经验,偶尔还需要创造力。幸运的是,有一些启发式方法如递归树可以帮助我们成为一个好的猜测者。

如果一个递归式与自己以前见过的某个式子类似,那么猜测类似的解就是合理的。比如,对于T(n)= 2T(⌊n/2⌋+17)+n,这时候看起来很困难,因为增加了17。直观地说,这个附加项不会显著影响递归的求解。因为当n很大的时候,T(n/2)和T(n/2+17)的差别并没有那么大。因此,可以猜测T(n)=O(n·lg n),可使用置换法验证确实是正确的,过程如下:

算法导论-上课笔记1:算法基础/递归/分治法/渐近符号表示/程序运行时间_渐近符号_16


上图的证明还没有结束,但是我无法继续下去了,因为没有求解思路。一种好的猜测方法是在刚开始时寻找递归式的松的上下渐近界,然后缩小范围,逐步逼近,慢慢减少不确定性的范围。例如,对:

算法导论-上课笔记1:算法基础/递归/分治法/渐近符号表示/程序运行时间_算法_14


因为在递归式中有n,因此可以从T(n)=Ω(n)开始,然后可以证明T(n)=O(n2)。之后,可以逐渐降低上限和提高下限,直到最后收敛到正确的、渐近紧的解T(n)=O(n·lg n)。代数替换有时也能解决未知的问题,也许可以将一个递归式转换为类似于自己以前见过的递归式。举个栗子,如下:

算法导论-上课笔记1:算法基础/递归/分治法/渐近符号表示/程序运行时间_渐近符号_18


上式看起来很难,但是可以通过改变变量来简化循环。为了方便起见,不考虑将值(如√n)四舍五入为整数。过程如下:

算法导论-上课笔记1:算法基础/递归/分治法/渐近符号表示/程序运行时间_运行时间_19

5.3 迭代法

迭代法对代数能力的要求较高,主要是通过不断迭代将原迭代式展开为级数,并求和。如:对于T(n)=n+3T(⌊n/4⌋),有:T(n)=n+3T(⌊n/4⌋)=n+3(⌊n/4⌋+3T(⌊n/16⌋))=n+3(⌊n/4⌋+3(⌊n/16⌋+3T(⌊n/64⌋)))=n+3⌊n/4⌋+9⌊n/16⌋+27T(⌊n/64⌋)=…应该循环迭代到什么程度呢?第i项是3iT(⌊n/4i⌋),其中i=1,2,…当⌊n/4i⌋=1时,迭代停止。通过继续迭代直到这一点,并使用⌊n/4i⌋≤n/4i,我们得到一个递减的序列:

算法导论-上课笔记1:算法基础/递归/分治法/渐近符号表示/程序运行时间_递归_20


迭代法通常导致大量的代数运算。要点:

(1)迭代次数:需要将递归式迭代到边界条件的次数;

(2)级数求和:从迭代过程的每一级产生的项的总和。

有时在迭代一个循环的过程中,可以不用做所有的数学运算就能猜出解,这时可以放弃继续迭代,转而采用置换法,这种方法通常需要较少的代数计算。即展开递归式为迭代求和的过程中,有时只需要部分展开,然后根据其规律来猜想递归式的解,接着用置换法进行证明。

当递归包含向下取整​​⌊⌋​​​、向上取整​​⌈⌉​​时,式子会变得特别复杂。通常假设递归只定义在某一个数的幂次上,这样是有帮助的。举个栗子,对T(n)=n+3T(⌊n/4⌋),如果假设对于某个整数k,n=4k,则向下取整​​⌊⌋​​便可以被省略。

画递归树可以从直观上表示迭代法,也有助于猜想递归式的解。当递归描述分治法类的算法的运行时间时,递归树特别有用。

在递归树中,每个结点表示一个单一子问题的代价,子问题对应某次递归函数调用。将树中每层中的代价求和,得到每层代价,然后将所有层的代价求和,得到所有层次的递归调用的总代价。递归树最适合用来生成好的猜测,然后即可用置换法来验证猜测是否正确。当使用递归树来生成好的猜测时,常常需要忍受一点儿“不精确”。但如果在画递归树和代价求和时非常仔细,就可以用递归树直接证明解是否正确。

下面以递归式T(n)=3T(⌊n/4⌋)+Θ(n2)为例来看一下如何用递归树生成一个好的猜测。首先关注如何寻找解的一个上界。因为舍入对求解递归式通常没有影响,因此可以为递归式T(n)=3T(⌊n/4⌋)+cn2创建一棵递归树,其中已将渐近符号改写为隐含的常数系数c>0。

算法导论-上课笔记1:算法基础/递归/分治法/渐近符号表示/程序运行时间_分治法_21


上图显示了如何从递归式T(n)=3T(⌊n/4⌋)+cn2构造出递归树。为方便起见,假定n是4的幂,这样所有子问题的规模均为正数。上图a显示了T(n),它在图b中扩展为一棵等价的递归树。根结点中的cn2项表示递归调用顶层的代价,根的三棵子树表示规模为n/4的子问题所产生的代价。图c显示了进一步构造递归树的过程,将图b中代价为T(n/4)的结点逐一扩展。继续扩展树中每个结点,根据递归式确定的关系将其分解为几个组成部分,即孩子结点。图d显示了扩展完毕的递归树,其高度为log4n,递归树共有log4n+1层。

因为子问题的规模在每一步减少为上一步的1/4,所以最终必然会达到边界条件。那么根结点与T(1)的子问题距离多远呢?深度为i的结点对应规模为n/4i的子问题,其中深度i=0,1,2,…因此,当n/4i=1,或等价地i=log4n时,子问题规模变为T(1)。因此,递归树有log4n+1层。

接下来确定树的每一层的代价。每层的结点数都是上一层的3倍,因此深度为i的结点数为3i。因为每一层子问题规模都是上一层的1/4,所以对i=0,1,2,…,log4n-1,深度为i的每个结点的代价为c(n/4i)2。做一下乘法可得,对i=0,1,2,…,log4n-1,深度为i的所有结点的总代价为3ic(n/4i)2=(3/16)icn2。树的最底层深度为log4n,有3log4n=nlog43个结点,每个结点的代价为T(1),总代价为nlog43T(1),即Θ(nlog43),因为假定T(1)是常量。

现在求所有层次的代价之和,确定整棵树的代价:

算法导论-上课笔记1:算法基础/递归/分治法/渐近符号表示/程序运行时间_算法_22


上图用到了公式:

算法导论-上课笔记1:算法基础/递归/分治法/渐近符号表示/程序运行时间_运行时间_23


但是最后一行的公式看起来有些凌乱,但可以再次充分利用一定程度的不精确,并利用无限递减几何级数作为上界。回退一步,并应用如下公式,其中当和是无限的且|x|<1时,有无限递减几何级数:

算法导论-上课笔记1:算法基础/递归/分治法/渐近符号表示/程序运行时间_分治法_24


得到:

算法导论-上课笔记1:算法基础/递归/分治法/渐近符号表示/程序运行时间_分治法_25


这样,对原始的递归式T(n)=3T(⌊n/4⌋)+Θ(n2),推导出了一个猜测T(n)=O(n2)。在上面的例子中,cn2的系数形成了一个递减几何级数,利用上上一个公式,得出这些系数的和的一个上界——常数16/13。由于根结点对总代价的贡献为cn2,所以根结点的代价占总代价的一个常数比例。换句话说,根结点的代价支配了整棵树的总代价。实际上,如果O(n2)确实是递归式的上界(稍后就会证明这一点),那么它必然是一个紧确界。为什么?因为第一次递归调用的代价为Θ(n2),因此Ω(n2)必然是递归式的一个下界。现在用置换法验证猜测是正确的,即T(n)=O(n2)是递归式T(n)=3T(⌊n/4⌋)+Θ(n2)的一个上界。希望证明T(n)≤dn2对某个正的常数d成立。与之前一样,使用常数c>0,有:

算法导论-上课笔记1:算法基础/递归/分治法/渐近符号表示/程序运行时间_递归_26


当d≥(16/13)c时,最后一步推导成立。因此,T(n)=O(n2)是递归式T(n)=3T(⌊n/4⌋)+Θ(n2)的一个上界。

5.4 主方法

主方法也称为母函数法,这种方法只能求解形式为T(n)=aT(n/b)+f(n)的递归式,其中a≥1和b>1,f(n)是渐近正函数。虽然主方法需要记住三个范例,但是当遇到许多递归式时的解可以很快地确定。由于n/b可能不是整数,故用T(⌊n/b⌋)或T(⌈n/b⌉)替换T(n/b),这样做是不会影响渐近性的。在计算分治法递归式时,可以方便地省略向下取整​​⌊⌋​​​、向上取整​​⌈⌉​​。

设a≥1,b>1,且a、b为常数,设f(n)为某个函数,设T(n)在非负整数上定义为递归式T(n)=aT(n/b)+f(n),其中将n/b解释为T(⌊n/b⌋)或T(⌈n/b⌉),则T(n)是渐近有界的,如下:

1、如果f(n)=O(n(logba)-ε),对于某个常数ε>0成立,则:T(n)=Θ(nlogba);

2、如果f(n)=Θ(nlogba)则:T(n)=Θ(nlogba·lg n);

3、如果f(n)=Ω(n(logba)+ε),对于某个常数ε>0成立,且a·f(n/b)≤c·f(n)对于某个常数c<1和所有足够大的n成立,则:T(n)=Θ(f(n))。

总之,将函数f(n)与nlogba进行比较,直觉上,解是由这两个函数中较大的一个决定的:

1、nlogba更大些,则解为T(n)=Θ(nlogba);

2、两个函数大小相同,则乘以一个对数因子,解为T(n)=Θ(nlogba·lg n)=Θ(f(n)·lg n);

3、f(n)更大些,则解为T(n)=Θ(f(n))。

有必要了解一些技术细节:

1、在第一种情况中,不是f(n)小于nlogba就够了,而是要多项式意义上的小于。也就是说,f(n)必须渐近小于nlogba,要相差一个因子nε,其中ε是大于0的常数;

2、在第三种情况中,不是f(n)大于nlogba就够了,而是要多项式意义上的大于,而且还要满足正则条件af(n/b)≤cf(n);

3、在遇到的多项式界的函数中,多数都满足此条件。但是这三种情况并未覆盖f(n)的所有可能性:情况1和情况2之间有一定间隙,f(n)可能小于nlogba但不是多项式意义上的小于;情况2和情况3之间也有一定间隙,f(n)可能大于nlogba但不是多项式意义上的大于。如果函数f(n)落在这两个间隙中,或者情况3中要求的正则条件不成立,就不能使用主方法来求解递归式。

举几个例子:

(1)T(n)=9T(n/3)+n,过程如下:

算法导论-上课笔记1:算法基础/递归/分治法/渐近符号表示/程序运行时间_算法_27


(2)T(n)=T(2n/3)+1,过程如下:

算法导论-上课笔记1:算法基础/递归/分治法/渐近符号表示/程序运行时间_递归_28


(3)T(n)=3T(n/4)+nlg n,过程如下:

算法导论-上课笔记1:算法基础/递归/分治法/渐近符号表示/程序运行时间_渐近符号_29

6 常用函数

1、向下取整⌊⌋、向上取整​⌈⌉​:对任意实数x,用⌊x⌋表示小于或等于x的最大整数,读作x的向下取整,用⌈x⌉表示大于或等于x的最小整数,读作x的向上取整。对所有实数x,有:x-1<⌊x⌋≤x≤⌈x⌉<x+1。对任意整数n,有:⌈n/2⌉+⌊n/2⌋=n。向下取整函数f(x)=⌊x⌋是单调递增的,向上取整函数f(x)=⌈x⌉也是单调递增的。对任意实数x≥0和整数a、b>0,有:

算法导论-上课笔记1:算法基础/递归/分治法/渐近符号表示/程序运行时间_运行时间_30


2、模运算:对任意整数a和任意正整数n,a mod n的值就是商a/n的余数:a mod n=a-n·⌊a/n⌋,有0≤a mod n<n。若(a mod n)=(b mod n),则记a≡b(mod n),并称模n时a等价于b,即若a与b除以n时具有相同的余数,则a≡b(mod n)。等价地,a≡b(mod n)当且仅当n是b-a的一个因子。

3、对数:使用这些记号:

(1)以2为底的对数:lg n=log2n;

(2)自然对数:ln n=logen;

(3)取幂:lgk n=(lg n)k

(4)复合:lg lg n=lg(lg n)。

注意:对数函数只适用于公式中的下一项,所以lg n+k意指(lg n)+k而不是lg(n+k)。如果常量b>1,那么对n>0,函数logbn是严格递增的。对所有实数a>0,b>0,c>0和n,有:

算法导论-上课笔记1:算法基础/递归/分治法/渐近符号表示/程序运行时间_分治法_31


在上面的每个等式中,对数的底都不为1。

根据上图第四个等式,对数的底从一个常量到另一个常量的更换仅使对数的值改变一个常量因子,所以当不关心这些常量因子时,例如在O记法中,经常使用记号lg n。2是对数的最自然的底,因为非常多的算法和数据结构涉及把一个问题分解成两个部分。

常用公式:n!=o(nn),n!=ω(2n),lg(n!)=Θ(n·lg n)

7 补充知识

7.1 P问题

对于某个问题,若存在某种运行时间为多项式级别的算法可以解决该问题,则称该问题为P问题。其中P是polynominal的首字母,翻译为多项式。

例如:【3 递归与分治法】一节提出的排序问题就是一种常见的问题,有诸多算法可以解决排序问题,如冒泡排序算法,该算法的运行时间为O(n2),为多项式级别的,因此排序问题属于P问题。

7.2 NP问题

对于某个问题,若能在多项式时间内验证得出一个正确解,则称该问题为NP问题。其中NP是nondeterministic polynominal,翻译为非确定性多项式。

P问题是NP问题的子集,这是因为一个存在多项式时间解法的问题,总能在多项式时间内验证该解。

nondeterministic,即非确定性体现在——不知道问题是否存在运行时间是多项式级别的算法,但总可以在多项式时间内验证并得出这个问题的一个正确解,则称该问题是NP问题。

旅行商问题(Traveling Salesman Problem,TSP问题)就是NP问题。

7.3 NPC问题

对于某个NP问题,若所有的NP问题都可以约化成该问题,换句话说,只要解决了这个NP问题,那么所有的NP问题都解决了,则称该NP问题为NPC问题。其中NPC是nondeterminism polynomial complete。其定义要满足2个条件:

1、该问题得是一个NP问题;

2、所有的NP问题都可以约简成为该问题。

7.4 NP hard问题

对于某个问题,若该问题满足NPC问题定义的第2条但不一定满足第1条,则称该问题是NP hard问题。

NP hard问题要比NPC问题的范围广,即所有的NP问题都能约化到NP hard问题,但是NP hard问题不一定是一个NP问题。

NP hard问题同样难以找到运行时间为多项式级别的算法。NP hard问题放宽了限定条件,因此可能比所有的NPC问题的时间复杂度更高,从而更难以得到解决。

算法导论-上课笔记1:算法基础/递归/分治法/渐近符号表示/程序运行时间_运行时间_32

END


举报

相关推荐

0 条评论