几种 DP 模型 I

2024-12-17T19:20:00+08:00 | 7分钟阅读 | 更新于 2024-12-17T19:20:00+08:00

@
几种 DP 模型 I

两年前的种的向日葵的萌芽,可惜还没成长就死了。

讲解了数位、状压、期望 dp。

一、数位 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)$,后两维状态表示为:

  1. $lim=1$: 已经填好的前 $i$ 位等于上界的前 $i$ 位。$lim=0$:已经填好的前 $i$ 位小于上界的前 $i$ 位。
  2. $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
 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
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
 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
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
1
2
3
4
5
    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
 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
#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,j)=\begin{cases}P_0\times P(i-1,j-1)+(1-P_0)\times P(i,j-1), & j\ge 2 \ (1-P_0)\times P(i,i) & j=1\end{cases}$$

呈环形结构,考虑变形。设 $P(i,1)=x$,则情况 1 2 联立必可构造出形如 $ax+c=0$ 的方程,解之即可。

由于本题空间较小,故需滚动数组优化。

Luogu P5249 Code
 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
#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;
}

© 2025 Toorean's Blog

🌱 Powered by Hugo with theme Dream.