HDU 数位dp七连,入门数位dp qwq
数位dp,顾名思义就是对数逐位分析的进行的dp。
数位dp的特点是:高维、小上界、常常采用记忆化搜索而不是循环递推进行dp、常以区间计数方式呈现。
因此数位dp有两个很通用的记忆化搜索板子(当然,dp数组不一定只有二维,dfs参数也不一定就只有仨;State也不一定就是bool,而一般是枚举类型。下面只是为了方便展示架构才这样写):
vint dfs(const int len, const State sta, const bool lim)
{
if (!len)
return sta == GOAL_STA; // 判断sta是否==满足条件的枚举值,满足则返回1,否则返回0
if (!lim && ~dp[len][sta]) // 非限制情况下,如果已经记忆,则直接返回。有限制的时候相当于得特判。
return dp[len][sta];
vint sum = 0;
const int upper_bound = lim ? num[len] : 9; // 确定这一位的上界。如果受限,那么上界是区间上界对应位置的数。否则是9
for (int x=0; x<=upper_bound; ++x)
{
State next;
/*
...
...
(根据当前状态sta和当前位x 推出下一状态next)
*/
sum += dfs(len-1, next, lim && x==ub); // 传递受限:本层受限且本层选的数达到上界时,下层才受限
}
return lim ? sum : dp[len][sta] = sum; // 非限制情况下才可记忆化。有限制的时候相当于得特判。
}
反搜只能用于【当搜到第k位发现满足条件时,无论之后第k-1到第1位再填什么都仍然会保持满足条件】的这种情况,否则反搜的粗剪枝将会出错(第k位满足而之后填数后又不满足了,剪枝剪多了)
例如像出现连续数字这种,就可以反搜;像能整除、求数位和这种,就不能(要把一切数位都给确定了才可判定,不可过早剪枝)
vint dfs(const int len, const State sta, const bool lim)
{
if (!len)
return 1; // 反搜直接返回1,因为上一层循环没continue说明这里必有贡献1
if (!lim && ~dp[len][sta]) // 非限制情况下,如果已经记忆,则直接返回
return dp[len][sta];
vint sum = 0;
const int upper_bound = lim ? num[len] : 9; // 确定这一位的上界
for (int x=0; x<=upper_bound; ++x)
{
State next;
/*
...
...
(根据当前状态sta和当前位x 推出下一状态next)
*/
if (next == GOAL_STA) // 满足题给条件,直接剪枝,不予统计
continue;
sum += dfs(len-1, next, lim && x==ub);
}
return lim ? sum : dp[len][sta] = sum; // 非限制情况下才可记忆化
}
虽然有正反搜两种记忆化搜索方式,但他们的计数逻辑本质是一样的:所有的贡献都是在搜到个位数(len==0)时产生的(因为只有搜到个位数位置了,整个数才能确定),然后再不断回滚、积累从而得到最后答案(最后答案倒是可能很大,但都是从最底层的那些1叠加起来的)。
那正搜反搜的差别主要在哪呢?主要体现在:
计数逻辑区别:
正搜搜到个位数时不一定会返回 1 1 1:只有此时的 sta \text{sta} sta 是目标状态时,这一次 len==0 \text{len==0} len==0 才可返回 1 1 1。
反搜搜到个位数时一定返回 1 1 1:因为不需要统计的情况已经 continue \text{continue} continue 了。只要在没 continue \text{continue} continue 的情况下递归进入了 len==0 \text{len==0} len==0 的情况,就必定要计这个 1 1 1。
总结就是:正搜不断记录、传递状态,到 len==0 \text{len==0} len==0 时只有当前状态是目标状态才 return 1 \text{return 1} return 1;而反搜每次循环时会判断是否满足题给特殊要求,满足就 continue \text{continue} continue 跳过(剪枝),所以 len==0 \text{len==0} len==0 时必返回 1 1 1(也正是由于这个区别,有时候反搜只要一个 bool \text{bool} bool 表示状态就足够去判断该不该 continue \text{continue} continue 了,而正搜还是需要把状态记录地更详细些。如下面第一道例题,反搜只要用一个 bool \text{bool} bool 表示刚才填的位是不是 4 4 4 就够了,而正搜需要有三种状态:{没出现49且末尾不是4、没出现49且末尾是4、出现过49} )
初始化区别:
TLE
)然后具体解题步骤大概是,先获得区间上界 N N N,然后把它逐位分解。然后直接上 dfs \text{dfs} dfs 即可。
注意调用 dfs \text{dfs} dfs 时 lim \text{lim} lim 参数必须传 true \text{true} true,且存储 N N N 各个数位的数组要是全局的。这样才可以正常引发后续的 lim \text{lim} lim 传递。
下面是一些例题(七道)。
求 [ 0 , N ] [0, N] [0,N] 中含有相邻 49 49 49 的数字个数
相邻特定数字,可以正搜,可以反搜。
正搜:
/*
http://acm.hdu.edu.cn/showproblem.php?pid=3555
正搜:搜满足题给特殊条件的数的个数。然后直接输出搜出的结果。
反搜:搜不满足题给特殊条件的数的个数。然后输出 N减去搜到的结果。
【注意】:当题给特殊条件需要把整个数确定下来(精确到个位)时才能判定是否满足的时候,
只能用正搜,不能用反搜!(因为反搜的continue是粗剪枝。比如本题,一出现49就可以剪枝了,但如果是其他条件(比如%13==0就去掉)就不能在某层rr==0时剪一大枝,因为整个数都没确定呢!再递归下去rr可能还会变化!有可能又不满足条件了!但出现49不一样,只要一出现,不管后面数位填什么都不会改变已经出现的事实)
换句话说,如果处理到第k位时发现某个条件满足,且不管第k+1到len位再填什么这个条件仍然都会满足,则可用反搜,否则只能用正搜。
正反搜的相同点:
正反搜计数时,所有的贡献都是在搜到某个个位数时产生的(因为只有搜到个位数位置了,整个数才能确定)。
然后再不断回滚、积累从而得到最后答案(最后答案倒是可能很大,但都是从最底层的那些1叠加起来的)。
正反搜的区别:
①计数逻辑区别:
1. 正搜搜到个位数时不一定会返回1:
如本题,如果此时的sta说明曾经有49出现,那么这一次len==0时才可返回1。
2. 反搜搜到个位数时一定返回1:
因为不需要统计的情况已经continue了。只要在没continue的情况下递归进入了len==0的情况,就必定要计这个1。
总结就是:正搜不断记录、传递状态,到len==0时如果状态满足要求就记1;而反搜每次循环时会判断是否满足题给特殊要求,满足就continue,所以len==0时必返回1
(也正是由于这个区别,有时候反搜只要一个bool表示状态就足够去判断该不该continue了,而正搜还是需要记录更详细的状态。如本题正搜需要记录三种状态:{没出现49且末尾不是4、没出现49且末尾是4、出现过49})
②初始化区别:
1. 正搜dp数组值为0的状态也是经过搜索得到的、有意义、需要记忆的值。所以一开始要把dp全赋为-1,然后当 !lim && dp[][]!=-1 时就返回dp[][]。
2. 而反搜,在大多数题目中,dp[][]为0的值都是无意义的(dp[1][...]一般不会为0吧,要不就说明0123456789全都被筛掉了...而dp[2][...]就更不可能为0了,不然二位数也全被筛掉了))
所以反搜一般可以不初始化dp[][]为-1。然后 !lim && dp[][] 时就返回 dp[][],!lim && !dp[][] 时就计算dp[][]然后再记忆。
(不过为了保险起见,最好还是认为反搜时dp为0状态也是有意义的。否则万一dp[][]在本题==0也是可能的,但还是认为它无意义,那就会多出一些重复的计算,可能导致TLE)
以下是正搜的代码:
*/
#include <cstdio>
#include <cstring>
#define FD(i, n) for(int i=0; i<=n; ++i)
#define MB 21
using LL = long long;
enum Sta
{
NO, P4, YES, SNT // 本题正搜的三种状态:NO--没出现49且末尾不是4、P4--没出现49且末尾是4、YES--出现过49
};
LL dp[MB][SNT];
int num[MB];
inline int scan(LL x)
{
int top = 0;
do num[++top] = x % 10;
while (x /= 10);
return top;
}
LL dfs(const int len, const Sta sta, const bool lim)
{
if (!len)
return sta == YES; // 正搜,曾出现过49才能返回1
if (!lim && ~dp[len][sta])
return dp[len][sta];
LL sum = 0;
const int ub = lim ? num[len] : 9;
FD(x, ub)
{
Sta next; // 在本题,正搜传递sta的逻辑比反搜稍复杂一些。
if (sta==YES || (sta==P4&&x==9))
next = YES;
else if (x==4)
next = P4;
else
next = NO;
sum += dfs(len-1, next, lim && x==ub);
}
return lim ? sum : dp[len][sta] = sum;
}
int main()
{
memset(dp, -1, sizeof(dp));
int T;
long long x;
scanf("%d", &T);
while (T--)
{
scanf("%lld", &x);
printf("%lld\n", dfs(scan(x), NO, true)); // 正搜,直接输出答案
}
return 0;
}
反搜:
/*
http://acm.hdu.edu.cn/showproblem.php?pid=3555
以下是反搜的代码:(本题可以用反搜)
*/
#include <cstdio>
#include <cstring>
#define FD(i, n) for(int i=0; i<=n; ++i)
#define MB 21
using LL = long long;
LL dp[MB][2];
int num[MB];
inline int scan(LL x)
{
int top = 0;
do num[++top] = x % 10;
while (x /= 10);
return top;
}
LL dfs(const int len, const bool prev4, const bool lim)
{
if (!len)
return 1; // 反搜,无脑返回1
if (!lim && ~dp[len][prev4])
return dp[len][prev4];
LL sum = 0;
const int ub = lim ? num[len] : 9;
FD(x, ub)
{
if (prev4 && x==9) // 上一位4这一位9,所以剪枝,跳过不统计
continue;
sum += dfs(len-1, x==4, lim && x==ub);
}
return lim ? sum : dp[len][prev4] = sum;
}
int main()
{
memset(dp, -1, sizeof(dp));
int T;
long long x;
scanf("%d", &T);
while (T--)
{
scanf("%lld", &x);
printf("%lld\n", x+1 - dfs(scan(x), false, true)); // 反搜,容斥原理减一减
}
return 0;
}
求 [ L , R ] [L, R] [L,R] 不是不吉利的数字的个数。
一个数字是不吉利的当且仅当它含有连续的 62 62 62 或者含有 4 4 4。
相邻特定数字,可以正搜,可以反搜。
但反搜方便,所以就只写反搜啦~
#include <cstdio>
#define FD(i, n) for(int i=0; i<=n; ++i)
#define MB 8
int dp[MB][2];
int sta[MB], top;
inline void scan(int x)
{
top = 0;
do sta[++top] = x % 10;
while (x /= 10);
}
int dfs(const int len, const bool prev6, const bool lim)
{
if (!len)
return 1;
if (!lim && dp[len][prev6])
return dp[len][prev6];
int sum = 0;
const int ub = lim ? sta[len] : 9;
FD(cur, ub)
{
if (cur == 4)
continue;
if (prev6 && cur == 2)
continue;
sum += dfs(len-1, cur==6, lim && cur==ub);
}
if (!lim)
dp[len][prev6] = sum;
return sum;
}
int main()
{
int l, r, sum_l, sum_r;
while (scanf("%d %d", &l, &r), l||r)
{
scan(l-1);
sum_l = dfs(top, false, true);
scan(r);
sum_r = dfs(top, false, true);
printf("%d\n", sum_r-sum_l);
}
return 0;
}
求 [ 0 , N ] [0, N] [0,N] 是 B-number \text{B-number} B-number 数字的个数。
一个数字是 B-number \text{B-number} B-number 当且仅当它含有连续的 13 13 13 且能整除 13 13 13。
只能正搜。
因为这里涉及精确取模,需要每位都确定了才可判定。
另外传递状态的时候,取模余数的传递得稍微推导一下。
#include <cstdio>
#include <cstring>
#define FD(i, n) for(int i=0; i<=n; ++i)
#define MB 11
#define MOD 13
enum Sta
{
NO, P1, YES, SNT
};
int dp[MB][MOD][SNT];
int num[MB];
inline int scan(int x)
{
int top = 0;
do num[++top] = x % 10;
while (x /= 10);
return top;
}
int dfs(const int len, const int r, const Sta sta, const bool lim)
{
if (!len)
return sta==YES && r==0; // 出现13,且除尽了
if (!lim && ~dp[len][r][sta])
return dp[len][r][sta];
int sum = 0;
const int ub = lim ? num[len] : 9;
FD(x, ub)
{
const int rr = (r*10 + x) % MOD; /* e.g. top=5, len=1, 那么现在这个数是 S_now == abcd * 10 + x
记 S_prev == abcd, 则 S_prev 可写成 k*13 + r 的形式
则 S_now == S_prev*10 + x == (k*13 + r)*10 + x
S_now % 13 == ((k*13 + r)*10 + x) % 13
== (k*13*10 + r*10 + x) % 13
== 0 + (r*10+x) % 13
== (r*10+x) % 13
整个传递过程有点像秦九韶,是吧
*/
Sta next;
if (sta==YES || (sta==P1&&x==3))
next = YES;
else if (x==1)
next = P1;
else
next = NO;
sum += dfs(len-1, rr, next, lim && x==ub);
}
return lim ? sum : dp[len][r][sta] = sum;
}
int main()
{
memset(dp, -1, sizeof(dp));
int x;
while (~scanf("%d", &x))
printf("%d\n", dfs(scan(x), 0, NO, true));
return 0;
}
求 [ L , R ] [L, R] [L,R] (均 ≤ 1 0 18 \le 10^{18} ≤1018)中有多少个数,其数位和能够被 10 10 10 整除( 0 0 0 当然也能被 10 10 10 整除)。
数位和,只能正搜。
【CGWR①】关于dp数组要不要取模维、要不要数位和维:请见以下代码注释
/*
数位之和整除某个数(如本题,数位和整除10):dp数组要有数位和那一维(因为涉及到数位和了)
但dp数组不要加取模结果那一维(不是数值取模,故不需要额外记录取模结果,也不需要用秦九韶传递取模结果)
dfs传递数位和,当!len时数位和若整除某个数则返回1
数字本身整除某个数(如HDU3652 含13且整除13):dp数组要加模结果那一维作为记录,并且要用秦九韶传递模结果
dfs传递模结果,当!len时模结果如果==0则返回1
数字本身整除数位和(如HDU4389 整除自己数位和):那都要记录了。dp数组既要记数位和,又要记模结果(实际上为了枚举还要记目标和,dp有四维)
*/
#include <iostream>
#include <cstring>
#define MB 20
#define MS 162
#define MOD 10
using vint = long long;
int num[MB];
template <typename T>
inline int scan(T x)
{
int top = 0;
do num[++top] = x % 10;
while (x /= 10);
return top;
}
vint dp[MB][MS+1];
vint dfs(const int len, const int d_sum, const int lim)
{
if (!len)
return d_sum % MOD == 0;
if (!lim && ~dp[len][d_sum])
return dp[len][d_sum];
vint sum = 0;
const int ub = lim ? num[len] : 9;
for (int x=0; x<=ub; ++x)
sum += dfs(len-1, d_sum+x, lim&&x==ub);
return lim ? sum : dp[len][d_sum] = sum;
}
template <typename T>
inline constexpr T count(const T N)
{
return N<0 ? 0 : dfs(scan(N), 0, true);
}
int main()
{
std::ios::sync_with_stdio(false), std::cin.tie(nullptr), std::cout.tie(nullptr);
memset(dp, -1, sizeof(dp));
int T;
vint L, R;
std::cin >> T;
for (int i=1; i<=T; ++i)
{
std::cin >> L >> R;
std::cout << "Case #" << i << ": " << count(R) - count(L-1) << '\n';
}
return 0;
}
求 [ L , R ] [L, R] [L,R] (均 ≤ 1 0 9 \le 10^9 ≤109)中有多少个数,其能够被其数位和整除。
又是取模,只能正搜。
注意到数位和最大不超过 81 81 81,所以枚举数位和 [ 1 , 81 ] [1, 81] [1,81] 进行数位 dp \text{dp} dp 统计。
(不必去重,因为不可能有数在多次枚举情况下同时满足整除条件,不然它就有多个互不相等的数位和了…)
#include <cstdio>
#include <cstring>
#define MAX_DIGIT 10
#define MAX_D_SUM 81
#define MAX_MOD MAX_D_SUM
#define MAX_RE MAX_MOD
int num[MAX_DIGIT+1];
inline int scan(int x)
{
int top = 0;
do num[++top] = x % 10;
while (x /= 10);
return top;
}
int goal_d_sum;
int dp[MAX_DIGIT+1][MAX_D_SUM+1][MAX_MOD+1][MAX_RE]; // dp[位数][位数和][模数(模的是目标数位和)][余数]
// 注意不同的goal_d_sum之间是不会影响的(同一批dfs共用一个goal_d_sum)所以不用担心不同批的dfs会交叉影响而不能正常完成记忆化功能。
int dfs(const int len, const int d_sum, const int r, const bool lim) // 只能正搜,因为涉及精确取模
{
if (!len)
return d_sum==goal_d_sum && r==0;
if (!lim && ~dp[len][d_sum][goal_d_sum][r])
return dp[len][d_sum][goal_d_sum][r];
int sum = 0;
const int ub = lim ? num[len] : 9;
for (int x=0; x<=ub; ++x)
sum += dfs(len-1, d_sum+x, (r*10+x) % goal_d_sum, lim&&x==ub);
return lim ? sum : dp[len][d_sum][goal_d_sum][r] = sum;
}
int count(const int N)
{
int len = scan(N), sum = 0;
for (goal_d_sum=1; goal_d_sum<=MAX_MOD; ++goal_d_sum) // 枚举所有可能的目标数位和,然后把答案全部加起来
sum += dfs(len, 0, 0, true); // 同一批dfs共用一个goal_d_sum
// 虽然枚举goal_d_sum遍历了MAX_MOD次,但彼此之间没有重复统计(因为一个数的数位和是唯一的,所以MAX_MOD次循环中不会对某个数统计多次)
// 所以sum不用再减什么了(另一道题(3709平衡数)也枚举统计了,但那道题的多次枚举会把"0"这个数多次统计,所以那道题需要去重)
return sum;
}
int main()
{
memset(dp, -1, sizeof(dp));
for (int T, i=scanf("%d", &T), L, R; i<=T && scanf("%d %d", &L, &R); ++i)
printf("Case %d: %d\n", i, count(R) - count(L-1));
return 0;
}
求 [ L , R ] [L, R] [L,R] (均 ≤ 1 0 18 \le 10^{18} ≤1018)。
求 [ L , R ] [L, R] [L,R] (均 ≤ 1 0 9 \le 10^9 ≤109)中有多少个数,其是力矩平衡的。
一个 len \text{len} len 位数(记其数位组成为 b l e n b l e n − 1 . . . b 2 b 1 b_{len}b_{len-1}...b_2b_1 blenblen−1...b2b1)力矩平衡当且仅当 ∃ p ∈ [ 1 , l e n ] , s . t . ∑ i = l e n 1 { b i × ( i − p ) } = = 0 \exist\ p \in [1, len],s.t. \sum\limits_{i\ =\ len}^{1}\{b_i\times (i-p) \} == 0 ∃ p∈[1,len],s.t.i = len∑1{bi×(i−p)}==0。
逐位分析,只能正搜。
但可以剪枝: 当当前累加力矩小于零的时候,低位再怎么填数也不可能恢复到 0 0 0(往后只会越减越多),故可直接剪掉。
然后本题具体做法是:枚举枢纽的位置 [ 1 , l e n ] [1, len] [1,len] 进行数位 dp \text{dp} dp 统计,然后累加答案再去重即可。
【CGWR②】 像这种 枚举 + 累加数位dp答案 的做法,一定要注意多次枚举时,有没有什么数会同时满足这多种不同情况下的特殊条件。如果有的话,要去重!!!
比如本题不管枢纽位置在哪,0 这个数都能满足力矩平衡,所以最后累计的答案要减去其多被统计的次数(也就是循环次数-1)
而有些题,虽然也枚举了,但不存在这种“多面手”,所以不需要去重操作。比如上面那道 4389. X mod f(x),肯定不存在某个数对多次枚举情况同时满足条件(不然它就具备多个互不相等的数位和了… 这怎么可能嘛)。所以最终答案就是累加和,不用减去啥。
【CGWR③】本题,力矩其实就是变相的数位和。所以dp数组需要有数位和维。
#include <iostream>
#include <cstring>
#define MB 20
#define MS 1666
using vint = long long;
int num[MB];
template <typename T>
inline int scan(T x)
{
int top = 0;
do num[++top] = x % 10;
while (x /= 10);
return top;
}
int pivot;
vint _dp[MS << 1][MB][MB]; // 这个力矩其实就是变相的数位和,所以dp数组需要数位和维
auto *dp = _dp + MS;
vint dfs(const int pos, const int M, const int lim)
{
if (!pos)
return M == 0;
if (M < 0)
return 0; // 剪枝
if (!lim && ~dp[M][pos][pivot])
return dp[M][pos][pivot];
vint sum = 0;
const int ub = lim ? num[pos] : 9;
for (int x=0; x<=ub; ++x)
sum += dfs(pos-1, M + (pos-pivot) * x, lim&&x==ub);
return lim ? sum : dp[M][pos][pivot] = sum;
}
template <typename T>
inline T count(const T N)
{
T sum = 0;
const int len = scan(N);
for (pivot=1; pivot<=len; ++pivot)
sum += dfs(len, 0, true);
// 枚举pivot遍历了len次。但注意不管pivot是啥,"0"这个数总是平衡数,所以它被多统计了len-1次,得减去
// 而除了0之外的其他数,对于两次不同的pivot,不可能同时满足力矩为0,所以不会重复统计。
// 所以答案就是 sum - (len-1)
return sum - (len-1);
}
int main()
{
std::ios::sync_with_stdio(false), std::cin.tie(nullptr), std::cout.tie(nullptr);
memset(_dp, -1, sizeof(_dp));
int T;
vint L, R;
std::cin >> T;
for (int i=1; i<=T; ++i)
{
std::cin >> L >> R;
std::cout << count(R) - count(L-1) << '\n';
}
return 0;
}
【CGWR④】 最后再来总结一下我个人数位 dp \text{dp} dp 容易出错的几个小地方:
继续加油!