本文最后更新于:2022年8月25日 下午
UPD 2022.7.4:删除了 QQ 表情包(太智障了影响阅读者的心情)。一并删除了一些冗杂的话。
如果你实在是看不懂,那……要不回去复习复习基础的东西?
如果你没有 DP 基础,建议你去博文里边耐心地翻一翻我之前写过的 DP 入门——《什么是 DP》
无后效性 我们在写 DP 的时候,我们只需要记录结果就可以了,而不需要记录决策是什么,这个东西叫做无后效性 。
这玩意在设计状态的时候比较重要,不然你的 DP 挂掉的几率……
背包 01 背包 来个例题:https://www.luogu.com.cn/problem/P1048 。
这道题异常的经典,是背包的不二之选。
根据上一篇博文的传统思路,我们肯定得先设计状态。
比如我们设计这样一个状态:
1 用 dp[n] 表示用了前 n 个物品的最大价值。
这样的状态是会挂掉的,因为前 $n$ 个物品占据了空间,会导致你有可能无法接着放物品,有后效性。
1 用 dp[k] 表示用了 k 的空间的最大价值。
一样歇菜,你不知道前面的物品哪个用了哪个没用,还是不行。
那么怎么解决后效性的问题呢?
很简单,把产生后效性的东西扔进 $dp$ 里边就行了。
正确的状态:
1 dp[n][k] 表示用到前 n 个物品,占据了 k 的空间的最大价值。
然后根据我们之前的方法,推出方程:
$dp[n][k] = \max(dp[n-1][k], dp[n-1][k-v[i]] + w[i])$
然后直接开始写,没啥好说的。
欸不不不,还有一点:
最终的答案,是在 $dp[n][…]$ 里边选一个最大的。
然后,你应该可以自己写出来了吧?
大水题,剪贴板。
https://www.luogu.com.cn/paste/ohdv5old
再来一个:https://www.luogu.com.cn/problem/P1049 。
这道题怎么办?
我们把它转化一下,既然求剩下的空间最小,我们就直接把能装下最多的空间 用上面的方法求出来,然后用总空间一减就行。
这玩意又叫做:适配器原理 。
适配器原理:
1 2 3 4 5 6 7 现在已知 A 问题的解决方式。 给你一个 B 问题。 你将它转换成 A 问题,用 A 问题的方式去求解。 最后,将输出换成 B 问题的。
过水,直接剪贴板。
https://www.luogu.com.cn/paste/4efph4qp
还有一个问题:https://www.luogu.com.cn/problem/P1060 。
这种题无非就是换换 $w$ 的算法而已,我相信你们都能自己写出来。
什么你实在不行?
好吧,给你自己去看:
https://www.luogu.com.cn/paste/h4qikm3r
太水了,直接剪贴板待遇。
我们再来看看:
你是不是发现,那个 $n$,是不是只需要用一次?
以后每一次,是不是跟很久之前的 $n$ 一点关系都没有了?
那,我还存着你干什么,浪费空间让出题人卡爆我吗?
我们就要使用下一招:
滚动数组! 好了,怎么写呢?
挺简单,既然你只需要一层的,我新开个数组 $last$ 专门用来存上面的东西,然后再用个 #include <cstring>
里边的 memcpy
进行数组的复制就好了。
三个程序滚动数组版如下:
https://www.luogu.com.cn/paste/jsr0vj8e
太长了就直接扔剪贴板里边了,不过分吧?!
我们还可以采用另一种滚动数组的方法:原地滚动数组 。
这一招就是换换循环顺序,然后你就可以直接过去了,连 $last$ 都不用开。
只放一个程序,不然我会累死。
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 62 63 64 65 66 67 68 69 70 71 #include <cstdio> #include <algorithm> #include <cstring> #define N 105 using namespace std;int V, n;int v[N], w[N];int dp[1005 ];int read () { int x = 0 , w = 1 ; char c = 0 ; while (c < '0' || c > '9' ) { if (c == '-' ) { w = -1 ; } c = getchar (); } while (c >= '0' && c <= '9' ) { x = x*10 +(c-'0' ); c = getchar (); } return x * w; }void write (int x) { if (x < 0 ) { x = -x; putchar ('-' ); } if (x > 9 ) { write (x / 10 ); } putchar (x % 10 + '0' ); }void inp () { V = read (), n = read (); for (int i=1 ; i<=n; i++) { v[i] = read (), w[i] = read (); } }void work () { for (int i=1 ; i<=n; i++) { for (int j=V; j>=0 ; j--) { if (j-v[i] >= 0 ) { dp[j] = max (dp[j], dp[j-v[i]] + w[i]); } } } int ans = -1 ; for (int i=0 ; i<=V; i++) { ans = max (ans, dp[i]); } write (ans); }int main () { inp (); work (); return 0 ; }
完全背包 换个问题:如果每个物品都可以取无限次,那怎么办?
https://www.luogu.com.cn/problem/P1616 。
也好说,我直接告诉你一个 trick:
原地滚动数组,循环方式变成正着来,因为现在无限次取用允许你正着更新:
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 62 63 64 65 66 67 68 69 70 71 72 #include <cstdio> #include <algorithm> #include <cstring> #define N 10005 #define ll long long using namespace std;int V, n;int v[N], w[N]; ll dp[10000005 ];int read () { int x = 0 , w = 1 ; char c = 0 ; while (c < '0' || c > '9' ) { if (c == '-' ) { w = -1 ; } c = getchar (); } while (c >= '0' && c <= '9' ) { x = x*10 +(c-'0' ); c = getchar (); } return x * w; }void write (ll x) { if (x < 0 ) { x = -x; putchar ('-' ); } if (x > 9 ) { write (x / 10 ); } putchar (x % 10 + '0' ); }void inp () { V = read (), n = read (); for (int i=1 ; i<=n; i++) { v[i] = read (), w[i] = read (); } }void work () { for (int i=1 ; i<=n; i++) { for (int j=v[i]; j<=V; j++) { if (j-v[i] >= 0 ) { dp[j] = max (dp[j], dp[j-v[i]] + w[i]); } } } ll ans = -1 ; for (int i=0 ; i<=V; i++) { ans = max (ans, dp[i]); } write (ans); }int main () { inp (); work (); return 0 ; }
多重背包 https://www.luogu.com.cn/problem/P1776
现在第 $i$ 个物品只允许用 $a[i]$ 次,怎么办?
简单,叫出题人出来,武力解决。
简单点的思路:
弄出 $a[1]$ 个 1 号物品,$a[2]$ 个 2 号物品……
然后把它们统一地存在一个数组里边,用这个数组来跑 01 背包。
然后你会惊喜地发现,复杂度变成了 $(V * \sum{a[i]})$。
然后出题人跟你文斗,直接大数据送你 TLE,然后末影珍珠瞬移走,留下你一人抱着省二的证书痛哭失声。
那怎么办呢?
二进制分组!
问个问题:
1 [1 , 2 , 4 , 5 ] 里面选一些数的和,是不是可以表示 12 以内所有的数?
proof:
[0, 7] 以内的数,肯定可以用 [1, 2, 4] 的子集和来表示,对吧?
[8, 12] 以内的数,先用个 5,接下来你要做的就是用 [1, 2, 4] 的子集和表示 [3, 7] 以内的数,这个问题刚才你已经解决过了。
把每种物品都分组,比如 14 个某物品可以分成 $[1, 2, 4, 7]$ 这些组。
分好这些组之后再扔给 01 背包去跑,直接输出结果。
物品总数就是:$\sum\log a[i]$。
其实不只是有二进制分组的优化方式,优先队列啥的也可以但是我不想写不然我会累死的。
然后是代码:
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 62 63 64 65 66 67 #include <cstdio> #include <algorithm> #define N 1000005 using namespace std;int n, m, ans;int dp[N], w[N], v[N];int pos = 1 ;int read () { int x = 0 , w = 1 ; char c = 0 ; while (c < '0' || c > '9' ) { if (c == '-' ) { w = -1 ; } c = getchar (); } while (c >= '0' && c <= '9' ) { x = x*10 +(c-'0' ); c = getchar (); } return x * w; }void write (int x) { if (x < 0 ) { x = -x; putchar ('-' ); } if (x > 9 ) { write (x / 10 ); } putchar (x % 10 + '0' ); }void inp () { n = read (), m = read (); for (int i=1 ; i<=n; i++) { int a = read (), b = read (), c = read (); for (int j=1 ; j<=c; j<<=1 ) { v[++pos] = j * b, w[pos] = j * a; c-=j; } if (c) { v[++pos] = b * c, w[pos] = a * c; } } }void work () { for (int i=1 ; i<=pos; i++) { for (int j=m; j>=v[i]; j--) { dp[j] = max (dp[j], dp[j-v[i]] + w[i]); } } write (dp[m]); }int main () { inp (); work (); return 0 ; }
换换脑子,看个这个:
记忆化方法 还记得斐波那契数列吗?
$f(n) = f(n - 2) +f(n - 1)$
我们怎么去求呢?
来个比较暴力的程序:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 #include <cstdio> using namespace std;int fib (int n) { printf ("call_fib(%d)\n" , n); if (n <= 2 ) { return 1 ; } return fib (n - 2 ) + fib (n - 1 ); }int main () { printf ("%d\n" , fib (10 )); return 0 ; }
这恐怖的调用次数……感觉随随便便我们就完蛋了,不是吗?
主要的问题是,无数的东西被重复调用了,这就很难受。
但是,有一种方法可以拯救我们:
记忆化!
具体来说就是开俩数组,一个用来看看有没有来过,另一个存储这个地方的值。
然后如果来过,直接返回值。
好了,进阶版:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 #include <cstdio> using namespace std;bool vis[1005 ];int sum[1005 ];int fib (int n) { if (vis[n]) { return sum[n]; } printf ("call_fib(%d)\n" , n); if (n <= 2 ) { return 1 ; } vis[n] = true ; return sum[n] = fib (n - 2 ) + fib (n - 1 ); }int main () { printf ("%d\n" , fib (10 )); return 0 ; }
好了,看看这个题:https://www.luogu.com.cn/problem/P1541
如果你一看难度就被吓回去了……我建议你还是回来的好,别害怕嘛,这题很简单。
直接设计状态:
1 dp[pos][a][b][c][d] 表示走到 pos,用了 a 张 1 ,b 张 2 ,c 张 3 ,d 张 4
其中第一维可以滚掉,不然空间不行。
然后就简单了,你直接把所有牌扔进去,然后每次计算从 1 走到了哪里,加上答案,进行 dp,然后只要牌没打空就接着打。
代码:
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 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 #include <cstdio> #include <algorithm> #define N 355 #define M 45 using namespace std;int sum[M][M][M][M];bool vis[M][M][M][M];int w[N], p[5 ], n, m;int read () { int x = 0 , w = 1 ; char c = 0 ; while (c < '0' || c > '9' ) { if (c == '-' ) { w = -1 ; } c = getchar (); } while (c >= '0' && c <= '9' ) { x = x*10 +(c-'0' ); c = getchar (); } return x * w; }void write (int x) { if (x < 0 ) { x = -x; putchar ('-' ); } if (x > 9 ) { write (x / 10 ); } putchar (x % 10 + '0' ); }void inp () { n = read (), m = read (); for (int i=1 ; i<=n; i++) { w[i] = read (); } while (m--) { int x = read (); p[x]++; } }int dp (int a, int b, int c, int d) { if (a == 0 && b == 0 && c == 0 && d == 0 ) { return w[1 ]; } if (vis[a][b][c][d]) { return sum[a][b][c][d]; } int pos = a + 2 * b + 3 * c + 4 * d; if (a) { sum[a][b][c][d] = max (sum[a][b][c][d], dp (a - 1 , b, c, d)); } if (b) { sum[a][b][c][d] = max (sum[a][b][c][d], dp (a, b - 1 , c, d)); } if (c) { sum[a][b][c][d] = max (sum[a][b][c][d], dp (a, b, c - 1 , d)); } if (d) { sum[a][b][c][d] = max (sum[a][b][c][d], dp (a, b, c, d - 1 )); } sum[a][b][c][d] += w[1 +pos]; vis[a][b][c][d] = true ; return sum[a][b][c][d]; }void work () { write (dp (p[1 ], p[2 ], p[3 ], p[4 ])); }int main () { inp (); work (); return 0 ; }