数位DP详解

数位DP(Digit Dynamic Programming)是一种专门解决“数字计数问题”的动态规划方法,尤其适用于处理“小于等于N的数中满足某条件的数有多少个”这类问题。由于数字的位数通常不超过20位,数位DP能通过状态压缩将看似复杂的计数问题转化为高效的DP求解。

一、数位DP的核心场景与优势

1.1 典型问题场景

数位DP专注于解决与数字的“数位特征”相关的计数问题,例如:

  • 计算小于等于N的数中,不含数字7的数有多少个;
  • 计算[L, R]范围内,数字中含有连续两个相同数字的数的个数;
  • 计算小于等于N的数中,各位数字之和为K的数有多少个。

这类问题若用暴力枚举(遍历每个数并检查条件),当N达到 10 18 10^{18} 1018时会直接超时,而数位DP利用数字的“数位分解”特性,将时间复杂度降至 O ( 位数 × 状态数 ) O(位数 \times 状态数) O(位数×状态数),通常可控制在 O ( 20 × 1000 ) O(20 \times 1000) O(20×1000)以内,高效且稳定。

1.2 核心优势

  • 针对性强:专为数字的数位特征设计,能自然融入数位相关的约束条件;
  • 效率高:不受N的大小影响,仅与数字的位数相关;
  • 扩展性好:同一框架可适配不同的数位约束条件(只需修改状态和转移逻辑)。

二、数位DP的核心思想与框架

2.1 数位分解与状态设计

数位DP的核心是将数字按位分解(如将123分解为[1,2,3]),然后从最高位到最低位逐位处理,同时记录必要的状态信息。关键状态通常包括:

  1. 当前处理的位位置(pos):标记正在处理第几位(从高位到低位);
  2. 约束标记(limit):表示当前位的取值是否受原数字对应位的限制(1表示受限制,只能取0到原数字该位的值;0表示不受限制,可任意取0-9);
  3. 其他自定义状态:根据问题条件设计,如“是否出现过数字7”“当前各位数字之和”等。

2.2 记忆化搜索(DFS + 缓存)

数位DP通常采用“记忆化搜索”实现,原因是:

  • 数位处理的递归逻辑清晰,便于维护“约束标记”等状态;
  • 记忆化可避免重复计算相同状态(如不同数字的某几位处理到相同pos且状态相同时)。

2.3 通用框架

int count(int n) {
    // 将n分解为数位数组(如n=123 → digits = [1,2,3])
    List<Integer> digits = new ArrayList<>();
    while (n > 0) {
        digits.add(n % 10);
        n /= 10;
    }
    Collections.reverse(digits);
    int len = digits.size();
    
    // 记忆化缓存:dp[pos][limit][...],...为其他自定义状态
    memo = new int[len][2][...];
    for (int[][] arr : memo) for (int[] sub : arr) Arrays.fill(sub, -1);
    
    // 从第0位开始处理,初始约束为true(受限制),其他状态初始化为0
    return dfs(0, true, ...);
}

// 递归函数:处理第pos位,limit表示是否受约束,其他参数为自定义状态
int dfs(int pos, boolean limit, ...) {
    // 递归终止:处理完所有位,返回1(表示找到一个合法数)
    if (pos == digits.size()) return 1;
    
    // 检查缓存,若已计算则直接返回
    if (memo[pos][limit ? 1 : 0][...] != -1) {
        return memo[pos][limit ? 1 : 0][...];
    }
    
    // 确定当前位的最大可取值(受limit约束)
    int max = limit ? digits.get(pos) : 9;
    int res = 0;
    
    // 枚举当前位的所有可能取值
    for (int d = 0; d <= max; d++) {
        // 新的约束标记:若原limit为true且d等于max,则新limit为true
        boolean newLimit = limit && (d == max);
        
        // 根据问题条件,判断当前取值d是否合法,更新自定义状态
        if (isValid(d, ...)) {
            res += dfs(pos + 1, newLimit, newState(...));
        }
    }
    
    // 缓存结果并返回
    memo[pos][limit ? 1 : 0][...] = res;
    return res;
}

三、实战案例:不含数字7的数的计数

3.1 问题描述

计算小于等于N的正整数中,所有数位都不包含数字7的数有多少个。
示例:N=18 → 合法数为1-6,8-16,18(共16个,排除7,17)。

3.2 状态设计

  • pos:当前处理的位(0-based,从最高位开始);
  • limit:是否受原数字约束(布尔值);
  • 无其他自定义状态(只需判断每位是否为7)。

3.3 代码实现

import java.util.*;

public class CountWithoutSeven {
    int[] digits;
    int[][][] memo; // memo[pos][limit][...],此处无其他状态,简化为二维

    public int count(int n) {
        if (n == 0) return 0;
        // 分解数位(如123 → [1,2,3])
        List<Integer> list = new ArrayList<>();
        int temp = n;
        while (temp > 0) {
            list.add(temp % 10);
            temp /= 10;
        }
        Collections.reverse(list);
        digits = new int[list.size()];
        for (int i = 0; i < digits.length; i++) {
            digits[i] = list.get(i);
        }

        // 初始化记忆化数组:pos范围0~len-1,limit为0或1
        memo = new int[digits.length][2][1]; // 第三维占位,实际用二维
        for (int[][] arr : memo) {
            for (int[] sub : arr) {
                Arrays.fill(sub, -1);
            }
        }

        // 从第0位开始,初始limit为true(受约束)
        return dfs(0, true);
    }

