Day 15 - 数位 DP 与状压 DP

数位 DP

下面将简要介绍数位 \(\text{DP}\)

引入

数位是指把一个数字按照个、十、百、千等等一位一位地拆开,关注它每一位上的数字。如果拆的是十进制数,那么每一位数字都是 \(0 \sim 9\),其他进制可类比十进制。

数位 \(\text{DP}\):用来解决一类特定问题,这种问题比较好辨认,一般具有这几个特征:

  1. 要求统计满足一定条件的数的数量(即,最终目的为计数);

  2. 这些条件经过转化后可以使用「数位」的思想去理解和判断;

  3. 输入会提供一个数字区间(有时也只提供上界)来作为统计的限制;

  4. 上界很大(比如 \(10^{18}\)),暴力枚举验证会超时。

数位 \(\text{DP}\) 的基本原理:

考虑人类计数的方式,最朴素的计数就是从小到大开始依次加一。但我们发现对于位数比较多的数,这样的过程中有许多重复的部分。例如,从 \(7000\) 数到 \(7999\)、从 \(8000\) 数到 \(8999\)、和从 \(9000\) 数到 \(9999\) 的过程非常相似,它们都是后三位从 \(000\) 变到 \(999\),不一样的地方只有千位这一位,所以我们可以把这些过程归并起来,将这些过程中产生的计数答案也都存在一个通用的数组里。此数组根据题目具体要求设置状态,用递推或 \(\text{DP}\) 的方式进行状态转移。

