初学数位DP

所谓数位dp,字面意思就是在数位上进行dp,数位的含义:一个数有个位、十位、百位、千位、等等,数的每一位就是数位。
数位DP一般应用于:
求出给定区间[A,B]内,符合条件P[i]的数 i 的个数。
条件P[i]一般与数的大小无关,而与 数的组成 有关
其实质就是换一种暴力枚举的方式,使得新的枚举方式满足dp的性质,然后记忆化就可以了。
 
参考来自以下链接
 

 
HDU 2089为前提讨论,题目大意为,多组数据,每次给定区间[n,m],求在n到m中没有"62"或"4"的数的个数。
试想:我们如果能有一个函数count(int x),可以返回[0,x]之间符合题意的数的个数。那么是不是直接输出count(m) - count(n-1)就是答案?
 
DP老规矩,先开一个二维数组f[][]。(dp的本质就是空间换时间)
f[i][j],表示 i 位数,最高位是 j 的数,符合题意的数有多少个。比如说f[1][2]=1;f[1][4]=0; f[2][6]=8;(60,61,63,64,65,66,67,68,69)。
 
我们先不关注这个 f 有什么用,我们先关注这个 f 如何来求。首先f[1][i] = 0;(if i==4),f[1][i]=1;(if i!=4)(0<= i <=9)。
以上这步是很显然的,那么根据这个题的数据范围,只需要递推到f[7][i]就行了。那么稍微理解一下就可以想出递推式:
if (j == 4) f[i][j] = 0;
else if (i != 6)f[i][j] = Σf[i - 1][j] (j = 0, 1, 2, 3, 5, 6, 7, 8, 9);
else if (i == 6)f[i][j] = Σf[i - 1][j] (j = 0, 1, 3, 5, 6, 7, 8, 9);
 
 
这里需要说明一下的是关于首位为 0 的情况,认为00052是长度为5,首位为0的符合条件的数,052是长度为3首位为0符合条件的数,自己在手推的时候就把0去掉了,怎么都看不出递推的规律。
 
那么我们现在得到了f数组,在重申一下它的定义:f[i][j]表示 i 位数,最高位是 j 的数,符合题意的数有多少个。
 

 
第二步就要关注怎么利用f数组做出上面我们说的那个函数count(int x),他们可以求出[0,x]中符合题意的数有多少个。
 
那么我们做一个函数int solve(int x) ,它可以返回[0,x)中符合题意的有多少个。那么solve(x+1)实际上与count(x)是等价的。
那么现在问题转化成了:小于x的,符合题意的数有多少个?
既然小于,从最高为开始比,必定有一位要严格小于x(前面的都相等)。所以我们就枚举哪一位严格小于(前面的都相等)。假设我们把x分为a1,a2,a3,.....,aL这样一个数组,长度为L,最高位为aL。
那么结果实际上就是这样:长度为L,最高位取[0,aL - 1]的所有符合题意的数的和,然后再加上长度L-1,最高位为[0,aL-1 - 1]的数,一直累加到最后长度为1或者碰到了4或该位是2上一位是6的情况也可以停止🛑。
 
#include<cstring>
#include<iostream>
#include<algorithm>
using namespace std;
const int maxn = 5e3 + 10;
int dp[10][10], n, m;
int bit[10];//存每一位的值
void init(){
     dp[0][0] = 1;
     for (int i = 1; i <= 7; i++){
           for (int j = 0; j <= 9; j++){
                if (j == 4)continue;
                for (int k = 0; k <= 9; k++){
                      if ((j == 6 && k == 2) || k == 4)continue;
                      dp[i][j] += dp[i - 1][k];
                }
           }
     }
}
int solve(int len){
     int ans = 0;
     bit[len + 1] = 0;
     for (int i = len; i; i--){
           for (int j = 0; j < bit[i]; j++){
                if (j == 2 && bit[i + 1] == 6)continue;
                ans += dp[i][j];
           }
           if (bit[i] == 4 || (bit[i] == 2 && bit[i + 1] == 6))break;
     }
     return ans;
}
int main(){
     init();
     while (cin >> n >> m, n&&m){
           m += 1;//由于solve(int x)只计算[1,x)
           int t1 = n, t2 = m, l1 = 0, l2 = 0;
           while (t1){
                bit[++l1] = t1 % 10;
                t1 /= 10;
           }
           t1 = solve(l1);
           while (t2){
                bit[++l2] = t2 % 10;
                t2 /= 10;
           }
           t2 = solve(l2);
           cout << t2 - t1 << endl;
     }
     return 0;
}
 

 

 
 
