数位dp

前言:为什么会想到学习数位dp,是因为群友问了一个蓝桥杯国赛的题要用数位dp解,然后闲的无聊就来学习数位dp了。

  • 数位dp的尝试方式大都不算特殊,绝大多是都是线性展开的,类似从左到右的尝试。
  • 数位dp是在数组的每一位上展开而已。
  • 不同的题目有不同的限制,数位dp解题的核心在于:可能性整理 + 排列组合相关的知识。

对于数位操作题型的探索

统计各位数字都不同的数字个数

题目大意:给你一个整数 ,统计并返回各位数字都不同的数字的个数,其中\(0 \le x \lt 10^n\)

【解题】:核心思路:统计1 - n 位数字对结果的贡献。

不难想到 i = 1 时, 需要求的结果时10 以内个位数字不同的数字个数为 10。

当 i > 1 时,由于限定该数字必须为 i 位,所以第一位不能是 0 ,共 9 种选法, 由于第一位选择了一个数组后面还有 10 - 1 = 9 种可以选, 同理往后可选择的方案依次减一。

code:

class Solution {
public:
    int countNumbersWithUniqueDigits(int n) 
    {
        if(n == 0) return 1;
        int ret = 9, sum = 10;
        for(int k = 2, s = 9; k <= n; k++, s--)
        {
            ret = ret * s;
            sum += ret;
        }
        return sum;
    }
};

最大数字为 N 的数字组合

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

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

【解题】:发现对于每一位数字的选择可以分为三种情况:

  1. 不选当前位数字,即组成一个位数小于 n 的数字。
  2. 当前位数字选择一个小于 n 对应位置的数字。
  3. 当前位数字选择一个等于 n 对应位置的数字。

用 dfs 搜索每一位可以填的数字,对于1,2这两种情况,后续可以填写任意数字(因为此时已经注定要小于等于 n 了),对于情况三,则需要填写小于等于 n 对应位置的数字的数。

code:

class Solution {
public:

    // ret :可选数字集合
    // len :当前选择数字的位数
    // offset :辅助提取 n 的第 len 位  
    // free :前一个数字的选择,0 表示前一个数字与n的相同,1表示小于对应数字
    // fix :前面是否选择过数字
    int f(int n, vector<int> & ret, int len, int offset, int free, int fix)
    {
        if(len == 0)
        {
            // return fix;
            // 如果到最后前面一个数字没有选择返回0
            return fix == 1 ? 1 : 0;
        }
        // 提取当前位的数字
        int cur = (n / offset) % 10;
        int ans = 0;
        if(fix == 0)
        {
            // 一直不选数字
            ans += f(n, ret, len - 1, offset / 10, 1, 0);
        }
        if(free == 1)
        {
            // 当前位 以即后面的位置可以随便选
            ans += ret.size() * f(n, ret, len - 1, offset / 10, 1, 1);
        }
        else
        {
            for(int i = 0; i < ret.size(); i++)
            {
                if(ret[i] < cur)
                {
                    ans += f(n, ret, len - 1, offset / 10, 1, 1);
                }
                else if(ret[i] == cur)
                {
                    ans += f(n, ret, len - 1, offset / 10, 0, 1);
                }
                else
                {
                    break;
                }
            }
        }
        return ans;
    }
    int atMostNGivenDigitSet(vector<string>& digits, int n) 
    {
        vector<int> ret;
        for(auto ch : digits) ret.push_back(ch[0] - '0');
        int tmp = n / 10;
        int len = 1;
        int offset = 1;
        while(tmp)
        {
            tmp /= 10;
            len++;
            offset *= 10;
        }
        return f(n, ret, len, offset, 0, 0);
    }
};

这段代码的速度已经击败100%的用户了,但是还是有可以优化的地方,我们先把这段代码的递归展开图画出来。

image

我们发现其实有很对的递归展开是无效的,换句话说我们其实可以一开始就算出来没有必要进行展开,下面对代码进行一点小优化:

code:

class Solution {
public:

