数位DP总结

目录:

一.简介

二.解法

三.经典例题

1. 洛谷P2657 [SCOI2009] windy 数(思路**重点**)

2. 洛谷 P4999 烦人的数学作业

3.P2602[ZJOI2010] 数字计数

4.洛谷P1836 数页码

四.总结

 数位DP
一.简介:
数位DP用于处理一些与数位有关的问题,主要是计数问题
数位DP往往都是这样的题型,给定一个闭区间[l,r],让你求这个区间中满足某种条件的数的总数。
 而这个区间可能很大,简单的暴力代码如下:
 int ans=0;
for (int i = l; i <= r; i++) {
    if (check(i))ans++;
}
我们发现,若区间长度超过1e8,我们暴力枚举就会超时了,而数位DP则可以解决这样的题型。
数位DP实际上就是在数位上进行DP。
二.解法:
 数位DP就是换一种暴力枚举的方式,使得新的枚举方式符合DP的性质,然后预处理好即可。
 (1)我们来看:我们可以用f(n)表示[0,n]的所有满足条件的个数,那么对于[l,r]
 我们就可以用[l,r]⟺f(r)−f(l−1),相当于前缀和思想。那么也就是说我们只要求出f(n)即可。
 (2)那么数位DP关键的思想就是从树的角度来考虑。
 将数拆分成位,从高位到低位开始枚举。我们可以视N为n位数,那么我们拆分N:an,a(n-1)...a1​。
 那么我们就可以开始分解建树,如下。之后我们就可以预处理再求解f(n)了,个人认为求解f(n)是最难的一步。
三.经典例题
1. 洛谷P2657 [SCOI2009] windy 数
 【题目描述】
windy定义了一种windy数。不含前导零且相邻两个数字之差至少为2的正整数被称为windy数。
 windy想知道,在A和B之间,包括A和B,总共有多少个windy数?
【输入】
包含两个整数,A B。
【输出】
一个整数。
 思路(详解):(重点)
(1)dfs需要记录的状态dp[pos][pre]
pos表示当前遍历的是第几位,pre表示前一位是几(从高到低遍历)
dp[pos][pre]就是记录了遍历第pos位时,前一位为pre时的状态数
 举例子:假设数5762,那么数位有4位,数位数组是这样存储的2 6 7 5
数组从0位开始,所以是 0位 到 3位那么当pos为2的时候,前一位(即第3位)有0 - 5这些情况
那么dp[2][0 - 5]分别存储了dp[2][0], dp[2][1]。。。。等等 这些情况
(2)最高位标记limit
 举个例子:我们在搜索[0,567]的数时,显然最高位搜索范围是0~5,而后面的位数的取值范围会根据上一位发生变化:
当最高位是1~4时,第二位取值为[0, 9];
当最高位是5时,第二位取值为[0, 6](再往上取就超出右端点范围了)
为了分清这两种情况,我们引入了limit标记:
1).若当前位limit = 1而且已经取到了能取到的最高位时,下一位limit = 1;
2).若当前位limit = 1但是没有取到能取到的最高位时,下一位limit = 0;
3).若当前位limit = 0时,下一位limit = 0。
我们设这一位的标记为limit,这一位能取到的最大值为res,
则下一位的标记就是i == res && limit(i枚举这一位填的数)
(3)前导0标记lead
由于我们要搜的数可能很长,所以我们的直接最高位搜起
举个例子:假如我们要从[0, 1000]找任意相邻两数相等的数
显然111, 222, 888等等是符合题意的数
但是我们发现右端点1000是四位数
因此我们搜索的起点是0000,而三位数的记录都是0111, 0222, 0888等等
而这种情况下如果我们直接找相邻位相等则0000符合题意而0111, 0222, 0888都不符合题意了
所以我们要加一个前导0标记
1)如果当前位lead = 1而且当前位也是0,那么当前位也是前导0,pos - 1继续搜;
2)如果当前位lead = 1但当前位不是0,则本位作为当前数的最高位,pos - 1继续搜;(注意这次根据题意st或其他参数可能发生变化)
当然前导0有时候是不需要判断的,上述的例子是一个有关数字结构上的性质,0会影响数字的结构,所以必须判断前导0;
 而如果我们研究的是数字的组成(例如这个数字有多少个111之类的问题),0并不影响我们的判断,这样就不需要前导0标记了。
 总之,这个因题而异,并不是必须要标记(当然记了肯定是不会出错的)
类似上述的分析过程,我们也可以得出:当lead = 1时,也不能记录和取用dp值!
if (!limit && !lead) dp[pos][pre] = ans;
前导0是无效的不用管,比如说0001含前导0就可以直接视作1,1001就不含前导0
(4)记忆化搜索
dp数组的下标表示的是一种状态,只要当前的状态和之前搜过的某个状态完全一样,
我们就可以直接返回原来已经记录下来的dp值。
再举个例子
假如我们找[0, 123456]中符合某些条件的数
假如当我们搜到1000 ? ? 时,dfs从下返上来的数值就是当前位是第1位,前一位是0时的方案种数,
 搜完这位会向上,这是我们可以记录一下:当前位第1位,前一位是0时,有这么多种方案种数
