数位DP入门

5 篇文章 0 订阅

数位dp


数位dp是一种 模板性很强的,但又比较灵活的动态规划类型

其主要由两种实现方式,一种是预处理,一种是记忆化搜索.前者比较复杂,而且不直观,故我们在此仅讨论第二种情况

一般来说,此算法主要由两部分组成,一个solve()函数,一个dfs()函数.solve(n)主要是用于给出从[1,n]之间符合题目要求的数的个数/和/费用等,故求[m,n]之间符合的个数一般是由solve(n)-solve(m-1)给出,这也是前缀思想的一个巧妙应用.

我们直接上题目.

不要62(HDU 2089)

题目链接

总结来看,题目要求有两条

  1. 数字中没有4
  2. 数字中没有子串"62"

数位dp的思想是这样的,在求解solve(n)的时候,我们先将n分解成数组存储,比如a[]

此操作代码如下

// a[]是全局变量
int solve(int n) {
    int pos = 0;
    while (n) {
        a[pos++] = n % 10;
        n /= 10;
    }
    return dfs(pos - 1, -1, 0, true);
}

关于dfs()函数的设计,接口为int dfs(int pos, int pre, int sta, int limit);``````pos表示当前处理到第几位(自高向低),pre表示前一位的数字,sta表示状态,其表示的意义比较灵活,limit表示此位是否有上限,比如"253",第一位如果取2,那么第二位最多取5,如果第一位取1或0,那么第二位的上限是9

代码如下

// pos 位置   pre前一位  sta 状态   limit 此位有无限制
// 事实上sta是表示此位的前一位是0,
// 对于第一位来说,位置是pos-1,pre是-1,sta是否,
int dfs(int pos, int pre, int sta, int limit) {
    // 所有位数已经枚举完,直接返回
    if (pos < 0) // 这个地方就是递归的终点,在处理完所有的位数之后,向上一层返回1,也就是给上一层加一种情况
        return 1;
    // 此位有无限制? 若有,上限就是原数中对应的位数;若无,就是9(十进制)
    int up = limit ? a[pos] : 9;
    int ans = 0;
    for (int i = 0; i <= up; ++i) {
        // 此处操作保证枚举中一定不含4
        if (i == 4)
            continue;
        // 如果前一位是6,这一位就一定不能是2
        if (pre == 6 && i == 2)
            continue;
        // 前三个好理解:1.继续寻找下一位,2.当前位的取值即下一位的pre,3.还有此位是否是6
        // 如果此位是存在上限的,且转移时还取到了这个上限,那么下一位一定是有上限的,非常合理
        ans += dfs(pos - 1, i, i == 6, limit && i == a[pos]);
    }
    return ans;
}

其实这样的代码已经能保证这个题的正确性了,但是可以通过"背包问题"的优化看出来,这个算法复杂度很高,几乎相当于暴力穷举,而且产生了大量的重复运算,这也是引入动态规划的问题所在,也就是空间换时间的思想,我们将dfs的参数集合看做一种状态,我们可以在计算完dfs的结果后使用数组记录下来,如果之前计算过这种状态,那么就可以在O(1)的时间内得到答案,大大减少了状态数

综上所述,我们也得到了dp[][]的设计思路,只要让dfs的参数带入后可以确定唯一的状态,也就是维数==参数数量,但是!这样可能会带来空间的浪费,而且维数过多也会影响记忆化对整个算法的效果

以上文dfs代码为例,参数有 pos,pre,sta,limit四个参数,但仔细思考limit参数,会发现,如果此位是受限的,那么因此计算出的dp数组不具备通用性(因为每个数的相同位的上限不一样,比如213 和 235,计算235时就不能使用之前计算213的数据),所以我们只记录limit为否的情况(这里还有个隐藏的理论就是如果这一位无上限,那么后面的所有位都没上限),换句话说,我们就在维度中去掉了limit这个维度.

另外,关于pre这个参数,就是记录上一位的数值,这一位需要10单位的内存.我们发现,举个例子

23456 和 24456,在计算到第三位的时候,pre分别为3和4,但是都不为6,而且对后面的数值的选择无影响(动态规划的无后效性),换言之,这一位的设计只需要记录前一位是否是6即可,然后我们仔细观察源代码中pre唯一的用途也是判断其是否是6,所以我们就对齐改进(以sta代替),sta表示其前的一位是否是6,只有0,1两种取值

