信奥赛C++提高组csp-s之数位DP详细讲解

信奥赛C++提高组csp-s之数位DP详细讲解

一、基本概念

数位DP(Digit DP)是一种用于解决数字位相关计数问题的动态规划方法,常用于统计满足特定条件的数字个数。典型应用场景包括:

  • 统计区间内包含不含某些数字的数的个数
  • 统计满足特定位模式的数的数量
  • 计算数字位属性的统计值(如数位和)
二、核心思想
  1. 数位拆分:将数字转换为字符串或数组逐位处理
  2. 状态压缩:记录前导零、是否受限、前位状态等关键信息
  3. 记忆化搜索:通过DP数组缓存中间计算结果
三、状态设计要素
  1. pos:当前处理的数位位置
  2. limit:前面是否已经达到上限
  3. pre:前一位数字的值
  4. lead:前导零状态
  5. 其他题目特定状态(如数位和、特定标记等)
四、经典案例1:[洛谷P2602 数字计数]

AC代码

cpp 复制代码
#include <bits/stdc++.h>
using namespace std;
#define ll long long 
int s[15];//存储拆位的数字
ll a,b;
//取数字n的[l,r]之间的数:s数组逆序存数位 
ll get(int l,int r){
	ll ans=0;
	for(int i=l;i>=r;i--) ans=ans*10+s[i];
	return ans;
} 
//求10的次方
ll p(int n){
	ll ans=1;
	while(n--) ans*=10;
	return ans;
} 
//求0到n中数字x出现的次数
ll dp(ll n,int x){
	if(n==0) return 0;
	int k=0;//k是数组s的下标 
	while(n){
		s[++k]=n%10;
		n/=10;
	}
	ll ans=0;
	//计算x在每一位上的可能(特殊情况:如果x是0,要考虑0不能出现在最高位) 
	for(int i=k-!x;i>=1;i--){//逆序循环:因为数组下标k存的n这个数的最高位 
		//情况1:前缀未取到up值的情况 
		if(i!=k) ans+=(get(k,i+1)-!x)*p(i-1);
		//情况2:前缀取到up值的情况 
		if(x==s[i]) ans+=get(i-1,1)+1;//2.1: x等于当前位的数,后面的只能取到最高位 
		else if(x<s[i]) ans+=p(i-1);//2.2: x小于当前位的数,后面的可以取满 
		
	}
	return ans;
}
int main() {
    cin>>a>>b;
    for(int i=0;i<=9;i++){//枚举统计每个数码在区间a到b中出现的次数 
    	cout<<dp(b,i)-dp(a-1,i)<<" "; //利用前缀和 
	}
    return 0;
}

代码功能

该代码统计区间 [a, b] 内每个数字(0-9)出现的次数。核心思想是逐位分析数字的每一位,计算每个数字在每一位上的出现次数,累加得到总数。代码通过 dp(n, x) 计算 [0, n] 内数字 x 的出现次数,最终结果为 dp(b, x) - dp(a-1, x)


关键函数解析
1. get(int l, int r)
  • 作用: 提取数字的高位部分。
  • 示例 : 若 s = [4,3,2](对应数字 234),get(3, 2) 返回 23(即高位部分 s[3]s[2] 组成的数字)。
2. p(int n)
  • 作用 : 计算 10n10n 的值。
3. dp(ll n, int x)
  • 步骤 :
    1. 拆位存储 : 将数字 n 逆序存入数组 s(如 234 存储为 s[1]=4, s[2]=3, s[3]=2)。
    2. 逐位分析 : 从最高位到最低位遍历每一位 i,计算 x 在该位的出现次数。
    3. 分情况计算 :
      • 高位自由组合 : 当 x 小于当前位的值,低位可以任意取值。
      • 高位受限 : 当 x 等于当前位的值,低位受限于原始数字。

以输入 100 234 为例,分析数字 0 的统计过程
1. 计算 dp(234, 0)
  • 拆位 : s = [4,3,2], k=3
  • 遍历每一位 :
    • 十位 (i=2) :
      • 高位部分 : get(3,3) = 2,计算 (2-1)*10^1 = 10(高位允许 0-1,共 2 种情况,但 x=0 时高位不能全为 0)。
      • 低位自由组合 : 因 0 < 3,低位可以取 0-9,加 10
      • 累计结果 : 10 + 10 = 20
    • 个位 (i=1) :
      • 高位部分 : get(3,2) = 23,计算 (23-1)*10^0 = 22
      • 低位自由组合 : 因 0 < 4,加 1
      • 累计结果 : 22 + 1 = 23,总和 20 + 23 = 43