当我们继续搜到1010 ? ? 时,我们发现当前状态又是搜到了第1位,并且上一位也是0,
这与我们之前记录的情况相同,这样我们就可以不继续向下搜,直接把上次的dp值返回就行了。
反例:接着上面的例子,范围[0, 123456]
如果我们搜到了1234 ? ? ,我们能不能直接返回之前记录的:当前第1位,前一位是4时的dp值?
答案是否定的
我们发现,这个状态的dp值被记录时,当前位也就是第1位的取值是[0, 9],而这次当前位的取值是[0, 5],
 方案数一定比之前记录的dp值要小。
当前位的取值范围为什么会和原来不一样呢?
如果你联想到了之前所讲的知识,你会发现:现在的limit = 1,最高位有取值的限制。
因此我们可以得到一个结论:当limit = 1时,不能记录和取用dp值!
没有限制的情况占多数,所以只记录没有高位限制的情况

if (!limit){
    dp[pos][pre] = ans;
}


有limit = 1限制的怎dp[pos][pre]么办呢?每次都重新算。

#include<iostream>
#include<cstring>
using namespace std;
int num[12], dp[12][12];
int dfs(int pos, int pre, int limit, int lead) {
    int ans = 0, i, up;
    if (pos == -1)  //搜完
        return 1;      //用作计数
    if (!limit && dp[pos][pre] != -1 && !lead)//没有最高位限制,已经搜过了,并且没有前导0
        return dp[pos][pre];      //记忆化搜索
    up = limit ? num[pos] : 9;//当前位最大数字 
    for (i = 0; i <= up; i++) {//从0枚举到最大数字 
        if (lead) {//有前导0不受限制 
            ans += dfs(pos - 1, i, limit && i == up, lead && i == 0);
        }
        else if (i - pre >= 2 || i - pre <= -2)//无前导0受限 
            ans += dfs(pos - 1, i, limit && i == up, lead && i == 0);
    }
    if (!limit && !lead)//没有最高位限制且没有前导0时记录结果 
        dp[pos][pre] = ans;
    return ans;
}
int solve(int x) {
    int pos = 0;
    while (x) {
        num[pos++] = x % 10;
        x /= 10;
    }   //按位储存
    return dfs(pos - 1, -1, 1, 1);
}
int main() {
    ios::sync_with_stdio(false);
    int lt, rt;
    cin >> lt >> rt;
    memset(dp, -1, sizeof(dp));
    cout<< solve(rt) - solve(lt - 1)<<'\n';
    return 0;
}


2. 洛谷 P4999 烦人的数学作业
题目大意: 问l ~ r区间每个数的数字和。
 1<=l<=r<=10^18   (1<=t<=20)
 输入格式:
 共t+1行
 第一行读入t,代表有t组数据
 第2到t+1行,读入li,ri
 输出格式:
 输出共t行,区间每个数的数字和mod10^9+7。

#include<iostream>
#include<cstring>
using namespace std;
typedef long long ll;
const ll mod=1e9 + 7;
ll t, l, r;
ll a[20], num;
ll dp[200][200];
ll dfs(ll pos, ll sum, bool limit) {
       if (pos==0)   //搜完
        return sum;
    if (!limit && dp[pos][sum] != -1)   //没有最高位限制,已经搜过了
        return dp[pos][sum];    //记忆化搜索
    ll up = limit ? a[pos] : 9;//根据top判断枚举的上界up
    ll ret = 0;
    for (int i = 0; i <= up; i++)
        ret = (ret + dfs(pos - 1, sum + i, limit && i == up)) % mod;
    if (!limit) 
        dp[pos][sum] = ret;//这里对应上面的记忆化,在一定条件下时记录,保证一致性,当然如果约束条件不需要考虑top,这里就是top就完全不用考虑了*
    return ret;
}
ll solve(ll x) {
    ll cnt = 0;
    while (x) {
        a[++cnt] = x % 10;
        x /= 10;
    }
    return dfs(cnt, 0, 1) % mod;//从最高位开始枚举
}
signed main() {
    cin >> t;
    memset(dp, -1, sizeof(dp));
    while (t--) {
        cin >> l >> r;
        cout << (solve(r) - solve(l - 1) + mod) % mod << '\n';
    }
    return 0;
}


3.P2602[ZJOI2010] 数字计数
题目描述
给定两个正整数a 和b,求在[a, b] 中的所有整数中,每个数码(digit)各出现了多少次。
输入格式
仅包含一行两个整数
a, b,含义如上所述。
输出格式
包含一行十个整数,分别表示0∼9[a, b] 中出现了多少次。
(1<=a<=b<=10^12)
 

