【算法】数位DP

数位DP

https://www.bilibili.com/video/BV1rS4y1s721/
可以看完视频讲解之后直接写例题,学模板。

当前位填的数字会受到前面填的数字的约束

前置知识——位运算与集合论

在这里插入图片描述

两个关键的式子:
x >> d & 1
x | (1 << d)

>> 和 & 的运算优先级是一样的,所以从左往右进行计算。

例题——2376. 统计特殊整数

https://leetcode.cn/problems/count-special-integers/
在这里插入图片描述

思路

https://leetcode.cn/problems/count-special-integers/solutions/1746956/shu-wei-dp-mo-ban-by-endlesscheng-xtgx/
在这里插入图片描述

代码模板(重要!⭐⭐⭐⭐⭐)

用 mask 记录已经选了哪些数字
用 isNum 记录是否前面都是前导零

class Solution {
    char[] s;
    int[][] memo;

    public int countSpecialNumbers(int n) {
        s = String.valueOf(n).toCharArray();
        int m = s.length;
        memo = new int[m][1 << 10];
        for (int i = 0; i < m; ++i) {
            Arrays.fill(memo[i], -1);   // -1表示没有被计算过
        }
        // 从下标0开始填,初始mask=0,isLimit=true,isNum=false
        return f(0, 0, true, false);
    }

    // 返回从i开始填数字,i前面填的数字的集合是mask,能构造出的特殊正数的数目
    // isLimit表示前面填的数字是否都是n对应位上的,如果为true,那么当前位至多为s[i],否则至多为'9'
    // isNum表示前面是否填了数字(是否跳过),如果为true,那么当前位可以从0开始,如果为false,那么我们可以跳过或者从1开始填数字   这个是为了处理无效的前导零(isNum=true表示前面都是前导零被跳过了)
    int f(int i, int mask, boolean isLimit, boolean isNum) {
        if (i == s.length) return isNum? 1: 0;
        if (!isLimit && isNum && memo[i][mask] != -1) return memo[i][mask];
        int res = 0;
        // 可以跳过当前位
        if (!isNum) res = f(i + 1, mask, false, false);
        // 如果前面填的数字都和n一样,那么这一位至多填数字s[i](否则就超过n了)
        int up = isLimit? s[i] - '0': 9;
        for (int d = isNum? 0: 1; d <= up; ++d) {
            if ((mask >> d & 1) == 0) {
                res += f(i + 1, mask | (1 << d), isLimit && d == up, true);
            }
        }
        if (!isLimit && isNum) memo[i][mask] = res;
        return res;
    }
}

