NeFut Logo NeFut
EN 管理员登录

数位动态规划:高效计算区间内特定数字特征的整数个数

发布于:2026-05-27 09:17 最后更新:2026-06-06 13:04
#C++ #Tutorial

核心逻辑与数学原理

数位动态规划(Digit DP)的核心本质是对大整数集合在逐位展开的形式下进行前缀树(Trie)式的拓扑计数优化。它主要用于解决形如“计算区间 $[L, R]$ 中满足某种特定数字特征(如不含特定数字、数位数字之和是质数等)的整数个数”的问题。

  1. 区间差分降维映射: 直接在闭区间 $[L, R]$ 上维护复杂的数位限制极度困难。由于计数函数 $f(X)$(表示 $[0, X]$ 内满足条件的个数)具有显式的可加性与独立性,可通过前缀差分将问题无损降维映射为两次独立的单边界计数:

$$\text{Ans} = f(R) - f(L - 1)$$

  1. 状态空间的 Trie 树结构与记忆化裁剪: 对于给定的上界 $X$,我们可以将从高位到低位的选数过程看作在一棵高维数字字典树(Trie 树)上的拓扑遍历。

状态设计与算法推导

数位 DP 最优雅、最不容易在考场写崩溃的实现方式是高度内聚的记忆化搜索模板

1. 核心搜索函数参数设计

定义 int dfs(int pos, int state, bool limit, bool lead)

2. 状态转移与记忆化控制

设当前位在 $X$ 中对应的数字上限为 $up$(若 limit 为真,则 $up = \text{digits}[pos]$,否则 $up = 9$)。 通过循环枚举当前位可以填入的数字 $i \in [0, up]$:

3. 记忆化回溯的数学准则

只有当 limit == falselead == false 时,当前 dfs(pos, state) 的返回值才能写入记忆化数组 dp[pos][state]。因为一旦有贴边限制或前导零污染,当前算出的值只是一个不完整的残缺子树,绝不能作为通用状态进行复用。


C++ 标准源码(NOIP风格)

以下源码解决经典数位 DP 模型:“求区间 $[L, R]$ 中不含连续相同数字(即相邻数位数字不同)的整数个数”,严格兼容 Linux g++ -O2 编译标准。

#include <iostream>
#include <vector>
#include <cstring>
#include <algorithm>

using namespace std;

long long dp[20][11]; // dp[pos][pre_digit] 记忆化数组
int digits[20];       // 存储上界 X 拆分后的每一位数字

// 核心记忆化搜索函数
long long dfs(int pos, int pre, bool limit, bool lead) {
    // 基准条件:所有数位处理完毕,说明成功构建了一个合法的数
    if (pos == 0) {
        return 1; 
    }

    // 致命踩坑点:只有在无限制、无前导零污染时,才能直接读取记忆化缓存
    if (!limit && !lead && dp[pos][pre] != -1) {
        return dp[pos][pre];
    }

    // 根据当前的贴边状态,动态锁定当前位的选择上界
    int up = limit ? digits[pos] : 9;
    long long res = 0;

    for (int i = 0; i <= up; ++i) {
        // 条件过滤:若非前导零状态下,当前位与上一位数字相同,则该分支非法
        if (!lead && i == pre) {
            continue;
        }

        // 递归进入下一数位
        // 1. 新的 limit 状态由当前 limit 和是否填到上限数字共同决定
        // 2. 新的 lead 状态由当前 lead 和当前位是否继续填 0 共同决定
        res += dfs(pos - 1, i, limit && (i == up), lead && (i == 0));
    }

    // 致命踩坑点:只有在无限制、无前导零污染时,才能将当前值固化进全局缓存
    if (!limit && !lead) {
        dp[pos][pre] = res;
    }

    return res;
}

// 求解 [0, n] 范围内的合法数量
long long solve(long long n) {
    if (n < 0) return 0;
    if (n == 0) return 1;

    int len = 0;
    while (n > 0) {
        digits[++len] = n % 10; // 将数字剥离,digits[1] 是最低位,digits[len] 是最高位
        n /= 10;
    }

    // 每次求解前,记忆化数组必须全部重置为 -1
    memset(dp, -1, sizeof(dp));

    // 从最高位开始往下刷,初始状态:处于贴边限制(true),处于前导零(true),前一位数字传入一个无关值 10
    return dfs(len, 10, true, true);
}

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    long long l, r;
    if (cin >> l >> r) {
        // 利用前缀差分实现闭区间 [L, R] 的无损映射
        cout << solve(r) - solve(l - 1) << "\n";
    }

    return 0;
}

NOIP 实战避坑指南

1. 错误地对有贴边限制(limit == true)的状态进行记忆化读写

2. 忽视差分边界 $L-1$ 导致整型下溢出


经典 NOIP/洛谷 真题

1. 洛谷 P2602 [ZJOI2010] 数字计数

2. 洛谷 P4999 烦人的数学作业

原文链接: local://15.3

[h] 返回首页