#include<iostream>
#include<cstring>
using namespace std;
typedef long long ll;
ll  dp[20][20], num[20];
ll l, r;
ll dfs(int pos, bool limit, bool lead, int dig, ll sum) {
    ll ans = 0;
    if (pos == 0)   //搜索完成
        return sum;
    if (!limit && dp[pos][sum] != -1 && lead)
        return dp[pos][sum];         //记忆化
    int up = limit ? num[pos] : 9;
    for (int j = 0; j <= up; j++) {
        ans += dfs(pos-1, limit&&(j==up),lead||j ,dig,sum+((j||lead)&&(j==dig)));
        //(对应位置不为0或无前导零)并且对应为要统计的数sum++;
    }
    if (!limit && lead) //非受限,并且无前导零,记录对应状态值
        dp[pos][sum] = ans;//对应记忆化
    return ans;
}
ll solve(ll x,int d) {
    memset(dp, -1, sizeof(dp));//初始化,统计的数字不同,每次清0
    ll cnt = 0;
    while (x) {
        num[++cnt] = x % 10;
        x /= 10;
    }
    return dfs(cnt,1,0,d,0);
}
int main()
{
    ios::sync_with_stdio(false);
    cin >> l >> r;
    for (int i = 0; i < 9; i++) {
        cout << solve(r,i) - solve(l - 1,i) << ' ';
    }
    cout << solve(r, 9) - solve(l - 1, 9) << '\n';
    return 0;
}

4.洛谷P1836 数页码
一本书的页码是从1∼n 编号的连续整数:1, 2, 3, ⋯, n
请你求出全部页码中所有单个数字的和,例如第123 页,它的和就是1 + 2 + 3 = 6
输入格式
一行一个整数n
输出格式
一行,代表所有单个数字的和。
(1<=n<10^9)

#include <iostream>
#include <cstring>
typedef long long ll;
using namespace std;
ll dp[15][105], a[20]; 
ll dfs(int pos, ll sum, bool limit)
{
	if(pos==0)  //搜完
		return sum;
	if(limit && dp[pos][sum] != -1) //没有最高位限制,已经搜过了
		return dp[pos][sum];  //记忆
	ll ans = 0;
	int up = limit?9:a[pos];
	for(int i = 0; i <= up; i++)
		ans += dfs(pos-1, sum+i, limit||(i<up));//都不满足仅可能在第一次出现的情况下出现
	if(limit)
		dp[pos][sum] = ans;
	return ans;
}
ll solve(ll x){
	int pos = 0;
	while(x){
		a[++pos] = x%10;
		x /= 10;
	} 
	return dfs(pos, 0, 0);
}
int  main()
{
	ll n;
	cin >> n;
	memset(dp, -1, sizeof dp);
	cout << solve(n);	
    return 0;
}


法2(打表大法)
对于数据小又容易超时的题,可以采取打表法
打表就是将所有输入情况的答案保存在代码中,输入数据后直接输出就可以了
打表法具有快速,易行(可以写暴力枚举程序)的特点,缺点是代码可能太大,或者情况覆盖不完
对于不会超时,数据规模适合打表,为了简洁你也可以打表
 思路:
直接暴力不能过,时间复杂度大到1e9
1e7的暴力能过 ,洛谷在线IDE实测400ms(不开O2)。
需要打1e9/1e7=100的表
 

#include <iostream>
using namespace std;
typedef long long ll;
using namespace std;
ll sum(int i) {
    ll ret = 0;
    while (i) {
        ret += i % 10;
        i /= 10;
    }
    return ret;
}
ll f(int l, int r) {
    ll ret = 0;
    for (int i = l; i <= r; i++) {
        ret += sum(i);
    }
    return ret;
}
ll res[110] = { 0,315000001,325000001,335000001,345000001,355000001,365000001,375000001,385000001,
395000001,404999992,325000001,335000001,345000001,355000001,365000001,375000001,
385000001,395000001,405000001,414999992,335000001,345000001,355000001,365000001,
375000001,385000001,395000001,405000001,415000001,424999992,345000001,355000001,
365000001,375000001,385000001,395000001,405000001,415000001,425000001,434999992,
355000001,365000001,375000001,385000001,395000001,405000001,415000001,425000001,
435000001,444999992,365000001,375000001,385000001,395000001,405000001,415000001,
425000001,435000001,445000001,454999992,375000001,385000001,395000001,405000001,
415000001,425000001,435000001,445000001,455000001,464999992,385000001,395000001,
405000001,415000001,425000001,435000001,445000001,455000001,465000001,474999992,
395000001,405000001,415000001,425000001,435000001,445000001,455000001,465000001,
475000001,484999992,405000001,415000001,425000001,435000001,445000001,455000001,
465000001,475000001,485000001,494999983 };//一个100大小的表
int main() {
    int n;
    cin >> n;
    ll s = 0;//存放和
    int i;
    for (i = 1; i * 1e7 <= n; i++) {//按一千万算整块
        s += res[i];
    }
    s += f((i - 1) * 1e7 + 1, n);//然后暴力算一千万以下的小块
    cout << s << '\n';
    return 0;
}

四.总结

个人感觉:数位dp由按位存储,记忆搜索,递归组成

细节处理:最高位标记,前导0标记有固定模板和规律(但也要注意题目变化主要体现在&&和||)
较难处理体现在如何将千变万化的限制条件以代码的形式体现

当然,学好数位dp还需要多刷题!

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值