一、前言
数位 DP 又称 数位动态规划,在 LeetCode 上属于难题,而 ACM 竞赛中属于中等题,甚至可以说是模板题。
数位 DP 的状态设计千变万化,但万变不离其宗,只要确定这个题是用数位 DP 求解,基本就很容易把状态套出来。当然,对于刚接触动态规划的同学,建议先看下 背包问题、最长单调子序列、最长公共子序列、记忆化搜索 等基础内容,对状态机和状态转移有一个初步的认识,那么,在学习数位 DP 时能够起到事半功倍的效果。
二、数位 DP 简介
1、数位 DP 定义
- 数位 DP 又称 数位动态规划,一般可以从题干就能确定这个题是否可以用 数位 DP 来求解。因为 数位 DP 的题目一般都描述成如下两种问法之一:
【问法1】给定一个闭区间 ,求在这个区间中,满足 某些条件 的数的个数。
【问法2】如果一个数字满足 某些条件,则称之为 数,给定闭区间 ,求这个区间中 数的个数。
- 这里的 某些条件 决定了状态转移的决策,这样说或许比较抽象,那么接下来,我们通过一个简单的例题来了解下 数位DP 的一般求解过程。
2、数位 DP 引例
【例题1】如果一个数的所有位数加起来是 的倍数, 则称之为 ,求区间 内 的个数;
- 对于这个问题,朴素算法就是枚举区间里的每个数,并且判断可行性,时间复杂度为 ,,肯定是无法接受的。
1)差分转换
- 对于区间 内求满足数量的数,可以利用差分法分解问题;
- 假设 内的 数量为 ,那么区间 内的数量就是 ;分别用同样的方法求出 和 ,再相减即可;
图二-2-1
2)数位性质
- 如果一个数字 满足 ,那么 从高到低肯定出现某一位,使得这一位上的数值小于 对应位上的数值,并且之前的所有高位都和 上的位相等。
- 举个例子,当 时,、、、,那么对于 而言,无论后面的字母取什么数字,都是满足 这个条件的。
- 如图二-2-2所示:
图二-2-2
- 如果我们要求 的值,可以通过枚举高位:当最高位为0,那么问题就转化成 的子问题;当最高位为1,问题就转化成 的子问题。
- 可以继续递归求解,而 由于每一位数字范围都是 ,可以转换成一般的动态规划问题来求解。
3)前缀状态
- 这里的前缀状态就对应了之前提到的 某些条件;
- 在这个问题中,前缀状态的描述为:一个数字前缀的各位数字之和对10取余(模)的值。
- 前缀状态的变化过程如图二-2-3所示:
图二-2-3
- 在【例题1】中,前缀状态的含义是:对于一个数 520 ,我们不需要记录 520 ,而只需要记录 7;对于 52013,我们不需要记录 52013,而只需要记录 1。这样就把原本海量的状态,变成了最多 10 个状态。
3、状态分析
1)状态定义
- 根据以上的三个信息,我们可以从高位到低位枚举数字 的每一位,逐步把问题转化成小问题求解。
- 我们可以定义 表示剩下还需要考虑 位数字,前面已经枚举的高位组成的前缀状态为 ,且用 表示当前第 位是否能够取到最大值(对于 进制,最大值就是 ,比如 10 进制状态下,最大值就是 9) 时的数字个数。我们来仔细解释一下这三维代表的含义:
- 1)当前枚举的位是 位, 大的代表高位,小的代表低位;
- 2) 就是前缀状态,在这个问题中,代表了所有已经枚举的高位(即数字前缀)的各位数字之和对10取余(模)。注意:我们并不关心前缀的每一位数字是什么,而只关心它们加和模10之后的值是什么。
图二-3-1
- 3) 表示的是已经枚举的高位中已经出现了某一位比给定 对应位小的数,那么后面枚举的每个数最大值就不再受 控制;否则,最大值就应该是 的对应位。举例说明,当十进制下的数 时,枚举到高位前三位为 “131”,, 那么第四位数字的区间取值就是 ;而枚举到高位前三位为 “130” 时,,那么第四位数字的区间取值就是 。参考 图二-2-2 加深理解。
2)状态转移
- 所以,我们根据以上的状态,预处理 的每个数位,表示成十进制如下:
- (其中 代表最高位, 代表最低位)
- 得出状态转移方程如下:
- 表示第 位取什么数字,它的范围是 ;
- 表示第 位能够取到的最大值,它由 决定,即:
3)初始状态
- 利用上述的状态转移方程,我们可以进行递归求解,并且当 的时候为递归出口,由于数字要求满足所有数字位数之和为 的倍数,所以只有 的情况为合法状态,换言之,当 时,有:
- 而我们需要求的,就是 。
4)记忆化
- 我们发现,如果按照以上的状态转移进行求解,会导致一个问题,就是会有很多重叠子问题,所以需要进行记忆化,比较简单的方法就是用一个三维数组 来记忆化。
- 当然,这里有一个技巧,就是 这个变量只有 、 两种取值,并且当它为 时,代表之前枚举的数的高位和所求区间 右端点中的 的高位保持完全一致,所以当 时,深度优先搜索树的分支最多只有 条,所以无须记忆化,每次直接算就可以。如图二-3-2所示的蓝色结点,就是那条唯一分支。
图二-3-2
- 综上所述,我们只需要用 表示长度为 ,且每个数字的范围为 ,且前缀状态为 的数字个数(这里 和进制有关,如果是 进制,那么 )。
- 采用普通深搜, 采用记忆化搜索。
三、数位 DP 代码实现
数位 DP 计算过程主要分为以下几步:
1、状态初始化
2、数位初始化
3、记忆化搜索
1、状态初始化
- 状态初始化主要是初始化 数组,将数组中的所有值都初始化为一个小于 0 的数即可,一般用 -1。
- c++ 代码实现如下:
const int maxl = 20;
const int maxstate = 10;
const int inf = -1;
#define ll long long
ll f[maxl][maxstate];
void init() {
memset(f, inf, sizeof(f));
}
复制代码
2、数位初始化
- 数位初始化就是将给定的区间 的右端点 按照数位分解到数组 中;
- 需要注意的是处理边界情况: 以及 的情况;
- c++ 代码实现如下:
const int base = 10;
ll g(ll x) {
if (x < 0) return 0;
if (x == 0) return 1;
int d[maxl];
int n = 0;
while (x) {
d[++n] = x % base;
x /= base;
}
return dfs(n, 0, false, d);
}
复制代码
3、记忆化搜索
- 记忆化搜索部分就是数位 DP 的核心实现,先给出代码,再来解释代码的含义:
ll dfs(int n, stType state, bool lim, int d[]) {
if(n == 0)
return isEndStateValid(state) ? 1 : 0; // 1)
ll sum = f[n][state];
if(lim && sum != inf) return sum; // 2)
sum = 0;
int maxv = lim ? base - 1 : d[n]; // 3)
for(int k = 0; k <= maxv; ++k) {
stType st = nextState(state, k);
bool nextlim = (k < maxv) || lim;
sum += dfs(n-1, st, nextlim, d); // 4)
}
if(lim) f[n][state] = sum; // 5)
return sum;
}
复制代码
- 1)当 的时候,即为递归出口,也就是所有的数位都已经枚举完毕,这时候通过 所有数字之和对 10 取余数的值来判断是否是一个题目中要求的数,是则返回 1,否则返回 0;通过
isEndStateValid(state)
进行判定:
#define stType int
bool isEndStateValid(stType state) {
return state == 0;
}
复制代码
- 2)当
lim = true
,表示接下来所有数的取值都不受给定的 限制,这时候如果f[n][state]
已经计算过了,则直接返回即可; - 3)通过
lim
来决定当前第 位数字枚举的上限:当lim = true
时,上限为base-1
;否则,为 ; - 4)这一步主要做状态转移,封装出一个
nextState(state, k)
函数来生成当前位取 时的下一步的状态,对于【例题1】来说,函数实现如下:
stType nextState(stType st, int digit) {
return (st + digit) % 10;
}
复制代码
- 5)当
lim = true
,进行记忆化操作,和步骤 (2)相照应; - 通过【例题1】,我们了解了一下 数位 DP 的大致求解过程,这个题也是最简单的,接下来我们来看下竞赛中一般会遇到什么样的问题。你会发现,除了状态设计和状态转移部分的差别,整体框架代码都是不变的,所以说 数位DP 就是模板题。
四、数位 DP 进阶
1、非法状态
- 所谓非法状态,就是对于某个数字,在它的某个前缀已经能够判定这个数不满足给定题目的条件时,无须继续往下枚举,而这个前缀状态被称为非法状态,我们通过【例题2】来理解下非法状态的实际含义。
【例题2】对于一个数字,如果出现 4 或者 62 ,则属于不吉利数字,给定 ,求区间 中非不吉利数字的个数。
- 对于任意一个在区间 内的数,它的某个前缀的最后一位数字只要是 “4” 或者 最后两位数字只要是 “62” 就一定不是我们要求的数,换言之,只要一个数的前缀以 “4” 或者 “62” 结尾,那么就一定是非法前缀状态,简称非法状态。
- 前缀状态 表示:已经枚举的数字的前缀的最后一位,所以合法的状态总共有 9 种情况:分别是以 0、1、2、3、5、6、7、8、9 结尾的,然后对所有合法状态,增加一位 的数字后进行状态转移;状态转移过程中会出现两种非法状态:
- 1)新增加的位等于 4;
- 2),且新增加的位等于 2;
- 除了非法状态不进行状态转移,其它状态都进行状态转移,我们只需要在 【例题1】的 数位 DP 代码基础上,修改
nextstate
和isEndStateValid
函数即可。 - c++ 代码实现如下:
const int invalidstate = -123456789;
bool isEndStateValid(stType state) {
return true;
}
stType nextState(stType st, int digit) {
if( (digit == 4) || (st == 6 && digit == 2) ) {
return invalidstate;
}
return digit;
}
复制代码
- 状态转移如图四-1-1所示:
图四-1-1
- 图中红色状态代表非法状态,蓝色状态代表任意的合法状态。当一个以 3 结尾的数字,增加一位 6,再增加一位 2,则变成非法状态,无法再进行状态转移,同样,增加一位 4,也能直接变成非法状态。而一个以 6 结尾的数字,增加一位 3,则又回到了 3 的状态。
- 可以定义一个和所有状态变量不重复的常量(一般可以用负数,如 -123456789)来表示非法状态。
2、饱和状态
- 所谓饱和状态,就是对于某个数字,在它的某个前缀已经能够判定这个数满足给定题目的条件,而这个前缀状态被称为饱和状态,我们通过【例题3】来理解下饱和状态的实际含义。
【例题3】给定一个 ,求小于等于 的数字中包含 49 的数的个数。
- 从题意可以得知,饱和状态 和 非法状态 正好是两个逆状态。
- 对于任意一个小于等于 的数,它的某个前缀的最后两位数字只要是 “49” 就一定是我们要求的数,换言之,只要一个数的前缀以 “49” 结尾,那么就一定是饱和前缀状态,简称饱和状态。饱和状态,无论接收什么数字,还是保持饱和状态不变。
- 前缀状态 表示:已经枚举的数字的前缀的最后一位,所以合法的状态总共有 10 种情况:分别是以 0、1、2、3、4、5、6、7、8、9 结尾的,然后对所有合法状态,增加一位 的数字后进行状态转移;状态转移过程中会出现一种饱和状态:
- 1),且新增加的位等于 9;
- 当某个状态变成饱和状态的那一刻,它前缀的最后一位也是9,为了区别原先的 9,我们可以用 10 进行编码,我们只需要在 【例题1】的 数位 DP 代码基础上,修改
nextstate
和isEndStateValid
函数即可。 - c++ 代码实现如下:
const int saturatedstate = 10;
bool isEndStateValid(stType state) {
return (state == saturatedstate);
}
stType nextState(stType st, int digit) {
if(st == 4 && digit == 9) {
st = saturatedstate;
}else if(st != saturatedstate){
st = digit;
}
return st;
}
复制代码
- 状态转移如图四-2-1所示:
图四-2-1
- 图中黄色状态代表饱和状态,蓝色状态代表任意的合法状态。当一个以 3 结尾的数字,增加一位 4,再增加一位 9,则变成饱和状态,无论怎么状态转移都回到自己;而一个以 4 结尾的数字,增加一位 3,则又回到了 3 的状态。
- 可以定义一个和所有状态变量不重复的常量来表示饱和状态,由于饱和状态也是要进行记忆化的,所以不能用负数。
3、组合状态
- 所谓组合状态,就是几种不同维度的状态,通过整数编码映射到一个整数中,方便计算。
【例题4】一个数的十进制包含子串 “13” 并且能被 13 整除,则被称为 B 数,求小于等于 的 B 数。
- 心里没有点 B 数,说的就是这道题了。
- 这个问题既要满足 饱和 又要满足 同余。
- 所以对于这个问题,我们发现,前缀状态 由两部分组成:
- 1)已经枚举的数字的前缀的最后一位;
- 2)已经枚举的数字前缀模 13 的值;
- 对于第(1)种情况而言,前缀最后一位总共 0、1、2、3、4、5、6、7、8、9 这 10 种情况,并且当之前一位是 1,再枚举一个 3 时,数字达到饱和状态,可以用 10 编码;而对于第(2)种情况,可以参考 【例题1】 直接采用取模的方式,总共 13 种;
- 那么,我们可以把 (1) 和 (2) 组合起来编码,编码成一个四位的十进制数。
- 例如:前缀最后一位为 4,且所有前缀数字之和模13 为 9,则可以用 409 来表示状态,最大的状态表示为 1012。
- 同样,我们只需要在 【例题1】的 数位 DP 代码基础上,修改
nextstate
和isEndStateValid
函数即可。 - c++ 代码实现如下:
const int invalidstate = -123456789;
const int saturatedstate = 10;
const int mod = 13;
bool isEndStateValid(stType state) {
return (state == saturatedstate * 100 + 0);
}
stType nextState(stType st, int digit) {
int high = st/100, low = st%100;
if(high == 1 && digit == 3) {
high = saturatedstate;
}else if(high != saturatedstate){
high = digit;
}
low = (low * 10 + digit) % mod;
return high * 100 + low;
}
复制代码
4、前导零状态
- 为什么要引入前导零状态?【例题5】会告诉你答案。
【例题5】如果一个数的二进制表示中 0 的个数大于等于 1,则称它为 ,给定一个区间 ,求其中 的个数。
- 在这个问题中,01 和 1 是同一个数,但是 01 是符合 的特征的,而 1 不符合,但是我们在利用数位 DP 求解的时候,如果没有处理前导零,就会把 1 误 当成 01 而统计成 。
- 前缀状态 表示:二进制表示的数前缀中,0的个数 减去 1的个数,所以状态范围为 。
- 则对于任意一个状态 st,在后面加上 0 和 1 后实现状态转移;
- 由于前导零不能作为正常零统计,所以需要加入一个初始状态,即前导零状态,只要编码不在 即可,可以用 来表示;
- 我们只需要在 【例题1】的 数位 DP 代码基础上,修改
nextstate
和isEndStateValid
函数即可。 - c++ 代码实现如下:
const int leadingzerostate = 33;
bool isEndStateValid(stType state) {
return (state >= 0) || (state == leadingzerostate);
}
stType nextState(stType st, int digit) {
if(st == leadingzerostate) {
if(digit == 0)
return leadingzerostate;
st = 0;
}
return st + (digit == 0 ? 1 : -1);
}
复制代码
- 状态转移如图四-4-1所示:
图四-4-1
- 图中绿色状态代表前导零状态,蓝色状态代表任意的合法状态。前导零状态的特点是遇到 0 则回到自己。
- 对于所有前导零会影响结果的问题,我们可以采用如下通用解法:
- 1)定义 1 个不和其它状态数字重复的 前导零状态;
- 2)状态转移的时候,如果源状态是前导零状态,在遇到下一位是零的情况,则维持前导零状态;否则,进入下一状态;
- 3)结束状态时判定如果是前导零状态,则表明这个状态表示的数就是 0,进行相应的判定。
5、位压缩状态
- 一个二进制数的每一位有两种取值 ,对于一些互相独立的状态,可以用一个整数来表示各个维度的组合。总共可以表示 种状态。
【例题6】一个数字的十进制下的每个位都不同,则称为 ,求小于 的数的个数。
- 为了确保十进制数的每一位都不同,那么在没增加一位的时候,都需要判断新增的这个数字在之前的高位数字中有没有出现过,数字一共 10 种,有 和 没有是两种状态,所以最多就是 种状态。
- 前缀状态 表示:每位数字的出现次数,例如 的二进制表示为,代表的是0、1、3 这三个数字出现过,所以我们在进行状态转移的时候,遇到 0、1、3 只能进入非法状态。
- 实际实现可以采用位运算加速,我们只需要在 【例题1】的 数位 DP 代码基础上,修改
nextstate
和isEndStateValid
函数即可。 - c++ 代码实现如下:
bool isValidState(stType state) {
return state != leadingzerostate;
}
stType nextState(stType st, int digit) {
if(leadingzerostate == st) {
if(digit == 0)
return leadingzerostate;
st = 0;
}
if( st & (1<<digit) ) {
return invalidstate;
}
return st | (1<<digit);
}
复制代码
6、二分优化
【例题7】一个数如果至少包含 3 个 6,则称为 “beast number”,给定一个 , 求第 个 “beast number”。
- 假设 区间内有 个满足条件的数,那么自然, 是 关于 的单调不降函数。
- 我们只需要求出满足 的最小的 就是答案了,于是可以用 二分 ,数位DP 求解 ,从而找出最小的 。
- 状态编码如下:
- 状态0:前缀数字最后位不是6,且未出现过连续3个6;
- 状态1:前缀数字最后位连续6的个数为1个,且未出现过连续3个6;
- 状态2:前缀数字最后位连续6的个数为2个,且未出现过连续3个6;
- 状态3:已经出现过连续3个6,饱和状态;
- 状态转移如图四-6-1所示:
图四-6-1
- 然后就是 二分答案 + 数位DP 判可行 了。
- 我们只需要在 【例题1】的 数位 DP 代码基础上,修改
nextstate
和isEndStateValid
函数即可。 - c++ 代码实现如下:
const int saturatedstate = 3;
bool isEndStateValid(stType state) {
return (state == saturatedstate);
}
stType nextState(stType st, int digit) {
if(st == saturatedstate) {
return saturatedstate;
}
if(digit == 6) {
return st + 1;
}
return 0;
}
复制代码
五、数位 DP 总结
1、状态转移图
图五-1-1
- 图五-1-1对四类状态进行了一个动态演示,接下来对这张图做一个简单的解释:
- 前导零状态●:接收 数字0 时回到自己,否则根据题意进入任意初始状态;
- 饱和状态●:接收任意数字都回到自己;
- 非法状态●:无法再进行状态转移;
- 其它状态●:进行正常状态转移的状态,可能到饱和状态,也可能到非法状态,但是无法回到前导零状态;
2、数位 DP 模板
- 关于 数位 DP 的内容到这里就全部结束了,如果还有不懂的问题可以留言告诉作者或者添加作者的微信公众号。
- 本文所有示例代码均可在以下 github 上找到:github.com/WhereIsHero…