关于数位DP,首先应该知道它的 1.基层原理 是什么,它可以2.解决哪些问题
基本原理:
首先用一个例子来说明数位DP。如果我们从 7000 数到 7999、从 8000 数到 8999、和从 9000 数到 9999 的过程非常相似,它们都是后三位从 000 变到 999,不一样的地方只有千位这一位,所以我们可以把这些过程归并起来,将这些过程中产生的计数答案(计数答案根据题目要求来决定)也都存在一个通用的数组里。此数组根据题目具体要求设置状态,用递推或 递归(相较于递推,递归的方式解决数位DP会更通用) 的方式进行状态转移。
数位 DP 中通常会利用常规计数问题技巧:
1.比如把一个区间内的答案拆成两部分相减,例如要求从l到r范围内某些数的个数,可以求ans[0,r]-ans[0,l]。如果输入l和r用的数字型,可以求ans[0,l-1]然后做差;如果用字符串输入,可以求ans[0,r]-ans[0,l],最后对l做特判(因为字符串减1不好操作)
2.那么有了通用答案数组,接下来就是统计答案。统计答案可以选择记忆化搜索(关于记忆化搜索的数组,根据哪些是决定性变量来设。不仅是数位DP,对于其他的DP问题,在设置DP数组时,要根据决定性变量来设置),也可以选择循环迭代递推。
3.为了不重不漏地统计所有不超过上限的答案,要从高到低枚举每一位,再考虑每一位都可以填哪些数字,最后利用通用答案数组统计答案。
数位DP的通用模板:
leetcode 最大为N的数字组合:
给定一个按 非递减顺序 排列的数字数组 digits
。你可以用任意次数 digits[i]
来写的数字。例如,如果 digits = ['1','3','5']
,我们可以写数字,如 '13'
, '551'
, 和 '1351315'
。
返回 可以生成的小于或等于给定整数 n
的正整数的个数 。
递归的含义是求不超过n的组合数,递归是通过枚举每位数字直到最后枚举的完所有的位数来确定答案
class Solution {
public:
int atMostNGivenDigitSet(vector<string> &digits, int n) {
auto s = to_string(n);
int m = s.length(), dp[m]//缓存表来记录,防止重复的状态再次计算;
memset(dp, -1, sizeof(dp)); // dp[i] = -1 表示 i 这个状态还没被计算出来
function<int(int, bool, bool)> f = [&](int i, bool is_limit/*前面填的数字是否收到限制*/, bool is_num/*前面是否有数字*/) -> int {
if (i == m) return is_num; // 如果填了数字,则为 1 种合法方案
if (!is_limit && is_num && dp[i] >= 0) return dp[i]; // 在不受到任何约束的情况下,返回记录的结果,避免重复运算(关于为什么只有i一个参数,是因为这些字符可以随意选,无需考虑哪些选过)
int res = 0;
if (!is_num) // 前面不填数字,那么可以跳过当前数位,也不填数字
// is_limit 改为 false,因为没有填数字,位数都比 n 要短,自然不会受到 n 的约束
// is_num 仍然为 false,因为没有填任何数字
res = f(i + 1, false, false);
char up = is_limit ? s[i] : '9'; // 根据是否受到约束,决定可以填的数字的上限
// 注意:对于一般的题目而言,如果这里 is_num 为 false,则必须从 1 开始枚举,由于本题 digits 没有 0,所以无需处理这种情况
for (auto &d : digits) { // 枚举要填入的数字 d
if (d[0] > up) break; // d 超过上限,由于 digits 是有序的,后面的 d 都会超过上限,故退出循环
// is_limit:如果当前受到 n 的约束,且填的数字等于上限,那么后面仍然会受到 n 的约束
// is_num 为 true,因为填了数字
res += f(i + 1, is_limit && d[0] == up, true);
}
if (!is_limit && is_num) dp[i] = res; // 在不受到任何约束的情况下,记录结果
return res;
};
return f(0, true, false);
}
};
递推的写法:
class Solution {
public:
int atMostNGivenDigitSet(vector<string>& digits, int n) {
string s = to_string(n);
int m = digits.size(), k = s.size();
vector<vector<int>> dp(k + 1, vector<int>(2));
dp[0][1] = 1;//dp[i][1]表示前i位(从高位开始)有数字并和n相等(就是is_limit)
//dp[i][0]是前面有数字但不受限(!is_limit&&is_num)
for (int i = 1; i <= k; i++) {
for (int j = 0; j < m; j++) {
if (digits[j][0] == s[i - 1]) {
dp[i][1] = dp[i - 1][1];
} else if (digits[j][0] < s[i - 1]) {
dp[i][0] += dp[i - 1][1];
} else {
break;
}
}
if (i > 1) {
dp[i][0] += m + dp[i - 1][0] * m;
}
}
return dp[k][0] + dp[k][1];
}
};
leetcode统计特殊整数
如果一个正整数每一个数位都是 互不相同 的,我们称它是 特殊整数 。
给你一个 正 整数 n
,请你返回区间 [1, n]
之间特殊整数的数目。
class Solution {
public:
int countSpecialNumbers(int n) {
auto s = to_string(n);
int m = s.length(), memo[m][1 << 10];
memset(memo, -1, sizeof(memo)); // -1 表示没有计算过
//此题中数位是关键参数
function<int(int, int, bool, bool)> f = [&](int i, int mask, bool is_limit, bool is_num) -> int {
if (i == m)
return is_num; // is_num 为 true 表示得到了一个合法数字
if (!is_limit && is_num && memo[i][mask] != -1)
return memo[i][mask];
int res = 0;
if (!is_num) // 可以跳过当前数位
res = f(i + 1, mask, false, false);
int up = is_limit ? s[i] - '0' : 9; // 如果前面填的数字都和 n 的一样,那么这一位至多填数字 s[i](否则就超过 n 啦)
for (int d = 1 - is_num; d <= up; ++d) // 枚举要填入的数字 d
if ((mask >> d & 1) == 0) // d 不在 mask 中
res += f(i + 1, mask | (1 << d), is_limit && d == up, true);
if (!is_limit && is_num)
memo[i][mask] = res;
return res;
};
return f(0, 0, true, false);
}
注:集合和二进制是可以相互表示的。设置整数mask,mask|(1<<d(0~9))就是在mask的二进制的第d位(从0位开始)由零变为1,相当于记录d这个数字已经用过了。mask>>d&1表示查看d这个数字有没有被用过。mask&(~(1<<d))把d变为0。mask^(1<<d)将指定位取反。关于二进制用集合来表示,可以具体看视频二进制和集合
leetcode统计整数数目统计整数数目
给你两个数字字符串 num1
和 num2
,以及两个整数 max_sum
和 min_sum
。如果一个整数 x
满足以下条件,我们称它是一个好整数:
num1 <= x <= num2
min_sum <= digit_sum(x) <= max_sum
.
请你返回好整数的数目。答案可能很大,请返回答案对 109 + 7
取余后的结果。
注意,digit_sum(x)
表示 x
各位数字之和。
class Solution {
const int MOD = 1'000'000'007;
int calc(string &s, int min_sum, int max_sum) {
int n = s.length();
vector<vector<int>> memo(n, vector<int>(min(9 * n, max_sum) + 1, -1));
//在此题中,位数和sum是决定性参数,所以记忆化时是以这两个参数设的数组
function<int(int, int, bool)> dfs = [&](int i, int sum, bool is_limit) -> int {
if (sum > max_sum) { // 非法
return 0;
}
if (i == n) {
return sum >= min_sum ? 1 : 0;
}
if (!is_limit && memo[i][sum] != -1) {
return memo[i][sum];
}
int up = is_limit ? s[i] - '0' : 9;
int res = 0;
for (int d = 0; d <= up; d++) { // 枚举当前数位填 d
res = (res + dfs(i + 1, sum + d, is_limit && d == up)) % MOD;
}
if (!is_limit) {
memo[i][sum] = res;
}
return res;
};
return dfs(0, 0, true);
}
public:
int count(string num1, string num2, int min_sum, int max_sum) {
int ans = calc(num2, min_sum, max_sum) - calc(num1, min_sum, max_sum) + MOD; // 避免负数
int sum = 0;
for (char c : num1) {
sum += c - '0';
}
ans += min_sum <= sum && sum <= max_sum; // num1 是合法的,补回来
return ans % MOD;
}
};
洛谷 数字计数数字计数
给定两个正整数 a 和 b,求在 [a,b] 中的所有整数中,每个数码(digit)各出现了多少次。
//这个题是我认为最难的一道数位DP
递推
#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) {//统计0~n各数字的个数
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];//后i-1位数码的和
for (int j = 0; j < a[i]; j++) ans[j] += mi[i - 1];//i位上某些数字的数码个数
//上面两个for循环只是求了部分数码,例如求4365的数码,上述操作只是求了0~3999的数码个数,还有
//4000~4365的数码
tmp -= mi[i - 1] * a[i];
ans[a[i]] += tmp + 1;//记录最高位的数码个数,如上例就是记录了4的数码个数
ans[0] -= mi[i - 1];//0不能做最高位,要减去
//下层for循环就是处理上例的365这个数字
}
}
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];//当填写i位数且不受限制时每个数码出现的个数=第i位贡献值+后i-1位贡献值(当不受限时个数码出现的个数是相同的)
mi[i] = 10ll * mi[i - 1];//统计第i位上每个数码贡献的次数
}
solve(r, ans1), solve(l - 1, ans2);
for (int i = 0; i < 10; ++i) printf("%lld ", ans1[i] - ans2[i]);
return 0;
}
递归
#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];
//f[]记忆化数组
//ksm[]不受限时后第i位数贡献值
//now[]记录从第1位到各个位上的数字组合
//p[]记录各位数
ll dfs(int u, int x, bool f0,
bool lim) { // u 表示位数,f0 是否有前导零,lim 是否都贴在上限上
//dfs的含义就是统计以最高位数字打头,位数与原数字相同的数字的数码个数,在次递归下一层dfs就是处理
//以下一位为最高位的数打头的数码的个数
if (!u) {
if (f0);//没有数字
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) {//统计数字的位数,并预处理now、ksm数组
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;
}
更多数位DP可以参考如下:
1.OI WIKI WIKI
3.灵茶山艾府 DP题单