动态规划(dynamic programming,这里的programming不是程序,而是表示表格)。它与分治算法类似,都是通过组合子问题的解来求解原问题。分治算法是将原问题分解为互不相交的子问题,递归的求解子问题,然后将解组合起来。
动态规划则不同,它应用于求解子问题重叠的情况,也就是不同的子问题会涉及相同的子子问题。这样,普通的递归方法会反复的求解那些公共子问题,因而浪费了时间,动态规划则是对公共子问题只求解一次,然后将其解保存在表格中,避免了不必要的重复工作。
动态规划通常用来解决最优化问题,这类问题通常有很多可行解,每个解法都有一个值,希望找到具有最优值(最小值或者最大值)的解。这样的解为一个最优解,有可能会有多个解都能得到最优值。
设计动态规划算法的步骤:
a:描述一个最优解的结构特征;
b:递归定义最优解的值;
c:计算最优解的值,通常采用自底向上的方式计算最优解的值;
d:利用计算出的信息构造一个最优解。
第1-3步是动态规划求解问题的基础。如果仅需要一个最优解的值,而非最优解本身,则第4步可以忽略。如果需要求得一个最优解,则有时要在第3步的计算中记录一些附加信息,以便用来构造一个最优解。
一:钢条切割问题
给定一个长度为n的钢条,以及一个价格表p,p中列出了每英寸钢条的价格,将长度为n的钢条切割为若干短钢条出售,求一个钢条的切割方案,使得收益 最大,切割工序没有成本。比如价格表p如下:
在该问题中,长度为n的钢条,一共有种不同的切割方案,因为可以再距离钢条左边为i(i=1,2,…,n-1)处,选择切割或者不切割。(类似于一个二进制数),比如下图表示了n=4的切割情况:
在该问题中,对于最优解 , 可以用更短的钢条的最优解来描述:将钢条从左边切割下长度为i的一段,只对剩下的n-i的一段进行继续切割(递归求解),而不对左边长度为i的一段在进行切割。这样对的求解可用下面的公式表示:
=
这样,原问题的最优解就表示成了子问题的最优解的形式。
为了求解规模为n的原问题,可以先求解形式完全一样,但规模更小的子问题,这样,钢条切割问题满足最优子结构:问题的最优解由相关子问题的最优解组合而成,这些子问题可以独立求解。
根据上面的公式,可以写出原始的切割方案:
CUT-ROD(p,n)
if n == 0
return 0
q = -∞
for i = 1 to n
q = max(q, p[i]+CUT-ROD(p, n-i))
return q
CUT-ROD的效率很差,这是因为CUT-ROD反复的求解一些相同的子问题,下图显示了当n==4时的调用情况:
下图表示了实际程序运行时,调用cutrod的情况,当n=4时,总共调用cutrod 16次,其中求解相同的子问题若干次:
分析CUT-ROD的运行时间,设T(n)表示第二个参数值为n时,CUT-ROD的调用次数。这个值等于递归调用树中,根为n的子树中的节点总数,T(0)=1。并且:
T(n) = 1 + 。根据数学归纳法可证明:T(n) = 。在递归调用树中,总共有个叶子节点,根到每个叶节点的路径都对应一种可能的切割方案。
可以使用动态规划的方法求解,CUT-ROD的效率低是因为它反复求解相同的子问题,动态规划方法是仔细安排求解循序,对每个子问题只求解一次,并将子问题的结果都保存下来,如果随后在此需要子问题的解,直接取值而不用再次计算。所以,动态规划方法是典型的时空权衡的例子,是用空间换时间。时间上的节省是巨大的,可能将一个指数时间转化为一个多项式时间。
动态规划方法有两种等价的实现方法,两种方法具有相同的渐进时间,仅有的差异是某些特殊情况下,自顶向下的方法没有真正递归考察所有可能的子问题,因为没有使用递归,所以自底向上的方法的时间复杂度函数通常具有更小的系数。
a:带备忘的自顶向下方法,该方法与之前的普通递归方法类似,只是会在过程中保存子问题的解,当需要一个子问题的解的时候,先查看是否已经保存过了,如果是,则直接使用即可。否则,按常规的递归方式计算子问题。所以称为带备忘的,因为它记住了之前已经计算出的结果。
MEMOIZED-CUT-ROD(p,n)
let r[0..n] be a new array
for i = 0 to n
r[i]= -∞
return MEMOIZED-CUT-ROD-AUX(p, n, r)
MEMOIZED-CUT-ROD-AUX(p,n, r)
if r[n] >= 0
return r[n]
if n == 0
q= 0
else q = -∞
for i = 1 to n
q= max(q, p[i]+MEMOIZED-CUT-ROD-AUX(p, n-i,r))
r[n] = q
return q
在带备忘的自顶向下的方法中,首先检查所需的值是否已知。
b:自底向上的方法:这种方法要恰当定义子问题的规模,使得任意子问题的求解只依赖于“更小的“子问题解。所以通常按照规模从小到大的顺序进行求解。当求救某个子问题时,它所依赖的更小的子问题的解都已经求解完毕了。
BOTTOM-UP-CUT-ROD(p,n)
let r[0..n] be a new array
r[0]= 0
for j = 1 to n
q= -∞
for i = 1 to j
q = max(q, p[i]+r[j-i])
r[j]= q
return r[n]
这两种算法具有相同的时间复杂度,BOTTOM-UP-CUT-ROD主要是双层嵌套循环,所以时间复杂度为Θ( )。MEMOIZED-CUT-ROD的时间复杂度也是Θ( )。可以使用子问题图进行分析。
当思考一个动态规划问题时,应该弄清楚子问题以及子问题之间的依赖关系。子问题图可以准确表达这些信息,比如下图反映了n=4时钢条切割问题的子问题图:
每个顶点对应一个子问题,如果求解子问题x的最优解时需要用到子问题y的最优解,那么图中会有一条x指向y的有向边。这个图可以看成是递归调用树的简化版,因为递归调用树中,相同子问题的结点合并为图中的单一顶点。
子问题图可以帮助我们确定动态规划算法的运行时间,由于每个子问题只求解一次,所以算法运行时间等于每个子问题求解时间之和。通常一个子问题的求解时间与图中该顶点的出度成正比,而子问题的数目等于图中的顶点数。所以,通常情况下,动态规划算法的运行时间与顶点和边的数量呈线性关系。
前面只给出了钢条切割问题的最优解的值(最大收益),并没有切得最优解,也就是最佳切割方案,所以,可以简单的扩展算法,得到最优解:
EXTENDED-BOTTOM-UP-CUT-ROD(p,n)
let r[0..n] and s[0..n] be new arrays
r[0]= 0
for j =1 to n
q= -∞
for i = 1 to j
if q < p[i]+r[j-i]
q = p[i]+r[j-i]
s[j] = i
r[j]= q
return r[n]
PRINT-CUT-ROD-SOLUTION(p,n)
(r,s)= EXTENDED-BOTTOM-UP-CUT-ROD(p, n)
while n > 0
print s[n]
n = n-s[n]
该方法通过数组s保存切割方案。
二:矩阵链乘法
给定n 个矩阵的序列,希望求它们的乘积: 。因为矩阵的乘法满足结合律,所以可以对n个矩阵序列加括号,来改变乘积顺序。比如对于矩阵链< , ,>可以有下面的加括号方案:
不同的加括号的方案,对于乘积运算的代价影响很大,两个矩阵相乘,A为p