《算法导论》学习笔记之二:算法基础

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/shanpenghui/article/details/83823215

伪代码注意事项

循环不变式

  我们搞算法的人都知道,通常将算法描述为用一种伪代码书写的程序。其中比较重要的概念就是循环不变式,它的作用主要帮助我们理解算法的正确性。关于循环不变式,有三种性质:
  初始化:循环的第一次迭代之前,它为真。
  保持:如果循环的某次迭代之前它为真,那么下次迭代之前它仍为真。
  终止:在循环终止时,不变式为我们提供一个有用的性质,该性质有助于证明算法是正确的。

缩进

  在伪代码中,缩进表示块结构。比如for、while循环体中,我们用缩进风格的表示方法来代替常规的块结构标志,这样可以提高代码的清晰性。

符号//

  符号//表示该行后面部分是个注释。

多重赋值

形如i=j=e的多重赋值表达式e的值赋给变量i和j;它应被处理成等价于赋值j=e后面跟着赋值i=j。

NIL

一个指针根本不指向任何对象。这时,我们赋给它特殊值NIL。

and和or

布尔运算符“and”和“or”都是短路的。就是说,当求值表达式“x and y”时,首先求值x。如果x求值为FALSE,那么整个表达式不可能求值为TRUE,所以不再求值y。另外,如果x求值为TRUE,那么就必须求值y以确定整个表达式的值。类似的,对表达式“x or y”,仅当x求值为FALSE时,才求值表达式y。短路的运算符使我们能书写像“x≠NIL and x.f=y”这样的布尔表达式,而不必担心当x为NIL时我们试图求值x.f将会发生什么情况。

初识算法复杂度

  很多童鞋在学习算法的时候对算法复杂度这个东西比较头疼,始终没有把握住怎么计算算法的复杂度,甚至连基础的算法复杂度怎么得到也不太清楚,只靠死记硬背来应付一下考试或面试,没有真正理解。这里我们从简单的插入排序算法来初步理解算法复杂度是如何计算得到的。

插入排序算法的复杂度计算

  我们先给出插入排序算法的伪代码:

INSERTION-SORT(A) 代价 次数
1 for j=2 to A.length c1 n
2 key=A[j] c2 n-1
3 // Insert A[j] into the sorted sequence A[1…j-1] 0 n-1
4 i=j-1 c4 n
5 while i>0 and A[i]>key c5 j = 2 n t j \sum_{j=2}^{n}t_{j}
6 A[i+1]=A[i] c6 j = 2 n ( t j 1 ) \sum_{j=2}^{n}(t_{j}-1)
7 i=i-1 c7 j = 2 n ( t j 1 ) \sum_{j=2}^{n}(t_{j}-1)
8 A[i+1]=key c8 n-1

  我们定义一个算法在特定输入上的运行时间是指执行的基本操作数或步数。目前,我们认为,执行每行伪代码需要常量时间。因此可以假定第i行的每次执行需要时间ci,其中ci是一个常量。对j=2,3,…,n,其中n=A.length,假设tj表示对于值j,第5行执行while循环测试的次数。当for或while循环退出时,执行测试的次数比执行循环体的次数多1,因此第6-7行是tj-1次。
  该算法的运行时间是执行每条语句的运行时间之和。需要执行ci步且执行n次的一条语句将贡献cin给总运行时间。为计算在具有n个值的输入上INSERTION-SORT的运行时间T[n],我们将代价与次数列对应元素之积求和,得:
(1) T ( n ) = c 1 n + c 2 ( n 1 ) + c 4 ( n 1 ) + c 5 j = 2 n t j + c 6 j = 2 n ( t j 1 ) + c 7 j = 2 n ( t j 1 ) + c 8 ( n 1 ) T(n)=c_{1}n+c_{2}(n-1)+c_{4}(n-1)+c_{5}\sum_{j=2}^{n}t_{j}+c_{6}\sum_{j=2}^{n}(t_{j}-1)+c_{7}\sum_{j=2}^{n}(t_{j}-1)+c_{8}(n-1) \tag 1
  就算对于给定规模的输入,算法的运行时间可能依赖于给定的是该规模下的哪个输入。例如,在INSERTION-SORT中,若输入数组已排好序,则出现最佳情况。这时,对每个j=2,3,…,n,我们发现在第5行,当i取其初值j-1时,有A[i]≤key。从而对j=2,3,…,n,有tj=1,该最佳情况的运行时间为:
