DP 练习记录

本文最后更新于:2022年10月27日 晚上

P4310

类似最长上升子序列,我们可以推出以下式子:

$\text{dp} = \max{dp_j + 1}, j < i$

并且 a[i] & a[j] 不为 0。(为什么不写进 $\LaTeX$ 呢?因为新博文的并不支持 & 这个东西,一用就挂。)

考虑位运算的特性,每一位并不相关,那么我们可以让 $dp_i$ 表示 二进制下第 i 位为 1 的最大值,这样我们每次找到一个数直接给他二进制拆分,先更新当前最大值,然后拿最大值去更新 dp 数组,这样时间复杂度就变成了 $\mathcal{O}(n \log n)$。

1
2
3
4
5
6
for(int i = 1; i <= n; ++i) {
int tmp = 1;
for(int j = 0; j < 64; ++j) if((a[i] >> j) & 1) tmp = max(dp[j] + 1, tmp);
for(int j = 0; j < 64; ++j) if((a[i] >> j) & 1) dp[j] = max(dp[j], tmp);
ans = max(ans, tmp);
}

P2184

不是很方便知道具体的边长,那么我们就转换思路为看能否圈出 a,b,c 长度的三条边。

则 $dp_{a, b, c}$ 表示能否围成 a,b,c 三条边。

一算空间爆掉了,想办法优化。

因为周长已知,那么我们只需要知道两条边即可推出第三边,那么我们就可以优化成:

$$dp_{i, j, k}$$ 表示第 i 个点,长度为 j,k 两条边能否围成。

经过研究可以发现,我们可以直接原地滚动数组,第一维直接省略掉,转化为。

考虑状态转移方程:

  1. 如果我们将当前边放到 j 处,则我们需要通过 $dp_{j - a_i, k}$ 推过来。
  2. 如果我们将当前边放到 k 处,则我们需要通过 $dp_{j, k - a_i}$ 推过来。
  3. 如果我们将当前边放到第三条边,我们只需要 $dp_{j, k}$ 推过来。

那么状态转移方程为:$dp_{i, j} |= (dp_{i - a_k, j}, dp_{i, j - a_k})$

现在我们已知了可以到达的边,去枚举这些边,然后判断是否可以凑出这三条边,再看能否围成三角形,最后统计答案输出即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
dp[0][0] = true;
for(int i = 1; i <= n; ++i)
for(int j = sum >> 1; j >= 0; --j)
for(int k = sum >> 1; k >= 0; --k) {
if(j >= a[i]) dp[j][k] |= dp[j - a[i]][k];
if(k >= a[i]) dp[j][k] |= dp[j][k - a[i]];
}

double ans = -1;
for(int i = sum; i > 0; --i)
for(int j = sum; j > 0; --j) {
if(!dp[i][j]) continue;
if(!is_ok(i, j, sum - i - j)) continue;
ans = max(ans, calc(i, j, sum - i - j));
}

P1133

首先分析题意,可以得出线性 DP。

一维肯定不够,我们需要知道上一个子问题状态选择了哪一棵树,因此加上一维,这样最初的状态就变成了:$dp_{i, \ 0/1/2}$,表示到了第 i 个位置,放高度为 10,高度为 1,高度为 2 的树的最大收益。

坑人的是,我们依旧无法转移,因为我们没办法知道这个树是比左右都低还是比左右都高。

那就,再塞进去一维!

现在变成了:$dp_{i, \ 0/1/2, \ 0/1}$ 表示第 i 个位置,放高度为 10,高度为 1,高度为 2 的树,并且当前的树比左右两边都矮或比左右两边都高的最大收益。

现在好像可以做了,我们推一下状态转移方程:

$$dp_{i, 0, 0} = \max{dp_{i-1, 1, 1}, dp_{i - 1, 2, 1}} + a[i]$$

$$dp_{i, 1, 0} = dp_{i - 1, 2, 1} +b[i]$$

$$dp_{i, 1, 1} = dp_{i - 1, 0, 0} + b[i]$$

