Dynamic Programming
Dynamic Programming(动态规划)是一种以空间换时间的算法,可以用来解决的问题都有一个共性:重叠子问题。用通俗的话说就是记忆化搜索。也就是说,所有的动态规划都是可以用搜索去写的,但是用简单的搜索写,会发现时间复杂度太高,从而达不到要求,因为在搜索的过程中我们重复计算了很多以前已经计算过的问题(重叠子问题),这里就会浪费大量的时间。
下面借助一个经典的入门题数塔来讲解具体的过程:
数塔的大致意思如下:在一棵二叉树中,每个节点都有一个权值,现在你的任务是需要求从根出发到树的最底层的任何一条路径中(每一次都只能往下走),所经过的节点权值加起来最大(或者最小)的一条路径,并输出这个最大值(最小值)。下图中,9是树的根,需要求的是从9出发,最后到达{19, 7, 10, 4, 16} 这一层的所有路径中,权值加起来最大的那一条。这里是{9-12-10-18-10}最大值为59.我们在看到这个题目的时候,最开始可能想到的是用搜索直接求解,写一个递归函数解决之。因为每个节点只能往下走,所以每个节点的路径必然是经过左儿子或者右儿子节点,那么最大路径也必然从左儿子或右儿子中间选取。如果我们写一个函数叫做 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]; }0>
这段代码和第一段代码的区别就是用二维数组 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 题,也可以看看。
当然如果有什么地方讲解不详细,或者有错误的话,欢迎讨论:)
=i;>