2. 计算 dp(99, 0)
  • 拆位 : s = [9,9], k=2
  • 遍历每一位 :
    • 十位 (i=2) :
      • 高位部分 : get(2,3) 越界,不计入。
    • 个位 (i=1) :
      • 高位部分 : get(2,2) = 9,计算 (9-1)*1 = 8
      • 低位自由组合 : 因 0 < 9,加 1
      • 累计结果 : 8 + 1 = 9
3. 最终结果
  • 区间 [100, 234]0 的出现次数 : 43 - 9 = 34
五、经典案例1:[洛谷P2602 数字计数] --- 方法2(深搜+记忆化递归)

AC代码

cpp 复制代码
#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
//pos:当前处理的位置
//cnt:当前数字出现的次数
//limit:是否受到上限限制
//lead:是否有前导0 
ll dp[15][15][2][2];//dp[pos][cnt][limit][lead]
int a[15];//存储数字的每一位
int target;//当前统计的目标数字(0-9)

ll dfs(int pos,int cnt,bool limit,bool lead){
	if(pos==-1) return cnt;//递归终点,返回当前数字的出现次数
	if(dp[pos][cnt][limit][lead]!=-1){
		return dp[pos][cnt][limit][lead];//记忆化 
	} 
	int up=limit ? a[pos]:9;//当前位的上限
	ll res=0;
	for(int i=0;i<=up;i++){
		int new_cnt=cnt+(i==target);//更新当前数字的出现次数
		if(lead && i==0) new_cnt=0;//前导0不计入统计
		bool new_limit=limit && (i==up);
		bool new_lead=lead && (i==0);
		res+=dfs(pos-1,new_cnt,new_limit,new_lead);
	} 
	return dp[pos][cnt][limit][lead]=res;//记忆化 
}
ll solve(ll x){
	int pos=0;
	while(x){
		a[pos++]=x%10;//将数字拆分为每一位
		x/=10; 
	}
	memset(dp,-1,sizeof(dp));//初始化DP数组
	return dfs(pos-1,0,true,true);//从最高位开始搜索 
} 
int main(){
	ll a,b;
	cin>>a>>b;
	for(int i=0;i<=9;i++){
		target =i;//设置当前统计的目标数字
		ll ans=solve(b)-solve(a-1);//计算区间[a,b]内的出现次数
		cout<<ans<<" "; 
	} 
	return 0;
} 
解题思路
  1. 数位DP :将问题转化为统计 [1, b][1, a-1] 中每个数字出现的次数,然后相减。
  2. 状态设计
    • pos:当前处理的数位位置。
    • limit:当前是否受到上限限制。
    • lead:是否有前导零。
    • cnt:当前数字的出现次数。
  3. 记忆化搜索:通过 DP 数组缓存中间结果,避免重复计算。
代码分析
  1. DFS 函数
    • pos:当前处理的数位位置。
    • cnt:当前数字 target 的出现次数。
    • limit:是否受到上限限制。
    • lead:是否有前导零。
    • 递归终点:当 pos == -1 时,返回当前数字的出现次数 cnt
    • 记忆化:通过 dp[pos][cnt][limit][lead] 缓存结果,避免重复计算。
  2. 状态转移
    • 遍历当前位的所有可能值 i(从 0up)。
    • 更新 new_cnt:如果 i == target,则 cnt 加 1。
    • 处理前导零:如果 leadtruei == 0,则 new_cnt 重置为 0。
    • 更新 limitlead 状态。
  3. Solve 函数
    • 将数字 x 拆分为每一位,存储在数组 a 中。
    • 初始化 DP 数组为 -1
    • 调用 dfs 函数,从最高位开始搜索。
  4. 主函数
    • 输入区间 [a, b]
    • 对每个数字 0-9,调用 solve 函数计算其在 [a, b] 内的出现次数,并输出结果。
复杂度分析
  • 时间复杂度 :O(10×log⁡10(b)×10×2×2),其中 10 是数字范围,log10(b) 是数字位数,10cnt 的范围,2limitlead 的状态数。
  • 空间复杂度:O(15×15×2×2),用于存储 DP 数组。
六、经典案例2:[洛谷P2657 windy数] --- 方法(深搜+记忆化递归)
AC代码
cpp 复制代码
#include <bits/stdc++.h>
using namespace std;
/*
	pos(当前处理位,0~14)
	pre(前一位的值,0~9 + 特殊标记10表示前导零)
	limit(当前位是否受原数字限制,0/1)
	lead(是否为前导零状态,0/1)
*/ 

int a[15];
int dp[15][11][2][2];//pre中的10表示前导0状态 

