数位 DP

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

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

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

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

一 数位间可转移,字典序 n 且最小的 x 。位数为 l105

方案: dfs

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

题意:

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

1n109,1k10

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

考虑 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);

边界维护:

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

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

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

时间:

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

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

例题 1.2:例题 1.1 的变种

题意:

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

1n109,1k10

题解:

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

考虑 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

题意:

给一个数 nk 个数 a1a2a3ak ,找到最小的 x 满足 xnx 每个数位都与 {a1a2a3ak} 无交。保证 {0129}{a1a2a3ak}{0}

1n104,k9
显然 x 要么 4 位,要么 5 位。直接在 105 范围暴力加上常数的 check 可以过题。

但是若 1n10105 怎么处理?

考虑数位 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,,9 这些数字在 [l,r](1lr1018) 中分别出现了多少次。

思路

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

首先将 n 按前 i1 个数位相同,第 i 个数位不同分类。最终能拆出 logN 个分类区间。

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

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

1x9999,前 0 个数位相同,第 1 个数位不同10000x11999,前 1 个数位相同,第 2 个数位不同12000x12299,前 2 个数位相同,第 3 个数位不同12300x12339,前 3 个数位相同,第 4 个数位不同12340x12344,前 4 个数位相同,第 5 个数位不同x=12345,5 个数位都相同

更一般的,若 N=a1a2a3am ,则 N 的分类为:

0 0 0 0  0 1mx0 9 9 9  9ma1 0 0 0  0xa1 a21 9 9  9a1 a2 0 0  0xa1 a2 a31 9  9a1 a2 a3 0  0xa1 a2 a3 a41  9a1 a2 a3 a4  0xa1 a2 a3 a4  a51x=a1 a2 a3 a4  a5

我们对 logN 个区间类分别计算答案,最后的答案是 logN 个类的答案合并。

这里显然是这 logN 个类的答案和。

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

一:考虑第 k (1<k<m)[a1 a2  ak1 0 0,a1 a2 ak1 ak1 9] 的情况。

  • i1 位是固定的 a1,a2,,ai1 。对每个 j (ji1) ,固定要选 aj 。剩下的可选位只有从 i 位开始往后的位置。第 i 位有 0,1,2,,ai1ai 种选择,后 mi 位每位都有 10 种选择。则 aj (ji1) 的方案数是 ai×10mi

  • i 位可选 0,1,2,,ai1ai 种,我们枚举这个数 x ,前 i1 位固定,后 m1 位可以任选,于是 x 的贡献是 10mi

  • i+1 位往右的数,可以是 0,1,2,,9 的任意数,我们枚举这个数 x ,在后 mi 个位置种选择一个位置放入 x 。第 i 位有 ai 种选择,其他位可以任选。于是 x 的贡献是 (mi1)ai×10mi1=(mi)×ai×10mi1

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

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

  • 如果第一个非零位是第 1 位,这一位有 a11 种选择, 剩下 m1 位可以任选。这 m1 位存在 0 的方案数,即存在一个 0 的方案数,答案为 (a11)(m11)10m11=(a11)×(m1)×10m2
  • 如果第一个非零位是第 i(i2) 位,这一位有 9 种选择(0 以外的 9 个数),剩下 mi 位可以任选。这 mi 位存在 0 的方案数是 9(mi1)10mi1=9×(mi)×10mi1

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

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

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

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

方案: 数位 DP cal(N)cal(R)cal(L1)

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

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

posted @   zsxuan  阅读(7)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 分享一个免费、快速、无限量使用的满血 DeepSeek R1 模型,支持深度思考和联网搜索!
· 使用C#创建一个MCP客户端
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· ollama系列1:轻松3步本地部署deepseek,普通电脑可用
· 按钮权限的设计及实现
点击右上角即可分享
微信分享提示