10.1 什么是动态规划
前面学过了用递归的方法解决问题。但是,单纯的递归,在解决某些问题的时候,效率
会很低。例如下面这道题目:
例题:数字三角形
问题描述
7
3 8
8 1 0
2 7 4 4
4 5 2 6 5
上图给出了一个数字三角形。从三角形的顶部到底部有很多条不同的路径。对于每条路
径,把路径上面的数加起来可以得到一个和,和最大的路径称为最佳路径。你的任务就是求
出最佳路径上的数字之和。
注意:路径上的每一步只能从一个数走到下一层上和它最近的左边的数或者右边的数。
输入数据
输入的第一行是一个整数 N (1 < N <= 100),给出三角形的行数。下面的 N行给出数字
三角形。数字三角形上的数的范围都在 0和 100之间。
输出要求
输出最大的和。
输入样例
5
7
3 8
8 1 0
2 7 4 4
4 5 2 6 5
输出样例
30
193
解题思路
这道题目可以用递归的方法解决。基本思路是:
以
D( r, j)表示第
r行第 j 个数字(r,j都从
1开始算
),以
MaxSum(r, j) 代表从第 r 行
的第 j 个数字到底边的最佳路径的数字之和,则本题是要求 MaxSum(1, 1) 。
从某个
D(r, j)出发,显然下一步只能走
D(r+1, j)或者
D(r+1, j+1)。如果走
D(r+1, j),那
么得到的
MaxSum(r, j)就是
MaxSum(r+1, j) + D(r, j);如果走
D(r+1, j+1),那么得到的
MaxSum(r, j)就是
MaxSum(r+1, j+1) + D(r, j)。所以,选择往哪里走,就看
MaxSum(r+1, j)和
MaxSum(r+1, j+1)哪个更大了。程序如下:
1. #include <stdio.h>
2. #define MAX_NUM 100
3. int D[MAX_NUM + 10][MAX_NUM + 10];
4. int N;
5. int MaxSum( int r, int j)
6. {
7. if( r == N )
8. return D[r][j];
9. int nSum1 = MaxSum(r+1, j);
10. int nSum2 = MaxSum(r+1, j+1);
11. if( nSum1 > nSum2 )
12. return nSum1+D[r][j];
13. return nSum2+D[r][j];
14.
15. }
16. main()
17. {
18. int m;
19. scanf("%d", &N);
20. for( int i = 1; i <= N; i ++ )
21. for( int j = 1; j <= i; j ++ )
22. scanf("%d", &D[i][j]);
23. printf("%d", MaxSum(1, 1));
24. }
上面的程序,效率非常低,在
N值并不大,比如
N=100的时候,就慢得几乎永远算不
出结果了。为什么会这样呢?是因为过多的重复计算。我们不妨将对
MaxSum函数的一次
调用称为一次计算。那么,每次计算
MaxSum(r, j)的时候,都要计算一次
MaxSum(r+1, j),
而每次计算
MaxSum(r, j+1)的时候,也要计算一次
MaxSum(r+1, j)。重复计算因此产生。在
题目中给出的例子里,如果我们将
MaxSum(r, j)被计算的次数都写在位置(r, j),那么就能
得到下面的三角形:
1 1
1 2 1
1 3 3 1
194
1 4 6 4 1
从上图可以看出,最后一行的计算次数总和是
16,倒数第二行的计算次数总和是
8。不
难总结出规律,对于
N行的三角形,总的计算次数是
20 +21+22 ……2N-1=2N。当
N= 100时,
总的计算次数是一个让人无法接受的大数字。
既然问题出在重复计算,那么解决的办法,当然就是,一个值一旦算出来,就要记住,
以后不必重新计算。即第一次算出
MaxSum(r, j)的值时,就将该值存放起来,下次再需要计
算
MaxSum(r, j)时,直接取用存好的值即可,不必再次调用
MaxSum进行函数递归计算了。
这样,每个
MaxSum(r, j)都只需要计算
1次即可,那么总的计算次数(即调用
MaxSum函数
的次数)就是三角形中的数字总数,即
1+2+3+……N = N(N+1)/2
如何存放计算出来的
MaxSum(r, j)值呢?显然,用一个二维数组
aMaxSum[N][N]就
能解决。aMaxSum[r][j]就存放
MaxSum(r, j)的计算结果。下次再需要
MaxSum(r, j)的值时,
不必再调用
MaxSum函数,只需直接取
aMaxSum[r][j]的值即可。程序如下:
1. #include <stdio.h>
2. #include <memory.h>
3. #define MAX_NUM 100
4. int D[MAX_NUM + 10][MAX_NUM + 10];
5. int N;
6. int aMaxSum[MAX_NUM + 10][MAX_NUM + 10];
7. int MaxSum( int r, int j)
8. {
9. if( r == N )
10. return D[r][j];
11. if( aMaxSum[r+1][j] == -1 ) //如果
MaxSum(r+1, j)没有计算过
12. aMaxSum[r+1][j] = MaxSum(r+1, j);
13. if( aMaxSum[r+1][j+1] == -1) //如果
MaxSum(r+1, j+1)没有计算过
14. aMaxSum[r+1][j+1] = MaxSum(r+1, j+1);
15. if( aMaxSum[r+1][j] > aMaxSum[r+1][j+1] )
16. return aMaxSum[r+1][j] +D[r][j];
17. return aMaxSum[r+1][j+1] + D[r][j];
18.
19. }
20. main()
21. {
22. int m;
23. scanf("%d", & N);
24. //将 aMaxSum全部置成-1, 表示开始所有的 MaxSum(r, j)都没有算过
25. memset(aMaxSum, -1, sizeof(aMaxSum));
26. for( int i = 1; i <= N; i ++ )
27. for( int j = 1; j <= i; j ++ )
28. scanf("%d", & D[i][j]);
29. printf("%d", MaxSum(1, 1));
30. }
这种将一个问题分解为子问题递归求解,并且将中间结果保存以避免重复计算的办法,
就叫做“动态规划”。动态规划通常用来求最优解,能用动态规划解决的求最优解问题,必
须满足,最优解的每个局部解也都是最优的。以上题为例,最佳路径上面的每个数字到底部
的那一段路径,都是从该数字出发到达到底部的最佳路径。
实际上,递归的思想在编程时未必要实现为递归函数。在上面的例子里,有递推公式:
因此,不需要写递归函数,从
aMaxSum[N-1]这一行元素开始向上逐行递推,就能求得
最终
aMaxSum[1][1]的值了。程序如下:
1. #include <stdio.h>
2. #include <memory.h>
3. #define MAX_NUM 100
4. int D[MAX_NUM + 10][MAX_NUM + 10];
5. int N;
6. int aMaxSum[MAX_NUM + 10][MAX_NUM + 10];
7. main()
8. {
9. inti, j;
10. scanf("%d", & N);
11. for( i = 1; i <= N; i ++ )
12. for( j = 1; j <= i; j ++ )
13. scanf("%d", &D[i][j]);
14. for( j = 1; j<= N; j ++ )
15. aMaxSum[N][j] = D[N][j];
16. for( i = N ; i > 1 ; i -- )
17. for( j = 1; j < i ; j ++ ) {
18. if( aMaxSum[i][j] > aMaxSum[i][j+1] )
19. aMaxSum[i-1][j] = aMaxSum[i][j] + D[i-1][j];
20. else
21. aMaxSum[i-1][j] = aMaxSum[i][j+1] + D[i-1][j];
22. }
23. printf("%d", aMaxSum[1][1]);
24. }
10.2 动态规划解题的一般思路
许多求最优解的问题可以用动态规划来解决。用动态规划解题,首先要把原问题分解为
若干个子问题,这一点和前面的递归方法类似。区别在于,单纯的递归往往会导致子问题被
重复计算,而用动态规划的方法,子问题的解一旦求出就会被保存,所以每个子问题只需求
解一次。
子问题经常和原问题形式相似,有时甚至完全一样,只不过规模从原来的
n变成了
n-1,
或从原来的
n×m变成了
n×(m-1) ……等等。找到子问题,就意味着找到了将整个问题逐
渐分解的办法,因为子问题可以用相同的思路分解成子子问题,一直分解下去,直到最底层
规模最小的的子问题可以一目了然地看出解(象上面数字三角形的递推公式中,
r=N时,解
就是一目了然的)。每一层子问题的解决,会导致上一层子问题的解决,逐层向上,就会导
致最终整个问题的解决。如果从最底层的子问题开始,自底向上地推导出一个个子问题的解,
那么编程的时候就不需要写递归函数。
在用动态规划解题时,我们往往将和子问题相关的各个变量的一组取值,称之为一个“状
态”。一个“状态”对应于一个或多个子问题,所谓某个“状态”下的“值”,就是这个“状
态”所对应的子问题的解。
具体到数字三角形的例子,子问题就是“从位于
(r,j)数字开始,到底边路径的最大和”。
这个子问题和两个变量
r和
j相关,那么一个“状态”,就是
r, j的一组取值,即每个数字的
位置就是一个“状态”。该“状态”所对应的“值”,就是从该位置的数字开始,到底边的最
佳路径上的数字之和。
定义出什么是“状态”,以及在该“状态”下的“值”后,就要找出不同的状态之间如
何迁移―――即如何从一个或多个“值”已知的“状态”,求出另一个“状态”的“值”。
状态的迁移可以用递推公式表示,此递推公式也可被称作“状态转移方程”。
如下的递推式就说明了状态转移的方式:
上面的递推式表明了如果知道了状态(
r+1,j)和状态(
r+1,j+1)对应的值,该如何
求出状态(r,j)对应的值,即两个子问题的解决,如何导致一个更高层的子问题的解决。
所有“状态”的集合,构成问题的“状态空间”。“状态空间”的大小,与用动态规划解
决问题的时间复杂度直接相关。在数字三角形的例子里,一共有
N×(N+1)/2个数字,所以
这个问题的状态空间里一共就有
N×(N+1)/2个状态。在该问题里每个“状态”只需要经过
一次,且在每个状态上作计算所花的时间都是和
N无关的常数。
用动态规划解题,经常碰到的情况是,
K个整型变量能构成一个状态(如数字三角形中
的行号和列号这两个变量构成“状态”)。如果这
K个整型变量的取值范围分别是
N1, N2, ……
Nk,那么,我们就可以用一个
K维的数组
array[N1] [N2]……[Nk]来存储各个状态的“值”。
这个“值”未必就是一个整数或浮点数,可能是需要一个结构才能表示的,那么
array就可
以是一个结构数组。一个“状态”下的“值”通常会是一个或多个子问题的解。
用动态规划解题,如何寻找“子问题”,定义“状态”,“状态转移方程”是什么样的,
并没有一定之规,需要具体问题具体分析,题目做多了就会有感觉。甚至,对于同一个问题,
分解成子问题的办法可能不止一种,因而“状态”也可以有不同的定义方法。不同的“状态”
定义方法可能会导致时间、空间效率上的区别。