数位 \(\text{DP}\) 中通常会利用常规计数问题技巧,比如把一个区间内的答案拆成两部分相减(即 \(\mathit{ans}_{[l, r]} = \mathit{ans}_{[0, r]}-\mathit{ans}_{[0, l - 1]}\)

那么有了通用答案数组,接下来就是统计答案。统计答案可以选择记忆化搜索,也可以选择循环迭代递推。为了不重不漏地统计所有不超过上限的答案,要从高到低枚举每一位,再考虑每一位都可以填哪些数字,最后利用通用答案数组统计答案。

接下来我们具体看几道题目。

例题一

例 1 Luogu P2602 数字计数

题目大意:给定两个正整数 \(a,b\),求在 \([a,b]\) 中的所有整数中,每个数码(digit)各出现了多少次。

方法一

解释

发现对于满 \(\mathit{i}\) 位的数,所有数字出现的次数都是相同的,故设数组 \(\mathit{dp}_i\) 为满 \(i\) 位的数中每个数字出现的次数,此时暂时不处理前导零。则有 \(\mathit{dp}_i=10 \times \mathit{dp}_{i−1}+10^{i−1}\),这两部分前一个是来自前 \(i-1\) 位数字的贡献,后一个是来自第 \(i\) 位的数字的贡献。

有了 \(\mathit{dp}\) 数组,我们来考虑如何统计答案。将上界按位分开,从高到低枚举,不贴着上界时,后面可以随便取值。贴着上界时,后面就只能取 \(0\) 到上界,分两部分分别计算贡献。最后考虑下前导零,第 \(i\) 位为前导 \(0\) 时,此时 \(1\)\(\mathit{i-1}\) 位也都是 \(0\),也就是多算了将 \(i-1\) 位填满的答案,需要额外减去。

实现

参考代码:

#include <bits/stdc++.h>
using namespace std;
const int N = 15;
typedef long long ll;
ll l, r, dp[N], mi[N];
ll ans1[N], ans2[N];
int a[N];

void solve(ll n, ll *ans) {
    ll tmp = n;
    int len = 0;
    while (n) a[++len] = n % 10, n /= 10;
    for (int i = len; i >= 1; --i) {
    for (int j = 0; j < 10; j++) ans[j] += dp[i - 1] * a[i];
    for (int j = 0; j < a[i]; j++) ans[j] += mi[i - 1];
    tmp -= mi[i - 1] * a[i], ans[a[i]] += tmp + 1;
    ans[0] -= mi[i - 1];
    }
}

int main() {
    scanf("%lld%lld", &l, &r);
    mi[0] = 1ll;
    for (int i = 1; i <= 13; ++i) {
    dp[i] = dp[i - 1] * 10 + mi[i - 1];
    mi[i] = 10ll * mi[i - 1];
    }
    solve(r, ans1), solve(l - 1, ans2);
    for (int i = 0; i < 10; ++i) printf("%lld ", ans1[i] - ans2[i]);
    return 0;
}

方法二

解释

此题也可以使用记忆化搜索。\(\mathit{dp}_i\) 表示不贴上限、无前导零时,位数为 \(i\) 的答案。

详见代码注释

过程

参考代码:

#include <cstdio>  //code by Alphnia
#include <cstring>
#include <iostream>
using namespace std;
#define N 50005
#define ll long long
ll a, b;
ll f[15], ksm[15], p[15], now[15];

ll dfs(int u, int x, bool f0,
        bool lim) {  // u 表示位数,f0 是否有前导零,lim 是否都贴在上限上
    if (!u) {
    if (f0) f0 = 0;
    return 0;
    }
    if (!lim && !f0 && (~f[u])) return f[u];
    ll cnt = 0;
    int lst = lim ? p[u] : 9;
    for (int i = 0; i <= lst; i++) {  // 枚举这位要填的数字
    if (f0 && i == 0)
        cnt += dfs(u - 1, x, 1, lim && i == lst);  // 处理前导零
    else if (i == x && lim && i == lst)
        cnt += now[u - 1] + 1 +
                dfs(u - 1, x, 0,
                    lim && i == lst);  // 此时枚举的前几位都贴在给定的上限上。
    else if (i == x)
        cnt += ksm[u - 1] + dfs(u - 1, x, 0, lim && i == lst);
    else
        cnt += dfs(u - 1, x, 0, lim && i == lst);
    }
    if ((!lim) && (!f0)) f[u] = cnt;  // 只有不贴着上限和没有前导零才能记忆
    return cnt;
}

ll gans(ll d, int dig) {
    int len = 0;
    memset(f, -1, sizeof(f));
    while (d) {
    p[++len] = d % 10;
    d /= 10;
    now[len] = now[len - 1] + p[len] * ksm[len - 1];
    }
    return dfs(len, dig, 1, 1);
}

int main() {
    scanf("%lld%lld", &a, &b);
    ksm[0] = 1;
    for (int i = 1; i <= 12; i++) ksm[i] = ksm[i - 1] * 10ll;
    for (int i = 0; i < 9; i++) printf("%lld ", gans(b, i) - gans(a - 1, i));
    printf("%lld\n", gans(b, 9) - gans(a - 1, 9));
    return 0;
}

例题二

例 2 HDU 2089 不要 62

题面大意:统计一个区间内数位上不能有 4 也不能有连续的 62 的数有多少。

解释

没有 4 的话在枚举的时候判断一下,不枚举 4 就可以保证状态合法了,所以这个约束没有记忆化的必要,而对于 62 的话,涉及到两位,当前一位是 6 或者不是 6 这两种不同情况计数是不相同的,所以要用状态来记录不同的方案数。\(\mathit{dp}_{\mathit{pos},\mathit{sta}}\) 表示当前第 \(\mathit{pos}\) 位,前一位是否是 6 的状态,这里 \(\mathit{sta}\) 只需要取 0 和 1 两种状态就可以了,不是 6 的情况可视为同种,不会影响计数。

实现

参考代码:

#include <cstdio>  //code by Alphnia
#include <cstring>
#include <iostream>
using namespace std;
int x, y, dp[15][3], p[50];

int pre() {
    memset(dp, 0, sizeof(dp));
    dp[0][0] = 1;
    for (int i = 1; i <= 10; i++) {
    dp[i][0] = dp[i - 1][0] * 9 - dp[i - 1][1];
    dp[i][1] = dp[i - 1][0];
    dp[i][2] = dp[i - 1][2] * 10 + dp[i - 1][1] + dp[i - 1][0];
    }
}

int cal(int x) {
    int cnt = 0, ans = 0, tmp = x;
    while (x) {
    p[++cnt] = x % 10;
    x /= 10;
    }
    bool flag = 0;
    p[cnt + 1] = 0;
    for (int i = cnt; i; i--) {  // 从高到低枚举数位
    ans += p[i] * dp[i - 1][2];
    if (flag)
        ans += p[i] * dp[i - 1][0];
    else {
        if (p[i] > 4) ans += dp[i - 1][0];
        if (p[i] > 6) ans += dp[i - 1][1];
        if (p[i] > 2 && p[i + 1] == 6) ans += dp[i][1];
        if (p[i] == 4 || (p[i] == 2 && p[i + 1] == 6)) flag = 1;
    }
    }
    return tmp - ans;
}

int main() {
    pre();
    while (~scanf("%d%d", &x, &y)) {
    if (!x && !y) break;
    x = min(x, y), y = max(x, y);
    printf("%d\n", cal(y + 1) - cal(x));
    }
    return 0;
}

例题三

例 3 SCOI2009 windy 数

题目大意:给定一个区间 \([l,r]\),求其中满足条件 不含前导 \(0\) 且相邻两个数字相差至少为 \(2\) 的数字个数。

解释

首先我们将问题转化成更加简单的形式。设 \(\mathit{ans}_i\) 表示在区间 \([1,i]\) 中满足条件的数的数量,那么所求的答案就是 \(\mathit{ans}_r-\mathit{ans}_{l-1}\)

对于一个小于 \(n\) 的数,它从高到低肯定出现某一位,使得这一位上的数值小于 \(n\) 这一位上对应的数值。而之前的所有位都和 \(n\) 上的位相等。

有了这个性质,我们可以定义 \(f(i,st,op)\) 表示当前将要考虑的是从高到低的第 \(i\) 位,当前该前缀的状态为 \(st\) 且前缀和当前求解的数字的大小关系是 \(op\)\(op=1\) 表示等于,\(op=0\) 表示小于)时的数字个数。在本题中,这个前缀的状态就是上一位的值,因为当前将要确定的位不能取哪些数只和上一位有关。在其他题目中,这个值可以是:前缀的数字和,前缀所有数字的 \(\gcd\),该前缀取模某个数的余数,也有两种或多种合用的情况。

