数位 DP

数位 DP 在我的理解中和普通 DP 并没有什么区别,由于自身的特性还可以很好的使用搜索处理,但是需要注意需要某些剪枝才能保证正确性。

难点一在于数位 DP 的理解,有些剪枝不仅仅是剪枝,确保的还是算法正确性。

难点二在于边界处理,这也与数位 DP 的理解有关。

DP 似乎主要靠直接写题,不需要非常多的前置知识。

一 数位间可转移,字典序 \(\geq n\) 且最小的 \(x\) 。位数为 \(l \geq 10^{5}\)

方案: dfs

例题 1.1 :https://www.luogu.com.cn/problem/CF1560F2

题意:

给一个 \(n\) ,找到 \(x(x \geq n)\)\(x\)\(k\) 美丽的。若 \(x\)\(k\) 美丽的,\(x\) 中的数位不会超过 \(k\) 种。

\(1 \leq n \leq 10^{9}, 1 \leq k \leq 10\)

题解:
\(n\) 的位数为 \(len\)
首先想到 \(x\) 一定也是 \(len\) 位的,因为答案最坏是 \(999\cdots9\)\(len\) 位)。

考虑 \(dfs\) 的状态,前两个状态是固定的。搜到了第几位?前缀有没有大于下界的前缀?
容易想到只需要再维护一个状态,即当前搜索到的数位总共有 \(cnt\) 种。
路径答案如果不方便维护,可以选择记录在 \(dfs\) 参数上,只会增加空间开销。这里方便维护,不妨直接维护。
只需要搜 \(len\) 位即可。
dfs 的第一个一定是字典序最小,但是除了特定算法比如欧拉路,dfs 不要考虑维护回溯后的答案。bool dfs 是典型的处理。

view
std::vector<int> d;
while (n) {
  d.push_back(n % 10);
  n /= 10;
}
std::reverse(d.begin(), d.end());
ll ans = 0;
int vis[10] = {0};
std::function<int(int, int, int)> dfs = [&](int x, int larger, int cnt) {
    if (x == d.size()) {
        return true;
    }
    for (int i = larger ? 0 : d[x]; i <= 9; i++) {
        vis[i] += 1;
        // 是剪枝,也是正确性
        ans = ans * 10 + i;
        if (cnt + (vis[i] == 1) <= k && dfs(x + 1, larger | (i > d[x]), cnt + (vis[i] == 1)))
            return true;
        ans /= 10;
        vis[i] -= 1;
    }
    return false;
};
dfs(0, 0, 0);

边界维护:

由于是找 \(\geq n\) 的字典序最小,只有下界。考虑 \(larger = 0\) 时从 \(d_x\) 枚举,否则从 \(0\) 枚举即可。
显然首位一般不会出现 \(larger = 1\) 的情况。

算法正确性与复杂度分析:

  1. 假设 \(larger = 1\) ,不妨假设前缀是 \(123\),那么此时的构造就是 \(123???\cdots\)\(3\) 往后的构造可以任选,搜下去一定存在答案。
  2. 假设 \(larger = 0\) ,不妨假设前缀是 \(d_0d_1d_2\) ,此时 \(d_2\) 往后的构造不能任选,搜下去不一定存在答案。

时间:

  1. \(2\) ,只有 \(larger = 0\) 才有可能会回溯。由 \(1\) ,最多只会回溯 \(1\) 次,可以使后面的数可以被任意构造。
  2. 如果不回溯,时间复杂度等于树的高度。回溯如果只有常数次,都可以认为时间复杂度就是 \(O(n)\)

正确性:同时可以观察 cnt + (vis[i] == 1) <= k ,似乎是个剪枝。实际上如果不加上去,不会影响时间复杂度,但由 \(1\) ,搜下去的不是答案。

例题 1.2:例题 1.1 的变种

题意:

给一个 \(n\) ,找到 \(x(x \geq n)\)\(x\)\(k\) 美丽的。若 \(x\)\(k\) 美丽的,\(x\) 中的数位至少 \(k\) 种。

\(1 \leq n \leq 10^{9}, 1 \leq k \leq 10\)

题解:

\(n\) 的位数为 \(len\)
可以想到 \(x\) 的位数应该为 \([len, max(len + 1, k)]\) ,且 \(k \leq 10\)

考虑 \(dfs\) 的状态。必须有:搜到了第几位?搜到的前缀是否大于下界的前缀?
显然也只需要多一个状态,当前数位的种类有 \(cnt\)

