前面
关于算法这一块,怎么说呢,目前为止我还是比较盲目的在OJ上面A水题,但即便如此AC率也很低——这到底还是不行的,不过终究是给自己探了个底子——在仅有基础概念的RAW状态下能走多远。
就目前来看,很多时候问题出在自己见得少,给不出思路。对于抽象思维要求不高的题目,知道怎么去做之后多数情况还下是能够拿到TLE(并非纯暴搜导致)或者AC的——当然TLE也不是算过,终究还是代码技巧不足;而对于需要有较高抽象能力的题目,或是比较Tricky的题目,则力有不逮——一个是因为见得少而不知道怎么下手,再就是抽象思维能力不过关。
因此,就算法这块,现在开始进行专项练习吧。咱对自己的要求不高,也不指望能有ACM的程度,但至少买的那三本书:《算法竞赛入门经典》、《算法竞赛入门经典训练指南》以及《挑战程序设计竞赛》不能白瞎了。
由于不知道自己对《刷油漆》那道题的题解理解正确与否,文中对那道题目的讲解我是没有信心的,虽然最终代码是AC了……还是要多做多体会。
正文
今次就看三个问题吧,从老生常谈的背包问题开始,到树形DP的简单介绍。
这里先推荐知乎上面关于DP的回答 看一看,两个高票答案已经将DP的核心问题讲得很明白了:就是“如何定义状态”以“如何控制状态的转移”——状态转移方程集中体现此两者,至于如何求解(递推或者是记忆化搜索)都只是求解方程的手段而已。
在谨记上面的条款之后,下面开始看3个题目,以协助理解。
From:HiHoCoder,HiHo一下第6周。
老题,网上有很多讲解的,包括状态方程怎么来的一应俱全,这里不去赘述,直接给AC的代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 #include <iostream> #include <vector> using namespace std ;typedef vector <int > vi;#define INT_MAX 2147483647 #define INT_MIN (-INT_MAX - 1 ) int main (int argc, char ** argv) { int N = 0 , M = 0 ; cin >> N >> M; if (N <= 0 || M <= 0 ) { cout << 0 << endl; return 0 ; } vector <vi> dp(N + 1 , vi(M + 1 , 0 )); vi need (N + 1 , 0 ) ; vi value (N + 1 , 0 ) ; for (int i = 1 ; i <= N; ++i) { cin >> need[i] >> value[i]; } int result = INT_MIN; for (int i = 1 ; i <= N; ++i) { for (int j = 1 ; j <= M; ++j) { if (j < need[i]) { dp[i][j] = dp[i - 1 ][j]; } else { int a = dp[i - 1 ][j]; int b = dp[i - 1 ][j - need[i]] + value[i]; dp[i][j] = (a>b) ? a : b; } if (dp[i][j] > result) { result = dp[i][j]; } } } cout << result << endl; return 0 ; }
需要注意一下:关于剩余空间M的For循环是从1开始加一递增的;而在采用记忆化搜索的递归解法中,剩余空间M并非是逐1递减的。这里需要逐步领会如何将尾递归转换为迭代的形式(PS:并非是指用显示Stack来辅助的方法)。
From: HiHoCoder,HiHo一下第7周。
题解HiHo上面已经有了,这里不赘述。下面的AC代码中并没有进行第二步的优化阶段:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 #include <iostream> #include <stdio.h> #include <stdlib.h> #include <string.h> using namespace std ;int VALUE[501 ];int NEED[501 ];int N, W;#define INT_MAX 2147483647 #define INT_MIN (-INT_MAX - 1 ) int main (int argc, char ** argv) { cin >> N >> W; for (int i = 0 ; i < N; ++i) { cin >> NEED[i + 1 ] >> VALUE[i + 1 ]; } int ** DP = (int **)new int *[N + 1 ]; for (int m = 0 ; m <= N; ++m) { DP[m] = new int [W + 1 ]; for (int n = 0 ; n <= W; ++n) { DP[m][n] = 0 ; } } for (int i = 1 ; i <= N; i++) { for (int j = 1 ; j <= W; j++) { int K = j / NEED[i]; for (int k = 0 ; k <= K; k++) { int r = DP[i - 1 ][j - k*NEED[i]] + k * VALUE[i]; if (DP[i][j] < r) DP[i][j] = r; } } } cout << DP[N][W]; return 0 ; }
From:HiHoCoder,HiHo一下第12周
虽然HiHo有给出题解,但是还是这里还是先借用《训练指南》P70的例题30的讲解开始吧——至少,对于我这种比较笨的人,HiHo的那个题解后半部分一开始是看不太懂的。
当然例题30并非是《刷油漆》原题,但也是树形DP的应用。
下面是整理的引用,需要注意的是后面两句:
无向无环图,或者称为森林,有多棵树组成,动态规划是解决树上的优化问题的常用工具。
为了能够使用动态规划,这要求:森林中的每个节点都互不相干,以便可以单独优化。
我们只考虑一棵树的情况:首先对这棵树进行DFS,将无根树转化为有根树,然后试着设置,并计算它的状态。
我们来看看单独的一棵树的情况,状态的定义HiHo题解中已经说明了,这里主要说明状态转移是如何发生的,也就是HiHo题解后半部分究竟是怎么回事。
先看给出的由case构造出来的树:
就从以节点4为根的子树开始吧。
显然我们要在这棵子树中选择三个节点,而如果想要对这棵子树进行评估,那么很显然,节点4也必须被选择(于是我们的DFS也就进行了2层了)。
剩下的两个节点只有从节点4的子树中选取了。
类比背包问题,这就相当于背包只剩下两个空间,而每个节点占用一个空间,选取的物品价值就相当于节点的最大估值,但这个值是存在于DP数组中的,并非是给定的值。
那么,对于节点4的每一个子树,都有可能分配0~2个空间。
这里应当注意:由于采用了DFS,我们已经把节点4的所有子树对应的状态都计算好了,因此,可以认为我们是在背包空间为2的情况下选取下面几个值来达到要求的最大值:
1 2 3 DP[8 ][0 ], DP[8 ][1 ], DP[8 ][2 ] DP[6 ][0 ], DP[6 ][1 ], DP[6 ][2 ] DP[7 ][0 ], DP[7 ][1 ], DP[7 ][2 ]
类比完全背包问题:完全背包问题要求对于每种物品选取K个,来得到最大值。这里可以认为是:对于子节点8,选取0~2个;对于子节点6,选取0~2个;对于子节点7,选取0~2个。
这里需要注意的是,上面关于如何选择的情况均是在自顶向下的情况下说明的——而实际代码中,采用的是自底向上的填充方式。
在完全背包中,状态转移方程以及对应代码为:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 DP[i ][j ] = max{ DP[i ][j ], DP[i-1 ][ j-k*w[i ] ] + k*v[i] | k = [0...j/w[i]] } for (int i = 1; i <= N ; ++i ) { for (int j = 1 ; j <= M ; ++j ) { int K = j /w [i ]; for (int k = 0 ; k <= K ; ++k ) { int r = DP [i-1 ][j-k *w [i ]]+k *v [i ]; if (r > DP[i ][j ]) DP[i ][j ] = r; } } }
那么,类比可以写出对于单个节点的DP值计算式:
1 DP[i ][j ] = max{ DP[i ][j ], DP[i ][j-k ] + DP[l ][k ] | l = [i的子节点] }
举个栗子:1 2 3 4 5 6 7 8 DP[4 ][3 ] = DP[4 ][2 ] + DP[6 ][1 ] = max{ (DP[4][1] + DP[8][1]), “DP[4][1] + DP[6][1],” (DP[4][1] + DP[7][1]) } + DP[6][1] // 显然max{...}中的DP[6 ][1 ]是不应该重复计算的
注意,完全背包的填充方式是在已经有的结果上再装上k个第i种物品(也就是剩余的空间还足够在第i中物品上做出多个选择);而在当前问题中我们则是在选择了当前子树中的k个节点之后,根节点上还可以再选择多少个其他子树中的节点。
那么尝试写出代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 DFS(int i, int M ) { for (each child ci of node i) { if (vis[ci ]) continue ; DFS(ci ,M -1); for ( m = [M ... 2]) { for ( k = [1 ... m ] ) { int r = DP[i][m -k] + DP[ci ][k]; if (r > DP[i][m ]) DP[i][m ] = r; } } } }
注意到M的值是从大到小计算的,这一点可以从前面列举的那个小例子中看出来:M值大的DP值是依赖于M值小的DP值的。而M的值最小只到2,原因在于,对于M=0时肯定为0,M=1时,就是节点本身。
下面是我的AC代码:
另:如果在HiHo一下的比赛排名中查找AC代码的话,会发现有关于edge、vis数组这样的东西,这是因为那些代码是基于无向图的,需要使用vis数组来标记节点以防止出现环的问题。大部分ACM相关的博客上也是此类型的代码,此类代码更具普适性。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 #include <iostream> #include <fstream> #include <vector> using namespace std ;int N, M;int V[101 ];int vis[101 ];#define INT_MAX 2147483647 #define INT_MIN (-INT_MAX - 1 ) vector <int > Tree[101 ];int DP[101 ][101 ];void solve (int n, int m) { vector <int >& children = Tree[n]; for (auto ci = 0 ; ci < children.size(); ++ci) { solve(children[ci], m - 1 ); for (int j = m; j >= 2 ; --j) { for (int k = 1 ; k < j; ++k) { int r = DP[n][j - k] + DP[children[ci]][k]; if (r > DP[n][j]) DP[n][j] = r; } } } } int main (int argc, char ** argv) { cin >> N >> M; for (int i = 1 ; i <= N; ++i) cin >> V[i]; int a, b; for (int i = 1 ; i < N; ++i) { cin >> a >> b; Tree[a].push_back(b); } for (int i = 1 ; i <= N; ++i) { DP[i][1 ] = V[i]; } solve(1 , M); cout << DP[1 ][M] << endl; return 0 ; }