数位dp思路总结及洛谷例题讲解

文章目录

  • 例题讲解
    • 例题1 P4999 烦人的数学作业
    • 例题2 P6218 [USACO06NOV] Round Numbers S
  • 总结和模板
  • 习题
    • P2657 [SCOI2009] windy 数
    • Acwing 310. 启示录

例题讲解

例题1 P4999 烦人的数学作业

题目链接
题目大意: 求区间 l — r l—r lr的数字和。但是数字和定义为每个数字不同数位的和,如123=1+2+3=6。

解题思路: l , r l,r l,r的范围是 1 0 18 10^{18} 1018。暴力枚举复杂度是 O ( r − l ) O(r-l) O(rl),显然不行。我们换一个方向枚举:枚举数码1-9在 l l l r r r出现的总次数,再分别和当前枚举的数码相乘就是所求答案。比如区间 97 − 102 : 97 , 98 , 99 , 100 , 101 , 102 97-102:97,98,99,100,101,102 97102:97,98,99,100,101,102:1出现3次,9出现4次,2,7,8均只出现1次。答案为 1 × 3 + 9 × 4 + 2 + 7 + 8 1×3+9×4+2+7+8 1×3+9×4+2+7+8。同时利用前缀和思想,先计算 0 0 0 r r r之间的数字和,再计算 0 0 0 l − 1 l-1 l1之间的数字和,再用前者减去后者即为 l l l r r r之间的数字和。

数位dp一般是求区间 l l l r r r之间符合限制条件的数字个数。 l l l r r r都是达到 long long 级别的数,无法直接枚举求答案;要求数字的限制条件一般是针对数字的数位的(数位指一个数中每一个数字所占的位置,这些位置,都叫做数位。从右端算起,第一位是“个位”,第二位是“十位”…数位总和为位数,如1234为四位数),所以解决这种问题,不会一个数字一个数字看,而是枚举数位后捕获一大把合法的数字。

状态设计: 最低位标记为第1位,当前枚举数码为 d i g i t digit digit d p [ i ] [ j ] dp[i][j] dp[i][j]表示从最高位到i位时 d i g i t digit digit的个数为 j j j时总的个数为多少。因为是从高位到低位枚举的,因此枚举完最后一个位置时,相当于枚举了一个完整的数字;而刚枚举最高位置时,此状态包含了所有数字。因此我们的答案就是枚举最高位置时的状态对应的数字和。

举个例子(假设我们只考虑当前数位最多有4位,能用的数字只有1和2,以此来简化问题)。
假设现在统计的 d i g i t digit digit是1,我们枚举了前两位为 12 12 12,此时计算的就是 d p [ 3 ] [ 1 ] dp[3][1] dp[3][1],表示从最高位到第3位时1(当前 d i g i t digit digit)的个数为1时总的个数。那么后两位有4种情况可以选择: 11 , 12 , 21 , 22 11,12,21,22 11,12,21,22
第一种情况 11 11 11:相当于枚举了第一个合法的数字: 1211 1211 1211 d i g i t 1 digit 1 digit1 共3个。
第二种情况 12 12 12:相当于枚举了第二个合法的数字: 1212 1212 1212 d i g i t 1 digit 1 digit1 共2个。
第三种情况 21 21 21:相当于枚举了第三个合法的数字: 1221 1221 1221 d i g i t 1 digit 1 digit1 共2个。
第四种情况 22 22 22:相当于枚举了第四个合法的数字: 1222 1222 1222 d i g i t 1 digit 1 digit1 共1个。
因此 d p [ 3 ] [ 1 ] dp[3][1] dp[3][1]可以算出为:3+2+2+1=8。