view
std::vector<int> d;
while (n) {
    d.push_back(n % 10);
    n /= 10;
}
std::reverse(d.begin(), d.end());
ll ans = 0;
int vis[10] = {0};
std::function<int(int, int, int)> dfs = [&](int x, int larger, int cnt) {
    if (x == d.size()) {
        return true;
    }
    for (int i = larger ? 0 : d[x]; i <= 9; i++) {
        vis[i] += 1;
        ans = ans * 10 + i;
        if (cnt + (vis[i] == 1) + d.size() - (x + 1) >= k && dfs(x + 1, larger | (i > d[x]), cnt + (vis[i] == 1)))
            return true;
        ans /= 10;
        vis[i] -= 1;
    }
    return false;
};
int m = d.size();
while (!dfs(0, 0, 0)) {
    d.resize((int)d.size() + 1);
    d.assign((int)d.size(), 0);
    d[0] = 1;
    assert((int)d.size() <= (int)std::max(k, m + 1));
}

可以看出 dfs 的变动只有 cnt + (vis[i]) <= k 变为了 cnt + (vis[i] == 1) + d.szie() - (x + 1) >= k 。
由于位数可能会更多(但只会多常数次)。原本的直接 dfs 变成了 while(dfs) 。

例题 1.3 https://atcoder.jp/contests/abc042/tasks/arc058_a

题意:

给一个数 \(n\)\(k\) 个数 \(a_1 a_2 a_3 \cdots a_k\) ,找到最小的 \(x\) 满足 \(x \geq n\)\(x\) 每个数位都与 \(\{a_1 a_2 a_3 \cdots a_k\}\) 无交。保证 \(\{012\cdots9\} \backslash \{a_1 a_2 a_3 \cdots a_k\} \neq \{0\}\)

\(1 \leq n \leq 10^{4}, \leq k \leq 9\)
显然 \(x\) 要么 \(4\) 位,要么 \(5\) 位。直接在 \(10^{5}\) 范围暴力加上常数的 \(check\) 可以过题。

但是若 \(1 \leq n \leq 10^{10^{5}}\) 怎么处理?

考虑数位 DP 。dfs 的初始状态为:搜了 x 位。前缀比 \(n\) 大?
甚至不需要增加状态。只用把不合法的数位跳过即可。

view
std::string str; std::cin >> str;
int n =str.size();
std::vector<int> d(n);
for (int i = 0; i < n; i++) {
  d[i] = str[i] - '0';
}
int k; std::cin >> k;
int v[10] = {0};
for (int i = 1; i <= k; i++) {
  int x; std::cin >> x;
  v[x] += 1;
}
std::set<int> legal;
for (int i = 0; i < 10; i++) if (!v[i]) legal.insert(i);
std::string ans = "";
std::function<bool(int, int)> dfs = [&] (int x, int larger) {
  if (x == d.size()) {
      return true;
  }
  for (int i = larger ? 0 : d[x]; i <= 9; i++) if (legal.count(i)) {
      ans.push_back((char)(i + '0'));
      if (dfs(x + 1, larger |= i > d[x]))
          return true;
      ans.pop_back();
  }
  return false;
};
int m = d.size();
while (!dfs(0, 0)) {
  d.resize((int)d.size() + 1);
  d.assign((int)d.size(), 0);
  d[0] = 1;
  assert((int)d.size() <= (int)std::max(m + 1, 10));
}
std::cout << ans << "\n";

二 数位间不可转移,但能按数位分类

方案: 按数位分类,并构造。

2.1 例题

询问 \(0,1,2,\cdots,9\) 这些数字在 \([l, r] (1 \leq l \leq r \leq 10^{18})\) 中分别出现了多少次。

思路

区间 \([l, r]\) 的答案等于区间 \([1, r]\) 的答案 - 区间 \([1, l - 1]\) 的答案。
于是问题转化为,统计 \(0,1,2,\cdots,9\) 这些数字在区间 \([1, N]\) 中分别出现了多少次。

首先将 \(n\) 按前 \(i - 1\) 个数位相同,第 \(i\) 个数位不同分类。最终能拆出 \(\log N\) 个分类区间。

分类的好处是方便在相同数位下处理。

例如 \(N = 12345\) ,则可以拆成如下区间:

\[\begin{aligned} 1 \leq x &\leq 9999, \textbf{前 0 个数位相同,第 1 个数位不同} \\ 10000 \leq x &\leq 11999, \textbf{前 1 个数位相同,第 2 个数位不同} \\ 12000 \leq x &\leq 12299, \textbf{前 2 个数位相同,第 3 个数位不同} \\ 12300 \leq x &\leq 12339, \textbf{前 3 个数位相同,第 4 个数位不同} \\ 12340 \leq x &\leq 12344, \textbf{前 4 个数位相同,第 5 个数位不同} \\ x &= 12345, \textbf{5 个数位都相同} \\ \end{aligned} \]

更一般的,若 \(N = \overline{a_1 a_2 a_3 \cdots a_m}\) ,则 \(N\) 的分类为:

