[C++]洛谷:数字计数 数位dp算法详解

首先,让我们来看一下今天的题目吧:

[原题]

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

[输入格式]

仅包含一行两个整数 a,b,含义如上所述。

[输出格式]

包含一行十个整数,分别表示 0~9 在 [a,b] 中出现了多少次。

[输入样例]

1 99

[输出样例]

9 20 20 20 20 20 20 20 20 20

 

[数据范围]

- 对于 30% 的数据,保证 a ≤ b ≤ 10^6;
- 对于 100% 的数据,保证 1 ≤ a ≤ b ≤ 10^12。


[解题思路]

给定一个区间[l, r],要我们求出区间内所有数字中每个数码的总数。如数字121,其中包含了两个数码1和一个数码2。

首先,假如我们使用暴力搜索,这是肯定可以找出答案的。但是由于数据范围在1~1e12之间,这是一个非常庞大的数据范围,用暴力必然会超时。因此,我们需要引入一个新的方法来解决这一类问题——数位dp


数位dp问题一般都有什么特征呢?

这类问题一般都会给出一个区间[l, r],要求在这个区间内计算或统计某一特定的属性。而数位dp算法则可以很好地解决大数据范围下的这一类问题。


那么,数位dp是如何去进行状态设计的呢?

数位dp是通过枚举每一个数位来进行动态规划的算法,其核心就在于提取数位并对每一位进行逐位枚举和筛选。

以本题为例,我们需要逐个统计数码0~9在[l, r]出现的次数,那么每一轮统计的目标数码我们假设为k。我们不断枚举最高位的数字,倘若这个最高位不为本轮的目标数码k,我们只需要将上一个状态的个数进行加和即可;倘若当前最高位为目标数码k,那我们则需要额外加上一部分数字。

譬如上一个dp状态下枚举到了两位数,当前的目标数码为3,那么当我们枚举最高位至3时,符合条件的数有300~399,而由于这些数的最高位都是目标数码3,因此我们在加上00~99中3的个数的基础上,需要额外加上100个3。

从上述的过程中,我们不难看出,如果需要设计一个存储状态的动规数组f来存储目标数码在当前状态下的个数,那我们需要三个相应的参数:f[i][j][k]表示当前枚举到了i位数,最高位为j,当前目标数码为k。

那么对于上一个状态最高位的枚举m∈[0, 9],我们不难得出相应的状态转移方程:

f[i][j][k] = ∑ f[i - 1][m][k] + (j == k) * 10 ^ (i - 1)

这样,我们就建立好了这样一个f数组,从中可以得到以j为最高位的i位数中所包含k数码的个数,虽然我们需要四层循环枚举参数,但由于每层循环是常数级的,四层循环也只需要9*9*9*12约为9e4的时间复杂度,所以这个过程的时间开销是并不高的。

当然,我们要求的是[l, r]这个范围中数码的个数,因此我们还需要对对应部分的结果进行加和才能最终得到答案。接下来,让我们看一下dp的过程吧。


数位dp

首先,我们知道最终需要求解的是[l, r]之间的各数码个数,然而l和r都在[1, 1e12]之间,假如直接对区间左右端点同时枚举会非常麻烦。这里,我们做这样一个小优化:我们设计int dp(int x, int k)这样一个函数去求解在数字1~x范围内数码k的个数。这样当我们确定了起点之后,我们就可以使用端点作差的形式,即dp(r) - dp(l - 1)去求解[l, r]范围内的数码k的个数。不难看出,这种思想与前缀和思想十分的类似。

因此,我们现在的问题转化成了求解[1, x]之间数码的个数这里,我们以x=329为例,进行进一步讲解。

尽管我们拥有初始化后的f数组,但由于无法直接获取x的k数码个数,我们需要对x进行分段统计

(1)首先,我们先分出位数与x不相同的部分。对于数位小于329的所有一位数(0~9)和两位数(10~99),他们的k数码总和都可以直接获取,即对j∈[1,9]的枚举下∑f[1][j][k]和∑f[2][j][k]的值(由于最高位不能为前导0,j应从1开始枚举)