    // ret :可选数字集合
    // len :当前选择数字的位数
    // offset :辅助提取 n 的第 len 位  
    // free :前一个数字的选择,0 表示前一个数字与n的相同,1表示小于对应数字
    // fix :前面是否选择过数字
    int f(int n, vector<int> & ret, vector<int> & cnt, int len, int offset)
    {
        if(len == 0)
        {
            // n 本身
            return 1;
        }
        // 提取当前位的数字
        int cur = (n / offset) % 10;
        int ans = 0;
        for(int i = 0; i < ret.size(); i++)
        {
            if(ret[i] < cur) ans += cnt[len - 1]; // 减去无效递归
            else if(ret[i] == cur) ans += f(n, ret, cnt, len - 1, offset / 10);
            else break;
        }
        return ans;
    }
    int atMostNGivenDigitSet(vector<string>& digits, int n) 
    {
        int m = digits.size();
        vector<int> ret;
        for(auto ch : digits) ret.push_back(ch[0] - '0');
        int tmp = n / 10;
        int len = 1;
        int offset = 1;
        while(tmp)
        {
            tmp /= 10;
            len++;
            offset *= 10;
        }
        vector<int> cnt(len + 1); // cnt[i] 表示 i 的前缀已经确定且比n小时后面可选数字的总数
        int ans = 0;
        cnt[0] = 1;
        for(int i = 1, k = m; i < len; i++, k *= m)
        {
            cnt[i] = k;
            ans += cnt[i]; // 表示前缀不选的组合数(减去无效递归)
        }
        return ans + f(n, ret, cnt, len, offset);
    }
};

统计整数数目

题目大意:给你两个数字字符串 num1num2 ,以及两个整数 max_summin_sum 。如果一个整数 x 满足以下条件,我们称它是一个好整数:

  • \(num1 \le x \le num2\)
  • \(min\_sum \le digit_sum(x) \le max\_sum\)

返回答案mod \(10^9 + 7\)的结果。

【解题】:num的返回很大超过了longlong的范围,因此我们要是从 0 开始枚举的话不但时间会超,而且我们也存不下,但是 min_sum 的范围有很小,这题的解法还是和上面的题一样,选择每一位的数。

统计(0, num2)的总数 cnt1,(0, num1 - 1)的总数 cnt2,用 cnt1 - cnt2 即是答案。

但是 num 给的是字符串,num1 - 1 需要我们单独设计字符串的减法,可以直接统计 (0, num1) 的数量cnt2,最后单独检查num1是否符合题意即可。


class Solution
{
public:
    const static int N = 410, M = 25;
    const int MOD = 1e9 + 7;
    int dp[M][N][2];
    static int mins, maxs, len;
    void init()
    {
        for (int i = 0; i < M; i++)
            for (int j = 0; j < N; j++)
                for (int k = 0; k < 2; k++)
                    dp[i][j][k] = -1;
    }

    // pos :当前需要填的位置
    // sum :前缀的累加和
    // free:后续位置是否可以自由填写
    int f(int pos, int sum, int free, string num)
    {
        // 可行性剪枝
        if (sum > maxs) return 0;
        if (sum + (len - pos) * 9 < mins) return 0;

        if (pos == len) return 1;
        if (dp[pos][sum][free] != -1) return dp[pos][sum][free]; // 记忆化搜索

        int ans = 0;
        int cur = num[pos] - '0';
        if (free == 1)
        {
            for (int i = 0; i <= 9; i++)
            {
                ans = (ans + f(pos + 1, sum + i, 1, num)) % MOD;
            }
        }
        else
        {
            for (int i = 0; i <= 9; i++)
            {
                if (i < cur) ans = (ans + f(pos + 1, sum + i, 1, num)) % MOD;
                else if (i == cur) ans = (ans + f(pos + 1, sum + i, 0, num)) % MOD;
                else break;
            }
        }
        return dp[pos][sum][free] = ans;
    }
    int check(string num)
    {
        int tmp = 0;
        for (auto ch : num)
        {
            tmp += ch - '0';
        }
        return (tmp >= mins && tmp <= maxs);
    }
    int count(string num1, string num2, int min_sum, int max_sum)
    {
        mins = min_sum, maxs = max_sum;
        vector<int> ret(M);
        len = num2.size();
        for (int i = 0; i < len; i++) ret[i] = num2[i] - '0';
        init();
        int cnt1 = f(0, 0, 0, num2);
        len = num1.size();
        for (int i = 0; i < len; i++) ret[i] = num1[i] - '0';
        init();
        int cnt2 = f(0, 0, 0, num1);
        return ((cnt1 - cnt2 + check(num1)) % MOD + MOD) % MOD;
    }
};
int Solution::mins = 0;
int Solution::maxs = 0;
int Solution::len = 0;