(2) T ( n ) = c 1 n + c 2 ( n 1 ) + c 4 ( n 1 ) + c 5 ( n 1 ) + c 8 ( n 1 ) T(n)=c_{1}n+c_{2}(n-1)+c_{4}(n-1)+c_{5}(n-1)+c_{8}(n-1) \tag 2 (3) = ( c 1 + c 2 + c 4 + c 5 + c 8 ) n ( c 2 + c 4 + c 5 + c 8 ) =(c_{1}+c_{2}+c_{4}+c_{5}+c_{8})n-(c_{2}+c_{4}+c_{5}+c_{8}) \tag 3
  我们可以把该运行时间表示为an+b,其中常量a和b依赖于语句代价ci。因此,它是n的线性函数。
  若输入数组已反向排序,即按递减排好序,则导致最坏情况。我们必须每个元素A[j]与整个已排序子数组A[1…j-1]中的每个元素进行比较,所以对j=2,3,…,n,有tj=j。注意到
(4) j = 2 n j = n ( n + 1 ) 2 1 \sum_{j=2}^{n}j=\frac{n(n+1)}{2}-1 \tag 4 (5) j = 2 n j 1 = n ( n 1 ) 2 \sum_{j=2}^{n}j-1=\frac{n(n-1)}{2} \tag 5
  所以在最坏情况下,INSERTION-SORT的运行时间为
(6) T ( n ) = c 1 n + c 2 ( n 1 ) + c 4 ( n 1 ) + c 5 ( n ( n + 1 ) 2 1 ) + c 6 ( n ( n 1 ) 2 ) + c 7 ( n ( n 1 ) 2 ) + c 8 ( n 1 ) T(n)=c_{1}n+c_{2}(n-1)+c_{4}(n-1)+c_{5}(\frac{n(n+1)}{2}-1)+c_{6}(\frac{n(n-1)}{2})+c_{7}(\frac{n(n-1)}{2})+c_{8}(n-1) \tag 6 (7) = ( c 5 2 + c 6 2 + c 7 2 ) n 2 + ( c 1 + c 2 + c 4 + c 5 2 c 6 2 c 7 2 ) n ( c 2 + c 4 + c 5 + c 8 ) =(\frac{c_{5}}{2} + \frac{c_{6}}{2} + \frac{c_{7}}{2} )n^{2}+( c_{1}+c_{2}+c_{4}+\frac{c_{5}}{2} - \frac{c_{6}}{2} - \frac{c_{7}}{2} )n-(c_{2}+c_{4}+c_{5}+c_{8}) \tag 7
  我们可以把最坏情况允许时间表示为a n 2 n^{2} +bn+c,其中常量a、b和c又依赖于语句代价ci。因此,它是n的二次函数。

初识分治算法

分治模式

  分治模式在每层递归时都有三个步骤:
  分解原问题为若干子问题,这些子问题是原问题的规模较小的实例;
  解决这些子问题,递归地求解各子问题。然而,若子问题的规模足够小,则直接求解。
  合并这些子问题的解成原问题的解。

归并排序

  归并排序算法完全遵循分治模式,直观上起操作如下:
  分解:分解待排序的n个元素的序列成各具n/2个元素的两个子序列。
  解决:使用归并排序递归地排序两个序列。
  合并:合并两个已排序的子序列以产生已排序的答案。
  归并排序算法的关键操作是“合并”步骤中两个已排序序列的合并。我们通过调用一个辅助过程MERGE(A,p,q,r)来完成合并,其中A是一个数组,p、q、r是数组下标,满足p≤q<r。该过程假设子数组A[p…q]和A[q+1…r]都已排好序。它合并这两个子数组形成单一的已排好序的子数组并代替当前的子数组A[p…r]。
  过程MERGE需要 Θ \Theta (n)的时间,其中n=r-p+1是待合并元素的总数。它按以下方式工作。举个玩扑克牌的例子,假设桌子上有两堆牌面朝上的牌,每堆都已排序,最小的牌在顶上。我们希望把这两堆牌合并成单一的排好序的输出堆,牌面朝下地放在桌上。我们的基本步骤包括在牌面朝上的两堆牌的顶上两张牌中选取较小的一张,将该牌从其堆中移开(该堆的顶上将显露一张新牌)并牌面朝下地将该牌放置到输出堆。重复这个步骤,直到一个输入堆为空,这时,我们只是拿起剩余的输入堆并牌面朝下地放置到输出堆。因为我们只是比较顶上的两张牌,所以计算每个基本步骤需要常量时间。因为我们最多执行n个步骤,所以合并需要 Θ \Theta (n)的时间。下面写出伪代码来实现该思想。

