数位 DP 进阶
数位 DP 进阶
日期:2026-07-14
关键词:动态规划、数位 DP、记忆化搜索
涉及题目:P2602 [ZJOI2010] 数字计数 | P4124 [CQOI2016] 手机号码 | P3286 [SCOI2014] 方伯伯的商场之旅
数位 DP
梳理一下数位 DP 的核心思想。
数位 DP,顾名思义,是对数字的“位”进行动态规划。它专门用于解决一类问题:
求区间 \([L, R]\) 内满足某种特定性质的数的数量(或最值、总和等)。
当 \(L\) 和 \(R\) 的范围达到 \(10^{18}\)(即 \(10^{18}\) 级别)时,传统的线性扫描完全失效,数位 DP 便成为了唯一的解法。
核心思想:逐位确定与记忆化
数位 DP 的本质是将数字按位拆分,通过 DFS(深度优先搜索)枚举每一位的可能取值,并利用记忆化来避免重复计算。
主流写法通常采用 DFS + 记忆化 的形式,相比递推,它逻辑更清晰,边界处理更自然。
一个标准的 DFS 函数通常包含以下核心参数:
pos:当前处理到第几位(通常从高位向低位递归)。state:当前的状态(这是解题的核心,例如前一位数字是什么、是否出现了某个特定数字等)。limit:是否受到上界的限制(这是数位 DP 最重要的标志之一)。lead:是否存在前导零。
深入理解 limit(限制位)
- 当我们构造数字时,如果之前填的每一位都和 \(R\) 的对应位一模一样,那么当前位就不能随意填写 0~9,最大值只能填 \(R\) 在这一位上的值。此时
limit = true。 - 反之,如果之前某一位已经填得比 \(R\) 小了,那么后面的位就可以自由填写 0~9,
limit = false。 - 关键结论:只有
limit = false的状态才具有通用性,才能进行记忆化存储;limit = true的状态是唯一的、临时的,不能存入 DP 数组。
深入理解 lead(前导零)
前导零指的是数字开头的 0。例如 00123 实际是 123。
- 在统计数字 0 的出现次数时,前导零不能算作有效的 0。
- 在判断数字性质(如单调性)时,前导零通常不影响判断逻辑。
- 我们通常使用
lead标记来区分当前是否处于前缀的零中。
一、 基础:P2602 [ZJOI2010] 数字计数
题目大意
求区间 \([a, b]\) 中 0~9 每个数字分别出现的总次数。
解题思路
这道题是数位 DP 的经典入门题。它不直接询问满足条件的数的个数,而是询问数字出现的总频次。这要求我们不仅要统计“有多少个”,还要统计“贡献了多少次”。利用 差分 思想 : ans = count(b, d) - count(a - 1, d)
状态设计:
我们需要同时维护两个维度的信息:
- 个数 (\(cnt\)):从当前位置开始,往后能构造出多少个合法的数。
- 和 (\(sum\)):从当前位置开始,往后目标数字一共出现了多少次。
因此,DFS 函数的返回值设计为 pair<ll, ll>。
状态转移:
假设当前位于第 pos 位,准备填入数字 i。递归调用下一位后,获得返回值 {cnt, sum}。
tot_cnt += cnt:累加低位返回的合法数字个数。tot_sum += sum:累加低位返回的目标数字出现次数。- 核心逻辑:如果当前填入的数字
i正好是我们统计的目标数字target,那么低位的每一个合法数(cnt个),其最高位都是target。因此,我们需要额外加上cnt。\[tot\_sum \leftarrow tot\_sum + sum + (i == target ? cnt : 0) \]
代码实现
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
using pll = pair<ll, ll>;
const int N = 20;
ll a,b;
ll dp_cnt[N][2][2];
ll dp_sum[N][2][2];
vector<int> dig;
int target;
pll dfs(int pos, bool lead, bool limit) {
if (pos == dig.size()) return { 1, 0 };
if (dp_cnt[pos][lead][limit] != -1)
return { dp_cnt[pos][lead][limit], dp_sum[pos][lead][limit] };
int up = 0;
if (limit) up = dig[pos];
else up = 9;
ll tot_cnt = 0, tot_sum = 0;
for (int i = 0; i <= up; i ++) {
bool n_lead, n_limit;
if (lead == true && i == 0) n_lead = true;
else n_lead = false;
if (limit ==true && i == up) n_limit = true;
else n_limit = false;
auto [cnt, sum] = dfs(pos + 1, n_lead, n_limit);
tot_cnt += cnt;
tot_sum += sum;
if (n_lead == false && i == target) tot_sum += cnt;
}
dp_cnt[pos][lead][limit] = tot_cnt;
dp_sum[pos][lead][limit] = tot_sum;
return { tot_cnt, tot_sum };
}
ll count(ll x, int d) {
if (x < 1) return 0;
dig.clear();
while (x > 0) {
dig.push_back(x % 10);
x /= 10;
}
reverse(dig.begin(), dig.end());
target = d;
memset(dp_cnt, -1, sizeof(dp_cnt));
memset(dp_sum, -1, sizeof(dp_sum));
auto [cnt, sum] = dfs(0, true, true);
return sum;
}
int main () {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> a >> b;
for (int d = 0; d <= 9; d ++) {
cout << count(b, d) - count(a - 1, d) << ' ';
}
return 0;
}
二、 进阶:P4124 [CQOI2016] 手机号码
题目大意
找出区间 \([L, R]\) 内满足条件的 11 位手机号码:
- 必须包含连续的三个相同数字。
- 不能同时包含数字 4 和数字 8。
解题思路
这道题引入了复杂的状态约束。我们需要在 DFS 的过程中,实时记录当前构造的数字所具有的特征,以便进行合法性判断。
状态设计:
为了涵盖所有约束,我们需要扩充 DFS 的参数列表:
pos: 当前位。pre1: 上一位数字(用于判断连续)。len: 当前连续的长度。has3: 是否已经出现连续三位相同的数字。has4,has8: 是否出现过 4 或 8。limit,lead: 常规参数。
剪枝优化:
这是提升效率的关键。如果在递归过程中发现 has4 和 has8 同时为 true,意味着这个数字已经违反了规则,可以直接返回 0,终止该分支的搜索。
前导零处理:
特别需要注意的是,前导零阶段不应参与连续性和数字出现的判断。只有当 lead 变为 false 时,才开始正式计数。
代码逻辑
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const int N = 13;
ll L, R;
ll dp[N][11][10][10][2][2];
vector<int> dig;
ll dfs(ll pos, ll pre1, bool len, bool has3, bool has4, bool has8, bool limit, bool lead) {
if (pos == dig.size()) {
if (has3 && !(has4 && has8)) return 1;
else return 0;
}
if (!limit && !lead && dp[pos][pre1][len][has3][has4][has8] != -1)
return dp[pos][pre1][len][has3][has4][has8];
int up = 0;
if (limit) up = dig[pos];
else up = 9;
ll res = 0;
for (int i = 0; i <= up; i ++) {
bool n_lead = lead && (i == 0);
bool n_limit = limit && (i == up);
int n_pre = i, n_len = len;
bool n_has3 = has3;
bool n_has8 = has8 || (i == 8);
bool n_has4 = has4 || (i == 4);
if (!n_lead) {
if (i == pre1) {
n_len = len + 1;
if (n_len >= 3) n_has3 = true;
}
else n_len = 1;
}
else {
n_pre = 10;
n_len = 0;
}
res += dfs(pos + 1, n_pre, n_len, n_has3, n_has4, n_has8, n_limit, n_lead);
}
if (!limit && !lead) dp[pos][pre1][len][has3][has4][has8] = res;
return res;
}
ll get(ll x) {
dig.clear();
while (x) {
dig.push_back(x % 10);
x /= 10;
}
reverse(dig.begin(), dig.end());
memset(dp, -1, sizeof(dp));
ll ans = dfs(0, 10, 0, 0, 0, 0, 1, 1);
return ans;
}
int main () {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> L >> R;
cout << get(R) - get(L - 1) << '\n';
return 0;
}
小结
这道题展示了如何处理多维度的 bool 约束。核心技巧在于:
- 状态参数化:将所有约束条件转化为 DFS 的参数。
- 及时剪枝:一旦发现非法状态,立即停止搜索。
- 严谨的前导零处理:确保前导零不影响数字属性的判断。
三、 提高:P3286 [SCOI2014] 方伯伯的商场之旅
题目大意
将区间 \([l, r]\) 内的所有数转换为 \(k\) 进制,然后计算将所有数位上的石子移动到同一个位置的代价(代价定义为权重乘以距离),求最小总代价。
解题思路
这是一道带权值的最优化问题,也是数位 DP 的难点。暴力枚举每个数的最优集合点是不可行的。
核心思想:贡献法与二次 DP
我们不关心最终的最优位置具体在哪,而是关心移动集合点带来的代价变化。
- 基准状态:假设所有数字都先移动到第 1 个位置,计算出总代价 \(S_1\)。
- 增量调整:考虑将集合点从位置 \(p\) 移动到 \(p+1\)。
- 对于位置 \(< p+1\) 的数字,距离增加了 1,代价增加 \(w\)。
- 对于位置 \(\ge p+1\) 的数字,距离减少了 1,代价减少 \(w\)。
- 决策:如果移动后代价变小(\(\Delta < 0\)),则说明移动是有益的。
代码实现
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const int N = 1e4 + 5, M = 105;
ll l, r, k;
ll dig[M];
ll dp[M][N];
ll dfs(ll now, ll sum, ll p, bool limit) {
if (!now) return max(sum, ll(0));
if (!limit && dp[now][sum] != -1) return dp[now][sum];
ll ans = 0;
int up = 0;
if (limit) up = dig[now];
else up = k - 1;
for (int i = 0; i <= up; i ++) {
int delta = 0;
if (p == 1) delta = i * (now - 1);
else {
if (now < p) delta = -i;
else delta = i;
}
ans += dfs(now - 1, sum + delta, p, limit && (i == up));
}
if (!limit) dp[now][sum] = ans;
return ans;
}
ll get(ll x) {
int t = 0;
while (x) {
dig[++ t] = x % k;
x /= k;
}
ll ans = 0;
for (int i = 1; i <= t; i ++) {
memset(dp, -1, sizeof(dp));
if (i == 1) ans += dfs(t, 0, i, 1);
else ans -= dfs(t, 0, i, 1);
}
return ans;
}
int main () {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> l >> r >> k;
cout << get(r) - get(l - 1) << '\n';
return 0;
}
小结
这道题代表了数位 DP 的高级应用:
- 化整为零:不求解绝对最优解,而是求解相对变化量。
- 二次 DP:通过多次 DP 来模拟过程的推进。
- 贡献思维:关注每一步决策对全局代价的边际影响。
总结
这三道题完美诠释了数位 DP 的进化路线:
- P2602 教会我们如何统计贡献。
- P4124 教会我们如何管理复杂约束。
- P3286 教会我们如何解决带权最优化问题。
掌握这三类模板,基本能应对绝大多数数位 DP 的题目。
注:部分由 AI 润色
原创·转载请注明出处
✦
浙公网安备 33010602011771号