\[\begin{aligned} \underbrace{\overline{0\ 0\ 0\ 0\ \cdots \ 0 \ 1}}_{m \textbf{个}} \leq x &\leq \underbrace{\overline{0\ 9\ 9\ 9\ \cdots \ 9}}_{m \textbf{个}} \\ \overline{a_{1}\ 0\ 0\ 0\ \cdots \ 0} \leq x &\leq \overline{a_{1}\ a_{2} - 1\ 9\ 9\ \cdots \ 9} \\ \overline{a_{1}\ a_{2}\ 0\ 0\ \cdots \ 0} \leq x &\leq \overline{a_{1}\ a_{2}\ a_{3} - 1\ 9\ \cdots \ 9} \\ \overline{a_{1}\ a_{2}\ a_{3}\ 0\ \cdots \ 0} \leq x &\leq \overline{a_{1}\ a_{2}\ a_{3}\ a_{4} - 1\ \cdots \ 9} \\ \cdots \\ \overline{a_{1}\ a_{2}\ a_{3}\ a_{4}\ \cdots \ 0} \leq x &\leq \overline{a_{1}\ a_{2}\ a_{3}\ a_{4}\ \cdots \ a_{5} - 1} \\ x &= \overline{a_{1}\ a_{2}\ a_{3}\ a_{4}\ \cdots \ a_{5}} \\ \end{aligned} \]

我们对 \(\log N\) 个区间类分别计算答案,最后的答案是 \(\log N\) 个类的答案合并。

这里显然是这 \(\log N\) 个类的答案和。

而对于某一类,我们考虑 \(0, 1, 2, \cdots, 9\) 中所有数字可以放到对应位置的情况。

一:考虑第 \(k \ (1 < k < m)\)\([\overline{a_{1}\ a_{2}\ \cdots \ a_{k - 1} \ 0 \cdots \ 0}, \overline{a_{1}\ a_{2}\ \cdots a_{k - 1} \ a_{k} - 1 \cdots \ 9}]\) 的情况。

  • \(i - 1\) 位是固定的 \(a_1, a_2, \cdots, a_{i - 1}\) 。对每个 \(j \ (j \leq i - 1)\) ,固定要选 \(a_j\) 。剩下的可选位只有从 \(i\) 位开始往后的位置。第 \(i\) 位有 \(0, 1, 2, \cdots, a_{i} - 1\)\(a_i\) 种选择,后 \(m - i\) 位每位都有 \(10\) 种选择。则 \(a_j \ (j \leq i - 1)\) 的方案数是 \(a_{i} \times 10^{m - i}\)

  • \(i\) 位可选 \(0, 1, 2, \cdots, a_i - 1\)\(a_i\) 种,我们枚举这个数 \(x\) ,前 \(i - 1\) 位固定,后 \(m - 1\) 位可以任选,于是 \(x\) 的贡献是 \(10^{m - i}\)

  • \(i + 1\) 位往右的数,可以是 \(0,1,2, \cdots, 9\) 的任意数,我们枚举这个数 \(x\) ,在后 \(m - i\) 个位置种选择一个位置放入 \(x\) 。第 \(i\) 位有 \(a_i\) 种选择,其他位可以任选。于是 \(x\) 的贡献是 \(\binom{m - i}{1} a_{i} \times 10^{m - i - 1} = (m - i) \times a_{i} \times 10^{m - i - 1}\)

最终一类中存在 \(x\) 的方案数,是这一类中每一位为 \(x\) 的方案数之积。

二:最后考虑第 \(1\) 类的情况,可以发现前导 0 是无效位。即第 \(1\) 类的\(1, 2, \cdots, 9\) 的贡献同其他类一样计算, \(0\) 的贡献要单独计算。
计算方式是:枚举第一个非零位的位置。

  • 如果第一个非零位是第 \(1\) 位,这一位有 \(a_{1} - 1\) 种选择, 剩下 \(m - 1\) 位可以任选。这 \(m - 1\) 位存在 \(0\) 的方案数,即存在一个 \(0\) 的方案数,答案为 \((a_{1} - 1) \binom{m - 1}{1} 10^{m - 1 - 1} = (a_{1} - 1) \times (m - 1) \times 10^{m - 2}\)
  • 如果第一个非零位是第 \(i (i \geq 2)\) 位,这一位有 \(9\) 种选择(\(0\) 以外的 \(9\) 个数),剩下 \(m - i\) 位可以任选。这 \(m - i\) 位存在 \(0\) 的方案数是 \(9 \binom{m - i}{1} 10^{m - i - 1} = 9 \times (m - i) \times 10^{m - i - 1}\)

三:\(m\) 类是只有 \(N\) 一个数,可以直接用 \(O(\log N)\) 的时间复杂度暴力计算。

三 数位间不可转移,但依赖数位计数

方案: 按数位分类,并计数

四 数位间可转移,方案数问题

方案: 数位 DP \(cal(N)\)\(cal(R) - cal(L - 1)\)

五 数位间可转移,非方案数问题

似乎没见过,实际出现了也很难做,可以考虑不进行 DP 。

posted @ 2024-04-26 11:56  zsxuan  阅读(4)  评论(0编辑  收藏  举报