数位DP (第一次写,很乐意接受检阅)

本文讲述了数位DP的核心原理、常见问题解决策略,涉及递推和记忆化搜索,以及如何通过实例演示LeetCode中的问题,如最大数字组合、特殊整数数量计算和整数数码计数。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

关于数位DP,首先应该知道它的 1.基层原理 是什么,它可以2.解决哪些问题

基本原理:

首先用一个例子来说明数位DP。如果我们从 7000 数到 7999、从 8000 数到 8999、和从 9000 数到 9999 的过程非常相似,它们都是后三位从 000 变到 999,不一样的地方只有千位这一位,所以我们可以把这些过程归并起来,将这些过程中产生的计数答案(计数答案根据题目要求来决定)也都存在一个通用的数组里。此数组根据题目具体要求设置状态,用递推或 递归(相较于递推,递归的方式解决数位DP会更通用) 的方式进行状态转移。

数位 DP 中通常会利用常规计数问题技巧:

1.比如把一个区间内的答案拆成两部分相减,例如要求从l到r范围内某些数的个数,可以求ans[0,r]-ans[0,l]。如果输入l和r用的数字型,可以求ans[0,l-1]然后做差;如果用字符串输入,可以求ans[0,r]-ans[0,l],最后对l做特判(因为字符串减1不好操作)

2.那么有了通用答案数组,接下来就是统计答案。统计答案可以选择记忆化搜索(关于记忆化搜索的数组,根据哪些是决定性变量来设。不仅是数位DP,对于其他的DP问题,在设置DP数组时,要根据决定性变量来设置),也可以选择循环迭代递推。

3.为了不重不漏地统计所有不超过上限的答案,要从高到低枚举每一位,再考虑每一位都可以填哪些数字,最后利用通用答案数组统计答案。

数位DP的通用模板:

leetcode 最大为N的数字组合:

给定一个按 非递减顺序 排列的数字数组 digits 。你可以用任意次数 digits[i] 来写的数字。例如,如果 digits = ['1','3','5'],我们可以写数字,如 '13''551', 和 '1351315'

返回 可以生成的小于或等于给定整数 n 的正整数的个数 。

递归的含义是求不超过n的组合数,递归是通过枚举每位数字直到最后枚举的完所有的位数来确定答案

class Solution {
public:
    int atMostNGivenDigitSet(vector<string> &digits, int n) {
        auto s = to_string(n);
        int m = s.length(), dp[m]//缓存表来记录,防止重复的状态再次计算;
        memset(dp, -1, sizeof(dp)); // dp[i] = -1 表示 i 这个状态还没被计算出来
        function<int(int, bool, bool)> f = [&](int i, bool is_limit/*前面填的数字是否收到限制*/, bool is_num/*前面是否有数字*/) -> int {
            if (i == m) return is_num; // 如果填了数字,则为 1 种合法方案
            if (!is_limit && is_num && dp[i] >= 0) return dp[i]; // 在不受到任何约束的情况下,返回记录的结果,避免重复运算(关于为什么只有i一个参数,是因为这些字符可以随意选,无需考虑哪些选过)
            int res = 0;
            if (!is_num) // 前面不填数字,那么可以跳过当前数位,也不填数字
                // is_limit 改为 false,因为没有填数字,位数都比 n 要短,自然不会受到 n 的约束
                // is_num 仍然为 false,因为没有填任何数字
                res = f(i + 1, false, false);
            char up = is_limit ? s[i] : '9'; // 根据是否受到约束,决定可以填的数字的上限
            // 注意:对于一般的题目而言,如果这里 is_num 为 false,则必须从 1 开始枚举,由于本题 digits 没有 0,所以无需处理这种情况
            for (auto &d : digits) { // 枚举要填入的数字 d
                if (d[0] > up) break; // d 超过上限,由于 digits 是有序的,后面的 d 都会超过上限,故退出循环
                // is_limit:如果当前受到 n 的约束,且填的数字等于上限,那么后面仍然会受到 n 的约束
                // is_num 为 true,因为填了数字
                res += f(i + 1, is_limit && d[0] == up, true);
            }
            if (!is_limit && is_num) dp[i] = res; // 在不受到任何约束的情况下,记录结果
            return res;
        };
        return f(0, true, false);
    }
};

递推的写法:

