数位 DP 习题题解(洛谷题单)

发表于 2025-07-11 1728 字 9 min read

文章目录
数位 DP 的洛谷题单题解:烦人的数学作业、windy 数、数字计数、Round Numbers。

本篇对应源码见仓库 solutions/xcpc/algorithm/dp/digit/。

难点在于状态转移上的处理的细节,也是极易错的点

P4999 烦人的数学作业

题意

给出一个区间 [L,R][L, R],求区间内每个数的数字和,如 123123 这个数的数字和为 1+2+3=61+2+3=6。(1≤L≤R≤1018)(1 ≤ L ≤ R ≤ 10^{18})

思路

  • dp[pos][sum]dp[pos][sum]: 表示当前处理到第 pospos 位,数字和为 sumsum 的所有情况的数位之和;
  • ansans: 所有满足情况的数字的数位之和;
  • dfs(pos,s,limit,lead)dfs(pos, s, limit, lead): 处理第 pospos 位数字,当前数字和为 ss,limit 表示是否受限于上界,lead 表示是否允许前导零;
  • dfsdfs 递归出口: ss。

示例代码

// dp[pos][sum] : pos 之前数字之和为 sum 的所有情况的数位之和
vector<vector<int>> dp(20, vector<int>(200, -1));
vector<int> num(20);

int dfs(int pos, int s, bool limit, bool lead) {
	if(pos == 0) return s;

	if(!limit && !lead && dp[pos][s] != -1) return dp[pos][s];
	int up = limit ? num[pos] : 9;
	int ans = 0;

	fer(i, 0, up + 1) {
		ans = (ans + dfs(pos - 1, s + i, limit && i == up, lead && !i)) % MOD;
	}

	if(!limit && !lead) dp[pos][s] = ans;
	return ans;
}

void solve() {
    ll l, r;
    cin >> l >> r;

    auto get = [&](ll n) -> int {
    	int len = 0;
    	dp.assign(20, vector<int> (200, -1));
    	while(n) {
    		num[++len] = n % 10;
    		n /= 10;
    	}
    	return dfs(len, 0, true, true);
    };
    cout << (get(r) - get(l - 1) + MOD) % MOD << '\n';
}

P6218 [USACO06NOV] Round Numbers S

题意

如果一个正整数的二进制表示中,00 的数目不小于 11 的数目,那么它就被称为「圆数」。 例如,9 的二进制表示为 1001,其中有 2 个 0 与 2 个 1。因此,9 是一个「圆数」。 计算区间 [l,r][l,r] 中有多少个「圆数」。

思路

  • dp[pos][cnt0][cnt1]dp[pos][cnt0][cnt1]: 表示当前处理到第 pospos 位,00 的个数为 cnt0cnt0,11 的个数为 cnt1cnt1 的所有情况的满足条件的数字个数;
  • ansans: 所有满足情况的数字的个数;
  • dfs(pos,cnt0,cnt1,limit,lead)dfs(pos, cnt0, cnt1, limit, lead): 处理第 pospos 位数字,当前 00 的个数为 cnt0cnt0,11 的个数为 cnt1cnt1,limit 表示是否受限于上界,lead 表示是否处于前导零状态;
  • 注意: 处于前导零状态时的 00 不能计入 cnt0cnt0 。

示例代码

// dp[pos][cnt0][cnt1]
int dp[40][40][40];
vector<int> num(40);

int dfs(int pos, int cnt0, int cnt1, bool limit, bool lead) {
	if(pos == 0) return lead || cnt0 >= cnt1;

	if(!limit && !lead && ~dp[pos][cnt0][cnt1]) return dp[pos][cnt0][cnt1];

	int up = limit ? num[pos] : 1;
	int ans = 0;

	fer(i, 0, up + 1) {
		int new_lead = lead && !i;
		ans += dfs(pos - 1, cnt0 + (!new_lead && !i), cnt1 + i, limit && i == up, new_lead);
	}

	if(!limit && !lead) dp[pos][cnt0][cnt1] = ans;
	return ans;
}

signed main() {
    IOS;

    int l, r;
    cin >> l >> r;

    auto get = [&](int n) -> int {
    	int len = 0;
    	memset(dp, -1, sizeof dp);
    	while(n) {
    		num[++len] = n & 1;
    		n >>= 1;
    	}
    	return dfs(len, 0, 0, true, true);
    };

    cout << get(r) - get(l - 1) << '\n';

    return 0;
}

P2602 [ZJOI2010]数字计数

题意

不含前导零且相邻两个数字之差至少为 2 的正整数被称为 windy 数。求在 [a,b][a, b] 总共有多少个 windy 数?