写出 状态转移方程\(f(i,st,op)=\sum_{k=1}^{\mathit{maxx}} f(i+1,k,op=1~ \operatorname{and}~ k=\mathit{maxx} )\quad (|\mathit{st}-k|\ge 2)\)

这里的 \(k\) 就是当前枚举的下一位的值,而 \(\mathit{maxx}\) 就是当前能取到的最高位。因为如果 \(\mathit{op}=1\),那么你在这一位上取的值一定不能大于求解的数字上该位的值,否则则没有限制。

我们发现,尽管前缀所选择的状态不同,而 \(f\) 的三个参数相同,答案就是一样的。为了防止这个答案被计算多次,可以使用记忆化搜索的方式实现。

实现

参考代码:

int dfs(int x, int st, int op)  // op=1 =;op=0 <
{
    if (!x) return 1;
    if (!op && ~f[x][st]) return f[x][st];
    int maxx = op ? dim[x] : 9, ret = 0;
    for (int i = 0; i <= maxx; i++) {
    if (abs(st - i) < 2) continue;
    if (st == 11 && i == 0)
        ret += dfs(x - 1, 11, op & (i == maxx));
    else
        ret += dfs(x - 1, i, op & (i == maxx));
    }
    if (!op) f[x][st] = ret;
    return ret;
}

int solve(int x) {
    memset(f, -1, sizeof f);
    dim.clear();
    dim.push_back(-1);
    int t = x;
    while (x) {
    dim.push_back(x % 10);
    x /= 10;
    }
    return dfs(dim.size() - 1, 11, 1);
}

例题四

例 4.SPOJMYQ10

