- 2022tysc0250 的博客
数位dp
- 2024-8-14 9:07:08 @
数位 dp
数位 dp 就是处理和具体的数有关的 dp,一般和每个数位有关。
大多数的题目形式类似于:统计 之间有多少整数满足某个性质。这个某性质往往是数位之间的性质。
比如有多少数包含 ,如 包含,而 不包含。、一般很大,如 。
算法复杂度往往和数的位数相关。一般数据范围不会真的这么大,比如 ,有时候可分段打表暴力拿分。
数位 dp 主要麻烦的地⽅在于前导零的处理、 之类限制的处理。
例题1
设 为第 位为 时的方案数。
所以边界是对于 ,。
然后转移方程是 $dp_{i,j}=\sum\limits_{x=\max(j-2,0)}^{\min(j+2,9)}dp_{i-1,x}$。
记得取模 。
最后答案就是 。
为什么是 ,因为不能有前导 。
但是单个 可以,所以特判 时输出 。
#include <bits/stdc++.h>
#define int long long
using namespace std;
int k,dp[1000001][10],sum;
const int mod = 1000000007;
signed main()
{
scanf("%lld",&k);
if(k == 1){printf("10");return 0;}//特判
for(int i = 0;i <= 9;i++) dp[1][i] = 1;//边界
for(int i = 1;i <= k;i++)
for(int j = 0;j <= 9;j++)
for(int x = max(j - 2,0ll);x <= min(j + 2,9ll);x++)
dp[i][j] = (dp[i][j] + dp[i - 1][x]) % mod;//转移
for(int i = 1;i <= 9;i++) sum = (sum + dp[k][i]) % mod;//累加
printf("%lld",sum);
return 0;
}
例题2
给定两个正整数 和 ,求在 中的所有整数中,被自身数位和整除的数有多少个。
一般用记忆化搜索的形式写,而不用其他 dp 常用的递推。
从起点向下搜索,到最底层得到⽅案数,一层一层向上返回答案并累加,最后从搜索起点得到最终答案。
对于 区间问题,一般转化为两次数位 ,即找 和 两段,再将结果相减就得到了我们需要的 。
即我们只需要考虑在 下这一问题怎么解决。
先将 进行数位拆分
比如 会拆成 ,从左到右下标递增, 的下标为
一般会设计 表示考虑前 位已经确定的情况下, 当前是否解除了枚举限制,当前是否有前导零(如果有其他限制 or 要求则增加更多状态即可,比如前一位或者前几位的状态)的⽅案数,一般数位 dp 的时间限制不高,多开几个状态也问题不⼤。
是否解除了枚举限制
什么是“考虑前 位已经确定的情况下,当前是否解除了枚举限制”?
由于我们要搜的数可能很长,所以我们从最高位开始搜起。
例如:我们在搜索 的数时,显然最高位搜索范围是 ,而后面的位数的取值范围会根据上一位发生变化:
- 当最高位是 时,第二位取值为 ;
- 当最高位是 时,第二位取值为 (再往上取就超出右端点范围了)
这一特点我们叫枚举限制,用 标记来记录。
- 如果当前 ,且当前位取得最⼤数位,则下一位的 ;
- 如果当前𝑙𝑖𝑚𝑖𝑡 =1,且当前位不是最⼤数位,则下一位的 ;
- 如果当前 ,则不论当前位取值,下一位的 。
简写:当前位能取的最⼤值为 ,则下一位的标记为 𝑖==𝑟𝑒𝑠&&𝑙𝑖𝑚𝑖t
。
前导 0 标记
假设我们需要找 内找相邻数位差值小于等于 的数的数量。
是合法的,但是加上前导零 就不合法了。因此需要加一个前导零标记来避免这种误判
- 如果当前 ,且当前位填 ,则下一位也是前导 ,;
- 如果当前 ,但当前位不是 ,则本位作为当前数的最高位,下一位 。
不是所有问题都需要判前导零,有影响才需要,当然加了也不会产生多⼤时间开销,都加上也行。
记忆化搜索
使用 DP 数组保存已经搜索过的情况。
DP 数组中一般不会保存 和 ,注意在当前 limit==0&&lead==0
的时候才能记录值。
解答
设 表示在 位数中数位和为 且数值 的有几个。
例如 ,因为 中满足的有 。
那么 手算慢,如何转移?
设 由 转移得到。
对于 ,,。( 是枚举的变量)
- 时 ;
- 时 。
所以
抽象死了,我觉得代码讲的比我好,让它来讲吧。
#include <bits/stdc++.h>
using namespace std;
int l,r,a[100],dp[12][100][100][100];
int dfs(int pos,int sum,int mod,int k,int limit)//记搜
{
if(pos <= 0) return sum == 0 && mod == 0;//边界,数位和是sum且取模后为0
if(dp[pos][sum][mod][k] != -1 && !limit) return dp[pos][sum][mod][k];//记录当前值,记忆化
int ret = 0;//暂时记录当前方案数
int res = limit?a[pos]:9;//res当前位能取到的最大值
for(int i = 0;i <= res;i++) ret += dfs(pos - 1,sum - i,(mod * 10 + i) % k,k,i == res && limit);
if(!limit) dp[pos][sum][mod][k] = ret;//当前状态方案数记录
return ret;
}
int calc(int x)//把数按位拆分
{
int len = 0;
while(x) a[++len] = x % 10,x /= 10;
int ans = 0;
for(int k = 1;k <= 9 * len;k++) ans += dfs(len,k,0,k,1);//枚举数位和
return ans;
}
signed main()
{
memset(dp,-1,sizeof(dp));
while(cin >> l >> r) printf("%d\n",calc(r) - calc(l - 1));
return 0;
}
** 题老是 TLE,卡时限很好玩吗,若交上去超时就再交,即可 AC。(害得我 debug 好久)
例题3
一
#include <bits/stdc++.h>
#define int long long
using namespace std;
int t,x,a[33],dp[33][2][2];//定义f[pos][n1][n2]表示准备搜索第pos位的数字,pos的前两位分别是n1,n2,其非魔鬼数数量
int dfs(int pos,bool n1,bool n2,bool flag)//深搜非魔鬼数的数量,记忆化搜索
{
if(!pos) return 1;
if(!flag && dp[pos][n1][n2] > -1) return dp[pos][n1][n2];
int maxn = 9,ans = 0;
if(flag) maxn = a[pos];
for(int i = 0;i <= maxn;i++)
{
if(n1 && n2 && i == 6) continue;//是魔鬼数,跳过,要统计的是非魔鬼数的数量
ans += dfs(pos - 1,n2,i == 6,flag && i == maxn);
}
if(!flag) dp[pos][n1][n2] = ans;
return ans;
}
bool check(int mid)
{
int n = 0,k = mid;
while(mid){a[++n] = mid % 10;mid /= 10;}
return (k + 1 - dfs(n,0,0,true)) >= x;//把0加上,减去非魔鬼数的数量=魔鬼数的数量
}
signed main()
{
scanf("%lld",&t);
while(t--)
{
scanf("%lld",&x);
memset(dp,-1,sizeof(dp));
int l = 0,r = 1e11 + 5;
while(l + 1 < r)//二分查找mid以内魔鬼的数量
{
int mid = l + r >> 1;
if(check(mid)) r = mid;
else l = mid;
}
printf("%lld\n",r);
}
return 0;
}
二
考虑先求出前 位的魔鬼数的总数。
记 为前 位从 开始有连续 个 的非魔鬼数的数量, 为前 位魔鬼数的总数。
对于 ,由于第 位可以选除 外的任意数字且没有其他限制,所以 。
对于 ,由于第 位必须填 且第 位必须不为 ,所以 。
对于 ,同 ,即 。
对于 ,若其由前 位的魔鬼数转移而来,则当前位可以填任意数;若其由前 位的非魔鬼数转移而来,则必须保证该数的最高两位(第 和 位)必须是 ,且当前位必须填 。所以 。
初始化 。
求出 数组后,我们便可以算出第 大的魔鬼数的长度,记为 。
接着,我们从高位到低位,依次确定每一位的值。用 从 到 枚举第 位的值,对于每个 ,我们用 统计在该位为 且前 位已经确定的情况下,魔鬼数的总数。如果 ,那么直接让 ,那么现在的 就表示在前 位确定且第 位为 中的数时,第 大的魔鬼数。否则如果 ,则当前 就一定是第 位的答案,因为 是从 开始一个一个枚举的。
那么现在问题就是如何求出 ,首先 肯定是会加上 的。
我们对位置 时,记录一个数组 ,表示从最高位开始的连续 的个数,如果在某一位断了且没达到 ,那么更改为 ,如果达到了 ,那么这个数一定是魔鬼数,就不变。
还会加上该位为 时新增的魔鬼数,先将 更新,如果 ,那么 ,如果 ,那么 ,如果 ,那么 ,具体实现看代码。
代码如下:
#include<cstdio>
#include<iostream>
using namespace std;
long long f[21][4]={1};
int t,n;
int main(){
for(int i=1;i<=20;i++){
f[i][0]=9*(f[i-1][0]+f[i-1][1]+f[i-1][2]);
f[i][1]=f[i-1][0];
f[i][2]=f[i-1][1];
f[i][3]=10*f[i-1][3]+f[i-1][2];
}
scanf("%d",&t);
while(t--){
scanf("%d",&n);
int len=3;
while(f[len][3]<n)len++;
for(int i=len,cnt=0;i;i--){
for(int j=0;j<=9;j++){
long long tot=f[i-1][3];
if(j==6||cnt==3)for(int k=max(3-cnt-(j==6),0);k<3;k++)tot+=f[i-1][k];
if(tot<n)n-=tot;
else{
if(cnt<3)j==6?cnt++:cnt=0;
printf("%d",j);
break;
}
}
}
printf("\n");
}
}