int dfs(int pos,int pre,int limit,int lead) {
    if(pos==-1) return lead ? 0:1;  //递归终点:全前导零返回0,否则返回1(找到一个合法数)
    if(dp[pos][pre][limit][lead] != -1) 
        return dp[pos][pre][limit][lead];
    
    int up=limit ? a[pos]:9; // 当前位最大值(受原数字限制)
    int res=0; // 当前状态下的合法数字个数
    
    for(int i=0; i<=up; i++) {// 遍历当前位所有可能取值
        if(!lead && abs(i-pre)<2) continue;// 剪枝:非前导零时,检查相邻差是否≥2
         // 更新状态
		bool new_lead=lead&&(i==0); // 是否仍是前导零
        int new_pre=new_lead ? 10:i;// 前导零时pre标记为10
        bool new_limit=limit&&(i==up);// 更新limit
        res += dfs(pos-1,new_pre,new_limit,new_lead);
    }
    
    return dp[pos][pre][limit][lead]=res;// 记忆化
}

int solve(int x) {
	if(x==0) return 0; // 直接处理x=0的情况
    int pos=0;
    while(x){// 拆分数位到a数组
        a[pos++]=x%10;
        x/=10;
    }
    memset(dp,-1,sizeof(dp));// 重置记忆化数组
    return dfs(pos-1,10,true,true); // 初始状态:最高位、pre=10(前导零)、limit=true、lead=true 
}

int main() {
    int a,b;
    cin>>a>>b;
    cout<<solve(b)-solve(a-1);
    return 0;
}
代码功能解析

1. 全局变量与数据结构
复制代码
int a[20];               // 存储数字的每一位(逆序,低位在前)
int dp[20][11][2][2];    // 记忆化数组:pos(位)、pre(前一位值)、limit(是否受限)、lead(前导零)
  • a 数组 :存放待处理数字的每一位,例如数字 234 存储为 a = [4,3,2]pos 从0开始)。
  • dp 数组 :四维记忆化数组,维度分别为:
    • pos(当前处理位,0~19)
    • pre(前一位的值,0~9 + 特殊标记10表示前导零)
    • limit(当前位是否受原数字限制,0/1)
    • lead(是否为前导零状态,0/1)

2. 核心函数 dfs
复制代码
int dfs(int pos, int pre, int limit, int lead) {
    if (pos == -1) {
        return lead ? 0 : 1; // 递归终点:全前导零返回0,否则返回1(找到一个合法数)
    }
    if (dp[pos][pre][limit][lead] != -1) 
        return dp[pos][pre][limit][lead];
    
    int up = limit ? a[pos] : 9;     // 当前位最大值(受原数字限制)
    int res = 0;                     // 当前状态下的合法数字个数
    
    for (int i = 0; i <= up; ++i) {  // 遍历当前位所有可能取值
        // 剪枝:非前导零时,检查相邻差是否≥2
        if (!lead && abs(i - pre) < 2) 
            continue;
        
        // 更新状态
       	bool new_lead = lead && (i == 0);     // 是否仍是前导零
        int new_pre = new_lead ? 10 : i;     // 前导零时pre标记为10
        bool new_limit = limit && (i == up); // 更新limit
        res += dfs(pos-1, new_pre, new_limit, new_lead);
    }
    
    return dp[pos][pre][limit][lead] = res;  // 记忆化
}
  • 递归终止条件pos == -1 时,若仍有前导零(lead=true),返回0(如数字 000 无效);否则返回1(找到一个合法数字)。
  • 记忆化检查:若当前状态已计算过,直接返回缓存结果。
  • 当前位取值范围 :若 limit=true,最大值是原数字的当前位值 a[pos];否则为9。
  • 状态转移
    • 前导零处理 :若当前位是前导零(lead=true)且取值为0,则 new_lead 保持为true,pre 标记为10。
    • 相邻差判断:若非前导零状态,检查当前位与前一位的差值是否≥2,否则跳过。
  • 递归调用 :处理下一位,更新 limit(若当前位已达上限,则下一位受限)。

3. 初始化函数 solve

cpp

复制

复制代码
int solve(int x) {
    if (x == 0) return 0;        // 直接处理x=0的情况
    int pos = 0;
    while (x) {                  // 拆分数位到a数组
        a[pos++] = x % 10;
        x /= 10;
    }
    memset(dp, -1, sizeof(dp)); // 重置记忆化数组
    return dfs(pos-1, 10, 1, 1); // 初始状态:最高位、pre=10(前导零)、limit=1
}
  • 拆分数位 :将数字 x 逆序存入 a 数组,例如 234 存储为 [4,3,2]
  • 初始调用 :从最高位开始递归,pre=10 表示前导零状态,limit=1 表示最高位受原数字限制。

