动态规划(dynamic programming)是运筹学的一个分支,是求解决策过程(decision process)最优化的数学方法。20世纪50年代初美国数学家R.E.Bellman等人在研究多阶段决策过程(multistep decision process)的优化问题时,提出了著名的最优化原理(principle of optimality),把多阶段过程转化为一系列单阶段问题,利用各阶段之间的关系,逐个求解,创立了解决这类过程优化问题的新方法——动态规划.
动态规划初步解决的是理解状态和状态转移方程;理解最优子结构和重叠子问题.熟练运用递推法和记忆化搜索求解问题.下面以经典例题数字三角形为例详细的阐述动态规划的基本思路和特点.
题目大意:
在数字三角形中寻找一条从顶部到底部的路径,使路径上经过的数字之和最大.路径上的每一步都只能左下或右下行走.输出最大值.如下图所示.
7
3 8
8 1 0
2 7 4 4
4 5 2 6 5
详细分析:
如果熟悉回溯法,可能会发现这是一个动态的决策问题.每次有两个选择:左下或者右下.如果用回溯可以求出所有可能的路线,就可以从中选取最优路线.但是我们需要知道像枚举回溯这种方法虽然具有解决问题的一般性,也正因为此,并没有解决某些问题的特殊性,使得其效率低下.比如说在此如果用回溯法,一个n层的数字三角形的完成路线有2^(n-1)条.
为了得到高效的算法,需要更深一步的抽象的对问题进行分析.在此之前需要对递归有一定的认知,之前的文章中有:浅谈递归和再谈递归.
首先采用二维数组的数据结构来存放数字三角形D[MAX][MAX].把当前位置(r,j)看成一个状态,然后定义该状态对应的指标函数MaxSum(r,j)为从格子(r,j)出发能得到的最大和(包括格子(r,j)本身).在这样的定义下,原问题要求的就是求MaxSum(1,1).
下面看看状态是怎么转移的.典型的递归问题:从格子(r,j)出发,可以选择往左或往右走.往左走走到(r+1,j),后需要求MaxSum(r+1,j),根据指标函数定义我们知道它的意思是:为从格子(r+1,j)出发能得到的最大和(包括格子(r+1,j)本身);同理,往右走为MaxSum(r+1,j+1).由于我们要对这两种进行决策,那么自然选择符合题目期望的较大值.换句话说,我们得到了状态转移方程:
MaxSum(r,j)=Max{MaxSum(r+1,j),MaxSum(r+1,j+1)}+ D[r,j].
起点是一样的,而之后我们在每次进行决策出来的都是最佳的,所以最后一定是最佳的.这样的性质被称为最优子结构.如果问题的最优解所包含的子问题的解也是最优,那么称其具有最优子结构.
int n;
int D[MAX][MAX];
int maxSum[MAX][MAX];
int MaxSum(int r,int j){
if(r==n) maxSum[r][j]=D[r][j];
else maxSum[r][j]=D[r][j]+max(MaxSum(r+1,j),MaxSum(r+1,j+1));
return maxSum[r][j];
}
int main()
{
int i,j;
cin>>n;
for(i=1;i<=n;i++)
for(j=1;j<=i;j++)
cin>>D[i][j];
cout<<MaxSum(1,1)<<endl;
return 0;
}
用直接递归的方法计算状态转移方程这样可能造成时间效率太低,原因是大量的子问题被重复计算.
1,1 | ||||||||||||
2,1 | 2,2 | |||||||||||
3,1 | 3,2 | 3,2 | 3.3 | |||||||||
4,1 | 4,2 | 4,2 | 4,3 | 4,2 | 4,3 | 4,3 | 4,4 |
为了更能直观的感受所以画了上图,不难发现,有很多点被重复计算,像图中的(3,2)(4,2)(4,3).
为了能够解决这个问题,我首先能想想到的是记忆化搜索.简而言之,就是将将算过结果标识,并且保存.下次计算时,通过标识若发现已经计算,直接拿来用即可.
int n;
int D[MAX][MAX];
int maxSum[MAX][MAX];
int MaxSum(int r,int j){
if(maxSum[r][j]!=-1) return maxSum[r][j];//说明曾经被算出来过.
if(r==n) maxSum[r][j]=D[r][j];
else maxSum[r][j]=D[r][j]+max(MaxSum(r+1,j),MaxSum(r+1,j+1));
return maxSum[r][j];
}
int main()
{
int i,j;
cin>>n;
for(i=1;i<=n;i++)
for(j=1;j<=i;j++){
cin>>D[i][j];
maxSum[i][j]=-1;
}
cout<<MaxSum(1,1)<<endl;
return 0;
}
还有一种方法,就是递归转递推.用递推法计算状态转移方程.递推的关键是边界(i:1-5,j<=i)和计算顺序(从下到上).
30 |
|
|
|
|
23 | 21 |
|
|
|
20 | 13 | 10 |
|
|
7 | 12 | 10 | 10 |
|
4 | 5 | 2 | 6 | 5 |
从最下面一步步推到最上面:例如maxSum[4][1]=max(maxSum[5][1],maxSum[5][2])+D[4][1];
int i,j;
cin>>n;
for(i=1;i<=n;i++)
for(j=1;j<=i;j++)
cin>>D[i][j];
for(int i=1;i<=n;i++)
maxSum[n][i]=D[n][i];
for(int i=n-1;i>=1;i--)
for(int j=1;j<=i;j++)
maxSum[i][j]=max(maxSum[i+1][j],maxSum[i+1][j+1])+D[i][j];
cout<<maxSum[1][1]<<endl;
后两种的算法时间复杂度均为n^2.从2^n到n^2是一个巨大的优化,这正是利用了它的特点:具有大量重叠子问题.
最后总结一下:
递归到动归的一般转化方法:
递归函数有n个参数,就定义一个n维数组,数组的下标是递归函数参数的取值范围,数组元素的值是递归函数的返回值,这样就可以从边界值开始,逐步填充数组,相当于计算递归函数的逆过程.
动归解题的一般思路:
- 将原问题分解为子问题.
子问题的形式和原问题相同,只不过规模变小了.子问题都解决,原问题即解决.子问题的解一旦求出就会被保存,所以每个子问题只需求解一次.
2.确定状态
在用动态规划解题时,我们往往将和子问题相关的各个变量的一组取值,称之为一个”状态”.一个状态对应于一个或多个子问题,所谓的某个状态下的值,就是这个状态所对应的之问题的解.在数字三角形中,与之问题相关的两个变量,行号和列号就是状态.
一共有n*(n+1)/2个数字,那么该问题的状态空间里一共就有n*(n+1)/2个状态.整个问题的时间复杂度是状态数目乘以计算每个状态所需的时间.O(n^2).
用动态规划解题,经常碰到的情况是,k个整形变量能构成一个状态(如上所述的行号列号).如果k个整形变量的取值范围分别是N1,N2,N3…Nk,那么,我们可以用一个k维的数组array[N1][ N2][ N3]…[Nk]来存储各个状态的值.这个值未必是一个整数或浮点数,可能是需要一个结构才能表示的,那么array就可以是一个结构数组.一个状态下的值通常会是一个或多个子问题的解.
3.确定一些初始状态(边界状态)的值
以”数字三角形”为例,初始状态就是底边数字,值就是底边数字值.
4.状态转移方程
定义出什么样的状态,以及在该状态下的值后,就要找出不同的状态之间如何转移,即如何从一个或多个值已知的状态,求出另一个状态的值(人人为我递推型).状态的转移可以用递推公式表示,此递推公式也可被称为”状态转移方程”.
数字三角形的状态转移方程:
MaxSum(r,j)= D[r,j]; r=n
MaxSum(r,j)=Max{MaxSum[r+1,j],MaxSum[r+1,j+1]}+ D[r,j]; 其他情况
5.能用动规解决的问题的特点
- 问题具有最优子结构
如果问题的最优解所包含的子问题的解也是最优,那么称其具有最优子结构.
2.无后效性
当前的若干个状态值一旦确定,则此后过程的演变就只和这若干个状态的值有关,和之前是采取的那种手段或经过哪条路演变到当前状态没有关系.
在本专栏中,有枚举,回溯,递归,动归,深搜,广搜,贪心等详细讲解,有兴趣的朋友可以看看.