Dynamic Programming(动态规划)是一种以空间换时间的算法,可以用来解决的问题都有一个共性:重叠子问题。用通俗的话说就是记忆化搜索。也就是说,所有的动态规划都是可以用搜索去写的,但是用简单的搜索写,会发现时间复杂度太高,从而达不到要求,因为在搜索的过程中我们重复计算了很多以前已经计算过的问题(重叠子问题),这里就会浪费大量的时间。

下面借助一个经典的入门题数塔来讲解具体的过程:

数塔的大致意思如下:在一棵二叉树中,每个节点都有一个权值,现在你的任务是需要求从根出发到树的最底层的任何一条路径中(每一次都只能往下走),所经过的节点权值加起来最大(或者最小)的一条路径,并输出这个最大值(最小值)。下图中,9是树的根,需要求的是从9出发,最后到达{19, 7, 10, 4, 16} 这一层的所有路径中,权值加起来最大的那一条。这里是{9-12-10-18-10}最大值为59.2084-1我们在看到这个题目的时候,最开始可能想到的是用搜索直接求解,写一个递归函数解决之。因为每个节点只能往下走,所以每个节点的路径必然是经过左儿子或者右儿子节点,那么最大路径也必然从左儿子或右儿子中间选取。如果我们写一个函数叫做 f(i, j) 用来求每个点到最底层的最大路径和的话,那么这个函数的大致轮廓就可以写成:

int f(int i, int j)
{
if(i>MAX) //超过层数
 return 0;
if(j>i) //每一层的边界
 return 0;
int a = f(i+1, j); //左儿子
int b = f(i+1, j+1); //右儿子
if(a>b)
 return a+num[i][j];
else
 return b+num[i][j];
}

写出这个代码之后,对于层数不多的时候,我们是可以求出最终的答案的,不过当层数比较多的时候,就会发现,求结果所需要的时间太多了。每一条路径可能会计算好几次,路径{18-10}就会被计算3次,分别是{12-10-18-10},{12-6-18-10}和{15-6-18-10},其中有两次是浪费的,因为前面我们计算过一次,如果能够保存下来的话,那么就可以直接查询就行了。那么接下来我们来看是否可以改进上面的代码。

我们发现对于每个节点,都需要计算它的左儿子和右儿子到底层的最大路径和,这个路径是会重叠的,比如路径{9-12-6-18-10}和路径{9-15-6-18-10}的后面一段{6-18-10}就是重合的,也就是说用上面的代码我们会计算两次这条路径的值。这就造成了资源上的浪费,耗费了很多不必要的时间。那么现在我们用一个二维数组 dp[][] 记录下每个节点到底层的最大路径和,在第一次计算的时候,将这个结果赋值给二维数组 dp[][] 中相应的单元格,在后面需要的时候直接从 二维数组 dp[][] 里面取就行了,于是就有了下面的代码

int f(int i, int j)
{
if(i>MAX)
  return 0;
if(j<0 ||="" j="">i)
  return 0;
if(dp[i][j] == -1)//dp[][]数组初始化为-1,因为所有路径和都是正数,所以这里-1表示未计算过
  dp[i+1][j] = f(i+1, j);
if(dp[i+1][j+1] == -1)
  dp[i+1][j+1] = f(i+1, j+1);
if(dp[i+1][j]>dp[i+1][j+1])
  return dp[i+1][j]+num[i][j];
else
  return dp[i+1][j+1]+num[i][j];
}

这段代码和第一段代码的区别就是用二维数组 dp[][] 保存下了每一个状态,这样每一条路径我们就只会计算一次,对于一棵节点很多的树来说,这节省下来的时间是非常多的,可以自己生成一个符合条件的二叉树,用上面两段代码同时计算所需要的结果,最后对比运行时间。
到这里差不多动态规划的思想就出来了:空间换时间。从而解决有重叠子问题的问题。某些路径我们会计算很多次,那么就把这些结果保存下来,供后面需要的时候查询。当然本文的第二段代码,很多人叫做记忆化搜索,实际上思想是一样的,都是用空间换时间。当然接下来我们还可以继续把第二段代码写成非递归的,甚至对非递归的写法继续优化,这里就不涉及相应的内容了,这里给出一个优化版的非递归版本:(从底层开始计算,最后计算到树根结束,这样空间只需要 O(n) ),代码如下

int f(int i, int j)
{
int dp[MAX];//保存结果
for(int i=0; i=0; --i)
{
 for(int j=0; j<=i; ++j)="" {="" dp[j]="max(dp[j]," dp[j+1])="" +="" num[i][j];="" 最大值是由自己的左儿子和右儿子的最大路径和构成="" }="" return="" dp[0];="" }<="" pre=""> 

在动态规划里面,还有两个术语叫做“状态”和“转移方程”,通俗的说“状态”就是表示某一情况下的结果,比如本文第二段代码中 dp[i][j] 就表示的是 (i,j)这个节点的状态,而“转移方程”就阐述了如何从一个“状态”变化到“另外一个状态”,比如上面第三段代码中的第10行 dp[j] = max(dp[j], dp[j+1]) + num[i][j],表示了当前节点怎么从左儿子和右儿子的状态变化而来. 到这里,基本的动态规划问题,应该是能够理解了,当然能够理解不代表就能够解出新的题目,对于怎么选取状态怎么找出转移方程,这些问题都需要通过不停的训练才能够获得。动态规划里面最难的就是状态的选取以及转移方程,当然利用其譬如优先队列等东西优化就属于更高级的东西了,如果能够知道怎么表达状态,以及写出相应的转移方程,那么剩下的就只是苦力活了。

至于经典的最长公共子序列,最长上升子序列,背包问题等都是可以用上述思路来解

至于自己想找题目练习的话,我推荐 Topcoder,至于其他的 OJ,可以自行搜索。这里是 Topcoder 上所有的动态规划题目集合,可以自行选择相应的难度。另外推荐一篇讲动态规划的英文版的文章,也来自 Topcoder。网上还有人总结的 DP46 题,也可以看看。

当然如果有什么地方讲解不详细,或者有错误的话,欢迎讨论:)

Comments