统计特殊整形

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

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

【解题】:本题类似于 1.1 不同的是上界 n ,本题把 n 任意化了。

我们把答案分为三部分:

  • 位数小于 n 的位数;
  • 位数等于 n 的位数,最高位小于 n 的最高位;
  • 位数等于 n 的位数,最高位等于 n 的最高位;

不难发现,前两种情况其实不需要递归展开,我们可以直接算出来:

  • 对于位数小于 n 的位数的情况:9 * 9 * 8 * 7 * 6 ... 解法和 1.1 一样;
  • 对于位数等于 n 的位数,最高位小于 n 的最高位;

需要递归展开的仅仅是最后一种情况。

code:

class Solution {
public:
    // pos :当前要填的位,权值高的优先,这里是从高位往低位填
    // offset:用于提取num的pos位
    // status:状态压缩,它的第 i 个比特位表示 i 这个数字是否被用过
    int f(int num, vector<int>& cnt, int pos, int offset, int status)
    {
        if (pos == 0)
        {
            // 此时所选的数字恰好等于n
            return 1;
        }
        int first = (num / offset) % 10;
        int ans = 0;
        for (int i = 0; i < first; i++)
        {
            if (((1 << i) & status) == 0) ans += cnt[pos - 1]; // 直接结算
        }
        if (((1 << first) & status) == 0) ans += f(num, cnt, pos - 1, offset / 10, (1 << first) ^ status);
        return ans;
    }
    int countSpecialNumbers(int n)
    {
        int tmp = n / 10;
        int len = 1;
        int offset = 1;
        while (tmp)
        {
            tmp /= 10;
            len++;
            offset *= 10;
        }
        int ans = 0;
        // 位数少于 n 的位数
        if (len >= 2)
        {
            ans = 9;
            int ret = 9;
            for (int a = 2, b = 9; a < len; a++, b--)
            {
                ret *= b;
                ans += ret;
            }
        }

        // cnt[i] : 前面len - i 位已经确定了, 还剩 i 位没有确定,要求前缀len - i 不为 0,的剩余方案数
        vector<int> cnt(len + 1);
        cnt[0] = 1;
        for (int k = 10 - len + 1, i = 1; i < len; i++, k++) cnt[i] = cnt[i - 1] * k;

        // 位数等于 n 的位数,最高位小于 n 的最高位
        int first = n / offset;
        ans += (first - 1) * cnt[len - 1];

        // 递归展开:位数等于 n 的位数,最高位等于 n 的最高位
        ans += f(n, cnt, len - 1, offset / 10, (1 << first));
        return ans;
    }
};

至少有 1 位重复的数字

这题完全是上面那题的翻版,至少有一位重复出现的数字的反面就是没有重复数字出现。用n - 上面的答案就行。

code:

class Solution {
public:
    // pos :当前要填的位,权值高的优先,这里是从高位往低位填
    // offset:用于提取num的pos位
    // status:状态压缩,它的第 i 个比特位表示 i 这个数字是否被用过
    int f(int num, vector<int>& cnt, int pos, int offset, int status)
    {
        if (pos == 0)
        {
            // 此时所选的数字恰好等于n
            return 1;
        }
        int first = (num / offset) % 10;
        int ans = 0;
        for (int i = 0; i < first; i++)
        {
            if (((1 << i) & status) == 0) ans += cnt[pos - 1]; // 直接结算
        }
        if (((1 << first) & status) == 0) ans += f(num, cnt, pos - 1, offset / 10, (1 << first) ^ status);
        return ans;
    }
    int numDupDigitsAtMostN(int n)
    {
        int tmp = n / 10;
        int len = 1;
        int offset = 1;
        while (tmp)
        {
            tmp /= 10;
            len++;
            offset *= 10;
        }
        int ans = 0;
        // 位数少于 n 的位数
        if (len >= 2)
        {
            ans = 9;
            int ret = 9;
            for (int a = 2, b = 9; a < len; a++, b--)
            {
                ret *= b;
                ans += ret;
            }
        }

        // cnt[i] : 前面len - i 位已经确定了, 还剩 i 位没有确定,要求前缀len - i 不为 0,的剩余方案数
        vector<int> cnt(len + 1);
        cnt[0] = 1;
        for (int k = 10 - len + 1, i = 1; i < len; i++, k++) cnt[i] = cnt[i - 1] * k;

        // 位数等于 n 的位数,最高位小于 n 的最高位
        int first = n / offset;
        ans += (first - 1) * cnt[len - 1];

        // 递归展开:位数等于 n 的位数,最高位等于 n 的最高位
        ans += f(n, cnt, len - 1, offset / 10, (1 << first));
        return n - ans;
    }
};

