动态规划入门
动态规划是求解最优化问题的一种途径、一种方法,而不是一种特殊算法。动态规划程序设计往往是针对一种最优化问题,由于各种问题的性质不同,确定最优解的条件也互不相同,因而动态规划的设计方法对不同的问题,有不同的解题方法,而不存在一种万能的动态规划算法。因此同学们在学习本章时,必须具体问题具体分析,以丰富的想象力去建立模型,用创造性的技巧去求解。
一、什么是动态规划
前面我们学习过斐波那契(Fibonacci)数列,它的特点是后一项数据等于前两项数据的和,因此有递推公式:
fib(n) = fib(n-1) + fib(n-2)
其中fib(1)=1,fib(0)=0
递归实现如下:
function fib(n:longint):longint;
begin
if n=0 then fib:=0
else if n=1 then fib:=1 else fib:=fib(n-1) + fib(n-2);
end;
当n比较大时,所需要的时间就很长。这是为什么?主要原因在于上面的程序中存在大量的重复运算,如下图所示:
fib(2)就执行了3次,浪费了大量的时间。因此,我们可以采用动态规划来解决,我们可以采用顺推,当然,我们也可以使用递归实现。但是,单纯的递归,在解决某些问题的时候,效率会很低。我们一般用递推来实现动态规划。
【例题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
解题思路:
这道题目可以用递归的方法解决。基本思路是:
以d[i,j]表示第i行第j个数字(i,j都从1开始算),以dfs(i,j)代表从第 i 行的第 j 个数字到底边的最佳路径的数字之和,则本题是要求 dfs(1,1) 。
从某个d[i,j]出发,显然下一步只能走d[i+1,j]或者d[i+1,j+1]。如果走d[i+1,j],那么得到的dfs(i, j)就是 dfs(i+1,j) + d[i,j];如果走d[i+1,j+1],那么得到的dfs(i,j)就是dfs(i+1,j+1) + d[i, j]。所以,选择往哪里走,就看dfs(i+1,j)和dfs(i+1,j+1)哪个更大了。
程序如下:
C++语言描述如下:
#include<iostream>
using namespace std;
int d[110][110],n;
int dfs(int i,int j){
if (i==n) return d[i][j];
int k1 = dfs(i+1,j), k2 = dfs(i+1,j+1);
if (k1>k2) return k1+d[i][j];
return k2+d[i][j];
}
int main(){
cin >> n;
for(int i=1;i<=n;i++)
for(int j=1;j<=i;j++) cin >> d[i][j];
cout << dfs(1,1) << endl;
}
上面的程序效率非常低,在n值并不大,比如 n=100 的时候,就慢得几乎永远算不出结果了。为什么会这样呢?是因为过多的重复计算。我们不妨将对 dfs 函数的一次调用称为一次计算。那么,每次计算 dfs(i, j)的时候,都要计算一次 dfs(i+1,j),而每次计算 dfs(i,j+1)的时候,也要计算一次 dfs(i+1,j)。重复计算因此产生。在题目中给出的例子里,如果我们将 dfs(i,j)被计算的次数都写在位置(i, j) ,那么就能得到下面的三角形:
1
1 1
1 2 1
1 3 3 1
1 4 6 4 1
从上图可以看出,最后一行的计算次数总和是16,倒数第二行的计算次数总和是8。不难总结出规律,对于n行的三角形,总的计算次数是 20 + 21 + 22 …… 2n-1 = 2n 。当 n=100 时,总的计算次数是一个让人无法接受的大数字。
既然问题出在重复计算,那么解决的办法,当然就是,一个值一旦算出来,就要记住,以后不必重新计算。即第一次算出 dfs(i,j)的值时,就将该值存放起来,下次再需要计算 dfs(i,j)时,直接取用存好的值即可,不必再次调用 dfs 进行函数递归计算了。
这样,每个 dfs(i,j)都只需要计算1次即可,那么总的计算次数(即调用 dfs 函数的次数)就是三角形中的数字总数,即 1+2+3+……n = n(n+1)/2如何存放计算出来的 dfs(i,j)值呢?显然,用一个二维数组 a[n][n]就能解决。a[i][j]就存放dfs(r,j)的计算结果。下次再需要 dfs(i,j)的值时,不必再调用 dfs函数,只需直接取 a[i][j]的值即可。
程序如下:
C++语言描述如下:
#include<iostream>
using namespace std;
int d[110][110],a[110][110],n;
int dfs(int i,int j){
if (i==n) return d[i][j];
if (a[i+1][j]==0) a[i+1][j]=dfs(i+1,j); //如果dfs(i+1,j)没有计算过
if (a[i+1][j+1]==0) a[i+1][j+1]=dfs(i+1,j+1);
if (a[i+1][j]>a[i+1][j+1]) return a[i+1][j]+d[i][j];
return a[i+1][j+1]+d[i][j];
}
int main(){
cin >> n;
for(int i=1;i<=n;i++)
for(int j=1;j<=i;j++) cin >> d[i][j];
cout << dfs(1,1) << endl;
}
这种将一个问题分解为子问题递归求解,并且将中间结果保存以避免重复计算的办法,就叫做“动态规划” 。动态规划通常用来求最优解,能用动态规划解决的求最优解问题,必须满足,最优解的每个局部解也都是最优的。以上题为例,最佳路径上面的每个数字到底部的那一段路径,都是从该数字出发到达到底部的最佳路径。
实际上,递归的思想在编程时未必要实现为递归函数。在上面的例子里,有递推公式:
因此,不需要写递归函数,从 a[n-1]这一行元素开始向上逐行递推,就能求得最终 a[1][1]的值了。
程序如下:
C++语言描述如下:
#include<iostream>
using namespace std;
int d[110][110],a[110][110];
int main(){
int i,j,n;
cin >> n;
for(i=1;i<=n;i++)
for(j=1;j<=i;j++) cin >> d[i][j];
for(j=1;j<=n;j++) a[n][j] = d[n][j];
for(i=n;i>1;i--)
for(j=1;j<i;j++){
if (a[i][j]>a[i][j+1]) a[i-1][j] = a[i][j] + d[i-1][j];
else
a[i-1][j] = a[i][j+1] + d[i-1][j];
}
cout << a[1][1] << endl;
}
思考题:上面的几个程序只算出了最佳路径的数字之和。如果要求输出最佳路径上的每个数字,该怎么办?
二、动态规划解题的一般思路
许多求最优解的问题可以用动态规划来解决。 用动态规划解题, 首先要把原问题分解为 若干个子问题,这一点和前面的递归方法类似。区别在于,单纯的递归往往会导致子问题被重复计算,而用动态规划的方法,子问题的解一旦求出就会被保存,所以每个子问题只需求解一次。
子问题经常和原问题形式相似,有时甚至完全一样, 只不过规模从原来的n变成了n-1,或从原来的 n×m 变成了 n×(m-1) ……等等。找到子问题,就意味着找到了将整个问题逐渐分解的办法,因为子问题可以用相同的思路分解成子子问题,一直分解下去,直到最底层规模最小的的子问题可以一目了然地看出解(象上面数字三角形的递推公式中,i=n 时,解就是一目了然的) 。每一层子问题的解决,会导致上一层子问题的解决,逐层向上,就会导致最终整个问题的解决。 如果从最底层的子问题开始, 自底向上地推导出一个个子问题的解,那么编程的时候就不需要写递归函数。
在用动态规划解题时, 我们往往将和子问题相关的各个变量的一组取值, 称之为一个 “状态” 。一个“状态”对应于一个或多个子问题,所谓某个“状态”下的“值” ,就是这个“状态”所对应的子问题的解。
具体到数字三角形的例子,子问题就是“从位于(i,j)数字开始,到底边路径的最大和” 。这个子问题和两个变量 i和 j 相关,那么一个“状态” ,就是 i, j 的一组取值,即每个数字的位置就是一个“状态” 。该“状态”所对应的“值” ,就是从该位置的数字开始,到底边的最佳路径上的数字之和。
定义出什么是“状态” ,以及在该 “状态”下的“值”后,就要找出不同的状态之间如何迁移―――即如何从一个或多个“值”已知的 “状态” ,求出另一个“状态”的“值” 。状态的迁移可以用递推公式表示,此递推公式也可被称作“状态转移方程” 。
如下的递推式就说明了状态转移的方式:
上面的递推式表明了如果知道了状态(i+1,j)和状态(i+1,j+1)对应的值,该如何求出状态(i,j)对应的值,即两个子问题的解决,如何导致一个更高层的子问题的解决。
所有“状态”的集合,构成问题的“状态空间” 。 “状态空间”的大小,与用动态规划解决问题的时间复杂度直接相关。在数字三角形的例子里,一共有 n×(n+1)/2 个数字,所以这个问题的状态空间里一共就有 n×(n+1)/2 个状态。在该问题里每个“状态”只需要经过一次,且在每个状态上作计算所花的时间都是和 n 无关的常数。
用动态规划解题,经常碰到的情况是,K 个整型变量能构成一个状态(如数字三角形中的行号和列号这两个变量构成 “状态” ) 。 如果这K个整型变量的取值范围分别是N 1 , N 2 , …… N k ,那么,我们就可以用一个 K 维的数组 array[N 1 ] [N 2 ]……[N k ]来存储各个状态的“值” 。这个“值”未必就是一个整数或浮点数,可能是需要一个结构才能表示的,那么 array 就可以是一个结构数组。一个“状态”下的“值”通常会是一个或多个子问题的解。
用动态规划解题,如何寻找“子问题” ,定义“状态” , “状态转移方程”是什么样的,并没有一定之规, 需要具体问题具体分析, 题目做多了就会有感觉。 甚至, 对于同一个问题,分解成子问题的办法可能不止一种,因而“状态”也可以有不同的定义方法。不同的“状态”定义方法可能会导致时间、空间效率上的区别。
【例题2】最大子段和问题
给定 n 个整数(可能为负数)组成的序列 a[1],a[2],a[3],...,a[n],求该序列如a[i]+a[i+1]+...+a[j]的子段和的最大值。
当所给的整数均为负数时定义子段和为0,如果序列中全部是负数则最大子断和为0,依此定义,所求的最优值为 Max{0,a[i]+a[i+1]+...+a[j]},1≤i≤j≤n。
样例输入:
6
-2 11 -4 13 -5 -2
样例输出:
20
算法分析:
使用辅助数组dp[k]记录以k为尾的子段和中的最大子段和。
例如序列:-2 11 -4 13 -5 -2
则
b(1)=-2, b(2)=11, b(3)=7, b(4)=20, b(5)=15, b(6)=13
a(1)=-2, a(2)=11, a(3)=7, a(4)=13, a(5)=-5, a(6)=-2
b(1)<0 , b(2)>0 , b(3)>0, b(4)>0 , b(5)>0 , b(6)>0
状态转移方程:
C++语言描述如下:
#include<iostream>
using namespace std;
long a[1001],dp[1001];
int max1(int a,int b){
return a>b?a:b;
}
int main(){
int i,n,ans=0;
cin >> n;
for(i=1;i<=n;i++) cin >> a[i];
for(i=1;i<=n;i++){
dp[i]=max1(dp[i-1]+a[i],a[i]);
ans=max1(ans,dp[i]);
}
cout << ans << endl;
}