数位DP
数位 DP 的原理与实现:按位统计的数字动态规划。
一句话算法
数位 DP 不是枚举每个数,而是从高位到低位枚举数字前缀;limit 保证不超过上界,lead 保证前导零不被当成真实数字。
问题模型
数位 DP 解决的是一类按数位限制来计数的问题。
常见题面会问:
中有多少个数满足某种性质; - 不超过
的数中,有多少个数不含某个数字; - 不超过
的数中,有多少个数的相邻数位满足某个关系; - 不超过
的数中,有多少个数的数位和、模数、出现次数满足限制。
这些题的共同点是:直接枚举
所以数位 DP 通常先写前缀统计函数:
区间答案再转成前缀差:
本文用 luogu-P2657 windy 数作为主例。
windy 数定义:不含前导零,且相邻两个数字之差至少为
例如:
是 windy 数,因为 且 。 不是 windy 数,因为 。 不是 windy 数,因为 。 - 一位数
都是 windy 数。
给定
核心直觉
把所有不超过
假设要统计不超过
第 1 位: 0 1 2 3
第 2 位: 根据第 1 位和上界继续选择
第 3 位: 根据第 2 位和上界继续选择
- 如果第一位填
1或2,前缀已经小于357,后面的位可以在0..9中自由选择。 - 如果第一位填
3,前缀仍然贴着上界,第二位最多只能填5。 - 如果第一位填
0,这表示数字还没有真正开始,仍然处于前导零状态。
因此递归版数位 DP 的状态需要记住四类信息:
pos:当前还剩多少位要填。last:上一位真实填下去的数字。limit:前缀是否仍然贴着上界。lead:前面是否全是前导零。
其中 limit 和 lead 是数位 DP 最容易混乱的两个标记。
limit:是否贴着上界
limit == true 表示前面填出的前缀与
1
int up = limit ? digit[pos] : 9;
当前位选择 cur 后,下一层是否继续贴着上界,取决于:
只要某一位填得比上界小,后面所有位都不再受上界限制。
lead:是否仍是前导零
lead == true 表示前面填的全是 0,还没有开始形成一个正整数。
当前位选择 cur 后,下一层是否仍是前导零,取决于:
在 windy 数里,前导零不参与“相邻数字差至少为 007,但不能因为 0 和 7 相邻就判断
算法步骤
记忆化 DFS 写法:
- 写
solve(n),将的十进制数位拆到 digit[]。 - 从最高位开始调用
dfs(pos, last, limit, lead)。 - 在
dfs中枚举当前位cur,上界为limit ? digit[pos] : 9。 - 如果仍处于
lead,当前位可以继续填0,也可以填1..9开始数字,不检查相邻差。 - 如果已经不是
lead,当前位必须满足abs(cur - last) >= 2。 - 当
pos == 0时,若仍是lead,说明得到的是数字,本题不统计;否则得到一个合法正整数。 - 只有在
!limit && !lead时缓存dp[pos][last],因为此时后缀已经完全自由,且last是真实上一位。
状态含义:
1
dfs(pos, last, limit, lead)
表示当前还要填写 pos 位,上一位真实数字是 last,当前前缀是否贴着上界为 limit,是否仍处于前导零状态为 lead 时,后面能形成多少个合法 windy 数。
边界:
1
if (pos == 0) return lead ? 0 : 1;
核心转移:
1
2
3
4
5
6
7
8
9
10
11
int up = limit ? digit[pos] : 9;
for (int cur = 0; cur <= up; ++cur) {
bool next_limit = limit && (cur == digit[pos]);
bool next_lead = lead && (cur == 0);
if (lead) {
res += dfs(pos - 1, cur, next_limit, next_lead);
} else if (abs(cur - last) >= 2) {
res += dfs(pos - 1, cur, next_limit, false);
}
}
注意:lead == false 后,即使当前位填 0,这个 0 也是数字内部的真实 0,不是前导零。
算法证明
核心不变量: dfs(pos, last, limit, lead) 只统计所有与当前前缀一致、还剩 pos 位可填的合法后缀。
证明按搜索树理解最自然:
- 不重不漏枚举。 每个不超过
的非负整数,都对应搜索树中唯一一条从高位到低位的填数路径;每条路径也唯一确定一个数。 - 上界正确。 若
limit == true,当前位最多填digit[pos];若某一位填小了,后面limit变为false,任意填0..9都不会超过。 - 前导零正确。 若
lead == true,前面的0只是占位,不参与相邻数位判断;一旦填入非零数字,后续所有位都是真实数字。 - windy 条件正确。 当
lead == false时,last就是上一位真实数字,转移只保留abs(cur-last) >= 2的选择,因此所有保留下来的路径都满足相邻差限制。 - 边界正确。
pos == 0时,若仍处于lead,整条路径表示数字,本题要求正整数所以返回 ;否则返回 。
记忆化也正确:当 !limit && !lead 时,后续每位都可选 0..9,且 last 是真实上一位,所以答案只由 (pos,last) 决定,可以复用。
复杂度分析
设
- 状态数为
。 - 每个状态枚举
个数字。 - 单次
solve(n)的时间复杂度为,竞赛中可视为 。 - 空间复杂度为
。
对于 32 位整数,
代码实现
记忆化 DFS 模板
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
#include <bits/stdc++.h>
using namespace std;
// Windy number digit DP.
// A positive integer is windy if the absolute difference between any two
// adjacent digits is at least 2.
int dp[15][10];
int digit[15];
int dfs(int pos, int last, bool limit, bool lead) {
if (pos == 0) return lead ? 0 : 1;
if (!limit && !lead && dp[pos][last] != -1) return dp[pos][last];
int up = limit ? digit[pos] : 9;
int res = 0;
for (int cur = 0; cur <= up; ++cur) {
bool next_limit = limit && (cur == digit[pos]);
bool next_lead = lead && (cur == 0);
if (lead) {
res += dfs(pos - 1, cur, next_limit, next_lead);
} else if (abs(cur - last) >= 2) {
res += dfs(pos - 1, cur, next_limit, false);
}
}
if (!limit && !lead) dp[pos][last] = res;
return res;
}
int solve(int x) {
if (x <= 0) return 0;
int len = 0;
while (x > 0) {
digit[++len] = x % 10;
x /= 10;
}
return dfs(len, 0, true, true);
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
memset(dp, -1, sizeof(dp));
int l, r;
cin >> l >> r;
cout << solve(r) - solve(l - 1) << '\n';
return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
# 数位 DP(记忆化 DFS)统计 [l, r] 内 windy 数:相邻两位数字之差的绝对值 >= 2。
# 输入:一行两个整数 l r。输出:区间内 windy 数个数(只统计正整数,0 不算)。
# 递归深度等于十进制位数(2*10^9 只有 10 位),无需 sys.setrecursionlimit。
import sys
MAX_LEN = 15 # 10 位够用,15 留余量
MAX_DIGIT = 10
# dp[pos][last]:剩余 pos 位、上一位是 last、且不受 limit/lead 约束时的方案数;-1 表示未算。
dp = [[-1] * MAX_DIGIT for _ in range(MAX_LEN)]
# digit[pos]:上界 x 从低位数起的第 pos 位(1 下标)。
digit = [0] * MAX_LEN
def dfs(pos: int, last: int, limit: bool, lead: bool) -> int:
if pos == 0:
return 0 if lead else 1 # 全是前导零说明这个数就是 0,不算 windy 数
if not limit and not lead and dp[pos][last] != -1:
return dp[pos][last]
up = digit[pos] if limit else 9
res = 0
for cur in range(up + 1):
next_limit = limit and cur == digit[pos]
next_lead = lead and cur == 0
if lead:
# 还没填第一个有效数字,last 无意义,任意 cur 都合法
res += dfs(pos - 1, cur, next_limit, next_lead)
elif abs(cur - last) >= 2:
res += dfs(pos - 1, cur, next_limit, False)
# 只有贴不贴上界、且不在前导零阶段的状态才是可复用的
if not limit and not lead:
dp[pos][last] = res
return res
def solve(x: int) -> int:
if x <= 0:
return 0
length = 0
while x > 0:
length += 1
digit[length] = x % 10
x //= 10
return dfs(length, 0, True, True)
def main() -> None:
data = iter(map(int, sys.stdin.buffer.read().split()))
l = next(data)
r = next(data)
print(solve(r) - solve(l - 1))
if __name__ == "__main__":
main()
这份写法是最通用的数位 DP 写法。以后遇到类似题目,通常只需要改两处:
- 状态里除了
pos,last,limit,lead之外还要记录什么。 - 枚举当前位
cur时,如何判断这个选择是否合法。
例如:
- 统计数位和,需要增加
sum。 - 统计模数,需要增加
mod。 - 禁止出现某个数字,需要在枚举
cur时跳过它。 - 禁止出现某个相邻模式,需要记录上一位或前两位。
递推统计模板
对于 windy 数,还可以写成递推。
先预处理:
边界:
转移:
预处理完成后,计算 calc(n),也就是
- 位数少于
的所有 windy 数。 - 位数等于
,但最高位小于 最高位的所有 windy 数。 - 从高位到低位固定前缀,一旦当前位选得比
小,后面直接用 f表统计。
这个思路本质上仍然在走数位搜索树,只是把“不贴上界的子树大小”提前算好了。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
#include <bits/stdc++.h>
using namespace std;
// Iterative digit DP for Windy numbers.
// f[len][first_digit] = number of len-digit suffixes whose highest digit is
// first_digit and every adjacent digit differs by at least 2.
int f[12][10];
void init() {
for (int d = 0; d <= 9; ++d) f[1][d] = 1;
for (int len = 2; len <= 11; ++len) {
for (int first = 0; first <= 9; ++first) {
for (int next = 0; next <= 9; ++next) {
if (abs(first - next) >= 2) {
f[len][first] += f[len - 1][next];
}
}
}
}
}
int calc(int n) {
if (n <= 0) return 0;
vector<int> digit(1, 0);
while (n > 0) {
digit.push_back(n % 10);
n /= 10;
}
int len = (int)digit.size() - 1;
int res = 0;
for (int l = 1; l < len; ++l) {
for (int first = 1; first <= 9; ++first) {
res += f[l][first];
}
}
for (int first = 1; first < digit[len]; ++first) {
res += f[len][first];
}
// 一位数时下面的逐位收紧循环不会执行(pos 从 len-1 = 0 开始就终止),
// 而 n >= 1 的一位数字本身必是 windy 数,必须单独计入;
// 否则 len == 1 会漏计 n 自身(例如 calc(9) 错返回 8)。
if (len == 1) return res + 1;
for (int pos = len - 1; pos >= 1; --pos) {
for (int cur = 0; cur < digit[pos]; ++cur) {
if (abs(cur - digit[pos + 1]) >= 2) {
res += f[pos][cur];
}
}
if (abs(digit[pos] - digit[pos + 1]) < 2) break;
if (pos == 1) ++res;
}
return res;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
init();
int l, r;
cin >> l >> r;
cout << calc(r) - calc(l - 1) << '\n';
return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
# 数位 DP(迭代预处理 + 逐位统计)统计 [l, r] 内 windy 数:相邻两位差的绝对值 >= 2。
# 输入:一行两个整数 l r。输出:区间内 windy 数个数(只统计正整数,0 不算)。
# f[len][first]:长度恰为 len、最高位为 first、且相邻位差 >= 2 的串数量(允许前导零在低位)。
import sys
MAX_LEN = 11 # 2*10^9 只有 10 位,预计算到 11 留余量
MAX_DIGIT = 10
f = [[0] * MAX_DIGIT for _ in range(MAX_LEN + 1)]
def init() -> None:
for d in range(MAX_DIGIT):
f[1][d] = 1 # 长度 1 的串没有相邻位约束
for length in range(2, MAX_LEN + 1):
for first in range(MAX_DIGIT):
total = 0
for nxt in range(MAX_DIGIT):
if abs(first - nxt) >= 2:
total += f[length - 1][nxt]
f[length][first] = total
def calc(n: int) -> int:
if n <= 0:
return 0
digit = [0] # digit[0] 是哨兵,digit[1..len] 为低位到高位
while n > 0:
digit.append(n % 10)
n //= 10
length = len(digit) - 1
res = 0
# 1) 位数严格小于 length 的 windy 数,最高位取 1..9(不能有前导零)
for l in range(1, length):
for first in range(1, MAX_DIGIT):
res += f[l][first]
# 2) 位数等于 length、最高位小于上界最高位的 windy 数
for first in range(1, digit[length]):
res += f[length][first]
# len == 1 时下面的逐位收紧循环不执行(pos 从 0 开始就终止),
# 而 n >= 1 的一位数字本身必是 windy 数,必须单独计入(否则 calc(9) 会漏掉 9 自身)。
if length == 1:
return res + 1
# 3) 最高位与上界相同,从次高位起逐位收紧
for pos in range(length - 1, 0, -1):
for cur in range(digit[pos]):
if abs(cur - digit[pos + 1]) >= 2:
res += f[pos][cur]
# 上界自身在此位已违反 windy 条件,后面不可能再有合法前缀,直接停
if abs(digit[pos] - digit[pos + 1]) < 2:
break
if pos == 1:
res += 1 # 上界 n 本身也是 windy 数(len >= 2 的情形)
return res
def main() -> None:
data = iter(map(int, sys.stdin.buffer.read().split()))
l = next(data)
r = next(data)
init()
print(calc(r) - calc(l - 1))
if __name__ == "__main__":
main()
两种写法的选择:
| 写法 | 适合场景 | 优点 | 缺点 |
|---|---|---|---|
| 记忆化 DFS | 大多数数位 DP 题 | 状态自然,容易改限制 | 需要理解 limit、lead |
| 递推预处理 | 限制简单、状态少的题 | 常数小,不用递归 | 统计 calc(n) 的分段逻辑更绕 |
初学时建议先掌握记忆化 DFS。它和搜索树的关系最直接,也最容易迁移到复杂题目。
测试用例
普通用例:
输入:
1 10
输出:
9
解释:
更适合自测的用例:
输入:
1 20
输出:
17
解释:10、11、12、21 不合法,13..20 中合法的有 13,14,15,16,17,18,19,20 共
边界用例:
输入:
0 0
输出:
0
解释:本题统计正整数,
应用分类详解
数位 DP 的本质是:按位构造数字,把“是否超过上界”和“当前前缀携带的信息”放进状态里计数。
一、数位禁用与模式限制
- 典型模式: 题目限制某些数字不能出现,或某些相邻模式不能出现。
- 识别信号: 题面出现“不含某个数字”“不能出现连续若干位”“相邻数字满足某条件”。
- 核心建模: 状态记录上一位或有限自动机状态,转移时判断当前数字是否合法。
| 应用场景 | 经典题目 | 核心思路 |
|---|---|---|
| 相邻差限制 | luogu-P2657 | 记录上一位 last,要求 abs(cur-last) >= 2 |
| 禁止数字或子串 | hdu-2089 | 跳过数字 4,并记录上一位避免出现 62 |
| 十进制模式匹配 | 常见 AC 自动机数位 DP | 用自动机状态表示当前前缀匹配到哪里 |
二、数位和、模数与出现次数
- 典型模式: 合法性由各位数字的总和、余数、某个数字出现次数决定。
- 识别信号: 题面出现“各位数字之和”“能被
整除”“数字 出现了几次”。 - 核心建模: 状态增加
sum、mod、cnt等累计量。
| 应用场景 | 经典题目 | 核心思路 |
|---|---|---|
| 数字计数 | luogu-P2602 | 分别统计每个数字在 |
| 数位和限制 | 常见区间计数题 | 状态记录当前数位和,结束时判断范围 |
| 余数限制 | 常见整除计数题 | 转移 next_mod=(mod*10+cur)%m |
三、组合约束计数
- 典型模式: 数字必须同时满足多种条件,例如数位和、相邻关系、模数、是否含某类数字。
- 识别信号: 区间范围很大,但数位长度很小;条件只依赖有限历史和累计值。
- 核心建模: 把所有“未来还需要知道的信息”压进状态,状态规模可控时即可做。
| 应用场景 | 经典题目 | 核心思路 |
|---|---|---|
| 状态压缩数位 DP | 不重复数字计数 | 用 bitmask 记录哪些数字已经出现 |
| 双限制计数 | 数位和加模数 | 同时记录 sum 与 mod |
| 进制相关限制 | 二进制中 1 的个数 | 按二进制位做同样的 limit DP |
经典例题
-
luogu-P2657 windy 数 入门题。只需要记录上一位数字,是理解
limit、lead、last三者关系的最好练习。 -
hdu-2089 不要 62 经典禁止模式题。不能出现数字
4,也不能出现连续子串62,状态只需记录上一位是否为6。 -
luogu-P2602 数字计数 统计
中每个数字出现多少次。它能训练“前缀差”和“前导零不能乱算”的边界处理。 -
atcoder-abc208_e Digit Products 统计不超过
且数位乘积不超过 的数。它说明数位 DP 不只记录上一位,也可以记录乘积这类累计状态。
做题步骤
遇到数位 DP,可以按下面的顺序想:
- 先写
solve(n),最后答案通常是solve(r)-solve(l-1)。 - 把
拆成数位,常见写法是低位存在 digit[1],高位存在digit[len]。 - 从高位到低位填写,状态先固定写成
dfs(pos, ..., limit, lead)。 - 想清楚“判断当前位是否合法”需要知道哪些历史信息,把它们加入状态。
- 只有在
!limit时才考虑记忆化;如果lead的含义不好缓存,可以先不缓存lead状态。 - 边界处判断是否形成了合法数字。
一句话总结:数位 DP 的难点不是 DP 表有多大,而是状态语义要干净。只要 limit、lead 和“必须记住的历史信息”分清楚,大多数题都能按同一个模板改出来。