windy数

题目大意:不含前导零且相邻两个数字之差至少为 2 的正整数被称为 windy 数。windy 想知道,在 ab 之间,包括 ab ,总共有多少个 windy 数?

保证 1≤ab≤2×109

【解题】:

统计(a - b)区间内的windy数,其实使用(0 - b) 之间的windy数减去0 - a之间的windy数,这题的数据范围不大,没必要之前的一个题一样,用(0 - b)- (0 - a)然后单独判断a这个数是否合法,读入a的时候直接让a--,计算(0 - b) - (0 - a - 1)之间的windy数即可。

  • 保证数的大小小于 传入的num,像前面几题一样分为位数少于num的位数;位数等于num的位数但是当前位和num当前位的数字一样;位数等于num 的位数,但是当前位的数字和num当前位的数字一样。
  • 这次在选择数字的时候多了一个限制,当前选择的数字不能和前面数字的差值小于2,因此我们在传入参数的时候多传入一维pre表示前一个参数我们选择的情况。

code:

#include <iostream>
#include <vector>
#include <unordered_map>
#include <string>
#include <cmath>
using namespace std;
typedef long long LL;
typedef pair<int, int> PII;
#define endl '\n'
const int N = 15;

int a, b; 
int dp[N][N][N];
void init()
{
    for(int i = 0; i < N; i++)
    {
        for(int j = 0; j < N; j++)
        {
            for(int k = 0; k < N; k++)
            {
                dp[i][j][k] = -1;
            }
        }
    }
}
// num : 0 - num 的windy数
// offset :用于提取 num 的第 len 位
// len :从右到左第 len 位
// free:当前数字是否可以随便选择
int f(int num, int offset, int pre, int len, int free)
{
    if(num == 0) return 1;
    if(len == 0) 
    {
        // 不同管pre,假设前面什么都没选就是0
        return 1;
    }
    if(dp[pre][len][free] != -1) return dp[pre][len][free];
    int ans = 0;
    int cur = num / offset % 10;
    if(free == 0)
    {
        // 当前数字不能随便选择
        if(pre == 10)
        {
            // 前面没有选择过数字,此时就是num本身
            ans += f(num, offset / 10, 10, len - 1, 1); // 接着不选
            for(int i = 1; i < cur; i++)
            {
                ans += f(num, offset / 10, i, len - 1, 1);
            }
            ans += f(num, offset / 10, cur, len - 1, 0);
        }
        else
        {
            // 前面选择过数字
            for(int i = 0; i < cur; i++)
            {
                if(abs(i - pre) >= 2)
                {
                    ans += f(num, offset / 10, i, len - 1, 1);
                }
            }
            if(abs(cur - pre) >= 2) ans += f(num, offset / 10, cur, len - 1, 0);
        }
    }
    else // 可以随便选择
    {
        if(pre == 10)
        {
            // 前面还是没有选择过数字
            ans += f(num, offset / 10, 10, len - 1, 1); // 接着不选
            for(int i = 1; i <= 9; i++)
            {
                ans += f(num, offset / 10, i, len - 1, 1);
            }
        }
        else
        {
            // 前面选择过数字
            for(int i = 0; i <= 9; i++)
            {
                if(abs(i - pre) >= 2)
                {
                    ans += f(num, offset / 10, i, len - 1, 1);
                }
            }            
        }
    }
    return dp[pre][len][free] = ans;
}

void solve() 
{
    cin >> a >> b;
    a--;
    int len = 1;
    int offset = 1;
    int tmp = b / 10;
    while(tmp)
    {
        tmp /= 10;
        len++;
        offset *= 10;
    }
    init();
    // 由于前面什么都没选初始pre传入10,上一位和num一致free传入0表示不能随便选。 
    int cnt1 = f(b, offset, 10, len, 0);
    len = 1;
    offset = 1;
    tmp = a / 10;
    while(tmp)
    {
        tmp /= 10;
        len++;
        offset *= 10;
    }
    init();
    int cnt2 = f(a, offset, 10, len, 0);
    cout << cnt1 - cnt2 << endl;
}

