每日算法 — 使用java实现数位DP:状态设计与区间计数技巧

数位动态规划(Digit DP)是解决”区间 [L, R] 中满足某性质的数字个数”这类计数问题的利器。它将数字按位拆分,利用前缀和思想将区间计数转化为 f(R) - f(L-1),通过记忆化搜索高效枚举每一位的可能取值。本文用Java完整实现数位DP,覆盖基础模板到复杂条件计数。

一、问题引入:数字中的约束计数

问题:统计区间 [1, 1000000] 中,各位数字之和能被3整除的数字有多少个?

暴力枚举 O(R-L+1) 在范围大时不可行。数位DP通过按位处理,将复杂度优化到 O(log R × 状态数)。

核心思想:
– 将数字转为字符串/数组,从高到低逐位处理
– 用 tight 标记表示当前位是否受原数上界约束
– 用 dp[pos][sum][tight] 记录记忆化状态

二、基础模板:统计特定范围内数字个数

import java.util.*;

/**
 * 数位DP基础模板
 * 问题: 统计 [0, n] 中满足条件的数字个数
 */
public class DigitDPTemplate {

    private int[] digits;    // 将数字按位拆分(高位在前)
    private Long[][] memo;   // 记忆化数组: memo[pos][tight] -> 方案数

    /**
     * 统计 [0, n] 中,数字各位之和能被3整除的数字个数
     */
    public long countDivisibleBy3(long n) {
        if (n < 0) return 0;
        digits = toDigits(n);
        int len = digits.length;
        // memo[pos][sum_mod_3][tight],-1表示未计算
        memo = new Long[len][3][2];
        return dfs(0, 0, 1); // 从最高位开始,当前和mod3=0,受约束
    }

    /**
     * 深度优先搜索 + 记忆化
     * @param pos 当前处理到的位(0表示最高位)
     * @param sumMod 当前已处理位的数字和 mod 3
     * @param tight 1表示前面所有位都与n对应位相同(受约束),0表示已小于n(自由)
     * @return 从当前状态出发的有效数字个数
     */
    private long dfs(int pos, int sumMod, int tight) {
        if (pos == digits.length) {
            // 所有位处理完毕,判断最终状态是否满足条件
            return sumMod == 0 ? 1 : 0;
        }

        if (memo[pos][sumMod][tight] != null) {
            return memo[pos][sumMod][tight];
        }

        long res = 0;
        int upperBound = tight == 1 ? digits[pos] : 9; // 当前位最大可填数字

        for (int d = 0; d <= upperBound; d++) {
            int newTight = (tight == 1 && d == upperBound) ? 1 : 0;
            int newSumMod = (sumMod + d) % 3;
            res += dfs(pos + 1, newSumMod, newTight);
        }

        memo[pos][sumMod][tight] = res;
        return res;
    }

    /**
     * 将数字转为位数组(高位在前)
     */
    private int[] toDigits(long n) {
        String s = Long.toString(n);
        int[] res = new int[s.length()];
        for (int i = 0; i < s.length(); i++) {
            res[i] = s.charAt(i) - '0';
        }
        return res;
    }

    /**
     * 统计 [L, R] 范围内的满足条件的数字个数
     */
    public long countRange(long L, long R) {
        return countDivisibleBy3(R) - countDivisibleBy3(L - 1);
    }

    public static void main(String[] args) {
        DigitDPTemplate solver = new DigitDPTemplate();

        // 测试1: 小范围验证
        System.out.println("=== 测试: 各位和能被3整除的数字 ===");
        for (long n = 0; n <= 30; n++) {
            long count = solver.countDivisibleBy3(n);
            System.out.printf("[0, %2d] 中满足条件的数字个数: %d%n", n, count);
        }

        // 测试2: 大范围
        System.out.println("\n=== 大范围测试 ===");
        long L = 1, R = 1000000;
        System.out.printf("[%d, %d] 中各位和能被3整除的数字个数: %d%n", 
            L, R, solver.countRange(L, R));

        // 暴力验证小范围
        System.out.println("\n=== 暴力验证 [1, 100] ===");
        long brute = bruteForce(1, 100);
        long dp = solver.countRange(1, 100);
        System.out.printf("暴力: %d, 数位DP: %d, 一致: %s%n", brute, dp, brute == dp);
    }