而且很欣慰的一点就是,这里不用顾虑之前所说前导零00052和052是不用的数了!
👆👆👆👆👆👆以上是被人认为较难理解的递推数位DP解法👆👆👆👆👆👆
👇👇👇👇👇👇   以下将记录记忆化搜索的数位DP解法    👇👇👇👇👇👇
 
刚开始学习的看的博主写的不是很清楚,叫人自己看代码理解,恕我自言,我只是个弱鸡。导致我以为记忆化搜索很难理解,留下了心理阴影。。。
 
dfs的数位DP感觉真的有点像暴力枚举,但是高效了很多
我们来看最简单的枚举:
for (int i = le; i <= ri; i++){
     if (right(i))ans++;
}

 

 
这种枚举应该很好理解它是如何超时的吧,
我们再来看数位DP的枚举:控制上界枚举,从高位开始往下枚举,例如:ri=213,那么我们从百位开始枚举,百位的可能情况有0,1,2。
(又又出现了0的情况,突然害怕了起来😭)然后每一位枚举都不能让枚举的这个数超过上限213(下届就是0或者1,这个次要),当百位枚举了1,那么十位就是从0到9,因为百位1已经比上届2小了,后面数位枚举什么都不会超过上界。所以问题就在于:当高位枚举刚好达到上届时,那么紧接着的那一位枚举就有上届限制了。具体如这里假设百位枚举了2,那么十位能枚举的情况只有0到1,如果前两位枚举了21,最后一位只能是0到3(这一点这个好对于代码模板里的一个变量limit 专门用来判断枚举范围)。最后一个问题就在于:最高位枚举0,百位枚举0——相当于此时我们枚举的这个数最多是两位数,是有效数;如果十位继续枚举0,那么我们枚举的也是有效的数,因为我们要枚举的是小于等于ri的所有数,所有就不用再去考虑前导0的问题了!
 
 
下面是HDU 2089的另一个版本的题解 
这边的dp数组表示 dp[pos][sta]表示当前第pos位,前一位是否为6的状态,对于需要对limit进行判断在记忆化,这里我还是推荐看第三个链接的原题,解释的很好。
#include<cstring>
#include<iostream>
#include<algorithm>
using namespace std;
#define ll long long
const int maxn = 5e3 + 10;
int a[20], dp[20][2];
int dfs(int pos, int pre, int sta, bool limit){
     if (pos == -1)return 1;
     if (!limit  && dp[pos][sta] != -1)return dp[pos][sta];
     int up = limit ? a[pos] : 9;
     int tmp = 0;
     for (int i = 0; i <= up; i++){
           if (pre == 6 && i == 2)continue;
           if (i == 4)continue;
           tmp += dfs(pos - 1, i, i == 6, limit&&i == a[pos]);
     }
     if (!limit)dp[pos][sta] = tmp;
     return tmp;
}
int solve(int x){
     int pos = 0;
     while (x){
           a[pos++] = x % 10;
           x /= 10;
     }
     return dfs(pos - 1, -1, 0, true);
}
int main(){
     int le, ri;
     //memset(dp, -1, sizeof dp);真的真的可优化很多!!
     while (cin >> le >> ri, le&&ri){
           memset(dp, -1, sizeof dp);
           cout << solve(ri) - solve(le - 1) << endl;
     }
     return 0;
}
 
 

 

 
还有要讲的就是关于优化!
 
第一点真的是神奇中的神奇!——memset(dp,-1,sizeof dp);放到多组数据的外边!
我们先来看运行效果的差距,注意时间!优化后简直无敌!!
 
这一点是一个数位特点,使用条件是约束条件是每个数自身的属性,与输入无关!看起来真的好深奥的一句话
 
