C++数位 DP解析

目录

数位DP:从原理到实战的深度解析(对话版)

引言

数位DP(数位动态规划)是动态规划中一类专门解决数字统计问题的经典算法,比如"统计1~n中不含数字6的数的个数""计算区间[L,R]中各位数字之和等于10的数的数量"等。这类问题如果用暴力枚举,在数值范围很大(比如1e18)时完全不可行,而数位DP能通过"按位处理+状态记忆"将时间复杂度降到O(位数×状态数),是处理大数数字统计的"杀手锏"。本文以"新手提问+导师解答"的对话形式,拆解数位DP的核心逻辑、实现框架和经典例题,让你彻底掌握这一算法。

一、初识数位DP:它解决什么问题?

新手 :导师您好!我看到"数位DP"这个名字就懵了------"数位"是指数字的每一位(个位、十位、百位)吗?它和普通DP有什么区别?
导师 :没错!"数位"就是指数字的每一位,数位DP的核心就是按位处理数字,结合DP的状态记忆,统计满足特定条件的数字数量

先明确数位DP的适用场景:

  • 问题要求统计区间[L, R] 内满足某种"数位规则"的数字个数(比如不含4、各位和为9、是回文数等);
  • 数值范围极大(比如L=0,R=1e18),暴力枚举(遍历每个数检查条件)必然超时;
  • 条件仅与数字的各位数字有关,与数字的大小无直接关系(比如"不含6"只看每一位是不是6,和数字是6还是16无关)。

和普通DP的区别:

  • 普通DP的状态通常对应"前i个物品/前i个字符",数位DP的状态对应"处理到数字的第i位";
  • 数位DP需要处理前导零、数位上界限制(比如处理到第3位时,前两位是12,第3位最大只能是3,而不是9)这两个特殊边界。

二、数位DP核心框架:四步走

新手 :那数位DP的通用思路是什么?有没有固定框架可以套?
导师:数位DP有非常固定的实现框架,核心是"把数字拆成各位→递归处理每一位→记忆化存储状态→处理边界条件",具体分四步:

步骤1:将数字拆分为各位数组

比如数字n=123,拆成数组digits = [1,2,3](高位到低位),方便按位处理。

步骤2:定义递归函数(核心)

递归函数通常设计为:dfs(pos, state, limit, lead),参数含义:

  • pos:当前处理到第几位(从高位往低位处理,比如pos=0对应百位,pos=1对应十位);
  • state:当前的状态(根据问题定,比如"各位数字之和""是否出现过数字6"等);
  • limit:布尔值,表示当前位是否受上界限制(比如处理123的十位时,若百位是1,十位最大只能是2,limit=true;若百位小于1,十位可以取0-9,limit=false);
  • lead:布尔值,表示当前是否是前导零(比如数字0012其实是12,前导零需要特殊处理)。

函数返回值:从当前位开始,满足条件的数字个数。

步骤3:记忆化搜索(Memoization)

用数组/哈希表存储(pos, state)的计算结果(仅当limit=falselead=false时,状态才可以复用,因为受限制的状态无法通用),避免重复计算。

步骤4:处理区间[L, R]

数位DP通常先实现f(x)函数(统计0~x中满足条件的数的个数),然后答案就是f(R) - f(L-1)(注意处理L=0的情况)。

三、入门例题:统计0~n中不含数字6的数的个数

新手 :先从最简单的例子入手吧!比如"统计0~n中不含数字6的数的个数",用数位DP怎么实现?
导师:这个是数位DP的"Hello World",咱们一步步拆解实现。

例题分析

  • 条件:数字的每一位都不能是6;
  • 状态定义:因为只需要判断"是否出现6",所以state可以简化为"是否已经出现6"(0=未出现,1=出现);
  • 目标:统计state始终为0的数字个数。

完整代码实现(C++)

cpp 复制代码
#include <iostream>
#include <vector>
#include <cstring> // 用于memset
using namespace std;

// 存储各位数字
vector<int> digits;
// 记忆化数组:dp[pos][state],pos是当前位,state是是否出现6(0/1)
// -1表示未计算
int dp[20][2];

// 递归函数:pos-当前处理位,state-是否出现6,limit-是否受上界限制,lead-前导零
int dfs(int pos, int state, bool limit, bool lead) {
    // 递归终止:处理完所有位
    if (pos == digits.size()) {
        // 前导零(数字0)也算合法,只要state=0(未出现6)
        return state == 0 ? 1 : 0;
    }
    // 记忆化:如果不受限制且不是前导零,直接返回已计算的结果
    if (!limit && !lead && dp[pos][state] != -1) {
        return dp[pos][state];
    }
    // 当前位能取的最大值:受限制则取digits[pos],否则取9
    int up = limit ? digits[pos] : 9;
    int res = 0; // 记录当前位的合法数个数
    
    // 遍历当前位的所有可能取值
    for (int i = 0; i <= up; ++i) {
        // 前导零且当前位是0:state不变,lead仍为true
        if (lead && i == 0) {
            res += dfs(pos + 1, state, limit && (i == up), true);
        } else {
            // 计算新的state:如果当前位是6,或之前已经出现6,state=1
            int new_state = state | (i == 6 ? 1 : 0);
            // 新的limit:当前位受限制且取到了最大值
            bool new_limit = limit && (i == up);
            // 前导零结束,lead=false
            res += dfs(pos + 1, new_state, new_limit, false);
        }
    }
    
    // 记忆化存储:仅当不受限制且不是前导零时存储
    if (!limit && !lead) {
        dp[pos][state] = res;
    }
    return res;
}