class Solution {
public:
    int atMostNGivenDigitSet(vector<string>& digits, int n) {
        string s = to_string(n);
        int m = digits.size(), k = s.size();
        vector<vector<int>> dp(k + 1, vector<int>(2));
        dp[0][1] = 1;//dp[i][1]表示前i位(从高位开始)有数字并和n相等(就是is_limit)
                     //dp[i][0]是前面有数字但不受限(!is_limit&&is_num)
        for (int i = 1; i <= k; i++) {
            for (int j = 0; j < m; j++) {
                if (digits[j][0] == s[i - 1]) {
                    dp[i][1] = dp[i - 1][1];
                } else if (digits[j][0] < s[i - 1]) {
                    dp[i][0] += dp[i - 1][1];
                } else {
                    break;
                }
            }
            if (i > 1) {
                dp[i][0] += m + dp[i - 1][0] * m;
            }
        }
        return dp[k][0] + dp[k][1];
    }
};

leetcode统计特殊整数

如果一个正整数每一个数位都是 互不相同 的,我们称它是 特殊整数 。

给你一个 正 整数 n ,请你返回区间 [1, n] 之间特殊整数的数目。

class Solution {
public:
    int countSpecialNumbers(int n) {
        auto s = to_string(n);
        int m = s.length(), memo[m][1 << 10];
        memset(memo, -1, sizeof(memo)); // -1 表示没有计算过
        //此题中数位是关键参数
        function<int(int, int, bool, bool)> f = [&](int i, int mask, bool is_limit, bool is_num) -> int {
            if (i == m)
                return is_num; // is_num 为 true 表示得到了一个合法数字
            if (!is_limit && is_num && memo[i][mask] != -1)
                return memo[i][mask];
            int res = 0;
            if (!is_num) // 可以跳过当前数位
                res = f(i + 1, mask, false, false);
            int up = is_limit ? s[i] - '0' : 9; // 如果前面填的数字都和 n 的一样,那么这一位至多填数字 s[i](否则就超过 n 啦)
            for (int d = 1 - is_num; d <= up; ++d) // 枚举要填入的数字 d
                if ((mask >> d & 1) == 0) // d 不在 mask 中
                    res += f(i + 1, mask | (1 << d), is_limit && d == up, true);
            if (!is_limit && is_num)
                memo[i][mask] = res;
            return res;
        };
        return f(0, 0, true, false);
    }

注:集合和二进制是可以相互表示的。设置整数mask,mask|(1<<d(0~9))就是在mask的二进制的第d位(从0位开始)由零变为1,相当于记录d这个数字已经用过了。mask>>d&1表示查看d这个数字有没有被用过。mask&(~(1<<d))把d变为0。mask^(1<<d)将指定位取反。关于二进制用集合来表示,可以具体看视频二进制和集合​​​​​​

leetcode统计整数数目统计整数数目 

给你两个数字字符串 num1 和 num2 ,以及两个整数 max_sum 和 min_sum 。如果一个整数 x 满足以下条件,我们称它是一个好整数:

  • num1 <= x <= num2
  • min_sum <= digit_sum(x) <= max_sum.

请你返回好整数的数目。答案可能很大,请返回答案对 109 + 7 取余后的结果。

注意,digit_sum(x) 表示 x 各位数字之和。

class Solution {
    const int MOD = 1'000'000'007;

    int calc(string &s, int min_sum, int max_sum) {
        int n = s.length();
        vector<vector<int>> memo(n, vector<int>(min(9 * n, max_sum) + 1, -1));
         //在此题中,位数和sum是决定性参数,所以记忆化时是以这两个参数设的数组
        function<int(int, int, bool)> dfs = [&](int i, int sum, bool is_limit) -> int {
            if (sum > max_sum) { // 非法
                return 0;
            }
            if (i == n) {
                return sum >= min_sum ? 1 : 0;
            }
            if (!is_limit && memo[i][sum] != -1) {
                return memo[i][sum];
            }

            int up = is_limit ? s[i] - '0' : 9;
            int res = 0;
            for (int d = 0; d <= up; d++) { // 枚举当前数位填 d
                res = (res + dfs(i + 1, sum + d, is_limit && d == up)) % MOD;
            }

            if (!is_limit) {
                memo[i][sum] = res;
            }
            return res;
        };
        return dfs(0, 0, true);
    }

public:
    int count(string num1, string num2, int min_sum, int max_sum) {
        int ans = calc(num2, min_sum, max_sum) - calc(num1, min_sum, max_sum) + MOD; // 避免负数
        int sum = 0;
        for (char c : num1) {
            sum += c - '0';
        }
        ans += min_sum <= sum && sum <= max_sum; // num1 是合法的,补回来
        return ans % MOD;
    }
};

洛谷 数字计数数字计数

