动态规划是通过组合子问题的解来求解原问题。与分治方法不同的是,动态规划应用于子问题重叠的情况,即不同的子问题具有公共的子子问题。在这种情况下,分治策略会重复的计算那些公共子问题。而动态规划是对每个子子问题只求解一次,将其保存在一个表格中,从而避免重复计算这些问题。
动态规划通常用于求解最优化问题(optimization problem)。这类问题拥有多个解,我们希望从中计算出最优解。当然,有些问题可能会不止一个最优解,此时,我们只需按照需求或计算一个最优解,或计算出所有的最优解。
通常有4个步骤设计一个动态规划算法。
1. 刻画一个最优解的结构特征;
2. 递归的定义最优解的值;
3. 计算最优解的值,通常有两种方法,自底向上和自顶向下。
4. 利用计算出的信息构造一个最优解。
钢条切割的例子
给定一个长度为n英寸的钢条和一个价格表
pi(i=1,2,...n)
,求切割钢条的方案,使得销售收益
rn
最大。
假设切割长度与价格表如下所示
长度 i | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
---|---|---|---|---|---|---|---|---|---|---|
价格 | 1 | 5 | 8 | 9 | 10 | 17 | 17 | 20 | 24 | 30 |
因为在钢条左端
i(i=1,2,...n−1)
处可以选择切割和不切割两种方式,所以长度为
n
的钢条共有
- 4 = 4 , 收益 r=9
- 4 = 1+3,收益为 r=9
- 4 = 2+2,收益为 r=10
- 4 = 3+1 ,收益为 r=9
- 4 = 1+1+2, 收益为 r=7
- 4 = 1+2+1, 收益为 r=7
- 4 = 2+1+1,收益为 r=7
- 4=1+1+1+1,收益为 r=4
很明显,当4=2+2时,可以得到最高的收益。那么,我们怎样用形式化的语言来描述它呢?
- 我们先切割第一段,我们可以在第1个位置切割,即4=1+3,也可以在第2个位置切割,即4=2+2等等。
- 就得到1+3,2+2,3+1这些种情况。同时,还要考虑钢条不切割的情况。
- 计算最大收益。因为我们的目的是求怎样切割(或者干脆不切割)会得到最大收益。那么,最大收益 r=max(r(4),r(3)+r(1),r(2)+(2),r(1)+r(3)
- 依次切割,直到得到最大收益。
假设一个最优解是要将钢条切割成
k
段,那么最优解的切割方案为:
将钢条切割长度分别为 i1,i2,...ik 长度的钢条,得到的最大收益为:
更一般的,对于 rn(n>=1) ,我们可以用 更短的钢条的 最优切割来描述它。
第一个参数 pn 为不切割的方案。其他 n−1 个参数对应 n−1 种方案:对每个 i=1,2,3..n−1 ,首先将钢条的分为长度为 i 和
为了求解规模为 n 的原问题,我们先求解形式完全一样,但规模更小的子问题。即当完成首次切割后,我们将两段钢条看成两个独立的钢条切割问题实例。我们通过组合相关子问题的最优解,并在所有可能的两段切割方案中选取收益最大的,构成原问题的最优解。钢条问题满足最优子结构。
最优子结构:问题的最优解由相关子问题的最优解组合而成,而这些子问题都可以独立求解。
使用动态规划解决钢条切割的问题
虽然我们可以用较少较容易理解的递归代码解出此问题,然而,递归会重复计算相同的子问题,导致程序的运行时间以指数级的速度增长,即时间复杂度为
动态规划的思想:使用递归方法之所以效率这么低,是因为它会重复计算相同的子问题。因此,动态规划方法仔细安排求解顺序,对每个子问题只求解一次,并将其结果保存下来。如果再次需要子问题的解,只需查找保存的结果,而不必重新计算。因此,动态规划方法时典型的时空权衡(time-memory trade-off)的例子。
动态规划有两种等价的实现方法:
带备忘录的自顶向下方法(top-down with memorization)
此方法仍然按照自然的递归形式编写过程,但过程中会保存每个子问题的解(通常是保存在数组或者散列表中),当需要一个子问题的解时,会先判断是否保存过此解。如果是,则直接返回保存的值,从而节省了计算时间。否则,按照常规方式进行计算。
代码如下
public static int memorizedCutRod(int[] p, int n){
int result = 0;
//保存已计算过的子问题的解的数组
int res[] = new int[n+1];
for (int i = 0; i < res.length; i++) {
res[i] = -1;
}
result = memorizedCutRodAux(p, n, res);
return result;
}
public static int memorizedCutRodAux(int[] p, int n, int[] r){
//如果已经计算过该子问题的解,直接返回
if(r[n]>=0){
return r[n];
}
int q = -1;
if(n==0){
q = 0;
} else{
//r(n)=max(p[i]+r(n-i))
//p[i]表示切割成长度为i的钢条的收益
//r(n-i)剩余钢条的最大收益值
for(int i = 1;i<=n;i++){
q = Math.max(q, p[i]+memorizedCutRodAux(p, n-i, r));
}
}
r[n] = q;
return q;
}
自底向上的方法(bottom-up method)
这种方法一般需要恰当定义子问题的规模的概念,使得任何子问题的求解都只依赖于更小的子问题的求解。因而,我们可以将子问题按照规模排序,按照有小到大的顺序进行求解。当求解某个子问题时,它所依赖的那些更小的子问题都已经求解完毕,结果已经保存。每个子问题只需要求解一次,当我们求解它时,它的所有前提子问题都已经求解完成。
public static int bottomUpCutRod(int[] p, int n){
//保存子问题的结果,res[n]就是我们所需的最优解
int[] res = new int[n+1];
//依次求出规模为i = 1...n的子问题
for(int i=1; i<=n; i++){
int q = -1;
for(int j=1;j<=i;j++){
q = Math.max(q, p[j]+res[i-j]);
}
res[i] = q;
}
return res[n];
}
现在我们只是求出来收益的最大值,并没有求得解本身,即给出切割后每段钢条的长度。我们可以扩展动态规划算法,使之对每个子问题不仅保存最优收益值,还保存对应的切割方案。
代码如下
public static void extendedBottomUpCutRod(int[] p, int n){
int[] res = new int[n+1];
//用来保存最优解的切割的钢条的长度
int[] solve = new int[n+1];
res[0] = 0;
for (int i = 1; i <= n; i++) {
int q = -1;
for(int j = 1; j<=i;j++){
if(q<p[j]+res[i-j]){
q = p[j]+res[i-j];
solve[i] = j;
}
}
res[i] = q;
}
print(res[n], solve, n);
}
public static void print(int maxValue, int[] solve, int n) {
System.out.println(maxValue);
while(n>0){
System.out.print(solve[n]+", ");
n = n - solve[n];
}
}
测试代码
public static void main(String[] args) {
int[] p = {0,1,5,8,9,10,17,17,20,24,30};
int n = 9;
int result = memorizedCutRod(p, n);
System.out.println(result);
int result2 = bottomUpCutRod(p, n);
System.out.println(result);
extendedBottomUpCutRod(p, n);
}