算法基础知识
在讲解动态规划前,我们先回顾计算机算法的相关基础知识。分治策略(Divide-and-Conquer)是采用分而治之的思想,将难以直接求解的问题分解成若干容易求解的子问题,通过对子问题进行各个击破,最终合并子问题的解来获得原问题的解。分治策略主要包含三个步骤:
(1) 分解(Divide),将原问题分解为多个子问题。
(2) 解决(Conquer),逐个解决子问题。
(3) 合并(Combine),将子问题的解合并得到原问题的解。
分治策略是一种求解问题的思想,使用分治策略的常见算法有分治算法、递归算法和动态规划等。在实际使用中,如果子问题互不相交,可采用分治算法,例如归并排序;如果所有子问题都与原问题具有相同的形式,可采用递归算法;如果具有最优子结构和子问题重叠两个特性,可采用动态规划。
算法的设计思路一般有两种:自顶向下和自底向上。自顶向下的设计思路采用递归的求解方法,自底向上的设计思路采用迭代的求解方法。
下面我们以著名的Fibonacci数列为例,讲解递归算法和迭代算法的实际应用,深入理解自顶向下和自底向上的算法设计思路。
例子
我们称满足以下条件: F ( n ) = { 0 , n = 0 1 , n = 1 F ( n − 2 ) + F ( n − 1 ) , n ⩾ 2 F(n)=\left\{\begin{matrix} 0, &n=0 \\ 1, &n=1 \\ F(n-2)+F(n-1), & n\geqslant 2 \end{matrix}\right. F(n)=⎩⎨⎧0,1,F(n−2)+F(n−1),n=0n=1n⩾2 的序列 F ( n ) ~F(n)~ F(n) 为斐波那契(Fibonacci)数列,请编程打印出 ~ Fibonacci ~ 数列的前 30 ~30~ 30 项( n = 0 , 1 , ⋯ , 29 n=0,1,\cdots,29 n=0,1,⋯,29)的值。例如,打印 ~ Fibonacci 数列前 10 ~10~ 10 项的结果为: 0 0 0, 1 1 1, 1 1 1, 2 2 2, 3 3 3, 5 5 5, 8 8 8, 13 13 13, 21 21 21, 34 34 34。
解:本题可采用四种算法求解,包括普通递归算法,基于备忘录的递归算法,基于备忘录的迭代算法,以及无备忘录的迭代算法。普通递归算法和基于备忘录的递归算法都是采用自顶向下的设计思路。基于备忘录的迭代算法和无备忘录的迭代算法都是采用自底向上的设计思路。
普通递归算法
最简单的解法是直接采用普通递归算法,算法的伪代码如算法所示, f i b \mathrm{fib} fib 函数递归地调用自己,直到 n = 1 ~n=1 n=1 或者 n = 0 ~n=0~ n=0 时进入回溯阶段。此方法的缺点在于,随着输入规模 n ~n~ n 的增长,算法的时间复杂度呈指数级增长,程序运行时间变得异常长。导致该问题的原因在于,给定一个 n ′ ~{n}'~ n′ 求解 f i b ( n ) ~\mathrm{fib}(n)~ fib(n) 时,需要计算所有 n < n ′ ~n<{n}'~ n<n′ 的情况,并且可能遭遇多次重复计算。
以 n = 4 ~n=4~ n=4 为例,采用普通递归算法求解 ~ Fibonacci ~ 数列的图解如图 ~ \ref{fig_fib_recursion1} ~ 所示。按照 ~ Fibonacci ~ 数列的公式 F ( n ) = F ( n − 2 ) + F ( n − 1 ) ~F(n)=F(n-2)+F(n-1) F(n)=F(n−2)+F(n−1),计算 f i b ( 4 ) ~\mathrm{fib}(4)~ fib(4) 需要计算 f i b ( 2 ) ~\mathrm{fib}(2)~ fib(2) 和 f i b ( 3 ) ~\mathrm{fib}(3) fib(3),计算 f i b ( 3 ) ~\mathrm{fib}(3)~ fib(3) 时又需要重新计算一次 f i b ( 2 ) ~\mathrm{fib}(2) fib(2)。采用普通递归算法求解时,当输入规模 n ~n~ n 越大,遭遇的重复计算次数越多,花费时间越长。
基于备忘录的递归算法
接下来介绍基于备忘录的递归算法。采用计算机领域常用的用空间换时间的策略,引入备忘录机制,将已经计算的 f i b ( n ) ~\mathrm{fib}(n)~ fib(n) 存入备忘录中,将显著减少递归算法的时间复杂度。基于备忘录的递归算法伪代码如算法所示,算法中定义了名为 p a s t _ f i b ~\mathrm{past\_fib}~ past_fib 的备忘录(空字典),在计算 f i b ( n ) ~\mathrm{fib}(n)~ fib(n) 前,首先查找备忘录 p a s t _ f i b ~\mathrm{past\_fib} past_fib,如果 n ~n~ n 是 p a s t _ f i b ~\mathrm{past\_fib}~ past_fib 的键,则直接返回 p a s t _ f i b [ n ] ~\mathrm{past\_fib}[n] past_fib[n];否则先计算 f i b ( n ) ~\mathrm{fib}(n) fib(n),通过递归调用 f i b ( n − 2 ) ~\mathrm{fib}(n-2)~ fib(n−2) 和 f i b ( n − 1 ) ~\mathrm{fib}(n-1)~ fib(n−1) 实现,直到计算至 f i b ( 1 ) ~\mathrm{fib}(1)~ fib(1) 和 f i b ( 0 ) ~\mathrm{fib}(0)~ fib(0) 时开始回溯;
同样地以 n = 4 ~n=4~ n=4 为例,采用基于备忘录的递归算法求解 ~ Fibonacci ~ 数列的图解如图所示。按照 ~ Fibonacci ~ 数列的公式 F ( n ) = F ( n − 2 ) + F ( n − 1 ) ~F(n)=F(n-2)+F(n-1) F(n)=F(n−2)+F(n−1),计算 f i b ( 4 ) ~\mathrm{fib}(4)~ fib(4) 需要计算 f i b ( 2 ) ~\mathrm{fib}(2)~ fib(2) 和 f i b ( 3 ) ~\mathrm{fib}(3) fib(3),计算 f i b ( 3 ) ~\mathrm{fib}(3)~ fib(3) 时需要用到的 f i b ( 2 ) ~\mathrm{fib}(2)~ fib(2) 由备忘录 p a s t _ f i b [ 2 ] ~\mathrm{past\_fib}[2] past_fib[2] 直接返回。当输入规模 n ~n~ n 越大,相比于普通递归算法,基于备忘录的递归算法越多地减少重复计算次数。
虽然基于备忘录的递归算法降低了普通递归算法的时间复杂度,但是不可避免地频繁调用递归函数,增加了相应的开销。既然计算 f i b ( 4 ) ~\mathrm{fib}(4)~ fib(4) 时需要用到 f i b ( 2 ) ~\mathrm{fib}(2)~ fib(2) 和 f i b ( 3 ) ~\mathrm{fib}(3) fib(3),那为何不先计算出 f i b ( 2 ) ~\mathrm{fib}(2)~ fib(2) 和 f i b ( 3 ) ~\mathrm{fib}(3) fib(3),再去计算 f i b ( 4 ) ~\mathrm{fib}(4)~ fib(4) 呢?这样可以省掉递归调用的花销。
下面我们基于自底向上的设计思路,采用迭代的方法求解例题。
基于备忘录的迭代算法
基于备忘录的迭代算法求解 ~ Fibonacci ~ 数列的算法伪代码如算法所示。基于备忘录的迭代算法中定义了名为 p a s t _ f i b ~\mathrm{past\_fib}~ past_fib 的备忘录(空字典),在计算 f i b ( n ) ~\mathrm{fib}(n)~ fib(n) 前,首先查找备忘录 p a s t _ f i b ~\mathrm{past\_fib} past_fib,如果 n ~n n 是 p a s t _ f i b ~\mathrm{past\_fib}~ past_fib 的键,则直接返回 p a s t _ f i b [ n ] ~\mathrm{past\_fib}[n] past_fib[n];否则先计算 f i b ( 0 ) ~\mathrm{fib}(0) fib(0)、 f i b ( 1 ) \mathrm{fib}(1) fib(1) 直到 f i b ( n − 1 ) ~\mathrm{fib}(n-1)~ fib(n−1) 和 f i b ( n ) ~\mathrm{fib}(n) fib(n),并存入 p a s t _ f i b ~\mathrm{past\_fib} past_fib。
基于备忘录的迭代算法与基于备忘录的递归算法的区别在于,基于备忘录的递归算法的计算顺序是先由 f i b ( n ) ~\mathrm{fib}(n) fib(n)、 f i b ( n − 1 ) \mathrm{fib}(n-1) fib(n−1)、 f i b ( n − 2 ) \mathrm{fib}(n-2)~ fib(n−2) 递推到 f i b ( 1 ) ~\mathrm{fib}(1)~ fib(1) 和 f i b ( 0 ) ~\mathrm{fib}(0) fib(0),再由 f i b ( 0 ) ~\mathrm{fib}(0)~ fib(0) 和 f i b ( 1 ) ~\mathrm{fib}(1) fib(1) 开始回溯。基于备忘录的迭代算法的计算顺序是 f i b ( 0 ) ~\mathrm{fib}(0) fib(0)、 f i b ( 1 ) \mathrm{fib}(1)~ fib(1) 直到 f i b ( n − 2 ) ~\mathrm{fib}(n-2) fib(n−2)、 f i b ( n − 1 ) \mathrm{fib}(n-1)~ fib(n−1) 和 f i b ( n ) ~\mathrm{fib}(n) fib(n)。
无备忘录的迭代算法
由于 ~ Fibonacci ~ 数列求解问题的特殊性,它还可以采用求解,算法伪代码如算法 ~ \ref{fig_fib_iteration2} ~ 所示。算法只涉及到 3 ~3~ 3 个变量: f p a s t f_{past} fpast, f n o w f_{now} fnow,以及 f f u t u r e ~f_{future} ffuture。
同样地以 n = 4 ~n=4~ n=4 为例,采用无备忘录的迭代算法求解 ~ Fibonacci ~ 数列的图解如图所示。在算法的每轮迭代中,先用 f p a s t + f n o w ~f_{past}+f_{now}~ fpast+fnow 计算出 f f u t u r e ~f_{future} ffuture,再用 f n o w ~f_{now}~ fnow 代替旧 f p a s t ~f_{past}~ fpast 成为新一轮迭代中的新 f p a s t ~f_{past} fpast, f f u t u r e f_{future}~ ffuture 代替旧 f n o w ~f_{now}~ fnow 成为新一轮迭代中的新 f n o w ~f_{now} fnow,在新一轮迭代中,将新 f p a s t ~f_{past}~ fpast 和新 f n o w ~f_{now}~ fnow 相加得到新 f f u t u r e ~f_{future} ffuture,如此迭代下去直到触发终止条件为止。