MERGE(A,p,q,r) 代价 次数
1 n 1 n_{1} =q-p+1 c1 1
2 n 2 n_{2} =r-p c2 1
3 let L[1… n 1 n_{1} +1] and R[1… n 2 n_{2} +1] be new arrays c3 1
4 for i=1 to n 1 n_{1} c4 n 1 n_{1}
5   L[i]=A[p+i-1] c5 n 1 n_{1}
6 for j=1 to n 2 n_{2} c6 n 2 n_{2}
7   R[i]=A[q+j] c7 n 2 n_{2}
8 L[ n 1 n_{1} +1]= \infty c8 1
9 R[ n 2 n_{2} +1]= \infty c9 1
10 i=1 c10 1
11 j=1 c11 1
12 for k=p to r c12 n
13   if L[i]≤R[j] c13 n x n_{x} (<n)
14     A[k]=L[i] c14 n x n_{x} -1(<n)
15     i=i+1 c15 n x n_{x} -1(<n)
16   else A[k]=R[j] c16 n y n_{y} (<n)
17     j=j+1 c17 n y n_{y} (<n)

  上面的伪代码实现了归并排序的思想,但有一个额外的变化,以避免在每个基本步骤必须检查是否有堆为空。在每个堆的底部放置一张哨兵牌,它包含一个特殊的值,用于简化代码。这里,我们使用 \infty 作为哨兵值,结果每当显露一张值为 \infty 的牌,它不可能为较小的牌,除非两个堆都已显露出其哨兵牌(这里说明一下,在数学上我们知道表示无穷大的时候用± \infty ,这只是数学上的表达。如果将伪代码变成程序,则需要变成对应数据类型的上下限表示,例如int型的数据无穷则是INT_MIN或INT_MAX,double型的数据无穷则是DBL_MIN或DBL_MAX,这样当循环到最后时,可以在数据上判断两个堆是否都已显露出其哨兵牌了)。但是,一旦发生这种情况,所有哨兵牌都已被放置到输出堆。因为我们事先知道刚好r-p+1张牌将被放置到输出堆,所以一旦已执行r-p+1个基本步骤,算法就可以停止。
  过程MERGE的详细工作过程如下:第1行计算子数组A[p…q]的长度 n 1 n_{1} ,第2行计算子数组A[q+1,…r]的长度 n 2 n_{2} 。在第3行,我们创建长度分别为 n 1 n_{1} +1和 n 2 n_{2} +1的数组L和R(“左”和“右”),每个数组中额外的位置将保存哨兵。第4 ~ 5行的for循环将子数组A[p…q]复制到L[1… n 1 n_{1} ],第6 ~ 7行的for循环将子数组A[q+1…r]复制到R[1… n 2 n_{2} ]。第8 ~ 9行将哨兵放在数组L和R的末尾。第10 ~ 17 行在图1中。


