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问题都能迎刃而解。

相关推荐
Coder_Boy_1 小时前
Java高级_资深_架构岗 核心知识点——高并发模块(底层+实践+最佳实践)
java·开发语言·人工智能·spring boot·分布式·微服务·架构
小龙报1 小时前
【算法通关指南:数据结构与算法篇】二叉树相关算法题:1.二叉树深度 2.求先序排列
c语言·开发语言·数据结构·c++·算法·贪心算法·动态规划
yy.y--1 小时前
Java线程实现浏览器实时时钟
java·linux·开发语言·前端·python
仰泳的熊猫2 小时前
题目1529:蓝桥杯算法提高VIP-摆花
数据结构·c++·算法·蓝桥杯
yaoxin5211232 小时前
327. Java Stream API - 实现 joining() 收集器:从简单到进阶
java·开发语言
小糯米6012 小时前
C++ 树
数据结构·c++·算法
golang学习记2 小时前
Go 语言中和类型(Sum Types)的创新实现方案
开发语言·golang
掘根2 小时前
【C++STL】红黑树(RBTree)
数据结构·c++·算法
我笑了OvO2 小时前
常见位运算及其经典算法题(1)
c++·算法·算法竞赛