CC++ & Algorithm

数位动态规划:按位统计数字

较难2
语言版本:C++
概述:将数字拆成一位一位来统计,解决区间内满足条件的数的个数。

数位动态规划:像数位沙漏一样统计数字

这是什么?用来干什么?

数位动态规划(数位DP) 是一种专门用来解决“在某个区间内,满足某种条件的数有多少个”的问题的算法。比如:

  • 从 1 到 1000 中,有多少个数不包含数字 5?
  • 从 1 到 1 亿中,有多少个数是偶数?
  • 从 1 到 2023 中,有多少个数的各位数字之和等于 10?

如果直接枚举每个数,当区间很大(比如上亿)时,电脑会算到天荒地老。数位DP的核心思想是:不一个一个数,而是一位一位地“组装”数字。就像用沙漏计时,每次只关注一位数字,再结合之前的状态,快速统计出总数。


一、核心思想:按位统计,记忆化递归

1. 把数字拆成位

假设我们要统计 1 到 N 之间的数,首先把 N 的每一位拆开,存进数组。比如 N = 3456,拆成:

digits[0] = 6  (个位)
digits[1] = 5  (十位)
digits[2] = 4  (百位)
digits[3] = 3  (千位)

我们从最高位(千位)开始,一位一位地决定填什么数字。每填一位,剩下的位就可以用相同的方法继续。

2. 两个关键概念:tight 和 state

  • tight(边界约束):表示前面已经填的位是否完全和 N 的对应位一样。如果 tight = true,当前位就不能随便填,最大只能填 N 在这一位上的数字;如果 tight = false,说明前面已经有某位比 N 小了,后面所有位都可以填 0~9。
  • state(状态):用来记录前面已经满足的条件。比如“是否出现过数字5”“数字和是多少”“是否已经用了某个数字”等。具体是什么由题目决定。

3. 记忆化搜索

递归时,我们会遇到很多相同的情况:比如当前在第 3 位,前面已经填过的状态相同,而且 tight = false(即不受上界限制了)。这时递归结果是一样的,我们可以用 dp[pos][state] 存下来,下次直接取,不用重复计算。这就是记忆化


二、生活比喻:密码锁统计

想象一个四位密码锁,每个位置可以转 0~9。老板要求:密码中不能出现重复的数字。请问一共有多少个符合要求的密码?

我们可以从第一位开始,每个位置选一个数字,记住前面选了哪些数字。第一位可以选 0~9(共10种);第二位选了后,不能和第一位重复。这样一位一位选下去,就是数位DP的思想。

如果密码锁的上限是“7 3 5 1”(即最大密码是7351),那么计算1~7351中不重复数字的密码个数时,就需要考虑 tight —— 第一位不能超过7,第二位不能超过3(如果第一位正好=7),等等。


三、状态设计详解

标准的数位DP状态由三部分组成:

dp[pos][state][tight]
  • pos:当前处理到第几位(从最高位开始,通常 pos=0 表示个位,但实现时常用 pos 从 len-1 递减到 0)
  • state:根据题目需求定义的状态。例如统计不含数字5时,state 可以省略(因为不需要记录额外信息),只需要一个 dp[pos][tight]
  • tight:0 或 1,表示当前是否受上界限制。

示例:统计 1~N 中不含数字5的个数

这个例子中,状态只需要 pos 和 tight。为什么不需要 state?因为“不含5”这个条件只和当前位有关,不需要记住之前出现过的数字。只要当前位不是5即可。

int dp[10][2]; // dp[pos][tight],-1表示未计算

注意:有时候需要额外状态

比如统计“数字中不出现连续两个数字相同”的数量,就需要记录上一位是什么数字(state = 上一位数字)。再比如统计“数字和等于某个值”,就需要记录当前数字和(state = 当前和)。


四、代码逐步解释(以“不含5”为例)

完整代码后面会给出,这里先解析关键部分。

1. 预处理:将上限 x 拆成位

int digits[10]; // 存储上限数字的每一位,digits[0]是最低位(个位)
int len = 0;
while (x) {
    digits[len++] = x % 10;
    x /= 10;
}
// 此时 digits 是倒序存放的,比如 x=1234,得到 digits[0]=4,digits[1]=3,digits[2]=2,digits[3]=1
// 递归时我们从 len-1 开始(最高位)