int main()
{
    ios::sync_with_stdio(0), cin.tie(0), cout.tie(0);
    int T = 1;
	// cin >> T;
	while(T--)
	{
        solve();
	}
	return 0;
}

萌数

题目大意:“存在长度至少为 2 的回文子串”的数是萌的,想知道从 l 到 r 的所有整数中有多少个萌数。只需要输出答案对 1000000007(109+7)的余数。

对于全部的数据,n≤1000,l<r。

【解题】:

● 首先这题的 n 很长,longlong存不下,我们也没必要单独设计高精度减法,思路和前面的一样统计(0 - r) - (0 - l) 的萌数,单独判断a是否是萌数就行。

● 萌数的判断:存在长度至少为 2 的回文子串,我们不好单独判断这个条件,可以用总数减去它的反面:对于位置 i 和 i - 1, i - 2 位置的数都不同。

● 然后这题就变成了求(0 - num)之间 满足条件 当前选择的数和i - 1, i - 2 位置的数都不同的数的个数。

● 最后涉及到减法,即用num - 上面算出来的结果,由于num可能还是很大,我们采用秦九韶算法变还原边取模。

code:

#include <iostream>
#include <vector>
#include <unordered_map>
#include <cstring>
#include <cmath>
using namespace std;
typedef long long LL;
typedef pair<int, int> PII;
#define endl '\n'
const int N = 15;
const int MOD = 1e9 + 7;
string l, r;

int dp[1010][N][N][2];
void init()
{
    for(int i = 0; i <= 1000; i++)
    {
        for(int j = 0; j < N; j++)
        {
            for(int k = 0; k < N; k++)
            {
                for(int m = 0; m < 2; m++)
                {
                    dp[i][j][k][m] = -1;
                }
            }
        }
    }
}

int f(string num, int pos, int pp, int p, int free)
{
    if(pos == num.size())
    {
        return 1;
    }
    if(dp[pos][pp][p][free] != -1) return dp[pos][pp][p][free];
    int ans = 0;
    int cur = num[pos] - '0';
    if(free == 0) // 当前数字不能随便选择
    {
        if(p == 10) // 前面没有选择过数字
        {
            // num 本身
            ans += f(num, pos + 1, 10, 10, 1); // 继续不选
            for(int i = 1; i < cur; i++)
            {
               ans += f(num, pos + 1, p, i, 1);
               ans %= MOD;
            }
            ans += f(num, pos + 1, p, cur, 0);
            ans %= MOD;

        }
        else
        {
            // 前面选择过数字
            for(int i = 0; i <= cur; i++)
            {
                if(i != pp && i != p)
                {
                    if(i != cur) ans += f(num, pos + 1, p, i, 1);
                    else ans += f(num, pos + 1, p, cur, 0);
                    ans %= MOD;
                }
            }
        }
    }
    else // 当前位可以自由选择
    {
        if(p == 10) // 前面没有选择过数字
        {
            ans += f(num, pos + 1, 10, 10, 1); // 继续不选
            ans %= MOD;
            for(int i = 1; i <= 9; i++)
            {
                ans += f(num, pos + 1, p, i, 1);
                ans %= MOD;
            }
        }
        else // 前面选择过数字
        {
            for(int i = 0; i <= 9; i++)
            {
                if(i != pp && i != p)
                {
                    ans += f(num, pos + 1, p, i, 1);
                    ans %= MOD;
                }
            }
        }
    }
    return dp[pos][pp][p][free] = ans;
}

int calc(string s)
{
    if(s[0] == '0') return 0;
    int n = s.size();
    LL sum = 0;
    LL base = 1;
    for(int i = n - 1; i >= 0; i--)
    {
        sum = (sum + base * (s[i] - '0')) % MOD;
        base = (base * 10) % MOD;
    }
    sum = (sum + 1) % MOD;
    int ret = f(s, 0, 10, 10, 0);
    return ((sum - ret) % MOD + MOD) % MOD; 
}