第二相减??
大佬的思路有点跟不上了,所有先来做两个题进行巩固一下,上面简单的运用。
 
 
实例操作
51NOD  这个题我觉得自己写一下之后能比较好的理解dp这个数组 
题意大概就是说给定一个十进制的数N,在1-N所有的正数当中含有多少个1;
例如:n = 12,包含了5个1。1,10,12共包含3个1,11包含2个1,总共5个1。
 
分析:按照记忆化搜索的解法来的话,拿样例12老说,就是第一位从0遍历到1,0的时候是没有限制的,所有第0位可以是0到9,直到遍历到-1位就结束;第一位遍历1的时候,第0位有了限制,只能从0遍历到2,一直遍历到-1位结束;我们要记录的是1的个数,所有我们用一个变量ans来存上一层中遍历了多少个1。然后单纯的这样遍历的话,我提交试了一发,当然是超时了!因为没有用到我们记忆化操作的dp数组!从这里就可以很清楚的理解到我们的dp是以空间来换时间的操作。dp[pos][ans]记录的是,没有限制的情况下,在第pos位,上一层传过来的是ans个1时候的状态。
注意一下那个第一种优化的情况哦!真的能优化很多,如下图,下面那个是优化的情况!
 
 
下面当然是AC代码啦
#include<iostream>
#include<cstdio>
#include<cstring>
#include<string>
using namespace std;
typedef long long ll;
int a[20];
int dp[20][15];
int dfs(int pos, int ans, bool limit)
{
     //cout << a[pos] << " " << pos << " " << ans << " " << limit << " " << endl;
     if (pos == -1) return ans;;
     if (!limit && dp[pos][ans] != -1) return dp[pos][ans];
     int up = limit ? a[pos] : 9;
     int tmp = 0;
     for (int i = 0; i <= up; i++)
     {
           tmp += i == 1 ? dfs(pos - 1, ans + 1, limit && i == a[pos]) : dfs(pos - 1, ans, limit&&i == a[pos]);
     }
     if (!limit) dp[pos][ans] = tmp;
     return tmp;
}
int solve(int x)
{
     int pos = 0;
     while (x)
     {
           a[pos++] = x % 10;
           x /= 10;
     }
     return dfs(pos - 1, 0, true);
}
int main()
{
     int le, ri;
//    memset(dp, -1, sizeof dp);//可优化
     while (~scanf("%d", &ri)){
           memset(dp, -1, sizeof dp);
           printf("%d\n", solve(ri));
     }
     return 0;
}

 

 
递递推版本的题解,恕我能力有限,还是记忆化搜索真的好理解一点,/比心❤
 
