数位DP问题

用来解决一类对数位进行某种计数的问题,通常有以下特点:

  • 统计满足某种条件或约束的数量,且需要统计的区间跨度极大
  • 某些条件或约束可以利用数位拆分的方式去满足
  • 通常给定的区间 $[l,r]$ 可以拆分为 $[1,l],[1,r]$ 然后利用前置和做差计算

因为需要满足的约束以及边界条件较难进行完整判断,所以设计状态转移方程进行迭代很难成功

这里采用记忆化搜索的方式进行计算


例题一:https://www.luogu.com.cn/problem/P4999

给出一个区间 $[l,r]$ ,求 $l$ 到 $r$ 区间内每个数的数位和

const int mod=1e9+7;

LL dp[32][400];

LL dfs(int pos,bool lim,LL sum,int a[]){
    if (pos==0) return sum;
    if (!lim&&dp[pos][sum]!=-1) return dp[pos][sum];
    int k=lim?a[pos]:9;
    LL res=0;
    for (int i=0;i<=k;i++)
        res=(res+dfs(pos-1,lim&&i==k,sum+i,a))%mod;
    if (!lim) dp[pos][sum]=res;
    return res;
}

LL work(LL x){
    memset(dp,-1,sizeof dp);
    int idx=0,a[32];
    while (x){
        a[++idx]=x%10;
        x/=10;
    }
    return dfs(idx,1,0,a);
}

void solve(){
    LL l,r;
    cin>>l>>r;
    cout<<(work(r)-work(l-1)+mod)%mod<<endl;
}

记一个数的数位和为 $sum_x$ ,题意转化为求解 $\sum_{i=l}^r sum_i$ ,满足前缀和性质,则可以分解为求解 $\sum_{i=0}^r sum_i-\sum_{i=0}^{l-1} sum_i$

DFS 需要的参数有 $pos,lim,sum$ ,其中 $pos$ 表示当前枚举到了哪一个数位,$lim$ 表示当前位是否被上一位约束,$sum$ 表示枚举到第 $pos$ 位前数位和为 $sum$

逐行分析代码:

LL dfs(int pos,bool lim,LL sum,int a[]){
    if (pos==0) return sum;//从大到小枚举位,第0位递归终点返回sum
    if (!lim&&dp[pos][sum]!=-1) return dp[pos][sum];
    //如果当前位没有被约束,且曾经记录过当前的状态,那么直接使用
    int k=lim?a[pos]:9;//判断当前位能枚举到的上界
    //如果存在约束,这一位最大只能填到a的第pos位上的数,否则可以填到9
    LL res=0;
    for (int i=0;i<=k;i++)//枚举
        res=(res+dfs(pos-1,lim&&i==k,sum+i,a))%mod;
    //lim&&i==k表示约束的传递
    if (!lim) dp[pos][sum]=res;//如果这一位没有限制,那么将这个状态记忆化
    return res;
}

约束的传递:假如有一个数为 $8234$ ,从第四位开始枚举,因为枚举的所有数不能超过 $8234$ ,那么所有数的第四位也不应该超过 $8$

约束值一开始传入为 $1$ ,那么第四位就被约束到了 $[0,8]$ 。当枚举第三位时,如果第四位填的是 $8$ ,显然第三位就不能超过 $2$ ,如果第四位填的是 $[0,7]$ ,则第三位可以任填。

lim&&i==k 体现了传递性,只有上一位被约束后,下一位才会有被约束的资格

因为这道题中,前导 $0$ 对答案无贡献,所以无需考虑前导 $0$ 带来的影响

LL dp[32][400];

记忆化的 $dp_{i,j}$ 表示在无约束的情况下填到第 $i$ 位且前 $i$ 位的数位和为 $j$ 时 $[1,i]$ 位满足约束的所有数的数位和

为什么只有在没有约束的条件下才进行记忆?

因为如果当前位存在约束,那么之后的状态都不是共用的,即存在某些状态不合法,无法从这些状态转移


例题二:https://www.luogu.com.cn/problem/P2602

给定两个正整数 $a$ 和 $b$,求在 $[a,b]$ 中的所有整数中,每个数码各出现了多少次

LL dp[20][2][20][2];

LL dfs(int pos,bool lim,bool zro,int cnt,int dig,int a[]){
    if (!pos) return cnt;
    if (dp[pos][lim][cnt][zro]!=-1) return dp[pos][lim][cnt][zro];
    LL res=0;
    int k=lim?a[pos]:9;
    for (int i=0;i<=k;i++){
        if (zro)
            res+=dfs(pos-1,lim&&i==k,zro&&i==0,i==dig&&dig!=0?cnt+1:cnt,dig,a);
        else
            res+=dfs(pos-1,lim&&i==k,0,i==dig?cnt+1:cnt,dig,a);
    }
    dp[pos][lim][cnt][zro]=res;
    return res; 
}