接下来我们枚举前两位为 21 21 21,此时计算的还是 d p [ 3 ] [ 1 ] dp[3][1] dp[3][1],仍然表示从最高位到第2位时1的个数为1时的总的个数。而这个答案之前已经计算过了,没有继续往低位枚举的必要,因为后面4种情况枚举之后如下:
第一种情况 11 11 11:相当于枚举了第一个合法的数字: 2111 2111 2111 d i g i t 1 digit 1 digit1 共3个。
第二种情况 12 12 12:相当于枚举了第二个合法的数字: 2112 2112 2112 d i g i t 1 digit 1 digit1 共2个。
第三种情况 21 21 21:相当于枚举了第三个合法的数字: 2121 2121 2121 d i g i t 1 digit 1 digit1 共2个。
第四种情况 22 22 22:相当于枚举了第四个合法的数字: 2122 2122 2122 d i g i t 1 digit 1 digit1 共1个。
因此 d p [ 3 ] [ 1 ] dp[3][1] dp[3][1]仍然为:3+2+2+1=8。

最高位没有数字,第2位为1,此时仍然计算 d p [ 2 ] [ 1 ] dp[2][1] dp[2][1],仍然是8 ( 111 , 112 , 121 , 122 ) (111,112,121,122) (111,112,121,122)