// 计算0~n中不含数字6的数的个数
int f(int n) {
    digits.clear();
    // 把n拆分为各位数字(高位到低位)
    if (n == 0) {
        digits.push_back(0);
    } else {
        while (n) {
            digits.insert(digits.begin(), n % 10);
            n /= 10;
        }
    }
    // 初始化记忆化数组为-1
    memset(dp, -1, sizeof(dp));
    // 从第0位开始递归,初始state=0(未出现6),limit=true(第一位受限制),lead=true(前导零)
    return dfs(0, 0, true, true);
}

// 统计[L, R]中不含数字6的数的个数
int countNo6(int L, int R) {
    // 处理L=0的情况,避免f(-1)
    return f(R) - (L == 0 ? 0 : f(L - 1));
}

int main() {
    int L = 1, R = 100;
    cout << "区间[" << L << "," << R << "]中不含数字6的数的个数:" << countNo6(L, R) << endl;
    // 输出:81(100个数中,含6的有19个:6,16,...,96共10个,60-69共10个,减去重复的66,总计19个,100-19=81)
    return 0;
}

代码核心解析

  1. 拆分数位f(n)函数将n拆分为高位到低位的数组(比如123→[1,2,3]),方便按位处理;
  2. 递归终止条件pos == digits.size()表示所有位处理完毕,此时只要state=0(未出现6),就计数1;
  3. 记忆化判断 :只有limit=false(不受上界限制)且lead=false(无前导零)时,状态才可以复用------因为受限制的位(比如十位只能取0-2)的结果不能用到其他情况(比如十位可以取0-9);
  4. 前导零处理 :前导零(比如0012)本身不算有效数字,所以当lead=truei=0时,state不更新,lead仍为true;
  5. 区间处理countNo6(L, R) = f(R) - f(L-1),这是数位DP处理区间的通用技巧。

新手 :这段代码我能看懂!但为什么记忆化只存储limit=false的状态?
导师:举个例子:处理数字123的十位时,若百位是1(limit=true),十位最大只能取2,此时计算的结果只能用于"百位是1"的情况;若百位小于1(limit=false),十位可以取0-9,此时的结果可以复用给所有"百位小于1"的情况。因此只有不受限制的状态才有复用价值,这是数位DP记忆化的关键。

四、进阶例题:统计[L, R]中各位数字之和等于sum的数的个数

新手 :如果条件变成"各位数字之和等于sum",该怎么改?
导师 :核心是修改state的定义------把state从"是否出现6"改为"当前各位数字之和",其余框架不变。

完整代码实现(C++)

cpp 复制代码
#include <iostream>
#include <vector>
#include <cstring>
using namespace std;

vector<int> digits;
// dp[pos][sum]:处理到第pos位,当前和为sum的合法数个数
int dp[20][180]; // 最多20位,各位和最大20*9=180

// pos-当前位,cur_sum-当前和,target_sum-目标和,limit-是否受限,lead-前导零
int dfs(int pos, int cur_sum, int target_sum, bool limit, bool lead) {
    if (pos == digits.size()) {
        // 前导零(数字0)的和为0,只有target_sum=0时计数1
        return (lead ? (target_sum == 0) : (cur_sum == target_sum)) ? 1 : 0;
    }
    if (!limit && !lead && dp[pos][cur_sum] != -1) {
        return dp[pos][cur_sum];
    }
    int up = limit ? digits[pos] : 9;
    int res = 0;
    for (int i = 0; i <= up; ++i) {
        if (lead && i == 0) {
            // 前导零,和不变
            res += dfs(pos + 1, cur_sum, target_sum, limit && (i == up), true);
        } else {
            int new_sum = cur_sum + i;
            // 剪枝:和超过目标,直接跳过
            if (new_sum > target_sum) break;
            res += dfs(pos + 1, new_sum, target_sum, limit && (i == up), false);
        }
    }
    if (!limit && !lead) {
        dp[pos][cur_sum] = res;
    }
    return res;
}

// 计算0~n中各位和等于target_sum的数的个数
int f(int n, int target_sum) {
    digits.clear();
    if (n == 0) {
        digits.push_back(0);
    } else {
        while (n) {
            digits.insert(digits.begin(), n % 10);
            n /= 10;
        }
    }
    memset(dp, -1, sizeof(dp));
    return dfs(0, 0, target_sum, true, true);
}