4. 主函数

cpp

复制

复制代码
int main() {
    int a, b;
    cin >> a >> b;
    int ans = solve(b) - solve(a-1); 
    cout << ans;
    return 0;
}
  • 区间计算 :利用前缀和思想,结果为 [0, b] 的Windy数减去 [0, a-1] 的Windy数。
七、洛谷OJ练习题单
  1. 入门级

    • P2657 [SCOI2009] windy数
    • P2602 [ZJOI2010] 数字计数
  2. 提高级

    • P4127 [AHOI2009] 同类分布
    • P4999 烦人的数学作业
    • P2106 Sam数
  3. 进阶挑战

    • P3286 [SCOI2014] 方伯伯的商场之旅
    • P4798 [CEOI2015 Day1] 卡尔文球锦标赛
    • P6754 [BalticOI 2013 Day1] Palindrome-Free Numbers
  4. 综合应用

    • P4317 花神的数论题
    • P6218 [USACO06NOV] Round Numbers S
    • P3311 [SDOI2014] 数数

更多系列知识,请查看专栏:《信奥赛C++提高组csp-s知识详解及案例实践》:
https://blog.csdn.net/weixin_66461496/category_13113932.html


各种学习资料,助力大家一站式学习和提升!!!

cpp 复制代码
#include<bits/stdc++.h>
using namespace std;
int main(){
	cout<<"##########  一站式掌握信奥赛知识!  ##########";
	cout<<"#############  冲刺信奥赛拿奖!  #############";
	cout<<"######  课程购买后永久学习,不受限制!   ######";
	return 0;
}

1、csp信奥赛高频考点知识详解及案例实践:

CSP信奥赛C++动态规划:
https://blog.csdn.net/weixin_66461496/category_13096895.html点击跳转

CSP信奥赛C++标准模板库STL:
https://blog.csdn.net/weixin_66461496/category_13108077.html 点击跳转

信奥赛C++提高组csp-s知识详解及案例实践:
https://blog.csdn.net/weixin_66461496/category_13113932.html

2、csp信奥赛冲刺一等奖有效刷题题解:

CSP信奥赛C++初赛及复赛高频考点真题解析(持续更新):https://blog.csdn.net/weixin_66461496/category_12808781.html 点击跳转

CSP信奥赛C++一等奖通关刷题题单及题解(持续更新):https://blog.csdn.net/weixin_66461496/category_12673810.html 点击跳转

3、GESP C++考级真题题解:

GESP(C++ 一级+二级+三级)真题题解(持续更新):https://blog.csdn.net/weixin_66461496/category_12858102.html 点击跳转

GESP(C++ 四级+五级+六级)真题题解(持续更新):https://blog.csdn.net/weixin_66461496/category_12869848.html 点击跳转

GESP(C++ 七级+八级)真题题解(持续更新):
https://blog.csdn.net/weixin_66461496/category_13117178.html

4、CSP信奥赛C++竞赛拿奖视频课:

https://edu.csdn.net/course/detail/40437 点击跳转

· 文末祝福 ·

cpp 复制代码
#include<bits/stdc++.h>
using namespace std;
int main(){
	cout<<"跟着王老师一起学习信奥赛C++";
	cout<<"    成就更好的自己!       ";
	cout<<"  csp信奥赛一等奖属于你!   ";
	return 0;
}
相关推荐
轩情吖2 小时前
Qt多元素控件之QTreeWidget
开发语言·c++·qt·控件·qtreewidget·桌面级开发
轩情吖2 小时前
Qt多元素控件之QTableWidget
开发语言·c++·qt·表格·控件·qtablewidget
王老师青少年编程2 小时前
信奥赛C++提高组csp-s之状压DP详解及编程实例
c++·动态规划·csp·状压dp·信奥赛·csp-s·提高组
张张努力变强2 小时前
C++ 类和对象(五):初始化列表、static、友元、内部类等7大知识点全攻略
开发语言·数据结构·c++·算法
草莓熊Lotso2 小时前
Qt 显示与输入类控件进阶:数字、进度、输入框实战攻略
java·大数据·开发语言·c++·人工智能·qt
HellowAmy2 小时前
我的C++规范 - 指针指向
开发语言·c++·代码规范
小屁猪qAq2 小时前
ROS2 节点中使用参数
开发语言·c++·参数·ros2
CSDN_RTKLIB2 小时前
多线程锁基础
c++
坐怀不乱杯魂2 小时前
Linux网络 - Socket编程(IPv4&IPv6)
linux·服务器·网络·c++·udp·tcp