当从最高位到第 i i i位时 d i g i t digit digit个数为 j j j,如果 d p [ i ] [ j dp[i][j dp[i][j已经计算过了,就不必再次计算,直接使用之前保存过的值就好,这就是记忆化搜索( ̄y▽, ̄)╭ 。

但是如果最大的数字为2112,那么枚举前两位是21的时候接下去那一位只能是1(如果是2就超过最大数了),因此之前计算好的 d p [ 3 ] [ 1 ] dp[3][1] dp[3][1]不能使用,因为后面无法枚举完整的4种情况,只能老老实实往下算。在程序中用1个 b o o l bool bool变量 l i m i t limit limit来判断这种情况,如果枚举到当前数位 l i m i t limit limit为真,则不能使用已经保存的相应 d p dp dp值;若当前 l i m i t limit limit为真,且当前数位枚举的数字恰好和最大值对应数位的数字一致,则继续向更低位枚举时的 l i m i t limit limit也为真。但其实有 l i m i t limit limit限制的状态规模并不像有 l i m i t limit limit限制的那么多,这就像一些零零碎碎的状态需要另外处理,只要有限制,就既不能使用之前计算好的 d p dp dp值,这个状态自食其力地更新完也不能给 d p dp dp值用。

代码展示:

#include
#define LL long long
const int mod=1e9+7;
using namespace std;
int num[20],digit;
LL dp[20][20];//1ong long的类型最多有18位
LL dfs(int pos,bool limit,int sum){ 
    LL ans=0;
    if(pos==0)return sum;
    if(!limit&&dp[pos][sum]!=-1)return dp[pos][sum];
    int up=limit?num[pos]:9;//根据最大的数字确定上界
    for(int i=0;i<=up;i++){
        ans=(ans+dfs(pos-1,limit&&(up==i),sum+(i==digit)))%mod;
    }
    if(!limit)return dp[pos][sum]=ans;
    return ans;
}
LL solve(LL cur){
    num[0]=0;//num[0]表示cur的位数
    LL res=0;
    while(cur){
        num[++num[0]]=cur%10;
        cur/=10;
    }
    for(int i=1;i<=9;i++){
        memset(dp,-1,sizeof dp);
        digit=i;
        res=(res+dfs(num[0],1,0)*i%mod)%mod;
    }
}
int main(){
    int T;cin>>T;
    while(T--){
        LL l,r;cin>>l>>r;
        cout<<(solve(r)-solve(l-1)+mod)%mod<<endl;//取模之后可能r比l-1的数字和更小,加上mod保证是正数
    }
}

例题2 P6218 [USACO06NOV] Round Numbers S

题目链接
这道题和例题1很类似,只是此处需要判断二进制0和1的个数,因此前导零会产生影响,还需要一个 b o o l bool bool变量 l e a d lead lead表示当前枚举的数字是否含有前导0;同时需要两个状态:从高位到低位枚举二进制的0个数和1个数。
因此状态设计成 d p [ p o s ] [ z e r o ] [ o n e ] dp[pos][zero][one] dp[pos][zero][one]表示枚举到第 p o s pos pos位且从最高位到最低位的0个数为 z e r o zero zero,1个数为 o n e one one时符合条件的数字个数。
我们仍然举一个简单的例子来说明前导0的影响:假设当前数位为6,最大数字二进制为111111。
当前枚举到101,枚举位置为4,0个数为1,1个数为2,即计算 d p [ 4 ] [ 1 ] [ 2 ] dp[4][1][2] dp[4][1][2]。101后三位可以枚举为 000 , 001 , 010 , 011 , 100 , 101 , 110 , 111 000,001,010,011,100,101,110,111 000001010,011,100,101,110,111即枚举了数字 a 101000 , b 101001 , c 101010 , d 101011 , e 101100 , f 101101 , g 101110 , h 101111 a 101000,b 101001,c 101010,d 101011,e 101100,f 101101,g 101110,h 101111 a101000,b101001,c101010,d101011,e101100,f101101,g101110,h101111其中共 a , b , c , e a,b,c,e a,b,c,e 4个数字满足条件,因此更新 d p [ 4 ] [ 1 ] [ 2 ] dp[4][1][2] dp[4][1][2]为4。
继续枚举前3位为110,仍然为 d p [ 4 ] [ 1 ] [ 2 ] dp[4][1][2] dp[4][1][2],可以直接返回已经更新好的值。

当枚举前3位为011,似乎仍然是计算 d p [ 4 ] [ 1 ] [ 2 ] dp[4][1][2] dp[4][1][2],但此时前面的0并不能算是数字二进制里面的0,计算出来满足条件的数字个数会比 d p [ 4 ] [ 1 ] [ 2 ] dp[4][1][2] dp[4][1][2]更少,因为0的个数实际不包括前面的0。我们将第一位非零数字前面的零称为前导零,在状态和零的数量有关,并且前导零是不被统计的时候,就不能使用计算好的 d p dp dp值,也不用计算好的答案更新看似对应的 d p dp dp值。

#include
using namespace std;
int dp[33][33][33],num[33];
int dfs(int pos,bool limit,bool lead,int zero,int one){
    if(!pos){
        return zero>=one;
    }
    int ans=0;
    if(!limit&&!lead&&dp[pos][zero][one]!=-1)return dp[pos][zero][one];
    int up=limit?num[pos]:1;
    for(int i=0;i<=up;i++)
        ans+=dfs(pos-1,limit&&num[pos]==i,lead&&i==0,zero+(!lead&&i==0),one+(i==1));
    if(!limit&&!lead)return dp[pos][zero][one]=ans;
    return ans;
}
int solve(int cur){
    num[0]=0;
    memset(dp,-1,sizeof dp);
    while(cur){
        num[++num[0]]=cur&1;
        cur>>=1;
    }
    return dfs(num[0],1,1,0,0);
}
int main(){
    int l,r;cin>>l>>r;
    cout<<solve(r)-solve(l-1)<<endl;
    return 0;
}

总结和模板

d f s + d p dfs+dp dfs+dp,也就是记忆化搜索。这可以理解成一颗树,根就是我们求解的答案,而这棵树不同于普通树的点在于,它有很多重复的子树,这些子树可以在第一次计算之后保存下来,后面第二次第三次使用的时候只需要用之前第一次保存的子树答案。

  1. d p [ i ] [ j ] [ k ] . . . dp[i][j][k]... dp[i][j][k]...表示当前已经从高位到低位枚举了i位,到了状态j,k的答案(一般是枚举数字个数,也可能和例1一样是和数字个数相关的其他量)
  2. d f s dfs dfs传入参数一般是:当前数位 p o s pos pos;限制条件 l i m i t limit limit,最基础的限制是数位枚举的上界限制;是否有前导零 l e a d lead lead,但若当前的限制条件和0无关就不需要,例题1是求和,而0对求和没有影响,所以不需要;例题2是求解0个数大于1个数的数字,和0的数量有关系,因此需要限制前导0;当前状态 s t a t e 1 , s t a t e 2... state1,state2... state1,state2...记录对应题目的状态。
  3. 枚举完1个完整的数字,一般返回1(如果题目求解的是满足限制条件的数字个数),或者是和数字个数相关的其他值。
  4. 若当前状态对应的 d p dp dp值已经更新完,并且不是有限制和有前导0等零碎的和定义的 d p dp dp值不同的状态,直接返回 d p dp dp值。
#include
#include
using namespace std;
#define ll long long
const int mod=1e9+7;
ll dp[位数+1][state1][state2]...;//1
int num[位数+1];
ll dfs(int pos,bool limit,bool lead,int state){//2 
    if(pos==0)return;//3
    if(!limit&&!lead&&dp[pos][state]!=-1)return dp[pos][state];//4
    int up=9;if(limit)up=num[pos];//根据是否有限制及最大的数字,更新当前枚举数位数字的上界
    for(int i=0;i<=up;i++){
        ans+=dfs(pos-1,(i==up)&&limit,lead&&(i==0),updated state);
    }
    if(!limit&&!lead)dp[pos][state]=ans;
    return ans;
}
ll solve(ll x){
    memset(dp,-1,sizeof dp);
    num[0]=0;
    while(x){
        num[++num[0]]=x%10;
        x/=10;
    }
    return dfs(num[0],1,0,original state);
}
int main(){
   ll l,r;cin>>l>>r;
   cout<<solve(r)-solve(l-1)<<endl;
    return 0;
}

习题

题单链接

P2657 [SCOI2009] windy 数

题目链接
从洛谷的题解里学到了另外一种状态表示的方法,直接将前导零和上界限制直接作为状态的一部分,这样在更新dp的时候就不用费心判断是否可以使用和更新。

#include
using namespace std;
int num[20],dp[20][20][2][2];
//什么前导0和限制都不需要考虑了!
int dfs(int pos,bool limit,bool lead,int last){
    if(!pos)return 1;
    if(dp[pos][last][limit][lead]!=-1)return dp[pos][last][limit][lead];
    int ans=0;
    int up=limit?num[pos]:9;
    for(int i=0;i<=up;i++){
        if(abs(i-last)>=2||lead)
            ans+=dfs(pos-1,limit&&(i==num[pos]),lead&&(i==0),i);
    }
    return dp[pos][last][limit][lead]=ans;
}
int solve(int x){
    num[0]=0;
    while(x){
        num[++num[0]]=x%10;
        x/=10;
    }
    memset(dp,-1,sizeof dp);
    return dfs(num[0],1,1,11);
}
int main(){
    int l,r;cin>>l>>r;
    cout<<solve(r)-solve(l-1)<<endl;
    return 0;
}

Acwing 310. 启示录

题目链接
如果不考虑很大的数,最朴素的方法就是将这些数全部保存下来,然后用二分查找第k大的数是哪个。但是数字很大,因此我们在二分查找的时候只要知道此数字是第几大就好。
s o l v e ( x ) solve(x) solve(x)表示x是第几个数。 d p [ p o s ] [ s u m ] [ l i m i t ] [ o k ] dp[pos][sum][limit][ok] dp[pos][sum][limit][ok]表示从高枚举到低位,最后面的连续6个数为 s u m sum sum,有无上界限制 l i m i t limit limit,以及当前枚举是否已经合法 o k ok ok 的合法数字个数。

#include
#define LL long long
using namespace std;
LL dp[20][20][2][2];
int cnt,num[20];
LL dfs(int pos,int sum,bool limit,bool ok){
    if(!pos)return ok;
    LL &ans=dp[pos][sum][limit][ok];
    if(ans!=-1)return ans;
    ans=0;
    int up=limit?num[pos]:9;
    for(int i=0;i<=up;i++){
        int cur=(i==6)?sum+1:0;
        ans+=dfs(pos-1,cur,limit&&(i==num[pos]),ok||cur>=3);
    }
    return ans;
}
LL solve(LL x){
    num[0]=0;
    while(x)num[++num[0]]=x%10,x/=10;
    memset(dp,-1,sizeof dp);
    return dfs(num[0],0,1,0);
}
int main(){
    int T;cin>>T;
    while(T--){
        cin>>cnt;
        LL l=1,r=1e18,mid,ans;
        while(l<=r){
            mid=(l+r)>>1;
            if(solve(mid)<cnt){//直接找最小的那个
                l=mid+1;
            }
            else {
                r=mid-1;
                ans=mid;
            }
        }
        cout<<ans<<endl;
    }
    return 0;
}

你可能感兴趣的:(算法,动态规划,算法)