这样我们就确定了dp数组的两个维度:位数状态,

经过上述的讨论,dfs()函数应当由如下部分组成

  • 递归终点
  • 判断是否是之前算过的状态,如果有就直接返回对应的dp数组值
  • 根据limit确认上界
  • 枚举此位的所有可能取值,去除不可能的情况(比如此题的4,及"62"),向下递归
  • 更新dp数组
  • 返回结果

以下是完整的题解代码(已于杭电上通过此题)

// HDU 2089
#include <iostream>
#include <cstring>
using namespace std;

int dp[10][2]; // dp[i][j]的值表示当前第i位,在前一位是6(j==0时不是,j==1时是)的真假性为j的情况下,其包含的状态数
int a[10];

// pos 位置   pre前一位  sta 状态   limit 此位有无限制
int dfs(int pos, int sta, int limit) {
    // 所有位数已经枚举完,直接返回
    if (pos < 0)
        return 1;

    // 如果此位无限制,而且此种情况的pos已经记录完毕,就可以直接输出dp数组中的结果,不需要继续计算了
    if (!limit && dp[pos][sta] != -1)
        return dp[pos][sta];

    // 此位有无限制? 若有,上限就是原数中对应的位数;若无,就是9(十进制)
    int up = limit ? a[pos] : 9;
    int ans = 0;
    for (int i = 0; i <= up; ++i) {
        // 此处操作保证枚举中一定不含4
        if (i == 4)
            continue;
        // 如果前一位是6,这一位就一定不能是2
        if (sta && i == 2)
            continue;

        // 前三个好理解:1.继续寻找下一位,2.当前位的取值即下一位的pre,3.还有此位是否是6
        // 如果此位是存在上限的,且转移时还取到了这个上限,那么下一位一定是有上限的,非常合理
        ans += dfs(pos - 1, i == 6, limit && i == a[pos]);
    }
    if (!limit)
        dp[pos][sta] = ans;
    return ans;
}

int solve(int n) {
    int pos = 0;
    while (n) {
        a[pos++] = n % 10;
        n /= 10;
    }
    // pos-1表示位数
    // 就是指从最后一位开始计算,且状态为: 前一位不是6,当前位有上限
    return dfs(pos - 1, 0, true);
}

int main() {
    int n, m;
    memset(dp, -1, sizeof(dp));
    while (cin >> n >> m && (n || m)) {
        cout << solve(m) - solve(n - 1) << endl;
    }
    return 0;
}

B-number(HDU 3652)

题目链接

题意很简单,定义一种数(wqb-number),这种数中含有子串"13",而且可以被13整除,给你一个n(1<=n<=1e9),求[1,n]中这种数有多少

根据这数据范围,显然需要数位dp,含有"13"这个我们已经从上题中得到思路,只需要记录前一位或者记录状态就好,但是能被13整除的判断就需要另想办法

我们发现(a*10+b)%mod == ((a*10)%mod+b%mod)%mod == (((a%mod) * (10%mod))%mod+b%mod)%mod,也就是说一个数可以从高位至低位逐位取模然后累加.比如7654%10,先算7%10,得7,然后76%10 = ((7*10)%10 + 6%10))%10=6,然后再算765%10等等等等,算到最后,如果n%13==0,那么就能被13整除

除此之外还有一个小问题,就是在上文代码中,我们遇到"62"就直接跳过了,然而这个必须要有子串"13",所以还需要在处理的时候进行判断.我们定义一个状态sta,

  • sta为0: 在此位之前不存在"13",且上一位不是1
  • sta为1: 在此位之前不存在"13",且上一位是1
  • sta为2: 在此位之前已经存在"13"

也就是递归终止时判断sta是否为2以及mod(取余求和的结果)是否为0即可

综上所述,dp数组总共三维: 位置,余数,状态

代码如下

// HDU 3652 数位dp,求1~n中包含子串"13"且能被13整除的数的个数
#include <iostream>
#include <algorithm>
#include <cstring>

using namespace std;

int a[20];

int dp[20][15][4];

// 其实判断这个数能不能被13整除,只要把它前面每一位对13的余数都存下来,然后和下一位的进行计算取模(比如76%13 == ((7*10)%13 + 6%13)%13 == (7%13 * 10%13 + 6%13)),然后算完最后一位,如果是0,说明这个数能被13整除
// 然后一开始没注意这个题的要求,是既要能整除13,还含有13,整除十三上一段已经解释过了,问题还有如何记录这个是否达成了含有"13",实际上有三种状态,分别是
// 1.没达成,且前一位不是1
// 2.没达成,且前一位是1
// 3.已经达成了

