news 2026/6/26 6:04:27

数位DP:从“穷举数字”到“逐位拆解”

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
数位DP:从“穷举数字”到“逐位拆解”

引言

在算法竞赛中,我们经常遇到一类问题:给定一个区间 [L,R][L,R],统计其中满足某种条件的整数个数。条件可能是“数字中不能出现 4”、“相邻两位之差至少为 2”,或者“统计每个数码出现的次数”。当区间范围大到 10181018 甚至 1010010100 时,暴力枚举显然不可行。

数位 DP(Digit DP)正是为解决这类问题而生的。它的核心思想是:按位构造——从最高位到最低位一位一位地“拼”出数字,在拼的过程中进行动态规划。由于同一状态会被多次遇到,我们使用记忆化搜索来避免重复计算。

如果说暴力枚举是“从 1 数到 n,一个一个检查”,那么数位 DP 就是“像搭积木一样逐位组装数字,用记忆化跳过重复的组装过程”——它把 O(n)O(n) 的枚举优化到了 O(log⁡n)O(logn) 的量级。


前置知识

在学习数位 DP 之前,你需要掌握以下基础:

  1. 数位:一个数字的每一位。例如,数字 1234 的数位是 1、2、3、4。

  2. 前缀和思想:统计 [L,R][L,R] 内的答案,转化为 [1,R][1,R] 的答案减去 [1,L−1][1,L−1] 的答案。

  3. 记忆化搜索:将已经计算过的状态缓存起来,下次遇到时直接复用。

  4. 位运算基础:虽然数位 DP 不一定用到位运算,但理解二进制表示有助于理解某些变种。


第一章:数位DP的核心思想

1.1 从暴力到数位DP

考虑一个简单问题:统计 [1,n][1,n] 中不含数字 4 的整数个数。暴力做法是从 1 到 n 逐个检查,复杂度 O(nlog⁡n)O(nlogn)。当 n=109n=109 时,显然无法接受。

观察计数过程:从 7000 数到 7999、从 8000 数到 8999、从 9000 数到 9999,后三位都是从 000 变到 999,过程完全一样,只有千位不同。数位 DP 正是利用这种重复性:把“后三位从 000 到 999 的计数”预处理好,存到一个通用数组中,高位枚举时直接复用。

1.2 按位枚举

数位 DP 通常从最高位开始枚举每一位可以填的数字。例如,统计 [1,n][1,n] 中不含 4 的数字个数,设 n=3456n=3456:

  • 第 1 位(千位):可以填 0、1、2、3(不能超过 3)。填 0 时,后三位任意(000~999);填 1、2 时同理;填 3 时,需要继续看下一位。

  • 第 2 位(百位):如果千位填了 3,百位可以填 0、1、2、3、4,但不能超过 4……

  • 依此类推。

1.3 状态设计

数位 DP 的状态通常包含以下几个维度:

状态维度含义典型取值
pos当前处理到第几位从高位到低位
state与条件相关的状态信息前一位数字、是否已经出现过某个数字等
limit当前是否受到上界限制true/false
lead当前是否处于前导零状态true/false

前导零(leading zero)是指数字前面的 0,例如数字 5 在 4 位数中写作 0005,其中的 0 就是前导零。前导零在某些问题中需要特殊处理。


第二章:记忆化搜索实现

2.1 模板框架

数位 DP 最常用的实现方式是记忆化搜索。以下是一个通用的模板框架:

typedef long long ll; int digit[20]; // 存储 n 的每一位 ll dp[20][state][2]; // 记忆化数组 // pos: 当前处理到第几位(从高位到低位) // state: 当前状态(根据题目定义) // limit: 当前是否受到上界限制 // lead: 当前是否处于前导零状态 ll dfs(int pos, int state, bool limit, bool lead) { if (pos == 0) return 1; // 已经处理完所有位,返回 1(表示这是一个合法数字) if (!limit && !lead && dp[pos][state] != -1) return dp[pos][state]; // 记忆化:只有不受限制且不是前导零时才能复用 int up = limit ? digit[pos] : 9; // 当前位能填的最大数字 ll ans = 0; for (int i = 0; i <= up; i++) { // 根据题目条件判断 i 是否合法 if (!valid(i, state, lead)) continue; ans += dfs(pos - 1, new_state, limit && (i == up), lead && (i == 0)); } if (!limit && !lead) dp[pos][state] = ans; return ans; } ll solve(ll x) { if (x < 0) return 0; int len = 0; while (x) { digit[++len] = x % 10; x /= 10; } memset(dp, -1, sizeof(dp)); return dfs(len, init_state, true, true); }

