数位 DP(下)

发表于 2025-07-11 3030 字 16 min read

文章目录
数位 DP 的状态设计与记忆化条件,以不含 4 和 62 的数为完整例子。

本篇对应源码见仓库 solutions/xcpc/algorithm/dp/digit/。

我的总结

  1. 核心是枚举每一位,使用记忆化搜索
  2. 需要明白什么时候可以记忆化,也就是说什么样的状态是可以复用的
  3. 记忆化数组 dpdp 的状态定义: dp[pos][state]dp[pos][state] ,pospos 是当前处理的数位, statestate 是状态,可以是多个状态
  4. dfsdfs 的设计,参数有哪些。

那么解题需要分析以下问题:

  1. dpdp 数组的定义(需要几维,表示什么状态)
  2. dpdp 数组的状态什么时候可以复用
  3. ansans 的定义和计算方法
  4. dfsdfs 需要哪些参数
  5. 递归的出口条件以及返回值是什么
  6. 什么时候可以进行递归调用加到 ansans 中
  7. 什么时候可以对当前状态进行记忆化

别担心,我会从头开始,用最通俗易懂的方式,一步一步带你学会数位DP。我们开始吧!

什么是数位DP?

想象一下,让你统计从1到10000之间,所有不包含数字“4”的数的个数。

  • 暴力做法:你可以写一个循环,从1遍历到10000,对每个数,判断它每一位是不是4。如果不是,计数器加一。这很简单,但如果范围变成1到101810^{18}呢?循环肯定超时了。

  • 数位DP的用武之地:数位DP就是为了解决这种在给定区间 [L,R][L, R] 内,统计满足特定性质的数的个数的问题。它的核心思想是按位(从高位到低位)构造满足条件的数。

数位DP的核心思想与模板

数位DP的本质是一种记忆化搜索。我们不关心具体的数字是几,只关心构造到当前位置(pos)时,前面的数字满足了什么状态(state),以及当前位可以填什么数字。

通常,我们会将问题 solve(L, R) 转化为 count(R) - count(L-1),其中 count(x) 函数计算的是区间 [1, x] 内满足条件的数的个数。这样问题就统一了。

现在,我们来设计一个函数 dfs(pos, state, limit, leading_zero) 来进行记忆化搜索。这个函数的名字和参数不是固定的,但含义基本一致:

  • pos: 当前正在处理的是数字的第几位(通常从高位向低位,或者反过来,但从高位开始更直观)。

  • state: 一个或多个变量,用来记录之前位的状态对当前位选择的影响。这是数位DP最灵活的部分,需要根据具体题目来设计。比如,前面是否已经出现了数字“6”,或者前面所有数字的和是多少等等。

  • limit (关键参数): 一个布尔值,表示当前位可以填的数字是否受限制。

    • limit = true: 表示我们之前填的每一位数字,都和上限 x 的对应位数字完全一样。因此,当前位 pos 能填的数字最大不能超过 x 在这一位的数字。例如,我们要统计 [1, 567],当前处理到第二位(十位),且第一位(百位)填了5,那么这一位就只能填0到6。

    • limit = false: 表示我们之前填的某一位数字已经比 x 的对应位要小了。那么从当前位 pos 开始,后面所有的位都可以随便填(0到9),因为无论怎么填,最后组成的数都肯定小于 x。例如,统计 [1, 567],第一位我们填了4,那么后面两位就可以随便填00到99了。

  • leading_zero (常用参数): 一个布尔值,表示当前是否处于前导零状态。

    • leading_zero = true: 表示前面都是0(或者当前是最高位),当前位可以填0(从而继续是前导零),也可以填1-9的数字(从这一位开始就不是前导零了)。这个参数在处理一些与“0”相关的限制时非常有用,比如不能出现“0”或者“0”有特殊意义。

    • leading_zero = false: 表示前面已经填过非零数字了,当前位必须被视为一个正常的数字。

记忆化

我们会开一个DP数组,通常是 dp[pos][state]。当 limit = false 且 leading_zero = false 时,我们就可以把 dfs(pos, state, false, false) 的结果存起来。下次再遇到完全一样的 pos 和 state,直接返回结果,避免重复计算。

