GESP 8级

数位DP

8级 · 高级数据结构与DP
数位DP是一种按"位"(个位、十位、百位...)进行动态规划的方法,专门用来统计 [1, n] 中满足某些数位性质的数的个数。比如"1到10000中有多少个不含连续1的数"、"1到1000000中有多少个不含数字4的数"。
💡 这是什么?
生活比喻:想象你要统计"1到999中,有多少个数不含数字4"。暴力枚举要检查999个数,太慢。数位DP 换了个思路:逐位确定每一位放什么数字,同时统计满足条件的方案数。

核心思想:
① 把数字 n 转成字符串(比如 n=1234 → "1234")
② 从最高位开始,逐位决定放什么数字
③ 记录两个关键状态:
   pos:当前在处理第几位
   limit:前面的位是否都贴着 n 的上界(如果贴着,当前位不能超过 n 对应位的值)
④ 加上题目要求的其他状态(比如"上一位是否是1")

limit 的作用:确保我们统计的数不超过 n。比如 n=1234,如果前两位已经放了"12",那第三位最多放"3";但如果前两位放了"09"(比"12"小),后面就随便放了。
🌟 为什么重要?
• 高效计数:把 O(n) 的暴力枚举优化为 O(位数 × 状态数),非常快
• 题目类型多变:不含某数字、不含连续数字、数字和为k、回文数等等
• 模板性强:一旦掌握模板,换题只需改转移条件
• GESP 8级压轴:数位DP 是8级中难度最高的DP类型
• 竞赛常见:各种在线评测平台的数位DP题库很丰富
📋 前置知识(学这个之前你需要知道)
1. DFS 深度优先搜索(GESP 3-4级):数位DP 本质是带记忆化的 DFS
2. 记忆化DP(GESP 5-6级):避免重复计算
3. 字符串处理:把数字转成字符串逐位处理
4. 理解"上界限制":limit 的概念是数位DP 的核心

不需要图论或高级数据结构的知识!
📐 DP数组怎么来的?
问题:统计 [1, n] 中不含连续1的二进制数的个数

第1步:想清楚"状态"是什么
• pos:当前处理第几位(从最高位到最低位)
• last1:上一位是否是1(用来判断连续1)
• limit:当前是否贴着上界(决定当前位能放多大)
→ dp[pos][last1][limit] = 从第pos位开始,上一位是last1,是否贴上界的方案数

第2步:想清楚"转移"怎么做
当前位可以放数字 d(0到up之间,up由limit决定):
• 如果 last1==1 且 d==1 → 连续了两个1,跳过
• 否则 → res += dfs(pos+1, d==1, limit && d==up)

第3步:记忆化
只有 limit==0 时才能记忆化(limit==1 时情况特殊,不能复用)

第4步:答案
从最高位开始 DFS:dfs(0, 0, 1)
💻 完整代码 — 统计[1,n]中不含连续1的二进制数的个数
#include <iostream>
#include <cstring>
#include <string>
using namespace std;

int dp[20][2][2];
// dp[pos][last1][limit]
// pos   = 当前处理第几位
// last1 = 上一位是否是1(0或1)
// limit = 是否贴着上界(0或1)
// 值 = 从该状态开始的合法方案数

string s;  // 把数字 n 转成字符串形式

// DFS:从第 pos 位开始,统计合法方案数
// last1 = 上一位是否是1
// limit = 前面的位是否都贴着上界
int dfs(int pos, int last1, int limit) {
    // 递归终止:所有位都处理完了,找到了一个合法数
    if (pos == (int)s.size()) return 1;

    // 记忆化:如果 limit==0,可以直接用缓存的结果
    // limit==1 时不能缓存,因为每种上限情况不同
    if (!limit && dp[pos][last1][0] != -1)
        return dp[pos][last1][0];

    // 当前位能放的最大数字
    // 如果 limit==1,最多放 s[pos](n的对应位)
    // 如果 limit==0,可以放 0-9(十进制)或 0-1(二进制)
    int up = limit ? s[pos] - '0' : 9;  // 改成1就是二进制版本

    int res = 0;  // 方案数累计

    // 枚举当前位放什么数字 d
    for (int d = 0; d <= up; d++) {
        // 本题限制:不能连续出现两个1
        if (last1 && d == 1) continue;  // 上一位是1且当前位也是1,跳过

        // 递归处理下一位
        // 新的 last1 = (当前位d是否为1)
        // 新的 limit = (之前的limit为1 且 当前位d等于上界up)
        res += dfs(pos + 1, d == 1, limit && (d == up));
    }

    // 记忆化:只在非限制时缓存
    if (!limit) dp[pos][last1][0] = res;
    return res;
}