一定要注意!
memo[i][mask] 记录的是当 !isLimit && isNum 时 对应 i 和 mask 的结果。(即不受约束且是数字

因为真正 isLimit = true 到最后只有一次计算;(因为 true 就表示前面选择的数字的各位和 n 的各位是一样的。)
同理 isNum = false 时也是。(因为 false 就表示前面选择的数字都是前导零。)
这些情况在递归的过程中都只会遇到一次。

在这里插入图片描述
这里的状态个数为 l e n ( s ) ∗ 2 1 0 len(s) * 2^10 len(s)210,即 m ∗ 2 D m * 2^D m2D ,这里的 D = 10, 2 D 2 ^ D 2D即为 mask 的数量。

针对这道题,可以去掉 isNum 参数

由于 mask 中记录了数字,可以通过判断 mask 是否为 0 来判断前面是否填了数字,所以 isNum 可以省略

代码如下:

class Solution {
    char[] s;
    int[][] memo;

    public int countSpecialNumbers(int n) {
        s = String.valueOf(n).toCharArray();
        int m = s.length;
        memo = new int[m][1 << 10];
        for (int i = 0; i < m; ++i) {
            Arrays.fill(memo[i], -1);   // -1表示没有被计算过
        }
        // 从下标0开始填,初始mask=0,isLimit=true
        return f(0, 0, true);
    }

    // 返回从i开始填数字,i前面填的数字的集合是mask,能构造出的特殊正数的数目
    // isLimit表示前面填的数字是否都是n对应位上的,如果为true,那么当前位至多为s[i],否则至多为'9'
    int f(int i, int mask, boolean isLimit) {
        if (i == s.length) return mask != 0? 1: 0;
        if (!isLimit && mask != 0 && memo[i][mask] != -1) return memo[i][mask];
        int res = 0;
        // 可以跳过当前位
        if (mask == 0) res = f(i + 1, mask, false);
        // 如果前面填的数字都和n一样,那么这一位至多填数字s[i](否则就超过n了)
        int up = isLimit? s[i] - '0': 9;
        for (int d = mask != 0? 0: 1; d <= up; ++d) {
            if ((mask >> d & 1) == 0) {
                res += f(i + 1, mask | (1 << d), isLimit && d == up);
            }
        }
        if (!isLimit && mask != 0) memo[i][mask] = res;
        return res;
    }
}

相关题目练习

233. 数字 1 的个数⭐⭐⭐⭐⭐

https://leetcode.cn/problems/number-of-digit-one/
在这里插入图片描述

代码模板修改——记录cnt(前面已经选了几个1)

memo[i][j] 表示枚举到第 i 个下标时前面已经选择了 j 个1。

即 memo 数组的第二个维度是 cnt

class Solution {
    char[] s;
    int[][] memo;

    public int countDigitOne(int n) {
        s = Integer.toString(n).toCharArray();
        int m = s.length;
        memo = new int[m][m];
        for (int i = 0; i < m; ++i) Arrays.fill(memo[i], -1);
        return f(0, true, false, 0);  // 最后一个参数表示前面选了几个1;    
    }

    public int f(int i, boolean isLimit, boolean isNum, int cnt) {
        if (i == s.length) return cnt;
        if (!isLimit && isNum && memo[i][cnt] != -1) return memo[i][cnt];
        
        int res = 0;
        if (!isNum) res = f(i + 1, false, false, 0);
        int up = isLimit? s[i] - '0': 9;
        for (int d = isNum? 0: 1; d <= up; ++d) {
            res += f(i + 1, isLimit && d == up, true, cnt + (d == 1? 1: 0));
        }
        if (!isLimit && isNum) memo[i][cnt] = res;
        return res;
    }
}
代码优化——不需要isNum

这道题目不需要 isNum,因为就算是前导零,也不会影响 数字中 1 的个数。

class Solution {
    char[] s;
    int[][] memo;

    public int countDigitOne(int n) {
        s = Integer.toString(n).toCharArray();
        int m = s.length;
        memo = new int[m][m];
        for (int i = 0; i < m; ++i) Arrays.fill(memo[i], -1);
        return f(0, true, 0);  // 最后一个参数表示前面选了几个1;    
    }

    public int f(int i, boolean isLimit, int cnt) {
        if (i == s.length) return cnt;
        if (!isLimit && memo[i][cnt] != -1) return memo[i][cnt];
        
        int res = 0;
        int up = isLimit? s[i] - '0': 9;
        for (int d = 0; d <= up; ++d) {
            res += f(i + 1, isLimit && d == up, cnt + (d == 1? 1: 0));
        }
        if (!isLimit) memo[i][cnt] = res;
        return res;
    }
}

不用DP!从低到高枚举每一位🐂!

思路来自:虾皮0306春招实习笔试真题解析
在这里插入图片描述
题干中是“包含2的数字出现的次数”,但给出的解法实际上求出的是“数字2出现的次数”。

def numberOf2sInRange(n):
    multiplier, count, remainder = 1, 0, n
    while remainder:
        digit = remainder % 10
        left = n // (multiplier * 10)
        right = n % multiplier
        if digit > 2:
            count += (left + 1) * multiplier
        elif digit == 2:
            count += left * multiplier + right + 1
        else:
            count += left * multiplier
        multiplier *= 10
        remainder //= 10
    return count

解决该题的Java代码如下:

class Solution {
    public int countDigitOne(int n) {
        int multiplier = 1, count = 0, remainder = n;
        // 从低到高枚举每一位
        while (remainder != 0) {
            int digit = remainder % 10;         // 当前位
            int left = n / (multiplier * 10);   // 左边位
            int right = n % multiplier;         // 右边位
            // 根据digit与1的关系分三种情况处理
            if (digit > 1)count += (left + 1) * multiplier;
            else if (digit == 1) count += left * multiplier + right + 1;
            else count += left * multiplier;
            // 更新当前位的基数
            multiplier *= 10;
            remainder /= 10;
        }
        return count;
    }
}

面试题 17.06. 2出现的次数

https://leetcode.cn/problems/number-of-2s-in-range-lcci/
在这里插入图片描述
这道题目和上面那道题目几乎一模一样。

AC 代码如下:

class Solution {
    int[][] memo;
    char[] s;

    public int numberOf2sInRange(int n) {
        s = Integer.toString(n).toCharArray();
        int m = s.length;
        memo = new int[m][m];
        return f(0, true, 0);
    }

    public int f(int i, boolean isLimit, int cnt) {
        if (i == s.length) return cnt;
        if (!isLimit && memo[i][cnt] !=0) return memo[i][cnt];

        int res = 0, up = isLimit? s[i] - '0': 9;
        for (int d = 0; d <= up; ++d) {
            res += f(i + 1, isLimit && d == up, cnt + (d == 2? 1: 0));
        }
        if (!isLimit) memo[i][cnt] = res;
        return res;
    }
}

600. 不含连续1的非负整数⭐⭐⭐

600. 不含连续1的非负整数
在这里插入图片描述

将问题转换成只能选择 0 和 1 ,且 1 之间不能连续出现的数位 dp 问题即可。

class Solution {
    char[] s;
    int[][] memo;

    public int findIntegers(int n) {
        s = Integer.toBinaryString(n).toCharArray();
        int m = s.length;
        memo = new int[m][2];
        for (int i = 0; i < m; ++i) Arrays.fill(memo[i], -1);
        return f(0, true, 0);
    }

    public int f(int i, boolean isLimit, int last) {
        if (i == s.length) return 1;
        if (!isLimit && memo[i][last] != -1) return memo[i][last];

        int up = isLimit? s[i] - '0': 1;

        int res = f(i + 1, isLimit && up == 0, 0);
        if (last != 1 && up == 1) res += f(i + 1, isLimit && up == 1, 1) ;
        if (!isLimit) memo[i][last] = res;
        return res;
    }
}

902. 最大为 N 的数字组合

902. 最大为 N 的数字组合

在这里插入图片描述

class Solution {
    Set<Integer> digits = new HashSet();
    char[] s;
    int[] memo;

    public int atMostNGivenDigitSet(String[] digits, int n) {
        for (String d: digits) this.digits.add(Integer.parseInt(d));
        s = String.valueOf(n).toCharArray();
        int m = s.length;
        memo = new int[m];
        Arrays.fill(memo, -1);   // -1表示没有被计算过
        // 从下标0开始填,isLimit=true,isNum=false
        return f(0, true, false);
    }

    public int f(int i, boolean isLimit, boolean isNum) {
        if (i == s.length) return isNum? 1: 0;
        if (!isLimit && isNum && memo[i] != -1) return memo[i];
        int res = 0;
        // 可以跳过当前位
        if (!isNum) res = f(i + 1, false, false);
        // 如果前面填的数字都和n一样,那么这一位至多填数字s[i](否则就超过n了)
        int up = isLimit? s[i] - '0': 9;
        for (int d = isNum? 0: 1; d <= up; ++d) {
            if (digits.contains(d)) {
                res += f(i + 1, isLimit && d == up, true);
            }
        }
        if (!isLimit && isNum) memo[i] = res;
        return res;
    }
}

删去了 mask ,因为它允许数字重复。
增加了一个可选数字集合 digits,每一位可选的数字必须在这个集合内。

在这里插入图片描述

1067. 范围内的数字计数

https://leetcode.cn/problems/digit-count-in-range/
在这里插入图片描述

上面题目的变式题。

class Solution {
    char[] s;
    int[][] memo;
    int t;

    public int digitsCount(int d, int low, int high) {
        t = d;
        return op(high) - op(low - 1);
    }

    public int op(int n) {
        s = Integer.toString(n).toCharArray();
        int m = s.length;
        memo = new int[m][m];
        for (int i = 0; i < m; ++i) Arrays.fill(memo[i], -1);
        return f(0, true, false, 0);
    }

    public int f(int i, boolean isLimit, boolean isNum, int cnt) {
        if (i == s.length) return cnt;
        if (!isLimit && isNum && memo[i][cnt] != -1) return memo[i][cnt];
        
        int res = 0;
        if (!isNum) res = f(i + 1, false, false, 0);	// 前面是前导零,这里可以也跳过设置成零
        int up = isLimit? s[i] - '0': 9;
        for (int d = isNum? 0: 1; d <= up; ++d) {
            res += f(i + 1, isLimit && d == up, true, cnt + (d == t? 1: 0));
        }
        if (!isLimit && isNum) memo[i][cnt] = res;
        return res;
    }
}

最开始写的时候忘记了 if (!isNum) res = f(i + 1, false, false, 0); 这一句。

1397. 找到所有好字符串⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐

1397. 找到所有好字符串

在这里插入图片描述

这题超级难!

数位dp + kmp

关于 kmp 可见:我一定要 学会KMP字符串匹配

代码1——kmp风格1

https://leetcode.cn/problems/find-all-good-strings/solutions/2195814/ling-shen-shu-wei-dpmo-ban-kmp-by-zachar-qcoo/

class Solution {
    int n;
    int[][] dp;
    int[] next;
    int MOD = (int)1e9 + 7;

    public int findGoodStrings(int n, String s1, String s2, String evil) {
        this.n = n;
        int len = evil.length();
        dp = new int[n][len];
        for(int i = 0; i < n; i++) {
            Arrays.fill(dp[i], -1);
        }

        next = new int[len];
        for(int j = 0, i = 1; i < len; i++) {
            while(j > 0 && evil.charAt(i) != evil.charAt(j)) j = next[j - 1];
            if(evil.charAt(i) == evil.charAt(j)) j++;
            next[i] = j;
        }
        
        return dfs(s1, s2, evil, 0, 0, true, true);
    }

    public int dfs(String s1, String s2, String evil, int i, int j, boolean downLimited, boolean upLimited) {
        // 代表字符串中出现了 evil
        if(j == evil.length()) return 0;
        if(i == n) return 1;
        if(!downLimited && !upLimited && dp[i][j] != -1) return dp[i][j];

        long ans = 0;
        char down = downLimited ? s1.charAt(i) : 'a', up = upLimited ? s2.charAt(i) : 'z';
        for(char k = down; k <= up; k++) {
            int nj = j;
            while(nj > 0 && k != evil.charAt(nj)) nj = next[nj - 1];
            // 此处要注意,当 nj == 0 的时候,会存在 k != evil.charAt(nj) 的情况
            // 若直接 nj + 1 进入递归,是认为此时的两个字符一定是匹配上了,实际上可能并没有
            if(nj == 0 && k != evil.charAt(nj)) nj = -1;
            ans = (ans + dfs(s1, s2, evil, i + 1, nj + 1, downLimited && k == down, upLimited && k == up)) % MOD;
        }
        if(!downLimited && !upLimited) dp[i][j] = (int)ans;
        return (int)ans;
    }
}
代码2——kmp风格2(j从-1开始)👍👍👍👍👍

这是笔者自己根据上面代码修改来的。

dp[i][j] 表示枚举到第 i 位,前面匹配成功了evil 中的 j + 1 个字符(即 j 是 evil 的下标)。

class Solution {
    int n;
    int[][] dp;
    int[] next;     // kmp的next数组
    int MOD = (int)1e9 + 7;

    public int findGoodStrings(int n, String s1, String s2, String evil) {
        this.n = n;
        int len = evil.length();
        dp = new int[n][len];
        for(int i = 0; i < n; i++) {
            Arrays.fill(dp[i], -1);
        }

        next = new int[len];
        next[0] = -1;
        for(int j = -1, i = 1; i < len; i++) {
            while(j != -1 && evil.charAt(i) != evil.charAt(j + 1)) j = next[j];
            if(evil.charAt(i) == evil.charAt(j + 1)) j++;
            next[i] = j;
        }
        
        // 注意j初始为-1,表示一个都还没被匹配到
        return dfs(s1, s2, evil, 0, -1, true, true);
    }

    public int dfs(String s1, String s2, String evil, int i, int j, boolean downLimited, boolean upLimited) {
        // 代表字符串中出现了 evil
        if(j == evil.length() - 1) return 0;
        if(i == n) return 1;
        if(!downLimited && !upLimited && dp[i][j + 1] != -1) return dp[i][j + 1];   // 注意所有的dp都是dp[i][j + 1],因为j是从-1开始的

        long ans = 0;
        char down = downLimited ? s1.charAt(i) : 'a', up = upLimited ? s2.charAt(i) : 'z';
        for(char k = down; k <= up; k++) {
            // kmp的匹配过程
            int nj = j;
            while(nj != -1 && k != evil.charAt(nj + 1)) nj = next[nj];
            if (k == evil.charAt(nj + 1)) nj++;
            ans = (ans + dfs(s1, s2, evil, i + 1, nj, downLimited && k == down, upLimited && k == up)) % MOD;
        }
        if(!downLimited && !upLimited) dp[i][j + 1] = (int)ans;
        return (int)ans;
    }
}
kmp应用的相关题目——1392. 最长快乐前缀

1392. 最长快乐前缀

解法1——kmp

kmp 的 next 数组即为最长公共前后缀数组。

class Solution {
    public String longestPrefix(String s) {
        int n = s.length();
        int[] next = new int[n];
        next[0] = -1;
        for (int i = 1, j = -1; i < n; ++i) {
            while (j != -1 && s.charAt(i) != s.charAt(j + 1)) j = next[j];
            if (s.charAt(i) == s.charAt(j + 1)) j++;
            next[i] = j;
        }
        return s.substring(0, next[n - 1] + 1);
    }
}
解法2——Rabin-Karp 字符串编码

解析见:https://leetcode.cn/problems/longest-happy-prefix/solutions/172436/zui-chang-kuai-le-qian-zhui-by-leetcode-solution/

class Solution {
    public String longestPrefix(String s) {
        int n = s.length();
        long prefix = 0, suffix = 0;
        long base = 31, mod = 1000000007, mul = 1;
        int happy = 0;
        for (int i = 1; i < n; ++i) {
            prefix = (prefix * base + (s.charAt(i - 1) - 'a')) % mod;
            suffix = (suffix + (s.charAt(n - i) - 'a') * mul) % mod;
            if (prefix == suffix) {
                happy = i;
            }
            mul = mul * base % mod;
        }
        return s.substring(0, happy);
    }
}

1012. 至少有 1 位重复的数字

1012. 至少有 1 位重复的数字
在这里插入图片描述

解法1——转换(统计特殊整数)

用 n - 2376. 统计特殊整数 的结果就好了。

代码如下:

class Solution {
    char[] s;
    int[][] memo;

    public int numDupDigitsAtMostN(int n) {
        return n - countSpecialNumbers(n);
    }

    public int countSpecialNumbers(int n) {
        s = String.valueOf(n).toCharArray();
        int m = s.length;
        memo = new int[m][1 << 10];
        for (int i = 0; i < m; ++i) {
            Arrays.fill(memo[i], -1);   // -1表示没有被计算过
        }
        // 从下标0开始填,初始mask=0,isLimit=true
        return f(0, 0, true);
    }

    // 返回从i开始填数字,i前面填的数字的集合是mask,能构造出的特殊正数的数目
    // isLimit表示前面填的数字是否都是n对应位上的,如果为true,那么当前位至多为s[i],否则至多为'9'
    int f(int i, int mask, boolean isLimit) {
        if (i == s.length) return mask != 0? 1: 0;
        if (!isLimit && mask != 0 && memo[i][mask] != -1) return memo[i][mask];
        int res = 0;
        // 可以跳过当前位
        if (mask == 0) res = f(i + 1, mask, false);
        // 如果前面填的数字都和n一样,那么这一位至多填数字s[i](否则就超过n了)
        int up = isLimit? s[i] - '0': 9;
        for (int d = mask != 0? 0: 1; d <= up; ++d) {
            if ((mask >> d & 1) == 0) {
                res += f(i + 1, mask | (1 << d), isLimit && d == up);
            }
        }
        if (!isLimit && mask != 0) memo[i][mask] = res;
        return res;
    }
}

解法2——正面硬刚

来自:https://leetcode.cn/problems/numbers-with-repeated-digits/solutions/1748539/by-endlesscheng-c5vg/comments/2079449

2719. 统计整数数目⭐⭐⭐

https://leetcode.cn/problems/count-of-integers/

在这里插入图片描述
在这里插入图片描述

class Solution {
    char[] s;
    int[][] memo;
    int minSum, maxSum;
    final int mod = (int)1e9 + 7;

    public int count(String num1, String num2, int min_sum, int max_sum) {
        minSum = min_sum;
        maxSum = max_sum;
        int ans = op(num2) - op(num1) + mod;
        // 单独计算num1是否是合法的数字
        int sum = 0;
        for (char c: num1.toCharArray()) sum += c - '0';
        if (min_sum <= sum && sum <= max_sum) ans++;
        return ans % mod;
    }

    public int op(String num) {
        s = num.toCharArray();
        int m = s.length;
        memo = new int[m][Math.min(9 * m, maxSum) + 1];
        for (int i = 0; i < m; ++i) Arrays.fill(memo[i], -1);
        return f(0, true, 0);
    }

    public int f(int i, boolean isLimit, int digitSum) {
        if (digitSum > maxSum) return 0;    // 非法数字
        if (i == s.length) return digitSum >= minSum ? 1: 0;
        if (!isLimit && memo[i][digitSum] != -1) return memo[i][digitSum];

        int res = 0;
        int up = isLimit? s[i] - '0': 9;
        for (int d = 0; d <= up; ++d) {
            res = (res + f(i + 1, isLimit && d == up, digitSum + d)) % mod;
        }
        if (!isLimit) memo[i][digitSum] = res;
        return res;
    }
}

这里由于 num1 是个字符串,所以直接计算 <= num1 的合法数字个数,再单独判断 num1 这个数是否合法。

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Wei *

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值