文章目录

数位 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)

状态设计
我们需要同时维护两个维度的信息:

  1. 个数 (\(cnt\)):从当前位置开始,往后能构造出多少个合法的数。
  2. 和 (\(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 位手机号码:

  1. 必须包含连续的三个相同数字。
  2. 不能同时包含数字 4 和数字 8。

解题思路

这道题引入了复杂的状态约束。我们需要在 DFS 的过程中,实时记录当前构造的数字所具有的特征,以便进行合法性判断。

状态设计
为了涵盖所有约束,我们需要扩充 DFS 的参数列表:

  • pos: 当前位。
  • pre1: 上一位数字(用于判断连续)。
  • len: 当前连续的长度。
  • has3: 是否已经出现连续三位相同的数字。
  • has4, has8: 是否出现过 4 或 8。
  • limit, lead: 常规参数。

剪枝优化
这是提升效率的关键。如果在递归过程中发现 has4has8 同时为 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 约束。核心技巧在于:

  1. 状态参数化:将所有约束条件转化为 DFS 的参数。
  2. 及时剪枝:一旦发现非法状态,立即停止搜索。
  3. 严谨的前导零处理:确保前导零不影响数字属性的判断。

三、 提高:P3286 [SCOI2014] 方伯伯的商场之旅

题目大意

将区间 \([l, r]\) 内的所有数转换为 \(k\) 进制,然后计算将所有数位上的石子移动到同一个位置的代价(代价定义为权重乘以距离),求最小总代价。

解题思路

这是一道带权值的最优化问题,也是数位 DP 的难点。暴力枚举每个数的最优集合点是不可行的。

核心思想:贡献法与二次 DP
我们不关心最终的最优位置具体在哪,而是关心移动集合点带来的代价变化

  1. 基准状态:假设所有数字都先移动到第 1 个位置,计算出总代价 \(S_1\)
  2. 增量调整:考虑将集合点从位置 \(p\) 移动到 \(p+1\)
    • 对于位置 \(< p+1\) 的数字,距离增加了 1,代价增加 \(w\)
    • 对于位置 \(\ge p+1\) 的数字,距离减少了 1,代价减少 \(w\)
  3. 决策:如果移动后代价变小(\(\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 的高级应用:

  1. 化整为零:不求解绝对最优解,而是求解相对变化量。
  2. 二次 DP:通过多次 DP 来模拟过程的推进。
  3. 贡献思维:关注每一步决策对全局代价的边际影响。

总结
这三道题完美诠释了数位 DP 的进化路线:

  • P2602 教会我们如何统计贡献
  • P4124 教会我们如何管理复杂约束
  • P3286 教会我们如何解决带权最优化问题。

掌握这三类模板,基本能应对绝大多数数位 DP 的题目。


注:部分由 AI 润色

posted @ 2026-09-06 16:47  lvwangshu  阅读(0)  评论(0)    收藏  举报