关键点

  • limit参数控制当前位是否能自由填 0~9。如果limit = true,当前位不能超过digit[pos];否则可以填 0~9。

  • lead参数控制前导零。当lead = true且当前填 0 时,下一位仍然处于前导零状态。

  • 记忆化的前提是!limit && !lead,因为受限制或前导零的状态不是“通用”的,不能复用。

2.2 两种实现方式对比

实现方式优点缺点
记忆化搜索代码清晰、易于扩展、不易出错递归有栈开销
循环递推常数小、无递归代码复杂、状态设计困难

OI Wiki 指出,统计答案可以选择记忆化搜索,也可以选择循环迭代递推。对于初学者,强烈推荐记忆化搜索,因为它更直观、更容易调试。


第三章:性质与复杂度

性质说明
时间复杂度O(位数×状态数×10)O(位数×状态数×10),通常为 O(log⁡n×S)O(logn×S),其中 SS 是状态数
空间复杂度O(log⁡n×S)O(logn×S)
适用数据范围nn 可达 10181018 甚至 1010010100(需要用字符串存储)
核心技巧前缀和相减、记忆化搜索、状态压缩
常见条件不含某数字、相邻数字差限制、数字和限制、回文数等

第四章:例题与详细解析

例题1:Windy数 —— 洛谷 P2657

题目描述
Windy 定义了一种 Windy 数:不含前导零且相邻两个数字之差至少为 2的正整数被称为 Windy 数。给定 AA 和 BB,求 [A,B][A,B] 中 Windy 数的个数。

输入示例

1 10

输出示例

9

解题思路
这是一道经典的数位 DP 模板题。核心状态是上一位填了什么数字,因为判断当前位是否合法需要知道上一位的值。

详细解析

第一步:状态设计

  • pos:当前处理到第几位

  • last:上一位填的数字(用-2表示初始状态,确保最高位没有限制)

  • limit:是否受上界限制

  • lead:是否处于前导零状态

第二步:预处理(非必须,但可以加速)

也可以用递推预处理 f[i][j]f[i][j] 表示长度为 ii、最高位为 jj 的 Windy 数个数:

cpp

for (int i = 0; i <= 9; i++) f[1][i] = 1; // 1 位数都是 Windy 数 for (int len = 2; len <= 10; len++) { for (int i = 0; i <= 9; i++) { for (int j = 0; j <= 9; j++) { if (abs(i - j) >= 2) f[len][i] += f[len-1][j]; } } }

第三步:记忆化搜索实现