 给定两个正整数 a 和 b,求在 [a,b] 中的所有整数中,每个数码(digit)各出现了多少次。

//这个题是我认为最难的一道数位DP

递推

#include <bits/stdc++.h>
using namespace std;
const int N = 15;
typedef long long ll;
ll l, r, dp[N], mi[N];
ll ans1[N], ans2[N];
int a[N];

void solve(ll n, ll *ans) {//统计0~n各数字的个数
  ll tmp = n;
  int len = 0;
  while (n) a[++len] = n % 10, n /= 10;//统计每位上的数字数值
  for (int i = len; i >= 1; --i) {
    for (int j = 0; j < 10; j++) ans[j] += dp[i - 1] * a[i];//后i-1位数码的和
    for (int j = 0; j < a[i]; j++) ans[j] += mi[i - 1];//i位上某些数字的数码个数
    //上面两个for循环只是求了部分数码,例如求4365的数码,上述操作只是求了0~3999的数码个数,还有 
    //4000~4365的数码
    tmp -= mi[i - 1] * a[i];
    ans[a[i]] += tmp + 1;//记录最高位的数码个数,如上例就是记录了4的数码个数
    ans[0] -= mi[i - 1];//0不能做最高位,要减去
    //下层for循环就是处理上例的365这个数字
  }
}

int main() {
  scanf("%lld%lld", &l, &r);
  mi[0] = 1ll;//
  for (int i = 1; i <= 13; ++i) {//预处理
    dp[i] = dp[i - 1] * 10 + mi[i - 1];//当填写i位数且不受限制时每个数码出现的个数=第i位贡献值+后i-1位贡献值(当不受限时个数码出现的个数是相同的)
    mi[i] = 10ll * mi[i - 1];//统计第i位上每个数码贡献的次数
  }
  solve(r, ans1), solve(l - 1, ans2);
  for (int i = 0; i < 10; ++i) printf("%lld ", ans1[i] - ans2[i]);
  return 0;
}

递归 

#include <cstdio>  //code by Alphnia
#include <cstring>
#include <iostream>
using namespace std;
#define N 50005
#define ll long long
ll a, b;
ll f[15], ksm[15], p[15], now[15];
//f[]记忆化数组
//ksm[]不受限时后第i位数贡献值
//now[]记录从第1位到各个位上的数字组合
//p[]记录各位数

ll dfs(int u, int x, bool f0,
       bool lim) {  // u 表示位数,f0 是否有前导零,lim 是否都贴在上限上
//dfs的含义就是统计以最高位数字打头,位数与原数字相同的数字的数码个数,在次递归下一层dfs就是处理
//以下一位为最高位的数打头的数码的个数
  if (!u) {
    if (f0);//没有数字
    return 0;
  }
  if (!lim && !f0 && (~f[u])) return f[u];
  ll cnt = 0;
  int lst = lim ? p[u] : 9;
  for (int i = 0; i <= lst; i++) {  // 枚举这位要填的数字
    if (f0 && i == 0)
      cnt += dfs(u - 1, x, 1, lim && i == lst);  // 处理前导零
    else if (i == x && lim && i == lst)
      cnt += now[u - 1] + 1 +
             dfs(u - 1, x, 0,
                 lim && i == lst);  // 此时枚举的前几位都贴在给定的上限上。
    else if (i == x)
      cnt += ksm[u - 1] + dfs(u - 1, x, 0, lim && i == lst);
    else
      cnt += dfs(u - 1, x, 0, lim && i == lst);
  }
  if ((!lim) && (!f0)) f[u] = cnt;  // 只有不贴着上限和没有前导零才能记忆
  return cnt;
}

ll gans(ll d, int dig) {//统计数字的位数,并预处理now、ksm数组
  int len = 0;
  memset(f, -1, sizeof(f));
  while (d) {
    p[++len] = d % 10;
    d /= 10;
    now[len] = now[len - 1] + p[len] * ksm[len - 1];
  }
  return dfs(len, dig, 1, 1);
}

int main() {
  scanf("%lld%lld", &a, &b);
  ksm[0] = 1;
  for (int i = 1; i <= 12; i++) ksm[i] = ksm[i - 1] * 10ll;
  for (int i = 0; i < 9; i++) printf("%lld ", gans(b, i) - gans(a - 1, i));
  printf("%lld\n", gans(b, 9) - gans(a - 1, 9));
  return 0;
}

 更多数位DP可以参考如下:

1.OI WIKI WIKI

2.左程云084、085   084  085

3.灵茶山艾府  DP题单

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值