int dfs(int pos, int mod, int sta, int limit) {
    if (pos < 0)
        return (!mod) && (sta == 2);
    if (!limit && dp[pos][mod][sta] != -1)
        return dp[pos][mod][sta];
    int up = limit ? a[pos] : 9;
    int ans = 0;

    for (int i = 0; i <= up; ++i) {
        int newSta = sta;
        if (sta != 2 && i == 1) { // 只要没达成"13",而且当前位是1,那么转移的状态也必然是1
            newSta = 1;
        } else if (sta == 1) { // 如果没有达成"13"且上一位是1
            if (i != 3) { // 这一位不是3,状态转移成0
                newSta = 0;
            } else { // 这一位是3,状态转移成2
                newSta = 2;
            }
        } else if (sta == 2) { // 目标在之前已经达成,所以向后传递的状态也是2
            newSta = 2;
        }
        ans += dfs(pos - 1, (mod * 10 + i) % 13, newSta, limit && i == a[pos]);
    }
    if (!limit) {
        dp[pos][mod][sta] = ans;
    }
    return ans;
}

int solve(int n) {
    if (n < 0)
        return 0;
    int pos = 0;
    memset(a, 0, sizeof(a));
    while (n) {
        a[pos++] = n % 10;
        n /= 10;
    }
    return dfs(pos - 1, 0, 0, true);
}

int main() {
    memset(dp, -1, sizeof(dp));
    int n;
    while (cin >> n) {
        cout << solve(n) << endl;
    }
    return 0;
}

Seven Segment Display(ZOJ 3962)

题目链接

此题给出显示每个数字所需要的能量,然后给一个n和一个数num(八位十六进制含前导零),问数字显示n秒(没过一秒数字增长1),所需要的能量

我们定义solve(n)表示从00000000(16进制)~n(10进制)所需要耗费的能量之和

此题相对于前面的题目其实是由个数变成了问总花费,而且需要考虑前导零

总花费的处理方式如下:在参数中新增一个sum,用以表示截止至(不包含)当前层,累计花费,然后在递归出口返回sum.

前导零处理方式如下:在solve()函数中补零,保证八位

dp数组有两维:位置,sum

代码如下:

// ZOJ 3962
#include <iostream>
#include <cstring>

#define ll long long
using namespace std;
ll dp[20][1005];
int a[20];
int num[16] = {6, 2, 5, 5, 4, 5, 6, 3, 7, 6, 6, 5, 4, 5, 5, 4};

// 深刻体会: 对于数位DP这种模板来说,其实去掉利用dp数组的剪枝和更新dp数组两步,对程序的正确性是没有任何影响的,只是会显著影响程序的运行时间,这也就是dp用空间换时间的巧妙之处

// 换言之,可以利用这个特点来分析出dp的维数和每一维代表的意义,首先,对于这个dfs程序来说,也就是三个变量,或者我们可以这样认为: 三个参数就可以确定一个状态,但是!数位dp的前提是当前位无限制,所以只要在limit为无限制是才更新数组的话,dp数组就只需要两维了,也就是"pos"与"sum",但是要注意到sum其实并不会很大,毕竟即使是全是8,也不过是8*7=56个,所以也不会很大

// 注意此处还有一个前提就是如果此位是无限制的,那么之后的位也一定是无限制的,这也是dp的一个递归的基础,可以稍后再画图证明此问题

// 最后也就是说,只需要知道当前是第几位,并且累积到上一位的sum是多少,且这一位取值没有限制,那么其向后扩展的总数(总代价) 是可以通过dp[pos][sum]直接获得的

ll dfs(int pos, int limit, long long sum) {
    if (pos < 0)
        return sum;
    if (!limit && dp[pos][sum] != -1)
        return dp[pos][sum];
    int up = limit ? a[pos] : 15;
    ll ans = 0;
    for (int i = 0; i <= up; ++i) {
        ans += dfs(pos - 1, limit && i == a[pos], sum + num[i]);
    }
    if (!limit)
        dp[pos][sum] = ans;
    return ans;
}