    private static long bruteForce(long L, long R) {
        long count = 0;
        for (long i = L; i <= R; i++) {
            long sum = 0, x = i;
            while (x > 0) {
                sum += x % 10;
                x /= 10;
            }
            if (sum % 3 == 0) count++;
        }
        return count;
    }
}

三、进阶应用:统计不含特定子串的数字

问题:统计 [L, R] 中,数字的十进制表示不包含”13″子串的数字个数。

这需要引入额外的状态来记录前缀匹配情况。

/**
 * 数位DP进阶:统计不含特定子串的数字
 * 使用KMP自动机思想记录当前前缀匹配状态
 */
public class DigitDPSubstring {

    private int[] digits;
    private Long[][][] memo; // memo[pos][kmpState][tight]
    private String forbidden = "13"; // 禁止子串
    private int[] kmp; // KMP的next数组

    public DigitDPSubstring() {
        buildKMP();
    }

    private void buildKMP() {
        int m = forbidden.length();
        kmp = new int[m];
        for (int i = 1, j = 0; i < m; i++) {
            while (j > 0 && forbidden.charAt(i) != forbidden.charAt(j)) {
                j = kmp[j - 1];
            }
            if (forbidden.charAt(i) == forbidden.charAt(j)) {
                j++;
                kmp[i] = j;
            }
        }
    }

    /**
     * 获取添加数字d后的新KMP状态
     */
    private int transition(int state, int d) {
        char c = (char)('0' + d);
        while (state > 0 && c != forbidden.charAt(state)) {
            state = kmp[state - 1];
        }
        if (c == forbidden.charAt(state)) {
            state++;
        }
        return state;
    }

    public long countWithoutSubstring(long n) {
        if (n < 0) return 0;
        digits = toDigits(n);
        int len = digits.length;
        int m = forbidden.length();
        memo = new Long[len][m + 1][2];
        return dfs(0, 0, 1);
    }

    private long dfs(int pos, int state, int tight) {
        // state == forbidden.length() 表示已匹配到禁止子串,此路径无效
        if (state == forbidden.length()) {
            return 0;
        }
        if (pos == digits.length) {
            return 1; // 所有位处理完毕且未出现禁止子串
        }
        if (memo[pos][state][tight] != null) {
            return memo[pos][state][tight];
        }

        long res = 0;
        int upperBound = tight == 1 ? digits[pos] : 9;

        for (int d = 0; d <= upperBound; d++) {
            int newState = transition(state, d);
            int newTight = (tight == 1 && d == upperBound) ? 1 : 0;
            res += dfs(pos + 1, newState, newTight);
        }

        memo[pos][state][tight] = res;
        return res;
    }

    private int[] toDigits(long n) {
        String s = Long.toString(n);
        int[] res = new int[s.length()];
        for (int i = 0; i < s.length(); i++) {
            res[i] = s.charAt(i) - '0';
        }
        return res;
    }

    public long countRange(long L, long R) {
        return countWithoutSubstring(R) - countWithoutSubstring(L - 1);
    }

    public static void main(String[] args) {
        DigitDPSubstring solver = new DigitDPSubstring();

        System.out.println("=== 统计不含子串\"13\"的数字 ===");
        for (long n = 0; n <= 100; n++) {
            long count = solver.countWithoutSubstring(n);
            System.out.printf("[0, %3d] 中不含\"13\"的数字: %d%n", n, count);
        }

        long L = 1, R = 1000000;
        System.out.printf("\n[%d, %d] 中不含\"13\"的数字个数: %d%n", 
            L, R, solver.countRange(L, R));
    }
}

四、综合应用:windy数计数

windy数定义:相邻两位数字之差的绝对值至少为2的正整数。

/**
 * windy数计数: 相邻位数字差 >= 2
 */
public class WindyNumber {

    private int[] digits;
    private Long[][][] memo; // memo[pos][prevDigit][tight]

