数位dp入门

95 阅读4分钟

前言

数位dp适用题型:求某个区间[L, R]满足某种性质的数的个数

用从左到右填数的方法去遍历小于n的所有数字

技巧1:将[L,R]区间转化为[0, R] - [0, L - 1]再求解,如果求[0, R]区间则无需转换

技巧2:从高位到低位依次填数,分类讨论

将整数R用每一位数字存入字符数组中,则

每次填数分成两类:0~ai-1、ai

如果第i位填0-ai-1,则后面每一位可以填0~9

如果第i位填ai,则继续讨论下一位

例如:求小于56789的非负整数满足某种要求有多少个数字

当第一位数字填5时,第二位只能从0-6当中选择

当第一位数字填0-4时,第二位可以从0-9当中选择,此时第二位是不受限制的

参数含义

char s[] / String s

用来存储n的每一位数字,方便填数的时候比较n的对应数位

dp[i][sum]

dp[i][sum]表示从第i位开始填数,且第i位之前填的数满足题目限制为sum,有多少个合法数字

例如前三位填了456,从第四位开始填有dp[3][456]个合法数字

当之后遍历填数,前三位填了654,与之前情形一样,可以直接复用dp[3][456]

isLimit

isLimit 表示当前是否受到了 n的约束。若为真,当前位的上限就是s[i];若为假,当前位的上限是9。参照前言部分填数例子

isNum

isNum 表示当前位前面的位置是否填了数字。若为假,则当前位可以跳过;若为真,则要填入的数字可以从 0开始填

参数可以根据题意增加或减少

模板

class Solution {
    // 存储n的每一位数字
    char s[];  
    // 存储子问题的合法数字个数
    int dp[][]; 
    public int solve(int n) {
        s = Integer.toString(n).toCharArray();
        int length = s.length;
        dp = new int[length][length];
        for (int i = 0; i < length; i++) {
            // 将dp数组初始化为-1,代表没遇到过子问题
            Arrays.fill(dp[i], -1);
        }
        // 从第一位开始填,第一位是受到限制的,且之前没填过数字
        return f(0, true, false);
    }
 
    public int f(int i, boolean isLimit, booklean isNum) {
        //如果填到了最后一位,代表找到了一个合法数字
        if (i == s.length) {
            return 1;
        }
        // 遇到了相同的子问题,则直接复用
        if (!isLimit && isNum dp[i][] != -1) {
            return dp[i][];
        }
        //开始记录答案
        int res = 0;
        //之前的位置都没有填数,那么当前位置也可以选择跳过不填
        if (!isNum) {        
            res = f(i + 1, false, false);
        }
        // up是当前位置能填的最大数字
        int up = isLimit ? s[i] - '0' : 9;
        for (int d = 0; d <= up; d++) {
            // 开始填下一个位置
            res += f(i + 1, isLimit && d == up, true);
        }
        // 如果当前位置可以填0 - 9, 且之前的位置填了数,那么这种情况就可以复用,将其记录下来
        if (!isLimit && isNum) {
            dp[i][] = res;
        }
        return res;
    }
}

例题

1012. 至少有 1 位重复的数字 - 力扣(LeetCode)

class Solution {
    char[] s;                                 // 用来存储n的每一位数字
    // dp[i][mask]表示从第i位开始填,且之前填过的数为mask有多少种填法
    int[][] dp;                               
    public int numDupDigitsAtMostN(int n) {
        s = Integer.toString(n).toCharArray();   //用字符存储每一位n的数字
        int length = s.length;
        dp = new int[length][1 << 10];
        for (int i = 0; i < length; i++){
            Arrays.fill(dp[i], -1);              //-1表示没有计算过
        }
        return n - f(0, 0, true, false);
    }
/*
    从第i位开始填数字,i前面位置填过的数字集合是mask
    mask是二进制数,第i位为1代表填过的数字包含i
    例如10011代表填过了数字{4,1,0}
    isLimit表示前面位置填的数字是否都是n对应位置上的
    isNum表示前面位置是否填过数字
*/
    public int f(int i, int mask, boolean isLimit, boolean isNum) {
        if (i == s.length) {
            //最后一位数字填完了,如果前面填过数字则增加一个合法数字
            return isNum ? 1 : 0;
        }
        //前面位置没有填n对应位置上的数且填了数,那么后面填的所有数都不受限制
        if (!isLimit && isNum && dp[i][mask] != -1) {
            return dp[i][mask];
        }
        int res = 0;
        //选择跳过当前位,从下一位开始填数字
        if (!isNum) {        
            res = f(i + 1, mask, false, false);
        }
        //当前位置能填的数字的上界,如果isLimit则只能填0~s[n],否则可以填0~9
        int up = isLimit ? s[i] - '0' : 9;
        //开始遍历可以填入的数字d
        for (int d = isNum ? 0 : 1; d <= up; d++) {
        //mask右移d位,如果是1,则表示d已经在mask里了,当前位置不能填d,如果是0则可以填
            if ((mask >> d & 1) == 0) {
        //mask|(1 << d)表示把1左移d位存入mask中,代表d这个数已经填过了
        //如果之前的位置是isLimit且当前位置填了上限,那么之后的要填的数字还是isLimit
                res += f(i + 1, mask|(1 << d), isLimit && d == up, true);
            }
        }
        if (!isLimit && isNum) {
            dp[i][mask] = res;
        }
        return res;
    }
}