ll solve(ll n) {
    // 此处有个细节,这个数需要保证前导零,也就是说即使它不到0,也得补足八位,,而且这个a也是需要手动置零的,否则上一次的运算就会影响到它
    if (n < 0)
        return 0;
    int pos = 0;
    memset(a, 0, sizeof(a));
    while (n) {
        a[pos++] = static_cast<int>(n % 16);
        n /= 16;
    }
    return dfs(7, true, 0);
}


ll trans(const char *word, int len) { // 主函数中并没有采用这个,这样也是一种方法
    ll ans = 0;
    for (int i = 0; i < len; ++i) {
        if (word[i] >= '0' && word[i] <= '9')
            ans = ans * 16 + word[i] - '0';
        else
            ans = ans * 16 + word[i] - 'A' + 10;
    }
    return ans;
}

int main() {
    int T;
    ll n;
    memset(dp, -1, sizeof(dp));
    scanf("%d", &T);
    while (T--) {
        scanf("%lld", &n);
        --n;
        ll newnum;
        // 黑科技,直接输入大写16进制
        scanf("%llX", &newnum);
        ll newend = newnum + n;
        // 16^8==4294967296==0xffffffff+1
        if (newend <= 4294967295) { // 加入之后未溢出
            printf("%lld\n", solve(newend) - solve(newnum - 1));
        } else { // 溢出了,分开计算
            newend = newend % 4294967296;
            printf("%lld\n", solve((ll) 4294967295) - (solve(newnum - 1) - solve(newend)));
        }
    }

    return 0;
}

windy数(BZOJ 1026)

题目链接

题意非常简单

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

其实问题的关键是相邻的两个数,前一个数的取值会改变后一个数的取值范围(提高其下限),但问题的难点出现在第一个非零数的判断上,举个例子 1234567 其可能的取值是 0013579,问题在于前导零的标记问题,我们认为前导零标记的含义是:标记为真时,表示此位之前全是0,当且仅当标记为真且这一位也取零的话,这个标记会向下传递 ,

当然我们也可以让初始的pre为一个负数,当这个pre是负数时,这一位的最低取值就不受限制,如果取0,把pre向下传递即可,也算是一个小技巧

代码如下

// BZOJ 1026
#include <iostream>
#include <cstring>
#define ll long long
using namespace std;
int a[20];
ll dp[15][15];

int myAbs(int x) {
    if (x < 0)
        return -x;
    return x;
}

// 此题的唯一难点是初始条件的确定,比如输入100,其第一位很可能是0,也就是第二位不受第一位的限制(此处是指相差大于2),但是第二位也很有可能是0,那么这个关系很有必要继续下去.

// 所以我们先假设第一位的pre为-1(只要是负数就行,或者是0~9之外的其他数),然后特判,如果pre为-1,而且当前位是0,那么这个标记就要继续传下去,如果不是0就不用传,传当前数字就好

// 还有另一个细节是更新或者利用dp数组的时候,由于刚才的数组下标可能为负值,因此需要特判,在pre为负数的时候不进行更新和使用,避免越界.
ll dfs(int pos, int pre, int limit) {
    if (pos < 0)
        return 1L;
    // 注意这里判断pre>=0这个操作避免了数组越界情况的发生
    if (!limit && dp[pos][pre] != -1 && pre >= 0)
        return dp[pos][pre];
    int up = limit ? a[pos] : 9;
    ll ans = 0;
    for (int i = 0; i <= up; ++i) {
        if (myAbs(i - pre) < 2)
            continue;
        int cur = i;
        if (i == 0 && pre == -2)
            cur = pre;
        ans += dfs(pos - 1, cur, limit && i == a[pos]);
    }
    // 同理避免越界
    if (!limit && pre >= 0)
        dp[pos][pre] = ans;

    return ans;
}

ll solve(ll n) {
    if (n < 0)
        return 0L;
    int pos = 0;
    memset(a, 0, sizeof(a));
    while (n) {
        a[pos++] = static_cast<int>(n % 10);
        n /= 10;
    }
    return dfs(pos - 1, -2, true);
}

int main() {
    ll a, b;
    memset(dp, -1, sizeof(dp));
    while (cin >> a >> b) {
        cout << solve(b) - solve(a - 1) << endl;
    }
    return 0;
}

注意事项

  • a[]数组的归零,至少要保证dfs计算的位数都被覆盖了(比如刚才那个8位的,就必须把八位中未赋值的变成0,否则就会影响结果)
  • dp数组的初始化,初始化成-1和0均可,一般是-1
  • solve()函数的区间是闭区间
  • 0
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值