int check(string s)
{
    if(s.size() == 1) return 0;
    if(s[0] == s[1]) return 1; 
    for(int i = 2; i < s.size(); i++)
    {
        if(s[i] == s[i - 1] || s[i] == s[i - 2]) return 1;
    }
    return 0;
}

void solve() 
{
    cin >> l >> r;
    int len = l.size();
    init();
    int cnt1 = calc(r);
    len = r.size();
    init();
    int cnt2 = calc(l);
    cout << ((cnt1 - cnt2 + check(l)) % MOD + MOD) % MOD; 
}

int main()
{
    ios::sync_with_stdio(0), cin.tie(0), cout.tie(0);
    int T = 1;
	// cin >> T;
	while(T--)
	{
        solve();
	}
	return 0;
}

不含连续 1 的非负整数

题目大意:给定一个正整数,请你统计在范围的非负整数中,有多少个整数的二进制表示中不存在 连续的 1

【解题】:

看下面代码吧。

code1:

class Solution {
public:
    int findIntegers(int n) 
    {
        // cnt[i]:第 i 位填1 0都可以的情况下一共有多少种情况。
        vector<int> cnt(35);
        cnt[0] = 1; cnt[1] = 2; 
        // 当前位填 0 下一位可以填0 1 -> cnt[i - 1]
        // 当前位填 1 下一位必须填0 -> cnt[i - 2]
        for(int i = 2; i <= 30; i++) cnt[i] = cnt[i - 1] + cnt[i - 2];
        int ans = 0;
        for(int i = 30; i >= -1; i--)
        {
            if(i == -1) 
            {
                //  num 本身就合法
                ans++;
                break;
            }
            if((n & (1 << i)) != 0)
            {
                ans += cnt[i];
                if((n & (1 << (i + 1))) != 0) break; // 如果上一位还是 1,就直接结算,后续不合法.
            }   
        }
        return ans;
    }
};

code2:

class Solution {
public:
    int f(vector<int> & cnt, int pos, int num)
    {
        if(pos == -1) 
        {
            // num 本身
            return 1;
        }
        int ans = 0;
        if((num & (1 << pos)) != 0)
        {
            ans += cnt[pos]; // 结算后面的情况
            if((num & (1 << (pos + 1))) == 0) ans += f(cnt, pos - 1, num); // 只有前一位不是1的情况才往后递归
        }
        else ans += f(cnt, pos - 1, num); // 该位是 0 二进制中只能填 0 ,接着往后递归。
        return ans;
    }
    int findIntegers(int n) 
    {
        // cnt[i]:第 i 位填1 0都可以的情况下一共有多少种情况。
        vector<int> cnt(35);
        cnt[0] = 1; cnt[1] = 2; 
        // 当前位填 0 下一位可以填0 1 -> cnt[i - 1]
        // 当前位填 1 下一位必须填0 -> cnt[i - 2]
        for(int i = 2; i <= 30; i++) cnt[i] = cnt[i - 1] + cnt[i - 2];
        return f(cnt, 30, n);
    }
};

数字计数

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

  • 对于 100% 的数据,保证 1≤ab≤1012。

【解题】:

code:我们可以把答案拆分成每一位上是 d 出现合法数字的个数,用(1,b) - (1 - a - 1) 就可以得到最后的结果。

难度在于如何合理的讨论每种情况,还有对特殊情况 0 的处理,看下面代码。

#include <iostream>
#include <vector>
#include <unordered_map>
#include <cstring>
#include <cmath>
using namespace std;
typedef long long LL;
typedef pair<int, int> PII;
#define endl '\n'
const int N = 2e5 + 10;
LL a, b;