    private int dfs(int pos, boolean limit) {
        // 处理完所有位,返回1(找到一个合法数)
        if (pos == digits.length) {
            return 1;
        }

        // 检查缓存
        int limitIdx = limit ? 1 : 0;
        if (memo[pos][limitIdx][0] != -1) {
            return memo[pos][limitIdx][0];
        }

        // 当前位的最大可取值
        int max = limit ? digits[pos] : 9;
        int res = 0;

        // 枚举当前位的可能取值(0~max)
        for (int d = 0; d <= max; d++) {
            // 若当前位是7,则跳过(不合法)
            if (d == 7) {
                continue;
            }

            // 计算新的limit
            boolean newLimit = limit && (d == max);

            // 递归处理下一位
            res += dfs(pos + 1, newLimit);
        }

        // 缓存结果
        memo[pos][limitIdx][0] = res;
        return res;
    }

    public static void main(String[] args) {
        CountWithoutSeven solver = new CountWithoutSeven();
        System.out.println(solver.count(18)); // 输出16
        System.out.println(solver.count(77)); // 输出77 - 16(含7的数有16个)=61
    }
}

四、进阶案例:数字中含有连续相同数字的计数

4.1 问题描述

计算[L, R]范围内,至少有一对连续相同数字的数有多少个(如1223含有“22”,符合条件)。
提示:可转化为计算[0, R]的数量减去[0, L-1]的数量。

4.2 状态设计

需新增状态记录“上一位的数字”,以判断是否出现连续相同:

  • pos:当前位;
  • limit:是否受约束;
  • prev:上一位的数字(-1表示首位,无前置数字);
  • hasSame:是否已出现过连续相同数字(0表示未出现,1表示已出现)。

4.3 代码实现(核心部分)

public class CountWithConsecutive {
    int[] digits;
    int[][][][] memo; // pos, limit, prev+1(0~10), hasSame(0~1)

    public int count(int n) {
        if (n < 10) return 0; // 小于10的数不可能有连续相同
        // 分解数位...(同上)

        // 初始化记忆化数组:pos(0~len-1), limit(0~1), prev(0~10, -1用0表示), hasSame(0~1)
        memo = new int[digits.length][2][11][2];
        for (int[][][] arr : memo) {
            for (int[][] sub : arr) {
                for (int[] sub2 : sub) {
                    Arrays.fill(sub2, -1);
                }
            }
        }

        return dfs(0, true, -1, 0);
    }

    private int dfs(int pos, boolean limit, int prev, int hasSame) {
        if (pos == digits.length) {
            return hasSame; // 只有已出现连续相同才计数
        }

        int limitIdx = limit ? 1 : 0;
        int prevIdx = prev + 1; // 将-1映射为0,0~9映射为1~10
        if (memo[pos][limitIdx][prevIdx][hasSame] != -1) {
            return memo[pos][limitIdx][prevIdx][hasSame];
        }

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

        for (int d = 0; d <= max; d++) {
            boolean newLimit = limit && (d == max);
            int newHasSame = hasSame;

            // 判断是否与上一位相同(prev != -1时)
            if (prev != -1 && d == prev) {
                newHasSame = 1;
            }

            // 递归处理下一位,更新prev为当前d
            res += dfs(pos + 1, newLimit, d, newHasSame);
        }

        memo[pos][limitIdx][prevIdx][hasSame] = res;
        return res;
    }

    // 计算[L, R]的数量 = count(R) - count(L-1)
    public int rangeCount(int L, int R) {
        return count(R) - count(L - 1);
    }
}

五、数位DP的关键技巧与注意事项

5.1 状态设计技巧

  • 最小化状态:只记录必要的信息(如“是否出现过某数字”而非“出现的次数”);
  • 离散化状态:将连续状态(如数字和)限制在合理范围内(如和不超过200可直接存储);
  • 利用对称性:若某些状态等价(如奇数和偶数在某条件下无区别),可合并状态。

5.2 边界处理

  • 前导零问题:数字前的零不视为有效数字(如0012视为12,需在状态中标记是否有前导零);
  • 空数字处理:递归终止时需正确判断“是否构成有效数字”(如0是有效数字,但前导零的空串不是)。

5.3 性能优化

  • 记忆化数组初始化:每次处理新数字时需重置记忆化数组;
  • 状态压缩:将布尔状态(如limit)转为0/1整数,减少维度;
  • 剪枝:在枚举数位时,提前跳过不可能满足条件的分支(如已确定无法达成目标,直接返回0)。

总结

数位DP通过将数字按位分解,结合记忆化搜索高效处理与数位特征相关的计数问题:

  1. 数位分解:将数字转化为便于逐位处理的数组;
  2. 状态设计:包含位置、约束标记及问题相关的自定义状态;
  3. 记忆化搜索:通过缓存避免重复计算,降低时间复杂度。

That’s all, thanks for reading~~
觉得有用就点个赞、收进收藏夹吧!关注我,获取更多干货~

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值