为什么 limit = true 或 leading_zero = true 时不能记忆化?

因为当 limit 为真时,能填的数字范围是受限的,这个限制依赖于具体的上限 x。例如,同样是处理第2位,对于 x=567 和 x=589,limit 为真时能填的范围是不同的。所以这种情况下的结果不具有“泛用性”。前导零同理,它是一种临时的、不普遍的状态。

第一个例子:统计 [L, R] 区间内不含“4”和“62”的数字

我们来解一道经典题:统计在给定区间 [L, R] 内,不包含数字“4”,也不包含连续的“62”的数的个数。 (例如HDU 2089, “不要62”)

1. 问题转化

我们只用实现一个函数 count(n),它计算 [1, n] 中满足条件的数的个数。最终答案就是 count(R) - count(L-1)。

2. 设计 dfs 函数

我们需要一个函数 long long dfs(int pos, int pre, bool limit, bool leading_zero)。

  • pos: 当前处理第 pos 位(从高到低)。

  • pre: 前一位填的数字是什么。这是我们的 state,因为要判断是否出现“62”,必须知道前一位是不是6。

  • limit: 当前位是否受限。

  • leading_zero: 是否是前导零(这题其实可以不加,但作为模板我们带着)。

3. dfs 函数实现细节

#include <iostream>
#include <vector>
#include <string>
#include <algorithm>
#include <cstring> // for memset

using namespace std;

long long dp[20][10]; // dp[pos][pre]: dp数组
int N[20];            // 用来存储数字n的每一位

// dfs函数: 从高位到低位构造数字
// pos: 当前处理的是第几位 (从高到低, 比如n=123, pos=3是百位, pos=2是十位)
// pre: 前一位填的数字 (我们的state)
// limit: 当前位的选择是否受n的限制
// leading_zero: 是否有前导零 (这题可以简化,但作为模板保留)
long long dfs(int pos, int pre, bool limit, bool leading_zero) {
    // 1. 递归基(出口)
    // 如果所有位都填完了(pos=0),说明我们成功构造了一个满足条件的数,返回1
    if (pos == 0) {
        return 1;
    }

    // 2. 记忆化
    // 只有在不受限制,且没有前导零的情况下,结果才是通用的,可以记忆化
    if (!limit && !leading_zero && dp[pos][pre] != -1) {
        return dp[pos][pre];
    }

    // 3. 核心逻辑:遍历当前位可以填的数字
    long long ans = 0;
    // up决定了当前位能填数字的上限
    int up = limit ? N[pos] : 9;

    for (int i = 0; i <= up; ++i) {
        // 剪枝:检查当前填的数字i是否合法
        // a. 不能是4
        if (i == 4) continue;
        // b. 不能是"62"组合
        if (pre == 6 && i == 2) continue;

        // 递归到下一位
        // pos-1: 处理下一位
        // i: 下一位的pre就是当前位的i
        // limit && (i == up): 只有当前位受限(limit=true)且当前位填了上限值(i==up)时,下一位才继续受限
        // leading_zero && (i == 0): 只有当前是前导零且当前位也填0时,下一位才继续是前导零
        ans += dfs(pos - 1, i, limit && (i == up), leading_zero && (i == 0));
    }

    // 4. 记录结果到dp数组
    if (!limit && !leading_zero) {
        dp[pos][pre] = ans;
    }

    return ans;
}

// 计算[1, n]区间内满足条件的数的个数
long long count(int n) {
    if (n == 0) return 0; // 视题目要求,0是否满足。这里我们从1开始计数。

    int len = 0;
    while (n > 0) {
        N[++len] = n % 10;
        n /= 10;
    }

    memset(dp, -1, sizeof(dp));
    // 我们从最高位len开始,前一位数字可以认为是任意非6的数(比如10),limit=true, leading_zero=true
    return dfs(len, 10, true, true) - 1; // 减去0本身(如果dfs会把0算进去的话)
}


int main() {
    int l, r;
    while (cin >> l >> r && (l != 0 || r != 0)) {
        cout << count(r) - count(l - 1) << endl;
    }
    return 0;
}