$$dp_{i, 2, 1} = \max{dp_{i - 1, 0, 0},\ dp_{i - 1, 1, 0}} + c[i]$$

考虑一下初始化,直接设成 0 就可以了。

最后答案枚举最后一颗树的种类状态即可。

来一波操作:

1
2
3
4
5
6
7
8
9
10
11
12
for(int i = 1; i <= n; ++i) {
dp[i][0][0] = max(dp[i - 1][1][1], dp[i - 1][2][1]) + a[i];
dp[i][1][0] = dp[i - 1][2][1] + b[i];
dp[i][1][1] = dp[i - 1][0][0] + b[i];
dp[i][2][1] = max(dp[i - 1][0][0], dp[i - 1][1][0]) + c[i];
}

int ans = 0;

for(int i = 0; i < 3; ++i)
for(int j = 0; j < 2; ++j)
ans = max(ans, dp[n][i][j]);

然后我们壮烈地牺牲了 30 pts。

分析一下原因我们发现,我们未能正确处理好最后一棵树和第一颗树的关系,因此导致了 WA。

那我们就不得不换种思路:先枚举第一颗树的状态,然后以这个状态为基础去更新接下来的位置,然后在更新的途中就把最大值取了。

现在我们过了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
for(int j = 0; j < 3; ++j) {
for(int i = 0; i < 3; ++i)
for(int k = 0; k < 2; ++k)
dp[1][i][k] = 0;

if(j == 0) dp[1][j][0] = dp[1][j][1] = a[1];
else if(j == 1) dp[1][j][0] = dp[1][j][1] = b[1];
else if(j == 2) dp[1][j][0] = dp[1][j][1] = c[1];

for(int i = 2; i <= n; ++i) {
dp[i][0][0] = max(dp[i - 1][1][1], dp[i - 1][2][1]) + a[i];
dp[i][1][0] = dp[i - 1][2][1] + b[i];
dp[i][1][1] = dp[i - 1][0][0] + b[i];
dp[i][2][1] = max(dp[i - 1][0][0], dp[i - 1][1][0]) + c[i];
}

for(int i = 0; i < j; ++i) ans = max(ans, dp[n][i][0]);
for(int i = 2; i > j; --i) ans = max(ans, dp[n][i][1]);
}

POJ1742

一眼多重背包,上去先来个朴素的解法,成功过了样例,然后 TLE 了。

朴素

首先可以看出这是个多重背包,那么我们就可以将每一个物品分出来,按照 01 背包那么折腾,这样时间复杂度是 $\mathcal{O(m\sum_{i=1}^{n}c_i)}$ ,毫无疑问地去世。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
while(n = rd(), m = rd()) {
if(n == 0 && m == 0) break;
for(int i = 1; i <= n; ++i) a[i] = rd();
for(int i = 1; i <= n; ++i) c[i] = rd();
memset(dp, 0, sizeof(dp));
dp[0] = 1;

for(int i = 1; i <= n; ++i)
for(int j = 1; j <= c[i]; ++j)
for(int k = m; k >= a[i]; --k)
dp[k] |= dp[k - a[i]];

int ans = 0;
for(int i = 1; i <= m; ++i)
ans += dp[i];

printf("%d\n", ans);
}

二进制拆分

既然朴素的没辙,那我们就玩一下之前学过的二进制拆分来优化一下这个东西。

