「数位 DP」详解

233. 数字 1 的个数

面试题 17.06. 2出现的次数

788. 旋转数字

600. 不含连续1的非负整数

1012. 至少有 1 位重复的数字

2376. 统计特殊整数

902. 最大为 N 的数字组合

 

「数位 DP」属于会就不难,不会就巨难!!本篇文章就来好好详细的总结一下「数位 DP」吧吧吧!!

傻瓜式遍历

现在有一个需求:遍历<= n的所有数字 (OS:what the f**! 逗我玩呢,这么简单!!)

换种遍历方式吧「按位遍历」

熟悉「DP」和「记忆化搜索 (回溯 + 备忘录)」的同学肯定很清楚「状态」这个概念,「DP」中最难的也就是推出「状态转移方程」

「DP」是通过已知的状态推出未知的状态;而「记忆化搜索」是记录已经算出的状态,下次再遇到该状态时,直接复用即可,就不需要重复计算了

总之,它们俩的核心就是记录「状态」,避免重复计算,降低耗时

如果对上面说的内容不熟悉的同学,可以先学习下面推荐的文章:

「数位 DP」这类题目一般都是对一个数的数位动手脚,比如:求小于等于n的所有数中,数字1出现的个数

如果按照「傻瓜式遍历」,对于每一个数,都需要求出所有数位,然后判断,而且对于不同的数字都需要重新求所有数位,状态无法复用!!

那怎样的遍历才可以使「状态」得以复用呢??这里以n = 1234为例展开分析

首先我们需要以每一为单位,为了方便处理,先把数字n转化成字符数组s = ['1', '2', '3', '4']

现在考虑处于每一位上时面临的选择:(从左向右,即从高位开始) (温馨提示:结合下面的图一起食用)

下图中的isLimit标记是否受到了限制。若为真,则第i位填入的数字至多为s[i],否则可以是9。如果在受到限制的情况下填了s[i],那么后续填入的数字仍会受到限制 🚫

当我们依次在0 - 3位做了选择后,会形成一条路径,即为一个<= 1234的数,那么所有这样的路径构成的集合就是所有<= 1234的数,且从0开始

这样就实现了另外一种遍历的方式啦,即「按位遍历」

1

下面给出这种遍历方式的代码:

主角出场

铺垫了这么多,现在由题目 数字 1 的个数 引出今天的主角!!

题目很短,意思也很好懂,但也很容易超时。如果直接暴力的话,思路很简单,遍历每一个数,然后计算每一个数中 1 的个数即可,伪代码如下:

n的范围:0n109,求一个数中 1 的个数最坏需要循环 10 次 (109 大概是 230),所以最坏的时间复杂度为 O(10×n)

按照「按位遍历」的思路,先照葫芦画瓢,写一个遍历所有数字的代码 (这里不存储所有结果,只把个数返回一下):

是不是和上一部分的遍历代码一模一样!!

现在把返回值的定义变一下:返回从第i位开始,数字中1的个数

只需要多加一个参数即可:

这个代码其实已经满足题目的要求,但也还是会超时!!不过不过不过,这种遍历方式的状态可以复用

我们来分析一下,到底哪些状态可以复用!!为了简单分析,我们把上面的图抽离出部分内容单独分析

2

假设第一种情况的结果已经计算出来

如果我们处于第三种情况下的第1位的时候,思考:后面的部分还需要再次计算吗?

如果我们处于第二种情况下的第1位的时候,思考:后面的部分还需要再次计算吗?

还有最后一个问题,蓝色部分的结果可以复用吗?

说了这么多,都是教你如何唯一标识一个「状态」,其实这里有一个小窍门,记录「状态」是为了减少递归的次数,也就是说一次递归的结果对应着一个「状态」。所以看递归函数中的参数有哪些,就可以判断如何才能唯一标识一个「状态」

下面的代码中,递归函数为f(int i, int oneCnt, boolean isLimit),是不是和我们的状态对应上了!!下面给出的例子中会进一步阐明这个小窍门!

下面给出最终的代码:

题目实战

面试题 17.06. 2出现的次数

题目详情可见 面试题 17.06. 2出现的次数

几乎和上面分析的题目一模一样,这里直接给出代码:

旋转数字

题目详情可见 旋转数字

「好数」必须包含旋转后为不同的数字,用数组ISDIFF[]记录每个数字的旋转情况,见注释

通过一次剪枝,过滤掉了存在不能旋转数字的情况,见注释

对于备忘录emeo[][],一维表示位数i,二维表示是否包含旋转后为不同的数字hasDiff,是不是和递归函数的参数对应上了!

不含连续1的非负整数

题目详情可见 不含连续1的非负整数

第一步:先转化成二进制

通过一次剪枝,过滤掉了存在 1 的情况,见注释

对于备忘录emeo[][],一维表示位数i,二维表示是否包含旋转后为不同的数字prev,是不是和递归函数的参数对应上了!

至少有 1 位重复的数字

题目详情可见 至少有 1 位重复的数字

这个题目和上面给出的题目又一丢丢的不一样,在上面的题目中,00011其实是等价的,因为题目中并没有对0有特殊要求

但是在本题中,要求「至少有 1 位重复的数字」,此时00011就不等价了,0001是一个满足要求的数字,而1是不满足要求的数字,而在[0, n]中,0001其实是非法的,所以会出现错误 ❌

这里引入一个新的参数isNum,表示i前面是否已经选了数字。isNum主要的作用是过滤掉「前导零」,如果对「前导零」没有要求,则可以不使用该参数

先抛开isLimit限制,如果i前面已经选了数字,那么i可以选择的数字为[0, 9];如果i前面没有选数字,那么i可以选择的数字为[1, 9]

对于备忘录emeo[][],一维表示位数i,二维表示mask,是不是和递归函数的参数对应上了!

isNumisLimit一样,在判断「状态」是否存在时,需要特判一波!

统计特殊整数

题目详情可见 统计特殊整数

这个题目和上个题目一模一样,上个题目的要求「至少有 1 位重复的数字」,我们转化成了「n - 没有重复的数组」

所以转化后的f()函数刚好和本题的要求一致!

最大为 N 的数字组合

题目详情可见 最大为 N 的数字组合

和上面的大同小异!