2. 记忆化递归函数

int dfs(int pos, bool tight) {
    if (pos == -1) return 1; // 所有位都处理完,说明得到了一个有效数字,返回1
    // 如果当前状态已经算过,直接返回(仅当 tight=false 时才能用记忆,因为 tight=true 的结果只出现一次)
    if (!tight && dp[pos][0] != -1) return dp[pos][0];
    
    int limit = tight ? digits[pos] : 9; // 当前位能填的最大值
    int ans = 0;
    for (int d = 0; d <= limit; d++) {
        if (d == 5) continue; // 跳过数字5
        ans += dfs(pos-1, tight && (d == limit)); // 如果当前位填了上限值,且之前也tight,则继续tight
    }
    // 记忆化:只有不受限的情况才存,因为受限的情况每次上限不同
    if (!tight) dp[pos][0] = ans;
    return ans;
}

重要细节:记忆化只对 tight==false 的情况有效。因为 tight==true 时,当前位的上限是特定的,下次可能不同(比如上限不同或位置不同),不能共用。

3. 主函数:计算区间 [L, R]

int count(int x) {
    if (x < 0) return 0;
    // 拆位(前面已写)
    memset(dp, -1, sizeof dp); // 重置记忆化数组
    return dfs(len-1, true);
}
int main() {
    int L = 1, R = 1000;
    cout << count(R) - count(L-1) << endl; // 区间内个数 = 1~R的个数 - 1~(L-1)的个数
    return 0;
}

五、完整可运行代码示例(含详细注释)

下面是一个完整的 C++ 代码,统计 1~N 中不含数字5的个数,并测试了几个例子。

#include <iostream>
#include <cstring>
using namespace std;

int dp[10][2];   // dp[pos][tight],-1表示未计算
int digits[10];  // 存储上限数字的每一位,digits[0]存放个位

// 深度优先搜索:pos是当前处理的位置(从高位到低位,如len-1到0),tight表示前面是否完全等于上限
int dfs(int pos, bool tight) {
    if (pos == -1) return 1; // 所有位都处理完,得到一个有效数,计数1
    // 记忆化:如果 tight=false 并且已经算过,直接返回
    if (!tight && dp[pos][0] != -1) return dp[pos][0];
    
    int limit = tight ? digits[pos] : 9; // 当前位可以填的最大数字
    int ans = 0;
    for (int d = 0; d <= limit; d++) {
        if (d == 5) continue; // 不能包含数字5,跳过
        // 继续递归下一位:新tight = 原tight && (当前位d是否等于limit)
        ans += dfs(pos - 1, tight && (d == limit));
    }
    // 记录记忆化:只记录不受限的情况
    if (!tight) dp[pos][0] = ans;
    return ans;
}

// 计算 1~x 中符合条件的数字个数(x可以是0)
int count(int x) {
    if (x < 0) return 0;
    int len = 0;
    // 将x的每一位存入digits,注意个位在索引0
    while (x) {
        digits[len++] = x % 10;
        x /= 10;
    }
    memset(dp, -1, sizeof dp); // 每次重新计算前清空记忆化数组
    return dfs(len - 1, true);
}

int main() {
    // 测试区间 [1, 1000]
    int L = 1, R = 1000;
    int result = count(R) - count(L - 1);
    cout << "区间[" << L << "," << R << "]中不含5的数字个数: " << result << endl;
    
    // 另外测试几个区间
    cout << "1~10中不含5的个数: " << count(10) - count(0) << endl;   // 10个数中只有5被排除,应输出9
    cout << "1~100中不含5的个数: " << count(100) - count(0) << endl; // 每10个中有一个5,90个?实际81个(因为每10个中有一个5,但50-59有10个含5,所以一共19个含5,100-19=81)
    return 0;
}

运行结果示例

区间[1,1000]中不含5的数字个数: 729
1~10中不含5的个数: 9
1~100中不含5的个数: 81

六、常见错误与注意事项

1. 忘记记忆化条件

错误:在 tight=true 时也使用记忆化。 后果:当上限不同时,结果不一样,但用了缓存导致错误。 正确做法:只在 tight=false 时存和取 dp[pos][state]