题面大意:假如手写下 \([n,m]\) 之间所有整数,会有多少数看起来和在镜子里看起来一模一样?(\(n,m<10^{44}, T<10^5\)

解释

注:由于这里考虑到的镜像,只有 \(0,1,8\) 的镜像是自己本身。所以,这里的「一模一样」并不是传统意义上的回文串,而是只含有 \(0,1,8\) 的回文串。

首先,在数位 \(\text{DP}\) 过程中,显然只有 \(0,1,8\) 能被选中。

其次,由于数值超过 long long 范围,所以 \([n,m]=[1,m]-[1,n-1]\) 不再适用(高精度比较繁琐),而是需要对 \(n\) 是否合法进行判断,得出:\([n,m]=[1,m]-[1,n]+\mathrm{check}(n)\)

镜像解决了,如何判断回文?

我们需要用一个小数组记录一下之前的值。在未超过一半的长度时,只要不超上限就行;在超过一半的长度时,还需要判断是否和与之「镜面对称」的位相等。

需要额外注意的是,这道题的记忆化部分,不能用 memset,否则会导致超时。

实现

参考代码:

int check(char cc[]) {  // n 的特判
    int strc = strlen(cc);
    for (int i = 0; i < strc; ++i) {
    if (!(cc[i] == cc[strc - i - 1] &&
            (cc[i] == '1' || cc[i] == '8' || cc[i] == '0')))
        return 0ll;
    }
    return 1ll;
}

// now: 当前位, eff: 有效位, fulc: 是否全顶格, ful0: 是否全0
int dfs(int now, int eff, bool ful0, bool fulc) {
    if (now == 0) return 1ll;
    if (!fulc && f[now][eff][ful0] != -1)  // 记忆化
    return f[now][eff][ful0];

    int res = 0, maxk = fulc ? dig[now] : 9;
    for (int i = 0; i <= maxk; ++i) {
    if (i != 0 && i != 1 && i != 8) continue;
    b[now] = i;
    if (ful0 && i == 0)  // 全前导 0
        res += dfs(now - 1, eff - 1, 1, 0);
    else if (now > eff / 2)                                  // 未过半程
        res += dfs(now - 1, eff, 0, fulc && (dig[now] == i));  // 已过半程
    else if (b[now] == b[eff - now + 1])
        res += dfs(now - 1, eff, 0, fulc && (dig[now] == i));
    }
    if (!fulc) f[now][eff][ful0] = res;
    return res;
}

char cc1[100], cc2[100];
int strc, ansm, ansn;

int get(char cc[]) {  // 处理封装
    strc = strlen(cc);
    for (int i = 0; i < strc; ++i) dig[strc - i] = cc[i] - '0';
    return dfs(strc, strc, 1, 1);
}

scanf("%s%s", cc1, cc2);
printf("%lld\n", get(cc2) - get(cc1) + check(cc1));

例题五

例 5.P3311 数数

题面:我们称一个正整数 \(x\) 是幸运数,当且仅当它的十进制表示中不包含数字串集合 \(S\) 中任意一个元素作为其子串。例如当 \(S = \{22, 333, 0233\}\) 时,\(233233\) 是幸运数,\(23332333\)\(2023320233\)\(32233223\) 不是幸运数。给定 \(n\)\(S\),计算不大于 \(n\) 的幸运数个数。答案对 \(10^9 + 7\) 取模。

\(1 \leq n<10^{1201},1 \leq m \leq 100,1 \leq \sum_{i = 1}^m |s_i| \leq 1500,\min_{i = 1}^m |s_i| \geq 1\),其中 \(|s_i|\) 表示字符串 \(s_i\) 的长度。\(n\) 没有前导 \(0\),但是 \(s_i\) 可能有前导 \(0\)

解释

阅读题面发现,如果将数字看成字符串,那么这就是需要完成一个多模匹配,自然而然就想到 \(\text{AC}\) 自动机。普通数位 \(\text{DP}\) 中,先从高到低枚举数位,再枚举每一位都填什么,在这道题中,我们也就自然地转化为枚举已经填好的位数,再枚举此时停在 \(\text{AC}\) 自动机上的哪个节点,然后从当前节点转移到它在 \(\text{AC}\) 自动机上的子节点。

\(f(i,j,0/1)\) 表示当前从高到低已经填了 \(i\) 位(即在 \(\text{AC}\) 自动机上走过了 \(i\) 条边),此时停在标号为 \(j\) 的节点上,当前是否正好贴着上界。

至于题目中的「不包含」条件,只需在 \(\text{AC}\) 自动机上给每个模式串的结尾节点都打上标记,\(\text{DP}\) 过程中一旦遇上这些结尾节点就跳过即可。

转移很好想,详见代码主函数部分。

实现

参考代码:

#include <bits/stdc++.h>  //code by Alphnia
using namespace std;
#define N 1505
#define ll long long
#define mod 1000000007
int n, m;
char s[N], c[N];
int ch[N][10], fail[N], ed[N], tot, len;

void insert() {
    int now = 0;
    int L = strlen(s);
    for (int i = 0; i < L; ++i) {
    if (!ch[now][s[i] - '0']) ch[now][s[i] - '0'] = ++tot;
    now = ch[now][s[i] - '0'];
    }
    ed[now] = 1;
}

queue<int> q;

void build() {
    for (int i = 0; i < 10; ++i)
    if (ch[0][i]) q.push(ch[0][i]);
    while (!q.empty()) {
    int u = q.front();
    q.pop();
    for (int i = 0; i < 10; ++i) {
        if (ch[u][i]) {
        fail[ch[u][i]] = ch[fail[u]][i], q.push(ch[u][i]),
        ed[ch[u][i]] |= ed[fail[ch[u][i]]];
        } else
        ch[u][i] = ch[fail[u]][i];
    }
    }
    ch[0][0] = 0;
}

ll f[N][N][2], ans;

void add(ll &x, ll y) { x = (x + y) % mod; }

int main() {
    scanf("%s", c);
    n = strlen(c);
    scanf("%d", &m);
    for (int i = 1; i <= m; ++i) scanf("%s", s), insert();
    build();
    f[0][0][1] = 1;
    for (int i = 0; i < n; ++i) {
    for (int j = 0; j <= tot; ++j) {
        if (ed[j]) continue;
        for (int k = 0; k < 10; ++k) {
        if (ed[ch[j][k]]) continue;
        add(f[i + 1][ch[j][k]][0], f[i][j][0]);
        if (k < c[i] - '0') add(f[i + 1][ch[j][k]][0], f[i][j][1]);
        if (k == c[i] - '0') add(f[i + 1][ch[j][k]][1], f[i][j][1]);
        }
    }
    }
    for (int j = 0; j <= tot; ++j) {
    if (ed[j]) continue;
    add(ans, f[n][j][0]);
    add(ans, f[n][j][1]);
    }
    printf("%lld\n", ans - 1);
    return 0;
}

此题可以很好地帮助理解数位 \(\text{DP}\) 的原理。

习题

Ahoi2009 self 同类分布

洛谷 P3413 SAC#1 - 萌数

HDU 6148 Valley Number

CF55D Beautiful numbers

CF628D Magic Numbers

状压 DP

定义

状压 \(\text{DP}\) 是动态规划的一种,通过将状态压缩为整数来达到优化转移的目的。

例题 1

「SCOI2005」互不侵犯

\(N\times N\) 的棋盘里面放 \(K\) 个国王(\(1 \leq N \leq 9, 1 \leq K \leq N \times N\)),使他们互不攻击,共有多少种摆放方案。

国王能攻击到它上下左右,以及左上左下右上右下八个方向上附近的各一个格子,共 \(8\) 个格子。

解释

\(f(i,j,l)\) 表示前 \(i\) 行,第 \(i\) 行的状态为 \(j\),且棋盘上已经放置 \(l\) 个国王时的合法方案数。

对于编号为 \(j\) 的状态,我们用二进制整数 \(sit(j)\) 表示国王的放置情况,\(sit(j)\) 的某个二进制位为 \(0\) 表示对应位置不放国王,为 \(1\) 表示在对应位置上放置国王;用 \(sta(j)\) 表示该状态的国王个数,即二进制数 \(sit(j)\)\(1\) 的个数。例如,如下图所示的状态可用二进制数 \(100101\) 来表示(棋盘左边对应二进制低位),则有 \(sit(j)=100101_{(2)}=37, sta(j)=3\)

设当前行的状态为 \(j\),上一行的状态为 \(x\),可以得到下面的状态转移方程:\(f(i,j,l) = \sum f(i-1,x,l-sta(j))\)

设上一行的状态编号为 \(x\),在保证当前行和上一行不冲突的前提下,枚举所有可能的 \(x\) 进行转移,转移方程:

\[f(i,j,l) = \sum f(i-1,x,l-sta(j)) \]

实现

参考代码:

#include <algorithm>
#include <iostream>
using namespace std;
long long sta[2005], sit[2005], f[15][2005][105];
int n, k, cnt;

void dfs(int x, int num, int cur) {
  if (cur >= n) {  // 有新的合法状态
    sit[++cnt] = x;
    sta[cnt] = num;
    return;
  }
  dfs(x, num, cur + 1);  // cur位置不放国王
  dfs(x + (1 << cur), num + 1,
      cur + 2);  // cur位置放国王,与它相邻的位置不能再放国王
}

bool compatible(int j, int x) {
  if (sit[j] & sit[x]) return false;
  if ((sit[j] << 1) & sit[x]) return false;
  if (sit[j] & (sit[x] << 1)) return false;
  return true;
}

int main() {
  cin >> n >> k;
  dfs(0, 0, 0);  // 先预处理一行的所有合法状态
  for (int j = 1; j <= cnt; j++) f[1][j][sta[j]] = 1;
  for (int i = 2; i <= n; i++)
    for (int j = 1; j <= cnt; j++)
      for (int x = 1; x <= cnt; x++) {
        if (!compatible(j, x)) continue;  // 排除不合法转移
        for (int l = sta[j]; l <= k; l++) f[i][j][l] += f[i - 1][x][l - sta[j]];
      }
  long long ans = 0;
  for (int i = 1; i <= cnt; i++) ans += f[n][i][k];  // 累加答案
  cout << ans << endl;
  return 0;
}

例题 2

[POI2004] PRZ

\(n\) 个人需要过桥,第 \(i\) 的人的重量为 \(w_i\),过桥用时为 \(t_i\). 这些人过桥时会分成若干组,只有在某一组的所有人全部过桥后,其余的组才能过桥。桥最大承重为 \(W\),问这些人全部过桥的最短时间。

\(100\le W \le 400\)\(1\le n\le 16\)\(1\le t_i\le 50\)\(10\le w_i\le 100\).

解释

我们用 \(S\) 表示所有人构成集合的一个子集,设 \(t(S)\) 表示 \(S\) 中人的最长过桥时间,\(w(S)\) 表示 \(S\) 中所有人的总重量,\(f(S)\) 表示 \(S\) 中所有人全部过桥的最短时间,则:

\[\begin{cases} f(\varnothing)=0,\\ f(S)=\min\limits_{T\subseteq S;~w(T)\leq W}\left\{t(T)+f(S\setminus T)\right\}. \end{cases} \]

需要注意的是这里不能直接枚举集合再判断是否为子集,而应使用子集枚举,从而使时间复杂度为 \(O(3^n)\).

实现

参考代码:

#include <bits/stdc++.h>
using namespace std;

int main() {
  ios::sync_with_stdio(false);
  cin.tie(nullptr);
  int W, n;
  cin >> W >> n;
  const int S = (1 << n) - 1;
  vector<int> ts(S + 1), ws(S + 1);
  for (int j = 0, t, w; j < n; ++j) {
    cin >> t >> w;
    for (int i = 0; i <= S; ++i)
      if (i & (1 << j)) {
        ts[i] = max(ts[i], t);
        ws[i] += w;
      }
  }
  vector<int> dp(S + 1, numeric_limits<int>::max() / 2);
  for (int i = 0; i <= S; ++i) {
    if (ws[i] <= W) dp[i] = ts[i];
    for (int j = i; j; j = i & (j - 1))
      if (ws[i ^ j] <= W) dp[i] = min(dp[i], dp[j] + ts[i ^ j]);
  }
  cout << dp[S] << '\n';
  return 0;
}

习题

posted @ 2024-07-22 08:19  So_noSlack  阅读(29)  评论(0编辑  收藏  举报