但是这样的时间复杂度是 $\mathcal{O(m\sum_{i=1}^{n}\log{c_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
while(n = rd(), m = rd()) {
if(n == 0 && m == 0) break;
for(int i = 1; i <= n; ++i) a[i] = rd();
for(int i = 1; i <= n; ++i) c[i] = rd();

int tot = 0;
memset(b, 0, sizeof(b));
for(int i = 1; i <= n; ++i) {
for(int j = 1; j <= c[i]; j <<= 1) {
b[++tot] = j * a[i];
c[i] -= j;
}

if(c[i]) b[++tot] = a[i] * c[i];
}

memset(dp, 0, sizeof(dp));
dp[0] = 1;

for(int i = 1; i <= tot; ++i)
for(int j = m; j >= b[i]; --j)
dp[j] |= dp[j - b[i]];

int ans = 0;
for(int i = 1; i <= m; ++i)
ans += dp[i];

printf("%d\n", ans);
}

贪心

注意啊,这里只是在怎么决策的时候贪心了一下,整体上还是 DP。

我们发现一个奇妙的性质:这题我们只要可行性就好了,不需要最优性。

既然如此,如果前 i 种硬币能拼成 j,只有两种可能:

  1. j 早就被拼出来了。
  2. 用一下第 i 种硬币拼成 j。

很明显,我们能用 1 解决的就不用 2 解决。这样我们设一个 $used_j$ 表示让 $dp_j$ 在 i 阶段时为 true 至少需要多少种第 i 枚硬币,能用 1 就用1,并且将 $used_j$ 设成 0,这时候不需要第 i 种硬币来解决问题。

否则,考虑用当前硬币解决问题,退回到上一个状态,看一下那个状态耗费的第 i 种硬币数是否比给定的数量少,如果少的话并且那个状态已经拼出来了,我们就转移,否则就……

终于,时间复杂度被我们弄成了 $\mathcal{O(nm)}$,总算是过了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
while(n = rd(), m = rd()) {
if(n == 0 && m == 0) break;
for(int i = 1; i <= n; ++i) a[i] = rd();
for(int i = 1; i <= n; ++i) c[i] = rd();
memset(dp, 0, sizeof(dp));
dp[0] = 1;

for(int i = 1; i <= n; ++i) {
for(int j = 0; j <= m; ++j) used[j] = 0;
for(int j = a[i]; j <= m; ++j)
if(!dp[j] && dp[j - a[i]] && used[j - a[i]] < c[i])
dp[j] = true, used[j] = used[j - a[i]] + 1;
}

int ans = 0;
for(int i = 1; i <= m; ++i)
ans += dp[i];

printf("%d\n", ans);
}

P1006

线性 DP,我们先来分析一下状态,很明显,我们可以将当前路径的长度作为状态。

我们依然需要知道当前的末尾位置,因此我们还要想方设法地折腾出表示两个末尾位置的方法。

经过仔细的分析我们发现:我们走过的路径 $i$,和两个末尾节点的坐标 $x1, y1$, $x2, y2$ 的关系是这样的:

$i + 2 = x1 + y1 = x2 + y2$

注意,初始点的位置并不算进经过的路径中。

那么我们现在就可以用三个状态推知剩下的两个状态了:

$$y1 = i + 2 - x1, y2 = i + 2 - x2$$

这样一来我们就可以通过枚举三个维度,从而得到另外两个维度的信息。

设:$dp_{i, x1, x2}$ 表示路径长度为 i,一条路径的末节点的横坐标为 x1,另一条为 x2。

考虑 $x1 = x2$ 的转移:

  1. 如果当前 $x1 = x2$,那么向下走的时候会走到同一个节点上,那么我们就只能取一次值:$dp_{i + 1, x1 + 1, x2 + 1} = \max{dp_{i + 1, x1 + 1, x2 + 1}, dp_{i, x1, x2} + a_{x1, y1 + 1}}$

  2. 如果当前 $x1 = x2$,那么向右走的时候也会走到同一个节点上,同理:$dp_{i + 1, x1, x2} = \max{dp_{i + 1, x1, x2}, dp_{i, x1, x2} + a_{x1 + 1, y1}}$

当 $x1 = x2 + 1$ 或 $x2 =x1 + 1$

  1. 这时候向下走就要考虑一下了,因为一个节点会走到曾经取走过的节点上,这时候我们就要考虑一下向下更新的问题了。如果 $x1 + 1 = x2$,说明 x1 在 x2 的斜右上边,这时候我们先让 x1 走到与 x2 相同的位置上,此时我们只能够取到 $a_{x1 + 1, y1}$ 的位置上的价值(如果看不懂可以自行画图理解)。

  2. 第二种情况同理呦。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
for(int i = 0; i < n + m - 2; ++i)
for(int x1 = 1; x1 <= n && x1 <= i + 1; ++x1)
for(int x2 = 1; x2 <= n && x2 <= i + 1; ++x2) {
int y1 = i - x1 + 2, y2 = i - x2 + 2;
if(x1 == x2) { // 当两端点的横坐标在同一水平位置上
dp[i + 1][x1][x2] = max(dp[i + 1][x1][x2], dp[i][x1][x2] + a[x1][y1 + 1]); // 横着走只能更新一次
dp[i + 1][x1 + 1][x2 + 1] = max(dp[i + 1][x1 + 1][x2 + 1], dp[i][x1][x2] + a[x1 + 1][y1]); // 竖着往下走也只能更新一次0
} else {
dp[i + 1][x1][x2] = max(dp[i + 1][x1][x2], dp[i][x1][x2] + a[x1][y1 + 1] + a[x2][y2 + 1]); // 横着走两个都加
dp[i + 1][x1 + 1][x2 + 1] = max(dp[i + 1][x1 + 1][x2 + 1], dp[i][x1][x2] + a[x1 + 1][y1] + a[x2 + 1][y2]); // 竖着走也无妨
}

if(x1 + 1 == x2) dp[i + 1][x1 + 1][x2] = max(dp[i + 1][x1 + 1][x2], dp[i][x1][x2] + a[x1 + 1][y1]); // 要是竖着走到了同一个地方,只更新一下就可以
else dp[i + 1][x1 + 1][x2] = max(dp[i + 1][x1 + 1][x2], dp[i][x1][x2] + a[x1 + 1][y1] + a[x2][y2 + 1]); // 否则两个都加进去
if(x2 + 1 == x1) dp[i + 1][x1][x2 + 1] = max(dp[i + 1][x1][x2 + 1], dp[i][x1][x2] + a[x1][y1 + 1]); // 同理
else dp[i + 1][x1][x2 + 1] = max(dp[i + 1][x1][x2 + 1], dp[i][x1][x2] + a[x1][y1 + 1] + a[x2 + 1][y2]);
}

P5322

哇,上来这题弄得人毫无思路。

仔细瞅了瞅发现是个分组背包。

先想一个问题:派出的部队人数肯定严格大于对面的人数加 1,不然就是去白给,白给是肯定不能接受的。

那么,如何确定派出部队的方法呢?

观察到题目中派出部队的方法是相对于每一个阵地而言的,这样一来我们就可以按照每个阵地的角度上去思考,而不是从每个对手的角度去思考。

那么,我们就可以把到了哪一个阵地作为阶段,注意到剩余的部队人数也是一个不可或缺的附加维度,那么我们就应该将其也加入到状态中。

设 $dp_{i, j}$ 表示到了第 i 个阵地,还剩下 j 部队人数的最大受益值。

注意到第一维可以直接原地滚动数组秒掉,注意循环顺序,别无中生部队啊。

怎样更好地确定向每个阵地派出多少部队呢?

发现每个阵地上驻守的人数是无序的,也就是说是不好处理的,既然如此我们就把每个阵地上的人数升序排序,这样一来如果我们当前的部队人数能够击败第 k 个对手的,之前的对手肯定也都是可以击败的,这样一来我们就可以较为方便地处理向每个阵地派出的部队人数了。

这样方便了转移也方便了设计方程。

因为我们使用了滚动数组,转移时一定要注意先枚举部队人数然后再枚举每一个对手。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
for(int i = 1; i <= s; ++i)
for(int j = 1; j <= n; ++j)
a[j][i] = rd();

for(int i = 1; i <= n; ++i)
sort(a[i] + 1, a[i] + 1 + s);

for(int i = 1; i <= n; ++i)
for(int j = m; j >= 0; --j)
for(int k = 1; k <= s; ++k)
if(j > 2 * a[i][k])
dp[j] = max(dp[j - a[i][k] * 2 - 1] + k * i, dp[j]);

printf("%d", dp[m]);

DP 练习记录
http://dxrprime.github.io/2022/10/18/DP 练习记录/
作者
Shelter Prime
发布于
2022年10月18日
许可协议