数位dp
数位dp是一种 模板性很强的,但又比较灵活的动态规划类型
其主要由两种实现方式,一种是预处理,一种是记忆化搜索.前者比较复杂,而且不直观,故我们在此仅讨论第二种情况
一般来说,此算法主要由两部分组成,一个solve()函数,一个dfs()函数.solve(n)主要是用于给出从[1,n]
之间符合题目要求的数的个数/和/费用等,故求[m,n]
之间符合的个数一般是由solve(n)-solve(m-1)
给出,这也是前缀思想的一个巧妙应用.
我们直接上题目.
不要62(HDU 2089)
总结来看,题目要求有两条
- 数字中没有4
- 数字中没有子串"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()函数的区间是闭区间