[C++]洛谷:数字计数 数位dp算法详解_c++数位dp-程序员宅基地

技术标签: 算法  c++  CPP题集  动态规划  

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

[原题]

给定两个正整数 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)分块计算的过程,我们可以用下图来表示:

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

这样,我们就完成了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<ll> 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 <iostream>
#include <string.h>
#include <climits>
#include <vector>
#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<ll> 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;
}

版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
本文链接:https://blog.csdn.net/weixin_62712365/article/details/126077465

智能推荐

软件测试之功能测试_软件功能测试-程序员宅基地

文章浏览阅读2.3k次,点赞3次,收藏32次。功能测试黑盒测试的方法_软件功能测试

读取word模板,并写入数据到word文件中_qt word 模板华报告-程序员宅基地

文章浏览阅读7.2k次。读取word模板,并写入数据到word文件中_qt word 模板华报告

面向对象(Java)-程序员宅基地

文章浏览阅读35次。对面向对象的简单介绍,没啥深入的写

[加密]非对称加密STM32实现-程序员宅基地

文章浏览阅读659次。转自:https://blog.csdn.net/kangerdong/article/details/82432701把所有的准备工作都做完了以后,可以将加密算法移植到我们具体的项目中去了,在STM32中在出厂前已经将RSA的公钥私钥,CA数字签名和CA公钥烧写在STM32的flash上了。4.1 身份认证在wifi连接上服务器上后,客户端首先发起交换密钥请求,客户端将自己..._stm32实现md5加密

SQL语句查询MySQL数据库存储空间大小_data_length/1024/1024单位是啥-程序员宅基地

文章浏览阅读2.4k次。SQL语句命令如何查询数据库容量?SQL查询数据库存储空间分为统计所有数据库总容量,和查询单个指定数据库存储大小,数据库吧分享MySQL数据库存储容量大小查询SQL语句:SQL查询所有数据库容量大小查询所有数据库容量大小需要对information_schema进行操作,单位转换为MB,SQL语句如下:mysql> use information_schema;mysql> select concat(round(sum(DATA_LENGTH/1024/1024),2),'MB_data_length/1024/1024单位是啥

MATLAB异常处理机制_对于此运算,数组的大小不兼容-程序员宅基地

文章浏览阅读9.8k次,点赞3次,收藏10次。在程序运行时,当出现错误的输入、数据边界值问题或者程序本身有逻辑问题时,当前运行的程序会中断当前运行任务,提前退出运行状态,无法完成既定的任务。在程序中加入错误检查机制,合理处理程序可能出现的异常和错误,确保程序在所有可能条件下都能可靠运行,是现代编程语言的通用处理方式。在MATLAB中,使用try … catch语句,可以捕获异常并在catch块中处理异常,而不用让程序中断运行,确保程序的可靠性和鲁棒性。try … catch的语法结构如下:try statements %try语句块ca_对于此运算,数组的大小不兼容

随便推点

手机计算机应用的图片,怎么把手机的照片传到电脑 四种方法轻松导入-程序员宅基地

文章浏览阅读1w次。由于手机的小巧便捷我们的很多文件照片都是存储在手机上的,我们个每个人手机上都有很多照片如果将这些照片从手机中传输到电脑上,可以腾出一大部分手机空间,如何将手机照片传输到电脑上,相信很多小伙伴们还不知道吧,下面我就详细介绍一下。方法一:使用微信文件助手传输通过微信文件助手上传,使用电脑和手机登录同一微信,打开文件传输助手,选择需要的照片进行发送即可(传输图片时最好勾选原图上传,一次可勾选9张照片)。..._手机图片传电脑最简单的办法

如何远程登录Linux_linux远程登录另一台linux-程序员宅基地

文章浏览阅读1.3w次,点赞5次,收藏63次。本篇文章使用SSH,适合新手。第一步:在Linux上查看SSH的工作状态:打开Linux终端,输入systemctl status sshd,如图所示,红色方框里的内容说明你安装了SSH并且正在工作;第二步:查看服务器的IP地址:打开Linux终端,输入ifconfig,如图所示红色方框里的内容,即为IP地址(每一台电脑都不一样);第三步:连通测试:打开Windows系统的黑窗口,按住Windows+R,输入cmd,打开之后直接输入ping+IP地址(为第二步里面的IP地址),显示如图所示_linux远程登录另一台linux

xtrabackup 使用说明(续)_xtrabackup arch-程序员宅基地

文章浏览阅读168次。xtrabackup 使用说明(续) 背景:&amp;amp;amp;amp;amp;amp;nbsp; &amp;amp;amp;amp;amp;amp;nbsp; &amp;amp;amp;amp;amp;amp;nbsp; 关于物理备份工具xtrabackup的一些说明可以先看之前写过的文章说明:xtrabackup 安装使用。现在xtrabackup版本升级到了2.4.4,相比之前的2.1有了比较大的变化:innobackupex&am_xtrabackup arch

ICASSP 2022 | 腾讯AI Lab解读14篇入选论文-程序员宅基地

文章浏览阅读2k次。感谢阅读腾讯AI Lab微信号第146篇文章。本文介绍腾讯 AI Lab 入选 ICASSP 2022 的 14 篇论文。ICASSP(International Conference on Acoustics, Speech and Signal Processing)即国际声学、语音与信号处理会议,是IEEE主办的全世界最大的,也是最全面的信号处理及其应用方面的顶..._icassp

python调用windows cmd命令输出乱码_c:\program' is not recognized as an internal or ex-程序员宅基地

文章浏览阅读5.7k次,点赞6次,收藏7次。python调用windows command line时,如果cmd有返回(比如上一篇博客中的报错python调用带空格的windows cmd命令问题),遇到了输出乱码的情况:‘C:/Program’ �����ڲ����ⲿ���Ҳ���ǿ����еij������������ļ���解决方法:在调用cmd命令前先更改一下cmd的编码方式import osFDTDProgramPath = 'C:/Program Files/Lumerical/FDTD/bin/fdtd-solutio_c:\program' is not recognized as an internal or external command, operable

ubuntu 16.04 vm虚拟机 nat 配置静态ip_ubuntu vmplayer centos nat ip-程序员宅基地

文章浏览阅读775次。前言:这个问题困扰我好长时间,桥接的静态ip我会了,然而用nat 的方式配置集群会更好。(nat 方式客户机之间的通讯不经过路由器),所以想着换成nat方式会更好。要使用nat方式设置静态ip ,需要相当多的计算机网络知识了。第一先查看你的主机的网卡是否把网络共享给虚拟网卡vmnet8 了吗? 打开网络共享中心———&gt;更改适配器设置 看下图 然后右击 你用的那个网卡。我用的无线。所以右击 ..._ubuntu vmplayer centos nat ip