数位DP
不得不说,数位DP是我掌握的最不好的一个板块。其实数位DP还挺好理解的,状态设计也一目了然,但是请小心前导零。
从数位DP最基础的模版题:windy数开始做起
P2657 [SCOI2009] windy 数
题意:不含前导零且相邻两个数字之差至少为 2 的正整数被称为 windy 数。windy 想知道,在 a 和 b 之间,包括 a 和 b ,总共有多少个 windy 数?\(1≤a≤b≤2×10^9\)
状态分析
首先,这巨大的数据范围让我们不可能正常DP。事实上,数位DP的最明显特征就是数据范围,这就是让你明摆着写数位DP
那么知道是数位DP了,考虑状态。数位DP的状态是雷同的。主要由位数、推出下一位必须的条件以及是否压位、是否有前导零等东西构成。(压位的概念后面讲)
但状态并不是一成不变的。虽然大部分数位DP题已经相当类似,但也要“带着脑子做题”
对于这道题而言,位数为9,因为我们只需要枚举每一位的数字大小就行了,我们并不关心整个数字是多少。
推出下一位必须的条件很显然只有上一位数的大小,只要相减绝对值小于2,因此记下来
是否压位以及是否有前导零在此题显然需要考虑。因为前导零根据题意并不算做真正的“数字”,并不需要满足“相邻数字差至少为2”的条件,因此会影响答案。
“压位”的意思是指是否这一位前枚举的数与我们的“上界”(就是输入进来的数)完全相同。
比如我们已经枚举了113***(后面的*是还没枚举的),输入进来的数是113333,那我们第四位就不能是4及以上的数。
因此我们需要记录是否有这种特殊的情况。但我们最好不要把它写进DP状态里,因为有的题会有多组数据。
如果我们希望这种特殊的情况不影响下一组数据,我们必须每次都清空DP数组,而这会极大地增大常数,对于一些卡常题极不友好,因此养成好习惯,从模板题做起,不要把“压位”写进状态里。
综上,我们设定的DP状态是 \(f_{i,j}\) 表示枚举到倒数第 \(i\) 位(倒着枚举好统计答案),上一位是 \(j\) 的情况总数。至于压位与前导零,在哪里,之后讲。
粗略写法
众所周知,DP有两种写法:记忆化搜索以及正着写循环。
一般而言,数位DP使用记忆化搜索。记搜有两个优点:
- 可以合理方便地记下上文所说的压位以及前导零等不计入状态的信息
- 代码直观合理,短小精悍。写循环的话有的时候循环特别多(可以去找一些数位DP题的循环写法,十分壮观)
最后是几个实现的小细节: - 根据题干,枚举的数与上一位之间的差的绝对值至少为2,但如果上一位是前导零的话那么就没有这个限制
- 最后的边界条件需要注意。由于我使用vector来存储上界(原数),因此边界是
len==-1 - 注意要没有压位并且没有前导零才能记忆化,因为状态中没有这两个影响最终答案的值
- 注意判断压位后确定上界
- 我们将原先的 \(a \to b\) 的区间拆分成 \(1 \to a-1\) 与 \(1 \to b\) 两个区间求值后做差
有了状态与记忆化搜索的写法,我们就可以大概码出代码了。
code
(略有压行)
点击查看代码
#include<bits/stdc++.h>
using namespace std;
#define int long long
const int N=2e9+7;
int f[20][20];
vector <int> shu;
void init(){memset(f,-1,sizeof(f));}
int dfs(int len,int last,bool qian,bool ya)
{
if(len==-1) return 1ll;//边界条件
if(!ya&&!qian&&f[len][last]!=-1) return f[len][last];//注意不压位无前导零才能记忆化
int up=ya?shu[len]:9,res=0;//注意压位的上界 (用三元运算符直观清晰)
for(int i=0;i<=up;i++){
if(abs(last-i)>=2||qian)//如果是有前导零就可以无视条件
res+=dfs(len-1,i,qian&&(!i),ya&&(i==up));
//状态简洁一点,不要像某些 题解一样写的一大堆,其实一行就行了
}
if(!ya&&!qian) f[len][last]=res;return res;
}
int work(int x)
{
shu.clear();//记得清空
while(x){shu.push_back(x%10),x/=10;}//用vector存上界
return dfs(shu.size()-1,-2,1,1);//从高向低位枚举
}
signed main()
{
ios::sync_with_stdio(false),cin.tie(0),cout.tie(0);//关流,好习惯,但只能用cin
int l,r;cin>>l>>r;init();
cout<<work(r)-work(l-1)<<'\n';//做差
return 0;
}
时、空复杂度
时间复杂度一般而言就是你开的数组大小乘以转移的复杂度。这里所谓转移的复杂度是指当前为可以填的数的个数。因为我们相当于数组中的每个位置都会遍历一遍,然后对于每一个位置我们都会枚举当前填什么数。
为什么要强调空间复杂度呢?因为有一些毒瘤卡常题,因此注意状态设计,需要离散化的离散化,需要再压缩的再压缩(卡常大师)
对于这道题,算法时空复杂度都为 \(O(1)\)(常数复杂度值得拥有)
P3413 SAC#1 - 萌数
题意:给定 \(l,r\),求区间内有多少数拥有长度至少为2的回文串的数。\(l\le r\le 2^{1000}\)。答案对 \(1e9+7\) 取模。
状态
显然,巨大的数据范围绝对是数位DP。首先输入需要用字符串读入后存储。
然后,按照之前类似的步骤考虑状态。由于“长度至少为2”的回文串只有两种情况:
- 相邻两个数相等,如
11 - 两个隔着的数相等,如
121
当然,回文串还可能更长,但是因为已经对答案没有影响了,因此不需要去统计。
因此状态就只需要记录某个数的长度、上一个数是多少、上上个数是多少就行了:\(f_{i,j,k}\)
前导零
不过注意,就像文章开头强调的,数位DP需要特别注意前导零的情况。
由于形如 010 的数是不能算进答案的,因此在开头时,我们需要将上、上上一位的值设为10避免算重
但对于前导零的标记的计算也就需要分类讨论。如果上一位或者上上位是10也需要标记上有前导零(调我半个小时,害人不浅)
code
(状态那里有些压行,其它还好)双倍经验,只是求不是萌数的数的个数,注意左端点可以为0
点击查看代码
#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
const ll p=1e9+7;
const ll N=1005;
vector <ll> shu;
ll f[N][15][15][5];
void init(){memset(f,-1,sizeof(f));}
ll dfs(ll len,ll last,ll llast,bool hui,bool ya,bool qian,bool qqian)
{
if(len==-1) {return hui==1?1:0;}
if(!ya&&!qian&&!qqian&&f[len][last][llast][hui]!=-1) return f[len][last][llast][hui]%p;//还是注意没有前导零才能记忆化
ll up=ya?shu[len]:9,res=0;
for(ll i=0;i<=up;i++)
res=(res+dfs(len-1,i,last,hui||(((!qian&&last==i)||(!qqian&&llast==i))),ya&&i==up,qian&&(i==0),qqian&&(last==0||last==10))%p)%p;
//致死量。巧用逻辑运算以及三元运算符可以显著减小码量,但记得打括号
if(!ya&&!qian&&!qqian) f[len][last][llast][hui]=res%p;return res%p;
}
ll work(string s)
{
shu.clear();int cnt=s.length()-1;while(cnt>=0) shu.push_back(1ll*(s[cnt--]-'0'));
return dfs(1ll*shu.size()-1ll,10,10,0,1,1,1);//初始值记得设为10
}
int main()
{
ios::sync_with_stdio(false),cin.tie(0),cout.tie(0);
string l,r;init();ll sign=0;
cin>>l>>r;for(int i=l.length()-1;i>=1;i--) if(l[i]==l[i-1]) sign=1;
for(int i=l.length()-1;i>=2;i--) if(l[i]==l[i-2]) sign=1;
cout<<(work(r)-work(l)+sign+p)%p<<'\n';
return 0;
}
P9092 [PA2020] Liczba Potyczkowa
题意:给定 \(l,r\),求区间内能被每个位置的数整除的数有多少个。\(l\le r\le 10^{18}\)
状态
同样一眼数位DP。考虑怎么做。由于需要被每一位的数整除这个条件有些苛刻,如果我们把每一位都存下来,其实跟暴力没什么两样。
发现一个性质,就是除以一些数是否整除与除以它们的最小公倍数是否整除是等价的。因此我们不考虑存下来有哪些数,而是存其最小公倍数。
但现在还有一个问题,就是如何存的下原数。如果想要将原数放进状态里,时间与空间复杂度都得上天。看看上面的性质,发现有类似之处。
考虑一个数是否能被上述的最小公倍数整除,实际上与一个数%1~9的最小公倍数能否被上述的最小公倍数整除是等价的。
这个性质说出来貌似很显然,但如果要自己想出来还是要细心观察,多做多想。
因此,设计出来的状态是 \(f_{i,j,k}\),\(i\) 是位数,\(j\) 是当前数%2520(1~9的最小公倍数)的值,\(k\) 是除以的所有数的最小公倍数
因此,\(f\) 数组的大小是\(2520\times 2520\times 19 \approx 125000000\)的,又因为需要开 \(long\) \(long\) 然后就可以愉快的MLE了。
再考虑如何压缩状态。由于我们第二个2520存储的是除的数的最小公倍数,我们发现这些最小公倍数只有不到50种情况,因此考虑离散化后存储,大大缩小了时空复杂度。
总结一下,我们现在需要存储的是当前枚举的位数、当前数%2520的值与离散化后的除数的最小公倍数
小细节
- 由于题目已经强调了数中间不能有零,因此还需要考虑从哪个数开始枚举(前导零标记从0,没有从1)
- 注意离散化的处理方式。每种可能的最小公倍数其实就是2520的每种因数。(注意取模的优先级没有非大(又调我半个小时))
code
(这下不敢压行了)(三倍经验看讨论区)
点击查看代码
#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
vector <ll> shu;
const ll p=2520;
ll f[25][2525][50],book[2525],l,r,cnt=0;
ll _lcm(ll x,ll y){return x*y/__gcd(x,y);}
void init()
{
memset(f,-1ll,sizeof(f));
for(int i=1;i<=p;i++) if(!(p%i)) book[i]=++cnt;//特殊的离散化方式
}
ll dfs(ll len,ll sum,ll lc,bool ya,bool qian)
{
if(len==-1ll) return qian==1ll?0ll:sum%lc==0ll;
if(!ya&&f[len][sum][book[lc]]!=-1ll) return f[len][sum][book[lc]];
ll up=ya==1?shu[len]:9ll,res=0ll,down=qian==1?0ll:1ll;//记得处理下界
for(ll i=down;i<=up;i++)
res+=dfs(len-1ll,(1ll*sum*10+i)%p,(!i)?lc:_lcm(lc,i),ya&&i==up,qian&&i==0);//如果有零注意最小公倍数不变(避免除以0)
if(!ya&&!qian) f[len][sum][book[lc]]=res;return res;
}
ll work(ll x)
{
shu.clear();
while(x){shu.push_back(x%10),x/=10;}
return dfs(shu.size()-1,0,1,1,1);
}
signed main()
{
init();
cin>>l>>r;cout<<work(r)-work(l-1)<<'\n';
return 0;
}
P4127 [AHOI2009] 同类分布
有相当多的状压 DP 题与数论联系的比较紧密,其中一个原因是取模在状压 DP 中非常有用,可以压缩状态空间等等比如前面一个题就记录了原数模 \([1,9]\) 的最小公倍数。
比如这道题,我们如果要计算答案必须知道原数,但是显然不行。
我们考虑各位数字之和的数量是很少的,因此考虑枚举这个各位数字之和,然后最后的答案就要求原数模当前枚举的各位数字之和为 0。这个是好做的。
code
当然还要要求个位数字之和是当前枚举的这个数。
点击查看代码
#include<bits/stdc++.h>
bool Mbe;
using namespace std;
#define int long long
//namespace FIO{
// template<typename P>
// inline void read(P &x){P res=0,f=1;char ch=getchar();while(ch<'0' || ch>'9'){if(ch=='-') f=-1;ch=getchar();}while(ch>='0' && ch<='9'){res=(res<<3)+(res<<1)+(ch^48);ch=getchar();}x=res*f;}
// template<typename Ty,typename ...Args>
// inline void read(Ty &x,Args &...args) {read(x);read(args...);}
// inline void write(ll x) {if(x<0ll)putchar('-'),x=-x;static int sta[35];int top = 0;do {sta[top++] = x % 10ll, x /= 10ll;} while (x);while (top) putchar(sta[--top] + 48);}
//}
//using FIO::read;using FIO::write;
const int S=163;
int f[20][S][S][2][2],p,a[20],pw[20]; //当前正在填第几位,数位和,原数
int dfs(int i,int s1,int s2,bool ya,bool pre){
if(i==0){return (s1==p&&s2==0&&(!pre));}
if(f[i][s1][s2][ya][pre]!=-1)return f[i][s1][s2][ya][pre];
int up=(ya?a[i]:9),res=0;
for(int w=0;w<=up;w++){res+=dfs(i-1,s1+w,(s2+pw[i-1]*w)%p,(ya&&w==up),(pre&&w==0));}
return f[i][s1][s2][ya][pre]=res;
}
int calc(int x){
if(!x)return 0;
for(int i=1;i<=19;i++){a[i]=x%10;x/=10;}
int ans=0;
for(p=1;p<S;p++){
memset(f,-1,sizeof(f));
ans+=dfs(19,0,0,1,1);
}
return ans;
}
bool Med;
signed main(){
ios::sync_with_stdio(false),cin.tie(0),cout.tie(0);
//freopen(".in","r",stdin);
//freopen(".out","w",stdout);
pw[0]=1;for(int i=1;i<=19;i++)pw[i]=pw[i-1]*10;
int a,b;cin>>a>>b;cout<<calc(b)-calc(a-1)<<'\n';
cerr<<'\n'<<1e3*clock()/CLOCKS_PER_SEC<<"ms\n";
cerr<<'\n'<<fabs(&Med-&Mbe)/1048576.0<<"MB\n";
return 0;
}
P3311 [SDOI2014] 数数
这种多模式串匹配显然先跑一个 AC 自动机即可。然后我们对于状态就记录一个当前在 AC 自动机上哪个节点即可。好像很板,没什么好说的。
code
点击查看代码
#include<bits/stdc++.h>
bool Mbe;
using namespace std;
#define ll long long
//namespace FIO{
// template<typename P>
// inline void read(P &x){P res=0,f=1;char ch=getchar();while(ch<'0' || ch>'9'){if(ch=='-') f=-1;ch=getchar();}while(ch>='0' && ch<='9'){res=(res<<3)+(res<<1)+(ch^48);ch=getchar();}x=res*f;}
// template<typename Ty,typename ...Args>
// inline void read(Ty &x,Args &...args) {read(x);read(args...);}
// inline void write(ll x) {if(x<0ll)putchar('-'),x=-x;static int sta[35];int top = 0;do {sta[top++] = x % 10ll, x /= 10ll;} while (x);while (top) putchar(sta[--top] + 48);}
//}
//using FIO::read;using FIO::write;
const int N=1507,p=1e9+7;
namespace AC{
int ch[N][10],idcnt=0,cnt[N],fail[N];
void insert(string s){
int len=s.length();
int u=0;
for(int i=0;i<len;i++){
int v=s[i]-'0';
if(!ch[u][v])ch[u][v]=++idcnt;
u=ch[u][v];
}
cnt[u]++;
}
void build(){
queue <int> q;fail[0]=0;for(int i=0;i<10;i++)if(ch[0][i])q.push(ch[0][i]),fail[ch[0][i]]=0;
while(!q.empty()){
int u=q.front();q.pop();
for(int i=0;i<10;i++)if(ch[u][i])fail[ch[u][i]]=ch[fail[u]][i],q.push(ch[u][i]);
else ch[u][i]=ch[fail[u]][i];
}
}
}
using namespace AC;
int add(const int &x,const int &y){return x+y<0?x+y+p:x+y>=p?x+y-p:x+y;}
void upd(int &x,const int &y){x=add(x,y);}
int f[N][N][2][2],a[N];
int dfs(int i,int u,bool ya,bool pre){
if(f[i][u][ya][pre]!=-1)return f[i][u][ya][pre];
if(!pre){
int v=u;while(v!=0&&!cnt[v])v=fail[v];
if(cnt[v]){return f[i][u][ya][pre]=0;}
}
if(i==0){return f[i][u][ya][pre]=(!pre);}
int up=ya?a[i]:9,ans=0;
for(int w=0;w<=up;w++){upd(ans,dfs(i-1,pre&&w==0?0:ch[u][w],ya&&w==up,pre&&w==0));}
return f[i][u][ya][pre]=ans;
}
bool Med;
signed main(){
// ios::sync_with_stdio(false),cin.tie(0),cout.tie(0);
//freopen(".in","r",stdin);
//freopen(".out","w",stdout);
string n;cin>>n;int len=n.length();for(int i=0;i<len;i++)a[len-i]=n[i]-'0';
int m;cin>>m;for(int i=1;i<=m;i++)cin>>n,insert(n);
build();
memset(f,-1,sizeof(f));
cout<<dfs(1200,0,1,1)<<'\n';
cerr<<'\n'<<1e3*clock()/CLOCKS_PER_SEC<<"ms\n";
cerr<<'\n'<<fabs(&Med-&Mbe)/1048576.0<<"MB\n";
return 0;
}
P6669 [清华集训 2016] 组合数问题
\(k\) 都保证是质数了显然是在提示 Lucas 定理。
我们考虑 Lucas 的过程就是在做将 \(n,m\) 变成 \(k\) 进制数的过程。
这个时候每个组合数都一定不包含 \(k\) 的因子,因为每个组合数的 \(n,m\) 都小于 \(k\)。于是答案为 0 的原因只能是其中有某个组合数 \(m>n\)。
于是我们直接数位 DP 每一位填哪两个数并记录之前是否有 \(n<m\) 的情况即可。注意有很多压位需要判掉。
code
点击查看代码
#include<bits/stdc++.h>
bool Mbe;
using namespace std;
#define int long long
//namespace FIO{
// template<typename P>
// inline void read(P &x){P res=0,f=1;char ch=getchar();while(ch<'0' || ch>'9'){if(ch=='-') f=-1;ch=getchar();}while(ch>='0' && ch<='9'){res=(res<<3)+(res<<1)+(ch^48);ch=getchar();}x=res*f;}
// template<typename Ty,typename ...Args>
// inline void read(Ty &x,Args &...args) {read(x);read(args...);}
// inline void write(ll x) {if(x<0ll)putchar('-'),x=-x;static int sta[35];int top = 0;do {sta[top++] = x % 10ll, x /= 10ll;} while (x);while (top) putchar(sta[--top] + 48);}
//}
//using FIO::read;using FIO::write;
const int p=1e9+7;
int add(const int &x,const int &y){return x+y<0?x+y+p:x+y>=p?x+y-p:x+y;}
void upd(int &x,const int &y){x=add(x,y);}
int f[61][2][2][2][2],a[61],b[61],k;
int dfs(int i,bool t1,bool t2,bool t3,bool ya){//a 压 n,b 压 m,a 压 b,还没有 a<b 出现
if(f[i][t1][t2][t3][ya]!=-1){return f[i][t1][t2][t3][ya];}
if(i==0){return (!ya);}
int upa=t1?a[i]:k-1,upb=t2?b[i]:k-1,ans=0;
for(int x=0;x<=upa;x++){
for(int y=0;y<=(t3?min(x,upb):upb);y++){
upd(ans,dfs(i-1,t1&&x==upa,t2&&y==upb,t3&&x==y,ya&&y<=x));
}
}
return f[i][t1][t2][t3][ya]=ans;
}
void solve(){
int n,m;cin>>n>>m;
for(int i=1;i<=60;i++)a[i]=n%k,n/=k;
for(int i=1;i<=60;i++)b[i]=m%k,m/=k;
memset(f,-1,sizeof(f));
cout<<dfs(60,1,1,1,1)<<'\n';
}
bool Med;
signed main(){
ios::sync_with_stdio(false),cin.tie(0),cout.tie(0);
//freopen(".in","r",stdin);
//freopen(".out","w",stdout);
int T;cin>>T>>k;
while(T--)solve();
cerr<<'\n'<<1e3*clock()/CLOCKS_PER_SEC<<"ms\n";
cerr<<'\n'<<fabs(&Med-&Mbe)/1048576.0<<"MB\n";
return 0;
}

浙公网安备 33010602011771号