思路

  • dp[pos][pre]dp[pos][pre]: 表示当前处理到第 pospos 位,前一位数字为 prepre 的windy数总个数
  • ansans: 所有满足情况的数字的个数;
  • dfs(pos,pre,limit,lead)dfs(pos, pre, limit, lead): 处理第 pospos 位数字,前一位数字为 prepre,limit 表示是否受限于上界,lead 表示是否处于前导零状态;
  • 重点在于,在 forfor 内枚举当前位时什么时候才能进行 ans +=dfs(...)ans\ += dfs(...) 递归调用?
    • 当然我们可以有多个角度来思考这个问题
    • 角度一:根据 prepre 是否为 00 进行讨论。是,则考虑是否处于前导零阶段,是则可以递归调用,否则需要再考虑当前枚举的第 pospos 位是否 >=2>=2 ;prepre 不为 00,则直接考虑是否有 abs(i - pre) >= 2
    • 角度二:根据当前是否处于前导零阶段。是,则可以直接递归调用;否,则需要考虑是否有 abs(i - pre) >= 2
  • 如果你 WAWA 了,请仔细思考思考你的思路有哪些情况未考虑完整.

示例代码

// dp[pos][pre]
int dp[12][10];
vector<int> num(12);

int dfs(int pos, int pre, bool limit, bool lead) {
	if(pos == 0) return lead ? 0 : 1;

	if(!limit && !lead && dp[pos][pre] != -1) return dp[pos][pre];

	int up = limit ? num[pos] : 9;
	int ans = 0;
	fer(i, 0, up + 1) {
		// 角度一:pre是否为0
		// if(pre == 0) {
		// 	if(!lead && i < 2) continue;
		// 	ans += dfs(pos - 1, i, limit && i == up, lead && !i);
		// } else {
		// 	if(abs(i - pre) < 2) continue;
		// 	ans += dfs(pos - 1, i, limit && i == up, lead && !i);
		// }
		// 角度二:是否处于前导零阶段
		if(lead || abs(i - pre) >= 2) {
			ans += dfs(pos - 1, i, limit && i == up, lead && !i);
		}
	}

	if(!limit && !lead) dp[pos][pre] = ans;
	return ans;
}

signed main() {
    IOS;

    int l, r;
    cin >> l >> r;

    auto get = [&](int n) -> int {
    	memset(dp, -1, sizeof dp);
    	num[0] = 0;
    	while(n) {
    		num[++num[0]] = n % 10;
    		n /= 10;
    	}
    	return dfs(num[0], 0, true, true);
    };
    cout << get(r) - get(l - 1) << '\n';

    return 0;
}

P2602 [ZJOI2010]数字计数

题意

给定两个正整数 aa 和 bb,求在 [a,b][a,b] 中的所有整数中,每个数码(digit)各出现的次数。

思路

  • 依次枚举数码 dd ,每一次都进行数位dp
  • dp[pos][cnt]dp[pos][cnt]: 表示当前处理到第 pospos 位,当前枚举的数码 dd 出现次数为 cntcnt 时数码 dd 出现的总次数;
  • ansans: 数码 dd 出现次数;
  • dfs(pos, d, cnt, limit, lead)): 处理第 pospos 位数字,当前数码 dd 出现次数为 cntcnt,limit 表示是否受限于上界,lead 表示是否处于前导零状态;
  • 状态的转移:ans 常规处理,cnt呢?
    • 考虑到 a>=1a >= 1,我们不必管 0000 的情形对 0 的统计
    • 在非前导零阶段时,只需要 cnt + (i == d)
    • 在前导零阶段时,只需要 cnt + ( d && i == d)

思考:为什么在下面的示例代码中的注释部分进行状态转移是错误的?为什么 cnt3cnt3 的状态转移是错误的?

示例代码

// dp[pos][cnt]: 枚举到pos时出现了cnt次时总共出现的次数
ll dp[15][15];
vector<int> num(15);

ll dfs(int pos, int d, int cnt, bool limit, bool lead) {
	if(pos == 0) return cnt;

	if(!limit && !lead && ~dp[pos][cnt]) return dp[pos][cnt];

	int up = limit ? num[pos] : 9;
	ll ans = 0;

	fer(i, 0, up + 1) {
		// if(!lead) {
		// 	if(i == d) cnt++;
		// } else {
		// 	if(d && i == d) cnt++;
		// 	else if(pos == 1 && i == d) cnt++;
		// }
		int cnt1 = cnt + (lead && !i ? 0 : i == d);
		int cnt2 = cnt + (!lead && i == d || lead && d && i == d);
		int cnt3 = cnt + (!lead && i == d || d && i == d || pos == 1 && i == d);
		ans += dfs(pos - 1, d, cnt3, limit && i == up, lead && !i);
	}
	if(!limit && !lead) dp[pos][cnt] = ans;
	return ans;
}

signed main() {
    IOS;

    ll l, r;
    cin >> l >> r;

    auto get = [&](ll n, int d) {
    	memset(dp, -1, sizeof dp);
    	num[0] = 0;
    	while(n) {
    		num[++num[0]] = n % 10;
    		n /= 10;
    	}
    	return dfs(num[0], d, 0, true, true);
    };

    fer(i, 0, 10) {
    	cout << get(r, i) - get(l - 1, i) << ' ';
    }

    return 0;
}