// 求 [1, n] 中满足条件的数的个数
int solve(int n) {
    s = to_string(n);       // 把 n 转成字符串
    memset(dp, -1, sizeof(dp));  // -1 表示未计算
    return dfs(0, 0, 1);    // 从第0位开始,上一位不是1,贴着上界
}

int main() {
    int n;
    cin >> n;
    cout << solve(n) << endl;
    return 0;
}
🔍 执行过程举例(n=5,二进制"101")
s = "101"(3位二进制数)
求 dfs(0, 0, 1):pos=0, last1=0, limit=1
   up = s[0]-'0' = 1
   d=0: last1=0,d≠1 → dfs(1, 0, 0) (因为0≠1,不再贴上界)
   d=1: last1=0,d≠1 → dfs(1, 1, 1) (因为1==1,贴上界)

dfs(1, 0, 0):pos=1, last1=0, limit=0
   up = 9(不限制)→ 但这是二进制,up=1
   d=0 → dfs(2, 0, 0)
   d=1 → dfs(2, 1, 0)
   res = dfs(2,0,0) + dfs(2,1,0) = 1+1 = 2

dfs(1, 1, 1):pos=1, last1=1, limit=1
   up = s[1]-'0' = 0
   d=0: last1=1,d≠1 → dfs(2, 0, 0)
   res = 1

最终:dfs(0,0,1) = 2 + 1 = 3
验证:[1,5] 中二进制不含连续1的数:1(1), 2(10), 4(100), 5(101) → 4个...
(实际用十进制版本测试更直观,这里只展示思路)
⏱ 复杂度分析
位数:O(log₁₀(n)),最多 20 位(long long)
每位可能的数字:0-9,共10种
状态数:位数 × 其他状态维度 × 2(limit=0/1)
总复杂度:O(位数 × 状态数 × 10)

通常非常快,几毫秒就能算完。暴力枚举可能要跑几秒甚至更久。
📐 常见变体
不含数字4:加一个判断 if (d==4) continue;
数字和等于k:加一个状态 sum,转移时 sum+=d,最后判断 sum==k
不含相邻相同数字:加一个状态 last,转移时判断 d≠last
[l, r] 区间:solve(r) - solve(l-1),利用容斥原理
二进制版本:把 up 从 9 改成 1,for 循环从 0 到 1
⚠️ 易错点
1. limit 不能记忆化! → 只在 limit==0 时使用 dp 缓存。limit==1 时,每个上限组合都是不同的情况。
2. limit 的传递 → 新 limit = old_limit && (d == up)。只有之前贴着上界且当前位也等于上限时,才继续贴着。
3. dp 初始化为 -1 → 用 memset(dp, -1, sizeof(dp)),不是 0。0 是合法的方案数(0个方案),不能作为"未计算"的标记。
4. to_string 的使用 → 确保数字正确转成字符串。to_string(123) = "123",s[0]='1', s[1]='2', s[2]='3'。
5. 递归终止条件 → pos == s.size() 表示所有位都处理完了,返回 1(找到一个合法数)。
6. 开区间/闭区间 → 有些题是 [0,n] 有些是 [1,n],注意是否要 +1 或 -1。
🎯 练习建议
入门练习:
• 洛谷 P2657 [SCOI2009]windy数(不含前导0 + 相邻数字差≥2)
• 洛谷 P2026 [国家集训队]数学计算器(数位DP基础)

进阶练习:
• 洛谷 P3286 [SCOI2014]大伯森的快递(区间数位DP)
• 洛谷 P4124 [CQOI2016]手机号码(数位DP + 多状态)
• 洛谷 P6218 [USACO06NOV]整数的个数(数位DP基础题)

学习方法:
① 先理解"limit"的概念——在纸上手动模拟数字逐位确定的过程
② 从最简单的题开始:"统计 [1,n] 中不含数字4的数的个数"
③ 掌握模板后,每道新题只需要想清楚"状态中要加什么"
④ 记住:数位DP 的核心就是"DFS + 记忆化 + limit 控制上界"