    public long countWindy(long n) {
        if (n <= 0) return 0;
        digits = toDigits(n);
        int len = digits.length;
        memo = new Long[len][11][2]; // prevDigit用10表示"无前一位"(首位)
        return dfs(0, 10, 1);
    }

    private long dfs(int pos, int prev, int tight) {
        if (pos == digits.length) {
            return 1; // 形成一个有效数字(注意:全0的情况需要特殊处理)
        }
        if (memo[pos][prev][tight] != null) {
            return memo[pos][prev][tight];
        }

        long res = 0;
        int upperBound = tight == 1 ? digits[pos] : 9;

        for (int d = 0; d <= upperBound; d++) {
            // 处理前导零: prev=10表示还在前导零阶段
            if (prev == 10) {
                if (d == 0) {
                    // 继续保持前导零状态
                    res += dfs(pos + 1, 10, (tight == 1 && d == upperBound) ? 1 : 0);
                } else {
                    // 第一个非零数字
                    res += dfs(pos + 1, d, (tight == 1 && d == upperBound) ? 1 : 0);
                }
            } else {
                // 非首位,检查相邻位差
                if (Math.abs(d - prev) >= 2) {
                    res += dfs(pos + 1, d, (tight == 1 && d == upperBound) ? 1 : 0);
                }
            }
        }

        memo[pos][prev][tight] = res;
        return res;
    }

    private int[] toDigits(long n) {
        String s = Long.toString(n);
        int[] res = new int[s.length()];
        for (int i = 0; i < s.length(); i++) {
            res[i] = s.charAt(i) - '0';
        }
        return res;
    }

    public long countRange(long L, long R) {
        return countWindy(R) - countWindy(L - 1);
    }

    public static void main(String[] args) {
        WindyNumber solver = new WindyNumber();

        System.out.println("=== windy数计数(相邻位差>=2)===");
        long[][] tests = {{1, 10}, {1, 100}, {1, 1000}, {25, 50}, {1, 1000000}};
        for (long[] t : tests) {
            System.out.printf("[%d, %d] 中windy数: %d%n", 
                t[0], t[1], solver.countRange(t[0], t[1]));
        }

        // 小范围验证
        System.out.println("\n小范围验证 [1, 30]:");
        for (int i = 1; i <= 30; i++) {
            boolean isWindy = isWindyNumber(i);
            System.out.printf("%2d: %s%n", i, isWindy ? "windy" : "---");
        }
    }

    private static boolean isWindyNumber(int n) {
        String s = Integer.toString(n);
        for (int i = 1; i < s.length(); i++) {
            if (Math.abs(s.charAt(i) - s.charAt(i-1)) < 2) {
                return false;
            }
        }
        return true;
    }
}

五、数位DP的设计框架

设计数位DP问题的通用步骤:

1. 定义状态: dp[pos][state][tight]
   - pos: 当前处理到第几位
   - state: 与问题相关的额外状态(如数字和、前缀匹配、前一位数字等)
   - tight: 是否受上界约束

2. 确定转移: 枚举当前位d的取值,更新state和tight

3. 边界条件: pos == len 时判断是否满足条件

4. 前缀和转化: f(L, R) = f(R) - f(L-1)

六、复杂度分析

指标 复杂度 说明
时间 O(log R × S × 10) S为状态数,10为每位枚举数
空间 O(log R × S × 2) 记忆化数组大小

实际效率极高:对于 R = 10¹⁸,log R = 19,若状态数 S = 100,总计算量约 19 × 100 × 10 = 19000 次操作。

七、总结

数位DP的魅力在于将看似需要线性枚举的范围计数问题,通过”按位拆分 + 状态压缩 + 记忆化搜索”转化为对数级复杂度。其核心技巧有三:

  1. 前缀和转化:[L, R] = f(R) - f(L-1)
  2. tight标记:区分”紧贴上界”和”自由取值”两种状态
  3. 状态设计:根据题目条件设计合适的额外状态(和、差、匹配位置等)

掌握数位DP后,各类数字计数问题——无论是简单的整除判定,还是复杂的自动机匹配——都能迎刃而解。它是算法竞赛中范围计数的”万能钥匙”。