ll dfs(int pos, int last, bool limit, bool lead) { if (pos == 0) return 1; if (!limit && !lead && dp[pos][last+2] != -1) return dp[pos][last+2]; int up = limit ? digit[pos] : 9; ll ans = 0; for (int i = 0; i <= up; i++) { if (!lead && abs(i - last) < 2) continue; // 非前导零时检查差值 ans += dfs(pos - 1, i, limit && (i == up), lead && (i == 0)); } if (!limit && !lead) dp[pos][last+2] = ans; return ans; }

第四步:答案计算

ll solve(ll x) { if (x == 0) return 0; int len = 0; while (x) { digit[++len] = x % 10; x /= 10; } memset(dp, -1, sizeof(dp)); return dfs(len, -2, true, true); } // 主函数 cout << solve(R) - solve(L - 1) << endl;

时间复杂度:O(位数×10×状态数)≈O(10×10×2)=O(200)O(位数×10×状态数)≈O(10×10×2)=O(200) 每次查询。


例题2:数字计数 —— 洛谷 P2602

题目描述
给定两个正整数 aa 和 bb,求在 [a,b][a,b] 中的所有整数中,每个数码(0~9)各出现了多少次。

输入示例

1 99

输出示例

9 20 20 20 20 20 20 20 20 20

解题思路
需要对 0~9 每个数码分别做一次数位 DP。状态设计为:当前已经统计的该数码出现了多少次。

详细解析

第一步:状态设计

  • pos:当前处理到第几位

  • cnt:当前数码已经出现的次数

  • limit:是否受上界限制

  • lead:是否处于前导零状态(统计 0 时需要特殊处理)

第二步:记忆化搜索实现

ll dfs(int pos, int cnt, bool limit, bool lead, int target) { if (pos == 0) return cnt; if (!limit && !lead && dp[pos][cnt] != -1) return dp[pos][cnt]; int up = limit ? digit[pos] : 9; ll ans = 0; for (int i = 0; i <= up; i++) { int add = 0; if (!(lead && i == 0) && i == target) add = 1; // 非前导零且等于目标数码 ans += dfs(pos - 1, cnt + add, limit && (i == up), lead && (i == 0), target); } if (!limit && !lead) dp[pos][cnt] = ans; return ans; }

第三步:处理前导零

统计数码 0 时,前导零不能计入。例如数字 5 在 3 位数中写作 005,前面的两个 0 不应被统计。上面的代码通过lead && i == 0判断是否为前导零,如果是则不加 1。

第四步:答案计算

for (int target = 0; target <= 9; target++) { memset(dp, -1, sizeof(dp)); ll ansR = dfs(len, 0, true, true, target); // 需要对 a-1 再做一次相减 cout << ansR - ansL << " "; }

时间复杂度:O(10×位数×状态数)≈O(10×10×10)=O(1000)O(10×位数×状态数)≈O(10×10×10)=O(1000) 每次查询。


第五章:常见变体与应用场景

变体核心思路典型例题
不含某数字枚举时跳过该数字P2657(不含前导零且相邻差≥2)
数字和限制状态中记录数位和求数位和能被某数整除的数
回文数记录前半部分,后半部分对称判断
二进制数位DP按二进制位枚举不含连续 1 的个数
AC自动机+数位DP多模式串匹配

总结

数位 DP 通过逐位构造 + 记忆化搜索,将指数级的暴力枚举优化为多项式级。它的核心在于:

  1. 前缀和相减:[L,R][L,R] 的答案 = [1,R]−[1,L−1][1,R]−[1,L−1]。

  2. 状态设计:抓住题目条件的本质,设计合适的状态维度。

  3. 记忆化:只有不受限制且非前导零的状态才能复用。

从 P2657(Windy数)的“相邻差限制”到 P2602(数字计数)的“统计每个数码出现次数”,数位 DP 的套路高度统一:写一个dfs(pos, state, limit, lead),在枚举当前位数字时进行条件判断。掌握这个模板,你就能应对绝大多数数位 DP 题目。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/6/26 6:04:20

Calico IPIP CrossSubnet 与 IPIP 默认模式对比模式介

使用场景 参考官网文档 部署流程 本文分别部署默认 IPIP 模式与 IPIP CrossSubnet 模式&#xff0c;分别在请求同网段、不同网段时进行抓包对比 1.通过脚本快速生成 IPIP 默认模式 #!/bin/bashset -v# 1. Prepare NoCNI environment cat <<EOF | HTTP_PROXY HTTPS_P…

作者头像 李华
网站建设 2026/6/26 6:02:50

货运物流系统源码:支持多仓库管理

温馨提示&#xff1a;文末有资源获取方式~~在物流行业蓬勃发展与居民生活需求日益增长的背景下&#xff0c;货运搬家系统成为提高运输效率、降低运营成本的重要工具。一套成熟的货运搬家系统需要整合多方面技术&#xff0c;实现从订单管理到运输执行的全流程数字化。接下来&…

作者头像 李华
网站建设 2026/6/26 6:02:46

如何用AI防爆摄像机实现港口船舶零漏报偏航监测?

港口船舶偏航是引发碰撞、搁浅等重大事故的主要原因之一。传统的人工瞭望和雷达监测虽然有一定作用&#xff0c;但在恶劣天气、夜间或复杂航道环境下&#xff0c;漏报率偏高。AI防爆摄像机的出现&#xff0c;为港口船舶偏航监测提供了一种更智能、更可靠的解决方案。本文将从技…

作者头像 李华
网站建设 2026/6/26 6:02:13

超长型材拉弯加工,实测数据与效果差异几何?

超长型材拉弯加工实测&#xff1a;以梵希拉弯与四家对标主体为样本一、实测核心维度弧度精度&#xff1a;实测弯曲半径与设计值的偏差&#xff08;单位&#xff1a;mm&#xff09;。 回弹控制&#xff1a;卸载后型材的回弹量&#xff08;单位&#xff1a;mm&#xff09;。 表面…

作者头像 李华
网站建设 2026/6/26 5:58:15

航空DIC变形测量技术

航空测试里的DIC&#xff0c;到底解决了什么问题&#xff1f; 做航空结构测试的工程师对应变片不会陌生。机翼静力试验&#xff0c;上百片应变片贴满翼面&#xff0c;每个测点给你一个位置的应变值。数据可靠&#xff0c;精度够用&#xff0c;几十年的工程实践证明了这一点。但…

作者头像 李华
网站建设 2026/6/26 5:56:56

Claude Code 连续修复后台 Agent,开发团队该补哪些防线

Claude Code 最近的 changelog 里没有一个适合大标题炫耀的新模型&#xff0c;但 2.1.191、2.1.187、2.1.186 这些版本修了后台 agent、MCP、权限提示、凭证读取和长时间阻塞问题。对工程团队来说&#xff0c;这类“小修”往往比演示视频更接近真实风险。 更新里最值得看的不是…

作者头像 李华