DP 模型 I
一、数位 DP
数位 DP 是通过 DP 的方式来计算符合某种条件的数的个数,多用于计算大整数以及数码相关的问题。下面通过一道题来体会这种 DP 的过程与内涵。
例题:Luogu P2657
题意:给出 \(l\) 与 \(r\),求 \([l,r]\) 中满足相邻两个数字之差至少为 \(2\) 的正整数的个数,\(l,r\le 2\times10^9\)。
分析:注意到数据范围很大,故无法直接枚举求解。于是考虑动态规划。
显然区间中满足条件的数个数满足单调关系,且可以进行前缀和操作。我们设 \(ans(r)\) 表示 \([1,r]\) 中满足条件数的个数。则答案即为 \(ans(r)-ans(l-1)\),此时我们只需求解 \([1,n]\) 的答案,即可得题目之所求。
由于动态规划的阶段性,我们可以暂时确定求解答案的顺序。数位 DP 中,我们通常将一个大整数看作一个由 \([0,9]\) 构成的整数序列,自高位至地位一位一位地 DP。
于是状态设计便呼之欲出。设 \(f(i,j)\) 表示当前已经填好最高的前 \(i\) 位,且第 \(i\) 位为 \(j\) 的满足条件的数的个数。但倘若只限定如上条件,显然需要在原有基础上继续增加条件,于是状态即为 \(f(i,j,lim,zero)\),后两维状态表示为:
- \(lim=1\): 已经填好的前 \(i\) 位等于上界的前 \(i\) 位。\(lim=0\):已经填好的前 \(i\) 位小于上界的前 \(i\) 位。
- \(zero=1\):已经填好的前 \(i\) 位均为 \(0\),即含前导零。\(zero=0\):已经填好的前 \(i\) 位均为 \(0\),即不含前导零。
第三维状态的设计是显然的,而第四维状态对于前导零的认识,我们不妨举个例子:前 \(i-1\) 位均为 \(0\),而第 \(i\) 位欲填 \(1\),若未进行前导零的限制,则会判断第 \(i\) 位与第 \(i-1\) 位之差小于 \(2\),将这种情况就不会被统计到最终答案,故需加上这一维度条件的限制。
我们将这种形式化的状态代入到这道题中,于是有以下状态设计:
\(f(i,j,lim,zero)\):从高到低第 \(i\) 位为 \(j\),前 \(i\) 位满足任意相邻两位数字之差至少为 \(2\),且不超过上界。满足前 \(i\) 位均等/小于上界前 \(i\) 位,有/无前导零的方案数。
\(ans(k)\) 即为 \(\sum\limits_{i=0}^9[f(len,i,0,0)+f(len,i,1,0)]\)。
其中 \(len\) 指 \(k\) 的位数。
而初始状态,即为:\(f(0,0,1,1)=1\)
- 如何进行 DP?
不妨设当前值已知,用当前值更新所有可达点即可,即为刷表法。
具体来说,我们每次枚举一个已知状态,再枚举下一位要填的数字,判断是否可以满足条件,若满足,则用当前状态去更新下一位即可。
核心代码如下:
Code
int ans (int K) {
memset (f, 0, sizeof f);
memset (num, 0, sizeof num);
len = 0;
while (K) num[++ len] = K % 10, K /= 10;
f[0][0][1][1] = 1;
reverse (num + 1, num + len + 1);
for (int i = 0; i < len; i ++) {
for (int j = 0; j <= 9; j ++) {
for (int lim = 0; lim < 2; lim ++) {
for (int zero = 0; zero < 2; zero ++) {
for (int k = 0; k <= 9; k ++) {
if (!zero && abs (k - j) < 2) continue;
if (lim && k > num[i + 1]) break;
f[i + 1][k][lim && num[i + 1] == k][zero && !k] += f[i][j][lim][zero];
}
}
}
}
}
int ret = 0;
for (int i = 0; i <= 9; i ++)
ret += f[len][i][0][0] + f[len][i][1][0];
return ret;
}
最后所求即为 \(ans(r)-ans(l-1)\)。
练习:Luogu P4124
给出十一位数 \(l,r\),求 \([l,r]\) 中满足以下条件的数的个数:
- 数字中至少包含 \(3\) 个相邻的连续数字。
- 号码中不可同时出现 \(8\) 和 \(4\)。
分析:显然可以套用数位 DP 的常用状态设计,但亦需考虑如何表示第一个条件。
设计状态为 \(f(i,j,Same,\_8,\_4,lim)\),上文出现过的维度同前,下面解释新增三维度、删去前导零维度之因:
- \(Same\):以 \(i\) 结尾的连续串长度。
- \(\_8\):已填数中是否出现 \(8\)。
- \(\_4\):已填数中是否出现 \(4\)。
- 对于前导零维度,由于所给的 \(l\) \(r\) 均为十一位数,故在答案统计时会删去具有前导零的数的重复部分。不过当 \(l\) 达到最小值 \(10^{10}\) 时,\(l-1\) 会变成十位数,导致答案出错。我们只需强制使 \(len=11\) 即可。
特别的,为了状态设计无后效性,若当前状态 \(Same\) 已经达到 \(3\),我们认为其所可更新的所有状态的 \(Same\) 均为 \(3\)。
核心代码如下:
Code
ll work (ll x) {
memset (f, 0, sizeof f);
memset (num, 0, sizeof num);
len = 0;
while (x) num[++ len] = x % 10, x /= 10; len = 11;
f[0][0][1][1][0][0] = 1; reverse (num + 1, num + len + 1);
for (int i = 0; i < len; i ++) {
for (int j = 0; j <= 9; j ++) {
for (int Same = 1; Same <= 3; Same ++) {
for (int lim = 0; lim < 2; lim ++)
for (int k = 0; k <= 9; k ++) {
for (int _8 = 0; _8 < 2; _8 ++)
for (int _4 = 0; _4 < 2; _4 ++) {
if (_4 && _8) continue;
if (_4 && k == 8 || _8 && k == 4) continue;
if (lim && num[i + 1] < k) continue;
if (Same == 3)
f[i+1][k][Same][lim && num[i+1] == k][_8 || k==8][_4 || k==4] +=
f[i][j][Same][lim][_8][_4];
else
f[i+1][k][j==k ? Same+1 : 1][lim && num[i+1] == k][_8 || k==8][_4 || k==4] +=
f[i][j][Same][lim][_8][_4];
}
}
}
}
}
ll ret = 0;
for (int i = 0; i <= 9; i ++)
ret += f[len][i][3][0][0][0] + f[len][i][3][0][0][1] + f[len][i][3][0][1][0] +
f[len][i][3][1][0][0] + f[len][i][3][1][0][1] + f[len][i][3][1][1][0];
return ret;
}
二、状态压缩 DP
状态压缩,顾名思义,就是把一串复杂冗余的状态压缩成一个简洁易于表示的状态,数据范围通常较小。常见的是二进制压缩,如令 \(S\) 的二进制表示中第 \(i\) 位为 有/无使用第 \(i\) 个物品,即设计状态中含有集合。状态转移时结合一些位运算技巧即可。
例题 Luogu P3052
题意:给出 \(n\) 个物品,体积为 \(w _ 1, w _ 2, \cdots, w _ n\),现把其分成若干组,要求每组总体积小于等于 \(W\),问最小分组数量。\(n\le 18\)
分析:我们设 \(f(S)\) 表示集合 \(S\) 中所有物品已经分好组的最小分组数。若当前集合为 \(S\),每次枚举其子集 \(T\),若 \(S\oplus T\) 中所有物品体积之和小于 \(W\),则用 \(f(T)+1\) 来更新 \(f(S)\)。
复杂度分析:考虑 \([1,n]\) 中每个物品。每次枚举的子集要么包含 \(i\),要么不包含 \(i\)。接着枚举的子集之子集,要么包含 \(i\),要么不包含 \(i\)。又由于第二个集合为第一个集合的子集,故每个数有三个状态:属于两个集合,属于第一个集合,不在集合中。时间复杂度为 \(\mathcal O(3^n)\)。
核心代码如下。
Code
for (int i = 1; i < 1 << n; i ++) {
f[i] = n + 1;
for (int j = i; j; -- j &= i)
if (sum[j] <= W) f[i] = min (f[i], f[i ^ j] + 1);
}
例题 Luogu P1896
题意:在 \(N \times N\) 的棋盘里面放 \(K\) 个国王,使他们互不攻击,共有多少种摆放方案。国王能攻击到它上下左右,以及左上左下右上右下八个方向上附近的各一个格子,共 \(8\) 个格子。
分析:这道状压 DP 也许与上一题有些许不同,但本质如一。设计 \(f(i,S,k)\) 表示已经放完前 \(i\) 行,放了一共 \(k\) 个国王,第 \(i\) 行的放了国王王的集合为 \(S\) 的方案总数。
预处理所有一行上满足条件的集合,以优化 DP 时的时间复杂度。
依次枚举第 \(i\) 行状态 \(S\),上一行状态 \(T\),若满足题设条件,则用 \(f(i-1,T,k)\) 更新当前的 \(f(i,S,\mathrm {card}(S)+k)\)。
Code
#include <cstdio>
#include <vector>
#define LL long long
using namespace std;
const int MAXN = 10;
LL ans;
int n, k;
LL f[MAXN][1 << MAXN][MAXN*MAXN];
vector <int> op;
void init () {
for (int state = 0; state < 1 << n; state ++) {
if (state << 1 & state || state >> 1 & state)
continue ;
op.push_back (state);
}
f[0][0][0] = 1;
}
void dp () {
for (int i = 1; i <= n; i++) {
for (auto newS : op) {
for (auto sourS : op) {
if (newS & sourS ||
newS << 1 & sourS ||
newS >> 1 & sourS
) continue;
int count = 0, tmp = newS;
while (tmp) {
++ count;
tmp &= (tmp - 1);
}
for (int j = 0; j <= k - count; j++)
f[i][newS][j + count] += f[i - 1][sourS][j];
}
}
}
}
void calc () {
for (auto S : op)
ans += f[n][S][k];
}
int main (void) {
scanf ("%d %d", &n, &k);
init ();
dp ();
calc ();
printf ("%lld\n", ans);
return 0;
}
三、概率/期望 DP
相关的概念已经在 概率与期望 中讲到,故直接看例题。
Luogu P5249
题意:\(n\) 个人从第 \(1\) 个开始轮流朝自己开枪,射中的概率均为 \(P_0\),剩下的最后一个人为胜利者。求第 \(k\) 个人胜利的概率。特别的,若 \(P_0=0\),则认为第一个人胜利。
分析:设 \(P(i,j)\) 指剩余 \(i\) 人,第 \(j\) 人获胜的概率。考虑从第一个人开始,若射中,则剩下 \(i-1\) 人,原先的第 \(j\) 个人变为 \(j-1\),对于 \(P(i,j)\) 的贡献为 \(P_0\times P(i-1,j-1)\);若没中,则仍有 \(i\) 人,由于游戏继续,故当前的第 \(j\) 人仍变成第 \(j-1\) 人,做出贡献为 \((1-P_0)\times P(i,j-1)\)。考虑第 \(1\) 个人胜利的情况。此时其为第一个开枪者,绝对不中,而轮到下一个人后,其便成为第 \(i\) 个人,故概率为 \((1-P_0)\times P(i,i)\)。综上,如下:
呈环形结构,考虑变形。设 \(P(i,1)=x\),则情况 1 2 联立必可构造出形如 \(ax+c=0\) 的方程,解之即可。
由于本题空间较小,故需滚动数组优化。
Luogu P5249 Code
#include <iostream>
using namespace std;
const int MAXN = 1e4 + 10;
int n, k;
long double P0;
long double P[2][MAXN];
int main (void) {
scanf ("%Lf%d%d", &P0, &n, &k); int N = 1;
if (P0 < 1e-8) return puts ("0") & 0;
P[0][1] = 1;
for (int i = 2; i <= n; i ++) {
double a = 1, b = 0;
for (int j = 2; j <= i; j ++) a = a * (1 - P0), b = b * (1 - P0) + P[N ^ 1][j - 1] * P0;
P[N][1] = (1 - P0) * b / (1 - (1 - P0) * a);
for (int j = 2; j <= i; j ++) P[N][j] = P[N][j - 1] * (1 - P0) + P[N ^ 1][j - 1] * P0;
N ^= 1;
}
printf ("%.10Lf\n", P[N ^ 1][k]);
return 0;
}

浙公网安备 33010602011771号