代码讲解:

  1. count(n) 函数:

    • 将数字 n 拆分到数组 N 中。比如 n=567,N 存的会是 N[1]=7, N[2]=6, N[3]=5。这样 N[pos] 就对应了 pos 位的数字。

    • 初始化 dp 数组为-1,表示都没算过。

    • 调用 dfs(len, 10, true, true) 开始搜索。len是总位数,pre的初始值只要不是6就行(比如10),limit和leading_zero初始都为true。

    • dfs会把 0 也算作一个合法的数(因为它不含4和62),但题目通常要求统计 [1, n],所以要减去 dfs 为 0 构造出的那一种情况。

  2. dfs 函数:

    • 出口: pos == 0,所有位都安全填完,找到一个合法数字,返回1。

    • 记忆化: !limit && !leading_zero 时,dp[pos][pre] 的结果是通用的,可以存取。

    • 循环: for (int i = 0; i <= up; ++i) 是核心。up 由 limit 决定。这是数位DP的精髓——用 limit 来控制每一位的上限。

    • 剪枝: if (i == 4) 和 if (pre == 6 && i == 2) 是根据题目要求加的限制条件。

    • 递归: ans += dfs(...)。注意 limit 和 leading_zero 参数的更新:

      • limit && (i == up): 只有当前位本身是受限的,并且你填的数字又达到了上限,下一位才会继续受限。否则,下一位就自由了。

      • leading_zero && (i == 0): 只有当前允许前导零,并且你也确实填了0,下一位才继续是前- 导零状态。

如何学习与练习

  1. 理解模板: 把上面这个例子的代码和逻辑彻底搞懂。理解每一个参数的含义,尤其是 limit。可以拿纸笔,手动模拟 count(30) 的过程,看看 dfs 是如何被调用的,limit 是如何变化的。

    • dfs(2, 10, true, true) for 30:

      • pos=2, limit=true, up=3.

      • i=0: dfs(1, 0, false, true)

      • i=1: dfs(1, 1, false, false)

      • i=2: dfs(1, 2, false, false)

      • i=3: dfs(1, 3, true, false) (因为i==up)

  2. 修改模板: 尝试解决一些类似的问题,你只需要修改 dfs 的 state 和剪枝条件。

    • 例题1:Windy数:求 [L, R] 区间内,不含前导零且相邻两位数字之差至少为2的数的个数。

      • 你需要修改 state:pre 仍然是前一位的数字。

      • 你需要修改剪枝:if (!leading_zero && abs(i - pre) < 2) continue;

      • 注意 leading_zero 的处理。当 leading_zero 为 true 时,这个差值限制不生效。

    • 例题2:数字游戏:求 [L, R] 区间内,各位数字之和能被 K 整除的数的个数。

      • 你需要修改 state:int sum_mod,表示到目前为止,各位数字之和模 K 的结果。

      • 剪枝条件没有了。

      • 递归 state 变化:dfs(pos-1, (sum_mod + i) % K, ...)

      • 递归出口:if (pos == 0) return sum_mod == 0 ? 1 : 0;

  3. 练习平台:

    • 洛谷 (Luogu): 有很多数位DP的模板题和好题,可以按标签查找。

    • Codeforces: 也有很多数位DP题目,难度梯度更大。

    • HDU Online Judge: HDU 2089 是最经典的入门题。

总结一下关键步骤

  1. 转化问题: 将 [L, R] 的询问变成 count(R) - count(L-1)。

  2. 设计dfs函数: dfs(pos, state, limit, leading_zero)。确定 state 需要记录哪些信息来满足题目限制。

  3. 实现dfs:

    • 递归出口: pos == 0,根据 state 判断是否是合法方案。

    • 记忆化: !limit && !leading_zero 时存取 dp[pos][state]。

    • 循环与剪枝: for (int i = 0; i <= up; ++i),根据题目要求剪枝。

    • 递归调用: 正确更新 limit 和 leading_zero 状态。

  4. 编写count函数: 拆分数字,调用 dfs 并处理初始条件和边界情况。

数位DP就是这样一个“一旦学会,题型都一样”的算法。多花点时间理解第一个例子的模板,亲手敲一遍,再用它去解决几道不同的题目,你就会发现它的套路其实非常清晰。