(2)接下来,我们对位数与x相同的部分继续进行分割。我们从高位至低位不断枚举x的每一位,并根据枚举出的每一位将范围分割。经过步骤(1),1~329的范围中还有100~329的部分需要进一步计算。

        I.我们提取329的最高位3,将百位<3的部分进行分块计算,这样就将范围分成了100~299和300~329的两部分。对于100~299的部分,我们可以直接得到数码k的个数,即对j∈[1,2]的枚举下∑f[3][j][k]的值(同样,最高位不能为前导0,从1开始枚举)。


        II.我们提取329的次高位2,将十位<2的部分进行分块计算,这样就将范围分成了300~319和320~329的两部分。对于300~319的部分,其十位和个位中数码k的个数可以直接得到,即对j∈[0,1]的枚举下∑f[2][j][k]的值(由于这里是后两位,所以是可以枚举最高位为0的,同样,两位的0对应了“00”的情况,我们在初始化过程中是对这种情况进行更新的,所以可以放心使用)。而对于这区间内的每个数,百位都为3,因此,特别地当目标数码k为3时,我们的答案需要加上这个区间的长度20。


        III.我们提取329剩下的一位9,将个位<9的部分进行分块计算,这样就将范围分成了320~328和329的两部分。对于320~328的部分,其个位中的数码k的个数可以直接得到,即对j∈[0,8]的枚举下∑f[1][j][k]的值。而对于这区间内的每个数,百位都为3,十位都为2,因此,特别地当目标数码k为3或2时,我们的答案需要加上这个区间的长度9。


        IV.最后,我们只剩下了一个数329,我们只需要进行逐位判断并加到对应的目标数码的结果中即可。即,当目标数码k为3或2或9时,我们的答案+1即可。

对于步骤(2)分块计算的过程,我们可以用下图来表示:

[C++]洛谷:数字计数 数位dp算法详解_第1张图片

当然,我们可以仔细观察一下步骤II和III,对于数码3,我们需要额外加上29;对于数码2,我们需要额外加上9。不难发现,对于x任意位的数码,我们都需要额外加上处于该位后方数字对应的次数。因此,我们在枚举时只需要顺推出该位数字的剩余几位便可以一次将这些额外的计算加和。

[C++]洛谷:数字计数 数位dp算法详解_第2张图片

这样,我们就完成了dp过程的分析。


接下来,我们来看一下代码部分:

在实现递推数组f前,我们需要对所有的一位数进行初始化当最高位j为目标数码k时,数码k个数即为1,否则为0。

这段代码如下:

    //将数组初始化为0
    memset(f, 0, sizeof(f));
    //初始化所有一位数:若最高位j为k,则为1,否则为0
    for (ll j = 0; j <= 9; j++)
    {
        for (ll k = 0; k <= 9; k++)
        {
            if (j == k)
                f[1][j][k] = 1;
            else
                f[1][j][k] = 0;
        }
    }

接下来,我们分别枚举当前数位i、当前最高位j、目标数码k、上一个状态的最高位m,对f数组进行计算。注意,这里的最高位我们都从0开始枚举。初始化时我们对所有情况都进行更新,但是是否调用最高位为0的解需要具体情况具体分析(这些我在前面的部分都进行了标注)。

这段代码如下:

    for (ll i = 2; i <= 12; i++)//枚举数位
    {
        for (ll j = 0; j <= 9; j++)//枚举当前最高位
        {
            for (ll k = 0; k <= 9; k++)//枚举统计的数字
            {
                for (ll m = 0; m <= 9; m++)//枚举上一个状态的最高位
                {
                    f[i][j][k] += f[i - 1][m][k];
                }
                //若枚举的最高位为k,剩余几位无论怎么取值都有一个k,因此加上这一部分的值
                ll t = (j == k);
                for (ll s = 1; s < i; s++)
                    t *= 10;
                f[i][j][k] += t;
            }
        }
    }

对于dp的过程,我们只需要将传入的x进行数位分解,并按之前的分析一步步实现。特别地,当x为0时我们直接返回0。

这段代码如下:

ll dp(ll x, ll k) //找到1~x之间的数码k的个数
{
    //特判x为0的情况,返回0
    if (!x)
        return 0;

    ll ret = 0;
    vector num;//用来记录x每一位
    //提取数位:由于这里我们是从数组尾部压入,123存储到数组中就会变成321,因此在后续的过程中我们需要逆序遍历
    while (x)
    {
        num.push_back(x % 10);
        x /= 10;
    }

    //位数相同
    for (ll i = num.size() - 1; i >= 0; i--) //逆序存储,逆序遍历
    {
        ll cur = num[i];//取出当前位
        for (ll j = (i == num.size() - 1); j < cur; j++) //选择当前位(若在首位不能为0)
        {
            ret += f[i + 1][j][k];//对剩余位的个数加和
        }

        //如果当前数为目标k,则加上剩余的数
        if (cur == k)
        {
            ll n = 0;
            //这部分即是分析过程中需要加上的“额外部分”,从当前位置向前遍历并加和即可
            for (ll t = i - 1; t >= 0; t--)
            {
                n *= 10;
                n += num[t];
            }
            ret += n;
        }

        //当i==0时即数位的枚举到达了终点,最后我们未更新的部分只剩下了x本身
        //对于x本身,每一位判断是否为k并进行值的更新即可
        if (i == 0)
        {
            for (ll t : num)
            {
                if (t == k)
                    ret++;
            }
        }
    }

    //位数不同
    ll digit = num.size() - 1;
    for (; digit; digit--)
    {
        for (ll j = 1; j <= 9; j++)
            ret += f[digit][j][k];
    }
    return ret;
}

最后,让我们来看一下整段代码吧:

#include 
#include 
#include 
#include 
#define INF 0x3f3f3f3f
typedef long long ll;
using namespace std;

ll f[15][12][12]; // f[i][j][k]  数位为i且最高位为j的数中数字k的个数

void init()
{
    //将数组初始化为0
    memset(f, 0, sizeof(f));
    //初始化所有一位数:若最高位j为k,则为1,否则为0
    for (ll j = 0; j <= 9; j++)
    {
        for (ll k = 0; k <= 9; k++)
        {
            if (j == k)
                f[1][j][k] = 1;
            else
                f[1][j][k] = 0;
        }
    }
    for (ll i = 2; i <= 12; i++)//枚举数位
    {
        for (ll j = 0; j <= 9; j++)//枚举当前最高位
        {
            for (ll k = 0; k <= 9; k++)//枚举统计的数字
            {
                for (ll m = 0; m <= 9; m++)//枚举上一个状态的最高位
                {
                    f[i][j][k] += f[i - 1][m][k];
                }
                //若枚举的最高位为k,剩余几位无论怎么取值都有一个k,因此加上这一部分的值
                ll t = (j == k);
                for (ll s = 1; s < i; s++)
                    t *= 10;
                f[i][j][k] += t;
            }
        }
    }
}

ll dp(ll x, ll k) //找到1~x之间的数码k的个数
{
    //特判x为0的情况,返回0
    if (!x)
        return 0;

    ll ret = 0;
    vector num;//用来记录x每一位
    //提取数位:由于这里我们是从数组尾部压入,123存储到数组中就会变成321,因此在后续的过程中我们需要逆序遍历
    while (x)
    {
        num.push_back(x % 10);
        x /= 10;
    }

    //位数相同
    for (ll i = num.size() - 1; i >= 0; i--) //逆序存储,逆序遍历
    {
        ll cur = num[i];//取出当前位
        for (ll j = (i == num.size() - 1); j < cur; j++) //选择当前位(若在首位不能为0)
        {
            ret += f[i + 1][j][k];//对剩余位的个数加和
        }

        //如果当前数为目标k,则加上剩余的数
        if (cur == k)
        {
            ll n = 0;
            //这部分即是分析过程中需要加上的“额外部分”,从当前位置向前遍历并加和即可
            for (ll t = i - 1; t >= 0; t--)
            {
                n *= 10;
                n += num[t];
            }
            ret += n;
        }

        //当i==0时即数位的枚举到达了终点,最后我们未更新的部分只剩下了x本身
        //对于x本身,每一位判断是否为k并进行值的更新即可
        if (i == 0)
        {
            for (ll t : num)
            {
                if (t == k)
                    ret++;
            }
        }
    }

    //位数不同
    ll digit = num.size() - 1;
    for (; digit; digit--)
    {
        for (ll j = 1; j <= 9; j++)
            ret += f[digit][j][k];
    }
    return ret;
}

int main()
{
    ll l, r;
    cin >> l >> r;
    init();
    for (ll k = 0; k <= 9; k++)
    {
        //对区间右端与左端作差,类似前缀和思想
        cout << dp(r, k) - dp(l - 1, k) << ' ';
    }
    return 0;
}

你可能感兴趣的:(CPP题集,算法,c++,动态规划)