概述
动态规划(dynamic programming)与分治方法相似,都是通过组合子问题的解来求解原问题(子问题的求解是递归进行的,将其划分为更小的子子问题)。
- 分治方法将问题划分为互不相交的子问题,递归地求解子问题,再将它们的解组合起来,求出原问题的解。
- 与之相反,动态规划应用于子问题重叠的情况,即不同的子问题具有公共的子子问题。在这种情况下,分治算法会做许多不必要的工作,它会反复地求解那些公共子子问题。而动态规划算法对每个子子问题只求解一次,将其解保存在一个表格中,从而无需每次求解一个子子问题时都重新计算,避免了这种不必要的计算工作。
所以形象地说:动态规划是有记忆的分治。
动态规划方法通常用来求解最优化问题(optimization problem) 。这类问题可以有很多可行解,每个解都有一个值,我们希望寻找具有最优值(最小值或最大值)的解。可能有多个解都达到最优值。
具体来说,动态规划问题一般具有一下两个性质:
- 最优子结构:一个问题的最优解包含其子问题的最优解。
- 重叠子问题:问题空间必须足够“小”,即问题的递归算法会反复地求解相同的子间题,而不是一直生成新的子问题。
我们通常按如下 4 个步骤来设计一个动态规划算法:
- 刻画最优解的结构。
- 给出最优解的值的递推公式。
- 自底向上地计算最优解的值。
- 利用计算出的信息构造一个最优解。
步骤 1~3 是动态规划算法求解问题的基础。
- 如果我们仅仅需要一个最优解的值,而非解本身,可以忽略步骤 4 。
- 如果需要一个最优解, 有时就需要在执行步骤 3 的过程中维护一些额外信息,以便用来构造一个最优解。
所以形象地说:动态规划就是一种“数学归纳法”。
实例
斐波那契数列
如果使用一般的分治算法(递归),会有大量的重复计算。以下图为例,计算 F6 的递归过程中 F3 = F2 + F1 被重复进行了 3 次。
1 | def fibonacci(n): |
动态规划算法为了避免重复的子问题计算,把每次计算过的子问题的解都记录下来,以后在遇到相同子问题时,直接“背答案”。
1 | def fibonacci_dp(n): |
钢条切割
问题描述:
给定一段长度为 n 英寸的钢条和一个价格表 pi (i = l, 2, …, n)。求切割钢条方案,使得销售收益 r 最大。注意:如果长度为 n 英寸的钢条的价格 Pn 足够大,最优解可能就是完全不需要切割。
长度 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
---|---|---|---|---|---|---|---|---|---|---|
价格 | 1 | 5 | 8 | 9 | 10 | 17 | 17 | 20 | 24 | 30 |
解决办法:
首先我们将这个问题描述数学化。
如果一个最优解将钢条切割为 k 段(0 < k < n+1), 那么最优切割方案:
将钢条切割为长度分别为 i1, i2, …, ik 的小段,得到最大收益:
考虑给出 rn 的递推公式。
切割的第一刀,可以切在任何位置。且如果是最优解,则切后的两段钢条的切割方案也一定是最优解。
1 | def cut_rod(p, n): |
矩阵链乘法
问题描述:
给定一个 n 个矩阵的序列(矩阵链)<A1, A2, …, An >,且矩阵 Ai 的规模为 pi-1 *pi 。求完全括号化方案(使用结合律加括号),使乘积 A1A2…An 所需标量乘法次数最少。
解决办法:
首先:用子问题的最优解来递归地定义原问题最优解。
对矩阵链乘法问题,我们可以这么定义子问题:
对所有 1 <= i <= j <= n ,确定 <Ai, Ai+1, …, Aj > 的最小代价括化方案。
令
m[i, j]
表示计算矩阵 Ai Ai+1 … Aj 所需标量乘法次数的最小值,那么,原问题的最优解计算 A1A2…An 所需的最低代价就是m[1, n]
。
然后不难得出m[i, j]
的递推公式:
m[i, j]
的值给出了子问题最优解,但它并未提供足够的信息来构造最优解。为此,我们用 s[i, j]
保存 Ai Ai+1 … Aj 最优括号化方案的分割点位置k。
1 | def matrix_chain_order(p): |
最长公共子序列
问题描述:
给定两个序列 X = <x1, x2, …, xm> 和 Y = <y1, y2, …, yn>,求 X 和 Y 最长的公共子序列。
解决办法:
首先,观察到最长公共子序列的递归结构。
我们定义 c[i,j]
表示 Xi 和 Yj 的最长公共子序列的长度。
则不难得到递推公式:
1 | def lcs(X, Y): |