// 统计[L, R]中各位和等于sum的数的个数
int countSum(int L, int R, int sum) {
    return f(R, sum) - (L == 0 ? 0 : f(L - 1, sum));
}

int main() {
    int L = 1, R = 100, sum = 10;
    cout << "区间[" << L << "," << R << "]中各位和等于" << sum << "的数的个数:" << countSum(L, R, sum) << endl;
    // 输出:9(19,28,37,46,55,64,73,82,91)
    return 0;
}

核心修改点

  1. 状态定义dp[pos][cur_sum]表示处理到第pos位,当前各位和为cur_sum的合法数个数;
  2. 递归终止条件 :判断cur_sum == target_sum(非前导零)或target_sum == 0(前导零);
  3. 剪枝优化 :如果new_sum > target_sum,直接break(因为后续位都是非负数,和只会更大),减少递归次数;
  4. 参数传递 :递归函数增加target_sum参数,指定目标和。

五、数位DP的常见坑点与优化

新手 :学习数位DP容易踩哪些坑?有没有通用优化技巧?
导师:新手最容易踩的5个坑:

  1. 前导零处理:比如统计"回文数"时,前导零会导致0012被误判为回文数,必须单独处理;
  2. 记忆化条件 :只有limit=falselead=false时才存储状态,否则会导致结果错误;
  3. 状态范围:定义dp数组时要预估状态的最大值(比如各位和最大是20*9=180,dp数组要开够);
  4. 区间处理 :计算f(L-1)时要注意L=0的情况,避免出现负数;
  5. 剪枝时机:比如"各位和超过目标"时及时break,能大幅提升效率。

通用优化技巧:

  1. 剪枝:根据条件提前终止无效递归(比如和超过目标、已经出现非法数字);
  2. 状态压缩:如果状态是多个维度(比如"是否出现6"+"各位和"),可以将状态合并为一个整数(比如state = (has6 << 8) | cur_sum);
  3. 预处理:对于多次查询的场景,可以预处理数位数组和dp数组,避免重复初始化。

六、数位DP的经典应用场景

新手 :除了这两个例子,数位DP还能解决哪些问题?
导师:数位DP的应用场景非常广泛,核心是"数字的数位满足特定规则",常见场景:

  1. 数字合法性统计:不含某数字(如不含4、6)、包含某数字(如包含8)、各位数字满足大小关系(如非递减数123、非递增数321);
  2. 数字特征统计:各位和等于/大于/小于某值、数位乘积满足条件、是回文数、是质数(需结合质数判断);
  3. 组合计数:区间内满足条件的数的个数、第k个满足条件的数(需结合二分)。

七、总结

核心要点回顾

  1. 数位DP核心:按位处理数字+记忆化存储状态+处理limit(上界限制)和lead(前导零);
  2. 通用框架
    • 拆分数位为高位到低位的数组;
    • 定义递归函数dfs(pos, state, limit, lead)
    • 记忆化存储limit=falselead=false的状态;
    • f(R) - f(L-1)处理区间[L, R];
  3. 状态设计:根据问题条件定义state(如"是否出现6""当前各位和""是否是回文"等);
  4. 边界处理:重点关注前导零和上界限制,这是数位DP的核心难点。

学习建议

数位DP的核心是"框架固定,状态灵活"------只要掌握了通用框架,不同问题只需要修改状态定义和终止条件。建议你:

  1. 先吃透"不含数字6"的入门例题,手动推导递归过程,理解记忆化的作用;
  2. 练习"各位和等于sum""回文数统计"等进阶例题,掌握状态设计的技巧;
  3. 结合真题(比如NOIP、蓝桥杯的数位DP题)巩固,重点关注前导零和limit的处理。

记住:数位DP的关键不是背模板,而是理解"为什么要处理limit和lead""为什么只有不受限制的状态才能记忆化"------想清楚这两个问题,任何数位DP问题都能迎刃而解。

相关推荐
cch89181 小时前
汇编与Java:底层与高层的编程对决
java·开发语言·汇编
荒川之神2 小时前
拉链表概念与基本设计
java·开发语言·数据库
chushiyunen2 小时前
python中的@Property和@Setter
java·开发语言·python
小樱花的樱花2 小时前
C++ new和delete用法详解
linux·开发语言·c++
froginwe112 小时前
C 运算符
开发语言
fengfuyao9853 小时前
低数据极限下模型预测控制的非线性动力学的稀疏识别 MATLAB实现
开发语言·matlab
摇滚侠3 小时前
搭建前端开发环境 安装 nodejs 设置淘宝镜像 最简化最标准版本 不使用 NVM NVM 高版本无法安装低版本 nodejs
java·开发语言·node.js
t198751283 小时前
MATLAB十字路口车辆通行情况模拟系统
开发语言·matlab
yyk的萌3 小时前
AI 应用开发工程师基础学习计划
开发语言·python·学习·ai·lua
重生之我是Java开发战士4 小时前
【动态规划】简单多状态dp问题:按摩师,打家劫舍,删除并获得点数,粉刷房子,买卖股票的最佳时机
算法·动态规划·哈希算法