// 统计 1 - num 中数字d出现的次数
LL count(LL num, int d)
{
    // 情况1 : d != 0 d = 5
    // 30583
    // cur < d
    // 3058x 
    // 0 ~ 3057 0 ~ 0

    // cur > d
    // 305x3 
    // 0 ~ 304 0 ~ 9
    // 305 0 ~ 9

    // cur == d
    // 30x83
    // 0 ~ 29 0 ~ 99
    // 30 0 ~ 9

    // 情况2 : d == 0 
    // 30583
    // cur != 0
    // 3058x
    // 0001 ~ 3057 0 ~ 9
    // 3058  0 ~ 9

    // cur == 0
    // 3x583
    // 1 ~ 2 0 ~ 999
    // 3 0 ~ 583
    LL tmp = num;
    LL ans = 0;
    for(LL left = 1, right = 1, cur; tmp != 0; tmp /= 10, right *= 10)
    {
        left = tmp / 10; cur = tmp % 10;
        if(d == 0) left--; // 如果 d == 0,要求前缀从 1 开始,前去0的情况
        ans += left * right; // 当cur 前面的数字小于num时,后面可以任意选择 0 ~ right
        if(cur > d)
        {
            // 当cur > d 时前缀和num相同时,后面仍然可以自由选择 0 ~ right
            ans += right; 
        }
        else if(cur == d) 
        {
             // 当cur == d时,前缀和num相同,后面只能选择 0 ~ num % right
            ans += num % right + 1;
        }
    }
    return ans;
}

void solve() 
{
    cin >> a >> b;
    for(int i = 0; i <= 9; i++) cout << count(b, i) - count(a - 1, i) << " ";
}

int main()
{
    ios::sync_with_stdio(0), cin.tie(0), cout.tie(0);
    int T = 1;
	// cin >> T;
	while(T--)
	{
        solve();
	}
	return 0;
}

总结

通过这 9 道题下来可以发现数位dp其实就是分类讨论的搜索题,这类题通常是给定一个条件,然后让你统计[l, r]范围内满足条件的种数,它的解法是一种从左到右的尝试,用[1, r] 的情况数 - [1, l - 1]的情况数,将从[l, r]的枚举 + 检测行为简化成了从 num 的最高位开始尝试填写满足要求的数,只要把情况列清楚相信不会是难题。

它之所以叫数位dp,或许是因为出现了重复子问题的调用,对其采用了记忆化数组的优化,而记忆化数组又通常可以改写成有底到顶的动态规划。

昨天复习 dp 的时候突然想到为什么线性的 dp 的解法是从最后一步划分情况,因为它本身就是从记忆化搜索该来的,从顶到底逐步拆解出子问题,到最后递归出口回归当然可以变成从已知的子问题出发,从底到顶逐步堆积成最终结果。

最后来看一下蓝桥杯的这道国赛题:

[蓝桥杯 2022 国 B] 最大数字

给定一个正整数 N。你可以对 N 的任意一位数字执行任意次以下 2 种操作:

  1. 将该位数字加 1。如果该位数字已经是 9,加 1 之后变成 0
  2. 将该位数字减 1。如果该位数字已经是 0,减 1 之后变成 9

你现在总共可以执行 1 号操作不超过 A 次,2 号操作不超过 B 次。

请问你最大可以将 N 变成多少?

对于 100% 的数据, 1≤N≤1017;0≤A,B≤100

【解题】:这题其实考察的不是数位dp,看题解写的 dp 其实就是暴力枚举所有情况,和搜索是一样的,而且搜索有很多很好写的剪枝操作,时间空间优于 dp 的暴力转移。

code:

#include <iostream>
#include <vector>
#include <unordered_map>
#include <cstring>
#include <cmath>
using namespace std;
typedef long long LL;
typedef pair<int, int> PII;
#define endl '\n'
const int N = 2e5 + 10;
string ans;
int n;


void dfs(int pos, int a, int b, string s)
{
    if (pos == n)
    {
        if (s > ans)
        {
            ans = s;
        }
        return;
    }
    
    int up = '9' - s[pos], down = s[pos] - '0' + 1;
    if (up <= a || down <= b)
    {
        // 如果可以变成9,就都尝试一下
        s[pos] = '9';
        if(up <= a) dfs(pos + 1, a - up, b, s);
        if(down <= b) dfs(pos + 1, a, b - down, s);
    } 
    else 
    {
        // 两种操作都不能使该位置变为9,把操作1用完
        s[pos] += a;
        dfs(pos + 1, 0, b, s);
    }
}

void solve()
{
    string s; int a, b; cin >> s >> a >> b;
    n = s.size();
    dfs(0, a, b, s);
    cout << ans << endl;
}

int main()
{
    ios::sync_with_stdio(0), cin.tie(0), cout.tie(0);
    int T = 1;
    // cin >> T;
    while (T--)
    {
        solve();
    }
    return 0;
}

posted on 2026-06-29 10:21  我不爱吃汉堡  阅读(2)  评论(0)    收藏  举报

导航