NeFut Logo NeFut
EN 管理员登录

数位 DP 与代数数论的深度融合:整除性问题的动态规划解析

发布于:2026-05-29 00:44 最后更新:2026-06-06 13:04
#C++ #Tutorial

核心逻辑与数学原理

标准的数位 DP(18.1 节)通常处理与“数字的具体形状、相邻差值、是否包含特定子串”等高度依赖十进制字面特征的计数问题。然而,NOIP 提高组及省选的高频考察方向,往往会将数位 DP 与代数数论(整除性、同余系、最大公约数)跨界融合。

当引入“整除”约束(例如:求一个数能否被它的各个数位之和整除,或者能否被一个固定的数 $M$ 整除)时,我们无法在数值完全组合出来后再去进行余数判定,因为那会退化为暴力枚举。我们必须利用同余的拓扑可加性与可乘性,将整除状态实时维护在数位向下推进的链条中

1. 同余系统的数位状态机动态传导

设当前从高位到低位已经填好的前缀数字所代表的数值为 $X$。现在准备在当前位填入一个数字 $d$($0 \le d \le 9$)。根据位置记数法的代数原理,新的前缀数值 $X'$ 将变为:

$$X' = X \cdot 10 + d$$

如果我们关心这个数对一个大质数 $M$ 的整除性,由于模运算(Modular Arithmetic)满足:

$$(X \cdot 10 + d) \bmod M = \left( (X \bmod M) \cdot 10 + d \right) \bmod M$$

这意味着,我们不需要在状态中记录宏观的数值 $X$,而只需要记录 $X \bmod M$ 的余数状态。这一步代数降维将无限的整数空间成功坍塌到了大小仅为 $M$ 的有限同余状态机中。

2. 动态模数系统的最小公倍数(LCM)坍塌

以经典神题“Hate Nineteen”(求区间内能被自身各个数位之和整除的数)为例。

$$X \equiv R \pmod G \implies X \equiv R \pmod m \quad (\forall m \le 162)$$

因此,我们只需要在搜寻过程中,统一维护数字 $X$ 对全局最大公倍数 $G$ 的余数即可。但在实战中,$1 \sim 162$ 的 LCM 是一个庞大天文数字。实际上,我们可以动态维护当前已经出现的数位之和的 LCM,或者直接将“当前余数”和“当前的数位之和”同时塞进状态维度中,利用记忆化进行极度稀疏的网格剪枝。


状态设计与算法推导

以经典数论数位 DP 问题为例:“求区间 $[L, R]$ 中有多少个数,满足其自身能被其各个非零数位的乘积整除”。

1. 状态空间定义

由于 $1 \sim 9$ 的所有数字的最小公倍数为 $\text{lcm}(1, 2, 3, 4, 5, 6, 7, 8, 9) = 2520$。任何非零数位乘积的质因子只可能包含 $2, 3, 5, 7$,因此最终的乘积必然能整除 $2520$。我们可以在搜寻时统一对 $MOD = 2520$ 取模。

设计 DFS 状态空间为:int dfs(int pos, int sum_mod, int current_lcm, bool lead, bool limit)

2. 状态转移方程与同余推进

在当前位枚举填写数字 $i$:


C++ 标准源码(NOIP/省选高难数论特化模板)

以下源码为“数位乘积整除性”的高效实现。为了防止 f 数组因第三维 current_lcm 达到 2520 而爆内存(MLE),代码引入了离散化映射算子。因为 $1 \sim 9$ 的组合 LCM 实际上只有 48 种可能,将 2520 维打散映射到 48 维,常数和空间可直接暴扣 50 倍。

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

using namespace std;

const int BASE_MOD = 2520;

int digits[20];
int f[20][BASE_MOD][50]; // 优化:第三维使用离散化后的 LCM 编号(共48个)
int lcm_map[BASE_MOD + 5]; // 实际 LCM 到离散化编号的映射表

// 代数算子:求最大公约数
int gcd(int a, int b) {
    return b == 0 ? a : gcd(b, a % b);
}

// 代数算子:求最小公倍数
int lcm(int a, int b) {
    if (a == 0 || b == 0) return a + b;
    return (a * b) / gcd(a, b);
}

// 预处理:离散化 2520 的所有因子,精简状态空间
void preprocess() {
    int id = 0;
    for (int i = 1; i <= BASE_MOD; ++i) {
        if (BASE_MOD % i == 0) {
            lcm_map[i] = id++;
        }
    }
}

// pos: 数位; sum_mod: 统一对2520取模的余数; curr_lcm: 当前非零位的最大公倍数
int dfs(int pos, int sum_mod, int curr_lcm, bool lead, bool limit) {
    if (pos < 0) {
        // 终局数论判定:全局余数能否整除数字各有效位的最小公倍数
        return sum_mod % curr_lcm == 0 ? 1 : 0;
    }

    // 只有在无限制且脱离前导零时,才能使用复用因子编号
    int lcm_id = lcm_map[curr_lcm];
    if (!limit && !lead && f[pos][sum_mod][lcm_id] != -1) {
        return f[pos][sum_mod][lcm_id];
    }

    int up = limit ? digits[pos] : 9;
    int res = 0;

    for (int i = 0; i <= up; ++i) {
        int next_mod = (sum_mod * 10 + i) % BASE_MOD;
        int next_lcm = (lead && i == 0) ? curr_lcm : lcm(curr_lcm, i);

        res += dfs(pos - 1, next_mod, next_lcm, lead && (i == 0), limit && (i == up));
    }

    if (!limit && !lead) {
        f[pos][sum_mod][lcm_id] = res;
    }
    return res;
}

long long solve(long long n) {
    if (n <= 0) return 0;
    int len = 0;
    while (n > 0) {
        digits[len++] = n % 10;
        n /= 10;
    }
    // 初始 LCM 设为 1(乘法恒等元)
    return dfs(len - 1, 0, 1, true, true);
}

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

    preprocess();
    memset(f, -1, sizeof(f)); // 全局终身复用缓存

    long long l, r;
    if (cin >> l >> r) {
        cout << solve(r) - solve(l - 1) << "\n";
    }

    return 0;
}

NOIP 实战避坑指南

1. 动态模数未做安全初始化(零模引发崩溃)

2. 忽略模数顺延导致的拓扑状态错位


经典 NOIP/洛谷 真题

1. 洛谷 P4127 [AHOI2009] 同类分布

2. 洛谷 P2657 [SCOI2009] windy 数

原文链接: local://18.2

[h] 返回首页