目录
动态规划与分治法
动态规划(dynamic programming)与分治方法相似,都是通过组合子问题的解来求解原问题(在这里,“programming”指的是一种表格法,并非编写计算机程序)。分治方法将问题划分为互不相交的子问题,递归地求解子问题,再将它们的解组合起来,求出原问题的解。与之相反,动态规划应用于子问题重叠的情况,即不同的子问题具有公共的子问题(子问题的求解是递归进行的,将其划分为更小的子子问题)。在这种情况下,分治算法会做许多不必要的工作,它会反复求解那些公共子子问题,而动态规划算法对每个子子问题只求解一次,将其解保存在一个表格中,从而无需每次求解一个子子问题时都重新计算,避免了这种不必要的计算工作。
基本思想和步骤
动态规划方法通常用来求解最优化问题(optimization problem)。这类问题可以有很多可行的解,每个解都有一个值,我们希望寻找具有最优值(最小值或者最大值)的解。我们称这样的解为问题的一个最优解(an optimal solution),而不是(the optimal solution),因为可能有多个解都达到最优值。
我们通常按如下四个步骤来设计一个动态规划算法:
- 刻画一个最优解的结构特征(状态);
- 递归地定义最优解的值(状态转移方程);
- 计算最优解的值,通常采用自底向上的方法;
- 利用计算出的信息构造一个最优解。
步骤 1~3 是动态规划算法求解问题的基础,如果我们仅仅需要一个最优解的值,而非本身,可以忽略步骤 4.
动态规划的思想如下所述:动态规划方法仔细安排求解顺序,对每个子问题只求解一次,并将结果保存下来。如果随后再次需要此子问题的解,只需要查找保存的结果,而不必重新计算。因此,动态规划方法是付出额外的内存空间来节省计算时间,是典型的时空权衡(time-memory trade-off)的例子。而时间上的节省可能是非常巨大的:可能将一个指数时间的解转化为一个多项式时间的解。如果子问题的数量是输入规模的多项式函数,而我们可以在多项式时间内求解每个子问题,那么动态规划方法的总运行时间就是多项式阶的。
实现方法
动态规划有两种等价的实现方法:
第一种方法称为带备忘的自顶向下法(top-down with memoization)。此方法仍按自然的递归形式编写过程,但过程会保存每个子问题的解(通常保存在一个数组或散列表)中。当需要一个子问题的解时,过程首先检查是否已经保存过此解。如果是,则直接返回保存的值,从而节省了计算时间;否则,按通常方式计算这个子问题。我们称这个递归过程是带备忘的(memoized),因为它“记住”了之前已经计算出的结果。
第二种方法称为自底向上法(bottom-up method)。这种方法一般需要恰当定义子问题“规模”的概念,使得任何子问题的求解都只依赖于“更小的”子问题的求解。因为我们可以将子问题按规模排序,按由小至大的顺序进行求解。当求解某个子问题时,它所依赖的那些更小的子问题都已经求解完毕,结果已经保存。每个子问题只需求解一次,当我们求解它(也是第一次遇到它)时,它的所有前提子问题都已经求解完成。
两种方法得到的算法具有相同的渐进运行时间,仅有的差异是在某些特殊情况下,自顶向下的方法并未真正递归地考察所有可能的子问题。由于没有频繁的递归函数调用的开销,自底向上的方法的时间复杂性函数通常具有更小的系数。
钢条切割问题
问题:给定一段长度为 \(n\) 英寸的钢条和一个价格表 \(p_i,(i=1,2,...,n)\),求钢条切割方案,使销售受益 \(r_n\) 最大。注意,如果长度为 \(n\) 英寸的钢条的价格 \(p_n\) 足够大,最优解可能就是完全不需要切割。
下图给出价格表的样例:
长度 \(i\) | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
---|---|---|---|---|---|---|---|---|---|---|
价格 \(p_i\) | 1 | 5 | 8 | 9 | 10 | 17 | 17 | 20 | 24 | 30 |
考虑 \(n=4\) 的情况,通过上表数据,很容易知道,4 英寸钢条的最优方案就是 \(p_2+p_2=10\),而不是 \(p_4=9\) 或者 \(p_1+p_3=9\) 等。
长度为 \(n\) 英寸的钢条共有 \(2^{n-1}\) 种不同的切割方案,因为在距离钢条左端 \(i(i=1,2,...,n-1)\) 英寸出,总是可以选择切割或者不切割。
对于上述价格表,可以得到最优收益 \(r_i(i=1,2,...n)\) 及对应的切割方案:
更一般地,对于 \(r_n(n \ge 1)\),可以通过更短钢条的最优切割收益来表示:
不难注意到,根据上述关系表达式,为了求解长度为 \(n\) 的钢条收益,需要得到所有更小规模子问题的结果,通过组合子问题的最优解,选取收益最大者。这就是前文所述的,钢条切割问题满足最优子结构(optimal substructure)性质:问题的最优解由相关子问题的最优解组合而成,而这些子问题可以独立求解。
递归方法
除了上述求解方法以外,钢条切割问题还存在一种相似的但更为简单的递归求解方法:将钢条从左边切割下长度为 \(i\) 的一段,只对右边剩下的长度为 \(n-i\) 的一段继续进行切割(递归求解),左边部分不再切割,即问题分解为以下简化版本:
\[r_n=\max_{1 \le i \le n}(p_i+r_{n-i}) \]在此公式中,原问题的最优解只包含一个相关子问题的解,而不是两个。
伪代码实现如下:
上述方法在数据规模稍大的情况下,运行时间会变的很长,原因在于,递归代码中会反复求解已经计算过的子问题,如下递归树所示:
分析 CUT-ROD 的运行时间,令 \(T(n)\) 表示第二个参数值为 \(n\) 时 CUT-ROD 的调用次数,此值等于递归树中根为 \(n\) 的子树中的结点总数。
\[T(0)=1, T(n)=1+\Sigma_{j=0}^{n-1}T(j) \]第一项“1”表示函数的第一次调用(递归调用树的根节点),\(T(j)\) 为调用 CUT-ROD(p, n-i) 所产生的所有调用(包括递归调用)的次数,此处 \(j=n-i\)。可以证明,\(T(n)=2^n\)。
可以看到,递归求解的时间复杂度是指数级别,这是可以理解的,对于长度为 n 的钢条,CUT-ROD 显然考察了所有 \(2^{n-1}\) 种可能的切割方案。递归调用树中共有 \(2^{n-1}\) 个叶结点,每个叶结点对应一种可能的钢条切割方案。对每条从根到叶的路径,路径上的标号给出了每次切割前右边剩余部分的长度(子问题的规模)。
动态规划
下面给出的是自顶向下 CUT-ROD 过程的伪代码,加入了备忘机制:
这里,主过程 MEMOIZED-CUT-ROD 将辅助数组 \(r[0...n]\) 的元素均初始化为 $-\infin $,然后调用辅助过程 MEMOIZED-CUT-ROD-AUX。在辅助过程里,首先检查 \(r[n]\) 是否为已经保存的值,避免了像递归中的重复计算。
自底向上的版本如下:
自底向上版本采用子问题的自然顺序:若 \(i<j\),则规模为 \(i\) 的子问题比规模为 \(j\) 的子问题更小。因此,该过程依次求解规模为 \(j=0,1,...,n\) 的子问题。
自底向上算法和自顶向下算法具有相同的渐进运行时间。过程 BOTTOM-UP-CUT-ROD 的主体是嵌套的双重循环,内层 for 循环的迭代次数构成一个等差数列,不难分析其运行时间为 \(\Theta (n^2)\)。自顶向下的 MEMOIZED-CUT-ROD 的运行时间也是 \(\Theta (n^2)\):当求解一个之前已计算出结果的子问题是,递归调用会立即返回,它求解了规模为 \(0,1,...,n\) 的子问题;为求解规模为 \(n\) 的子问题,第6~7行的循环会迭代 \(n\) 次;因此,MEMOIZED-CUT-ROD 进行的所有递归调用执行次 for 循环的迭代次数也是一个等差数列,其和也是 \(\Theta(n^2)\)。
子问题图
当思考一个动态规划问题时,我们应该弄清楚所涉及的子问题及子问题之间的依赖关系。问题的子问题图准确地表达了这些信息。它是一个有向图,每个顶点唯一地对应一个子问题。若求子问题 x 的最优解时需要直接用到子问题 y 的最优解,那么在子问题图中就会有一条从子问题 x 的顶点到子问题 y 的顶点的有向边。例如,如果自顶向下过程在求解 x 时需要直接递归调用自身来求解 y,那么子问题图就包含从 x 到 y 的一条有向边。我们可以将子问题图看做自顶向下递归调用树的“简化版”或“收缩版”,因为树中所有对应相同子问题的结点合并为图中的单一顶点,相关的所有边都从父结点指向子结点。
子问题图 G=(V, E) 的规模可以帮助我们确定动态规划算法的运行时间。由于每个子问题只求解一次,因此算法运行时间等于每个子问题求解时间之和。通常,一个子问题的求解时间与子问题图中对应顶点的度(出射边的数目)称正比,而子问题的数目等于子问题图的顶点数。因此,通常情况下,动态规划算法的运行时间与顶点和边的数量呈线性关系。
典型题目
- 0/1 Knapsack, 0/1背包
- Unbounded Knapsack,无限背包
- Fibonacci Numbers,斐波那契数列
- Palindromic Subsequence,回文子系列
- Longest Common Substring,最长子字符串系列
参考文献
- 算法导论第三版 chapter 15