在这里插入图片描述

  通过维持以下循环不变式,执行r-p+1个基本步骤:
  在开始第12 ~ 17行for循环的每次迭代时,子数组A[p…k-1](因为k=p,…,r,A[p…k-1]指的是原数组中下标为p,…,k-1的数组片段,也是指MERGE最终输出堆的数组)按从小到大的顺序包含L[1… n 1 n_{1} +1]和R[1… n 2 n_{2} +1]中的k-p个最小元素。进而,L[i]和R[j]是各自所在数组中未被复制回数组A的最小元素。
  为了理解过程MERGE的运行时间是 Θ \Theta (n),其中n=r-p+1,注意到,第1 ~ 3行和第8 ~ 11行中的每行需要常量时间,第4 ~ 7行的for循环需要 Θ \Theta n 1 n_{1} + n 2 n_{2} )= Θ \Theta (n)的时间,并且,第12 ~ 17行的for循环有n次迭代,每次迭代需要常量时间。
  我们可以把过程MERGE作为归并排序算法中的一个子程序来用。下面的过程MERGE-SORT(A,p,r)排序子数组A[p…r]中的元素。若p≥r,则子数组最多有一个元素,所以已经排好序。否则,分解步骤简单地计算一个下标q,将A[p…r]分成两个子数组A[p…q]和A[q+1…r],前者包含 \lceil n/2 \rceil (注意是left floor和right floor取值上下界,取不大于n/2的最大整数)个元素,后者包含 了 \lfloor n/2 \rfloor 个元素。

MERGE-SORT(A,p,r)
1 if p < r
2   q = \lfloor (p+r)/ 2 \rfloor
3   MERGE-SORT(A,p,q)
4   MERGE-SORT(A,q+1,r)
5   MERGE(A,p,q,r)

  为了排序整个序列A=<A[1],A[2],…,A[n]>,我们执行初始调用MERGE-SORT(A,1,A.length),这里再次有A.length=n。图2-4自底向上地说明了当n为2的幂时该过程的操作。算法由以下操作组成:合并只含1项的序列对,形成长度为2的排好序的序列,合并长度为2的序列对,形成长度为4的排好序的序列,依此下去,直到长度为n/2的两个序列被合并最终形成长度为n的排好序的序列。


在这里插入图片描述

分析分治算法

  分析算法运行时间的递归式来自基本模式的三个步骤。我们假设T(n)是规模为n的一个问题的运行时间。若问题规模足够小,如对某个常量c,n≤c,则直接求解需要常量时间,我们将其写作 Θ \Theta (1)。假设把原问题分解成a个子问题,每个子问题的规模都是原问题的1/b。(对归并排序,a和b都为2,然而,我们将看到许多分治算法中,a≠b。)为了求解一个规模为n/b的子问题,需要T(n/b)的时间,所以需要aT(n/b)的时间来求解a个子问题。如果分解问题成子问题需要时间D(n),合并子问题的解成原问题的解需要时间C(n),那么得到递归式:
(8) T ( n ) = { Θ ( 1 ) n c a T ( n / b ) + D ( n ) + C ( n ) T(n)= \begin{cases} \Theta(1) \qquad \qquad \qquad \qquad \qquad \qquad 若n≤c\\ aT(n/b)+D(n)+C(n)\qquad \qquad 其他 \end{cases} \tag 8
  虽然MERGE-SORT的伪代码在元素的数量不是偶数时也能正确地工作,但是,如果假定原问题规模是2的幂,那么基于递归式的分析将被简化。这时分解步骤将产生规模刚好为n/2的两个子序列。这个假设并不影响递归式解的增长量级。
  下面我们分析建立归并排序n个数的最坏情况允许时间T(n)的递归式。归并排序一个元素需要常量时间。当有n>1个元素时,我们分解允许时间如下:
  分解:分解步骤仅仅计算子数组的中间位置,需要常量时间,因此,D(n)= Θ \Theta (1)。
  解决:我们递归地求解两个规模均为n/2的子问题,将贡献2T(n/2)的运行时间。
  合并:我们已经注意到一个具有n个元素的子数组上过程MERGE需要 Θ \Theta (n)的时间,所以C(n)= Θ \Theta (n)。
  当为了分析归并排序而把函数D(n)与C(n)想加时,我们是在把一个 Θ \Theta (n)函数与另一个 Θ \Theta (1)函数相加。相加的和是n的一个线性函数,即 Θ \Theta (n)。把它与来自“解决步骤”的项2T(n/2)相加,将给出归并排序的最坏情况运行时间T(n)的递归式:
(9) T ( n ) = { Θ ( 1 ) n = 1 2 T ( n / 2 ) + Θ ( n ) n &gt; 1 T(n)= \begin{cases} \Theta(1) \qquad \qquad \qquad \qquad \qquad \qquad 若n=1\\ 2T(n/2)+\Theta(n)\qquad \qquad \qquad \quad 若n&gt;1 \end{cases} \tag 9
  利用“主定理”(这个定理在这里就不展开说明了,之后再具体详细说明)可以证明T(n)为 Θ \Theta (nlgn),其中lgn代表 l o g 2 log_{2} n。因为对数函数比任何线性函数增长要慢。,所以对足够大的输入,在最坏情况下,运行时间为 Θ \Theta (nlgn)的归并排序将优于运行时间为 Θ \Theta ( n 2 n^{2} )的插入排序。
  我们可以直观地理解递归式(9)为什么是T(n)= Θ \Theta (nlgn),我们暂时不考虑主定理。把递归式(9)重写成:
(10) T ( n ) = { c n = 1 2 T ( n / 2 ) + c n n &gt; 1 T(n)= \begin{cases} c\qquad \qquad \qquad \qquad \qquad \qquad \quad若n=1\\ 2T(n/2)+cn\qquad \qquad \qquad \qquad 若n&gt;1 \end{cases} \tag{10}
  其中常量c代表求解规模为1的问题所需的时间以及在分解步骤与合并步骤处理每个数组元素所需的时间。
  图2-5图示了如何求解递归式(10)。为方便起见,假设刚好是2的幂。图的(a)部分
图示了T(n),它在(b)部分被扩展成一棵描绘递归式的等价树。项cn是树根(在递归的顶层引起的代价),根的两棵子树是两个较小的递归式T(n/2)。(c)部分图示了通过扩展T(n/2)再推一步的过程。在第二层递归中,两个子结点中每个引起的代价都是cn/2。我们通过将其分解成由递归式所确定的它的组成部分来继续扩展树中的每个结点,直到问题规模下降到1,每个子问题只要代价c。(d)部分图示了结果递归树。


在这里插入图片描述

  然后,我们把穿过这棵树的每层的所有代价相加。顶层具有总代价cn,下一层具有总代价c(n/2)+c(n/2)=cn,下一层的下一层具有总代价c(n/4)+c(n/4)+c(n/4)+c(n/4)=cn,等等。一般来说,顶层之下的第i层具有 2 i 2^{i} 个结点,每个结点贡献代价c(n/ 2 i 2^{i} ),因此,顶层之下的第i层具有总代价 2 i 2^{i} c(n/ 2 i 2^{i} )=cn。底层具有n个结点,每个结点贡献代价c,该层的总代价为cn。
  图2-5中递归树的总层数为lgn+1。其中n是叶数,对应于输入规模。一种非形式化的归纳论证将证明该断言。n=1出现基本情况,这时树只有一层。因为lg1=0,所以有lgn+1给出了正确的层数。作为归纳假设,现在假设具有 2 i 2^{i} 个叶的递归树的层数为lg 2 i 2^{i} +1=i+1(因为对i的任何值,都有lg 2 i 2^{i} =i)。因为我们假设输入规模是2的幂,所以下一个要考虑的输入规模是 2 i + 1 2^{i+1} 。具有n= 2 i + 1 2^{i+1} 个叶的一棵树比 2 i 2^{i} 个叶的一棵树要多一层,所以其总层数为(i+1)+1=lg 2 i + 1 2^{i+1} +1。
  为了计算递归式(10)表示的总代价,我们只要把每层的代价加起来。递归树具有lgn+1层,每层代价均为cn,所以总代价为cn(lgn+1)=cnlgn+cn。忽略低阶项和常量c便给出了期望的结果 Θ \Theta (nlgn)。
  看到这里有点懵逼,但是对算法复杂度计算又有了进一步的认识,今晚回头复习一下,到时候继续算法的征程,加油!

[1]: 《算法导论(原书第3版)》作者: Thomas H.Cormen / Charles E.Leiserson / Ronald L.Rivest / Clifford Stein 出版社: 机械工业出版社
[2]: https://blog.csdn.net/perfumekristy/article/details/8816340
[3]: https://blog.csdn.net/xmc281141947/article/details/56835567
[4]: http://www.mohu.org/info/symbols/symbols.htm
[5]:https://www.zybuluo.com/codeep/note/163962#3如何输入括号和分隔符

猜你喜欢

转载自blog.csdn.net/shanpenghui/article/details/83823215