LL work(LL x,int dig){
    int idx=0,a[20];
    memset(dp,-1,sizeof dp);
    while(x){
        a[++idx]=x%10;
        x/=10;
    }
    return dfs(idx,1,1,0,dig,a);
}

void solve(){
    LL a,b;
    cin>>a>>b;
    for (int i=0;i<=9;i++)
        cout<<work(b,i)-work(a-1,i)<<' ';
}

这道题中,需要对 $0$ 进行计数,因为前导 $0$ 不能参与计数,所以需要考虑这一因素

那么 DFS 传入的参数有:$pos,lim,zro,cnt,dig$ ,$zro$ 表示当前位前是否有前导 $0$ ,$cnt,dig$ 表示 $pos$ 位前,数位为 $dig$ 的数量

for (int i=0;i<=k;i++){
    if (zro)//如果存在前导0
        res+=dfs(pos-1,lim&&i==k,zro&&i==0,i==dig&&dig!=0?cnt+1:cnt,dig,a);
        //zro&&i==0表示前导0的传递,与约束的传递类似
        //dig为0时,如果存在前导0且当前填0,则需要刨去这一次的计数
    else
        res+=dfs(pos-1,lim&&i==k,0,i==dig?cnt+1:cnt,dig,a);
}

这里记忆化的 $dp$ 有四维空间,即把 DFS 的所有参数都记录下来,虽然有部分参数可以根据对答案的影响进行压缩,但在空间允许的条件下,这种方式可以减少思考过程

LL dp[20][20][2];

LL dfs(int pos,bool lim,bool zro,int cnt,int dig,int a[]){
    if (!pos) return cnt;
    if (!lim&&dp[pos][cnt][zro]!=-1) return dp[pos][cnt][zro];
    LL res=0;
    int k=lim?a[pos]:9;
    for (int i=0;i<=k;i++){
        if (zro)
            res+=dfs(pos-1,lim&&i==k,zro&&i==0,i==dig&&dig!=0?cnt+1:cnt,dig,a);
        else
            res+=dfs(pos-1,lim&&i==k,0,i==dig?cnt+1:cnt,dig,a);
    }
    if (!lim) dp[pos][cnt][zro]=res;
    return res; 
}

上面为压缩空间后的函数,只有不存在约束时,产生的结果才会被转移,所以每次 DFS 后都根据 $lim$ 判断是否记录


例题三:https://www.luogu.com.cn/problem/P2657

不含前导零且相邻两个数字之差至少为 $2$ 的正整数被称为 windy 数。windy 想知道,在 $a$ 和 $b$ 之间,包括 $a$ 和 $b$ ,总共有多少个 windy 数?

int dp[20][20];

int dfs(int pos,int lim,int zro,int p,int a[]){
    if (!pos) return 1;
    if (!lim&&!zro&&p!=11&&dp[pos][p]!=-1) return dp[pos][p];
    int res=0;
    int k=lim?a[pos]:9;
    for (int i=0;i<=k;i++){
        if (zro)
            res+=dfs(pos-1,lim&&i==k,zro&&i==0,i==0?p:i,a);
        else if (abs(i-p)>=2)
            res+=dfs(pos-1,lim&&i==k,0,i,a);
    }
    if (!lim&&!zro&&p!=11) dp[pos][p]=res;
    return res;
}

int work(int x){
    int idx=0,a[20];
    memset(dp,-1,sizeof dp);
    while (x){
        a[++idx]=x%10;
        x/=10;
    }
    return dfs(idx,1,1,11,a);
}

void solve(){
    int a,b;
    cin>>a>>b;
    cout<<work(b)-work(a-1)<<endl;
}

这道题引入了一个新的约束条件:相邻两位数之差为 $2$,参数也要新设置一个 $p$ 表示前一位填的数

另外在进入 DFS 时需要对 $p$ 的初值进行设置, 因为一开始枚举的 $pos$ 没有前一位,所以要设置一个不约束最高位的值,即 $\lvert p-a_{top}\rvert \ge 2$


浅谈在实现数位DP中迭代法与记忆化搜索的优劣性

迭代法主要依赖预处理后的 $dp_{i,j,\cdots}$ ,在约束很多时,边界条件也会增多,状态转移方程易写错或写漏

记忆化搜索在面对不同题时,需要变化的部分不多,无非是对 DFS 参数的设置以及向下搜索的特判

理论上这两种方式的时间复杂度相同,都会将所有状态仅访问一遍,但记忆化搜索需要调用多次递归,常数可能略大于迭代实现的常数

时间复杂度通常为:$O(\log n\times k)$ ,$k$ 表示限制条件,总时间复杂度为 $O(N)$ ,$N$ 为所有状态数