51NOD 1042 数字0-9的数量 这个题我的理解是上面那个题的升级版,因为需要考虑前导0的情况了!
题目的大概意思和上面一样,不过这一次需要统计0到9的个数,注意哦,有0!
分析:我1-9直接套用了上面的代码,然后单独处理了0;dfs的时候记录一下是否是前导零,满蠢的方法没啥讲的;
注意数据的范围!我用int后面DEBUG了半天,要死了!
#include<iostream>
#include<cstdio>
#include<cstring>
#include<string>
using namespace std;
typedef long long ll;
int a[20];
ll dp[10][20][10000];
ll cou[20];
ll dfs(int pos, int num, int ans, bool limit)
{
     //cout << a[pos] << " " << pos << " " << ans << " " << limit << " " << endl;
     if (pos == -1) return ans;
     if (!limit && dp[num][pos][ans] != -1) return dp[num][pos][ans];
     int up = limit ? a[pos] : 9;
     ll tmp = 0;
     for (int i = 0; i <= up; i++)
     {
           tmp += i == num ? dfs(pos - 1, num, ans + 1, limit && i == a[pos]) : dfs(pos - 1, num, ans, limit&&i == a[pos]);
     }
     if (!limit) dp[num][pos][ans] = tmp;
     return tmp;
}
ll dfs_zero(int pos, bool lead, ll ans, bool limit)
{
     //cout << pos << " " << lead << " " << ans << " " << limit << " " << endl;
     if (pos == -1) return ans;
     if (!limit && !lead && dp[0][pos][ans] != -1) return dp[0][pos][ans];
     
     int up = limit ? a[pos] : 9;
     ll tmp = 0;
     for (int i = 0; i <= up; i++){
           if (!lead){
                tmp += i == 0 ? dfs_zero(pos - 1, lead, ans + 1, limit && i == a[pos]) : dfs_zero(pos - 1, lead, ans, limit&&i == a[pos]);
           }
           else{
                if (i == 0)tmp += dfs_zero(pos - 1, lead, ans, limit && i == a[pos]);
                else tmp += dfs_zero(pos - 1, !lead, ans, limit && i == a[pos]);
           }
     }
     if (!limit&&!lead) dp[0][pos][ans] = tmp;
     return tmp;
}
int solve(ll l, ll r)
{
     int pos = 0;
     while (r){
           a[pos++] = r % 10;
           r /= 10;
     }
     cou[0] = dfs_zero(pos - 1, true, 0, true);
     for (int i = 1; i <= 9; i++){
           cou[i] = dfs(pos - 1, i, 0, true);
     }
     pos = 0;
     while (l){
           a[pos++] = l % 10;
           l /= 10;
     }
     cou[0] -= dfs_zero(pos - 1, true, 0, true);
     for (int i = 1; i <= 9; i++){
           cou[i] -= dfs(pos - 1, i, 0, true);
     }
     
     for (int i = 0; i <= 9; i++){
           cout << cou[i] << endl;
     }
     return 0;
}
int main()
{
     ll le, ri;
     memset(dp, -1, sizeof dp);//可优化
     while (~scanf("%lld%lld", &le, &ri)){
           solve(le - 1, ri);
     }
     return 0;
}

 

 
果然数位DP还是DP,需要找到状态,以及状态转移的方程,看到感觉是规律题,不要再手写打表了!
51 NOD 幸运数字 这个应该说是个DP题,但是确实用到了数位的思想;
分析:我们需要把2*N长度分为两段;第一段N是不能有前导零的,而第二段带有前导零;现在我们先来求带有前导零的方案数。
这里dp[pos][sum] pos表示的当前有是第几位数理解为有个位数也行,sum表示这几位数相加的和的值(假如有两位的话,取值范围是0到18);初始化当只有一位数的时候,dp[1][0-9]都是等于1,这个很好理解;然后我们思考一下就能得到:pos+1的sum能由pos的sum以及前九位得到;
这样就解决了带有前导零的情况,不带前导零的情况怎么解决了,手写一下就看到了规律;这个方案数直接就是dp[pos][sum]-dp[pos-1][sum];也就是在减去了当第一位为0时,和为sum的方案数;
 
#include<cstdio>
#include<iostream>
using namespace std;
#define ll long long
const int  mod = 1e9 + 7;
int dp[1005][9009];
int main(){
     
     memset(dp, 0, sizeof dp);
     int n; cin >> n;
     dp[0][0] = 1;
     for (int i = 1; i <= n; i++){
           dp[i][0] = 1;
           for (int j = 1; j <= 9 * i; j++){
                if (i == 1)dp[i][j] = 1;
                else {
                      for (int k = j - 9; k <= j; k++){
                           if (k >= 0)dp[i][j] = (dp[i][j] + dp[i - 1][k]) % mod;
                      }
                }
                
                //else if (j <= 9)dp[i][j] = (1LL * dp[i][j - 1] + dp[i - 1][j]) % mod; //这样写溢出了
                //else dp[i][j] = (1LL * dp[i][j - 1] + dp[i - 1][j] - dp[i - 1][j - 10]) % mod;
           }
     }
     ll sum = 0;
     for (int i = 0; i <= 9 * n; i++){
           sum = (sum + 1LL * dp[n][i] * (dp[n][i] - dp[n - 1][i]) % mod) % mod;
     }
     cout << sum << endl;
     return 0;
}
 

 

 
 
 
 
 
 
 
posted @ 2018-08-24 16:37  我只有一件白T恤  阅读(277)  评论(0编辑  收藏  举报