2. 位数的边界处理

  • 如果上限 x=0,循环拆位时 while (x) 不会执行,导致 len=0,递归时 pos = -1 直接返回1。这样 count(0) 返回1(代表数字0本身)。但通常我们统计从1开始,所以用 count(R)-count(L-1) 时,L=1时 count(0) 就是1(数字0),而0是否算有效?一般题目说“正整数”,则0不包含,可以在 count 函数中特判:如果 x<=0 返回0,或返回时减掉0。
  • 处理前导零:有些题目要求数字不能有前导零(比如统计不含5的正整数,0012实际上是12,前导零不计入)。这时需要在递归中加入一个 lead 状态(是否还在前导零阶段),并在最高位处理时限制。我们的例子中不需要,因为0本身也是一个数字,且不含5的0可以算作一个数。

3. 递归参数顺序

递归时 pos 从高位到低位,limit = digits[pos] 注意 digits 数组是倒序存放的,如 N=3456,则 digits[3]=3(千位),digits[2]=4(百位),等等。通常我们写循环 for pos = len-1 down to 0,但递归时从 len-1 开始传入即可。

4. 状态 state 的初始值

如果 state 需要记录之前的信息(比如数字和、前一个数字),递归开始时 state 要设为合适的初始值(比如0表示还没有数字,或者 -1 表示前一个数字不存在)。


七、变式与扩展

数位DP非常灵活,可以解决各种数字统计问题,只需要调整状态和条件。

题目要求需要记录的状态
不含数字5无(只用pos和tight)
偶数(个位是偶数)无(只需在最后一位判断是否偶数)
数字之和等于S当前数字和 sum
不包含连续两个相同数字上一位的数字 last
数字不重复(每位不同)一个mask(用二进制表示哪些数字出现过)
包含“666”子串当前已匹配“666”的长度(0/1/2/3)

例子1:统计1~N中各位数字之和等于10的个数

状态:dp[pos][sum][tight],其中 sum 表示当前已经填的数字之和。递归时,如果 sum 已经大于目标值就可以剪枝。

例子2:统计1~N中不出现连续两个相同数字的个数

状态:dp[pos][last][tight],last 表示上一位数字(0~9),初始可以设 last = -1 表示还没有上一位。递归时,当前位不能等于 last。


八、相关指引

  • 动态规划基础:如果你还不熟悉记忆化搜索,可以先学习经典的斐波那契数、爬楼梯等递归+记忆化问题。
  • 区间统计技巧f(R) - f(L-1) 是处理区间问题的通用方法,数位DP中非常常用。
  • 状态压缩:当状态需要用二进制记录(如数字是否出现过),可以学习状态压缩DP(如旅行商问题)中的位运算技巧。
  • 其他高级数位DP:可以用数位DP解决回文数、数字的平方和、数字的模等复杂问题。

数位DP像一把精确的尺子,帮我们“数”出大数据范围内的数字,而不需要真的一个个去数。掌握它,你就能轻松解决各种“1到1亿”的统计难题啦!

例题精讲

1单选题

在数位动态规划中,使用记忆化递归进行按位统计时,通常需要记录哪些状态参数来确保结果的正确性和避免重复计算?以下哪个参数通常不是必需的?

A当前处理到的位置(pos)
B当前是否已经达到上限(limit)
C当前是否已经是非前导零状态(lead)
D当前数字的奇偶性
2判断题

在数位DP中,若采用记忆化搜索,对于每个状态(pos, limit, lead, ...),无论limit为true还是false,其计算结果都可以被缓存复用,因为后续访问该状态时结果一定相同。

3单选题

在数位DP中,统计[0, N]范围内不含连续两个相同数字的数字个数时,状态通常需要记录前一个数字以确保不连续。以下哪一项不是实现中必须考虑的因素?

A前导零的情况,例如数字'0'作为首个数字时,前一个数字可以认为不存在
B当前数字是否等于前一个数字
C当前位是否达到上限
D记录已经使用的数字集合
4判断题

数位DP可以用于统计区间内二进制表示中1的个数总和,但需要针对二进制数位进行DP,状态同样包括位数、上限和前导零(不考虑前导零因为二进制没有前导零问题)。

5填空题
在数位DP中,为了跳过数字4,循环内应添加判断:if (___) continue; 其中变量d为当前位数字。请填写条件表达式。