力扣3906.统计网格路径中好整数的数目
- 一、题目要求
- 二、踩坑与复盘
- 三、解题思路
-
- [1 数值含义](#1 数值含义)
- [2 数组初始化](#2 数组初始化)
- [3 数组推导公式](#3 数组推导公式)
- [4 数组遍历顺序](#4 数组遍历顺序)
- 总体代码顺序
- 四、代码实现
- 五、上下界数位dp优化
- 六、复杂度分析
-
- [1 时间复杂度](#1 时间复杂度)
- [2 空间复杂度](#2 空间复杂度)
- 七、收获总结
一、题目要求
题目设定一种整数------好整数,判断好整数的步骤:
- 首先将当前整数用前导零补齐为16位(不足16位时补,整数最多16位)
- 将其16位整体按行优先放入一个4*4的二维数组里,即前四位数字从左向右组成二维数组的第一行,中间的四位数字组成二维数组的第二行,后面以此类推组成一个完整的二维数组。
- 题目另外还给出了一个字符串directions,此字符串由三个'R'和三个'D'组成,二维数组要从左上角(0,0)出发,应用directions进行移动,遇到'D',其行数加一,遇到'R',其列数加一,记录沿路访问的数字(包括起点)。
- 最后检查所有访问过的数字组成的序列(序列长度为7),此序列如果为非递减序列,那么当前整数为好整数。
题目给出了一个整数范围[l,r],求出在这个范围内所有好整数的数量返回此总数。
以下为力扣给出的其中一个示例:

二、踩坑与复盘
- 题目要求的非递减序列,其中非递减序列容易误认为只要整个序列不是递减就符合要求,但非递减的规定很严格,必须保证a1<=a2<=a3<=...,这才算是非递减。
- 我从一开始是想找这道题目其中的规律,因为第一眼看到二维数组的移动,感觉其移动应该带有一定玄机,从题目给出的固定移动可以得知,'R'和'D'是固定的总数,那么从左上角出发一定会经过三行和三列最后到达右下角。但其实仔细一想,虽然左上角和右下角都会经历,但中间值完全"随机"啊,不应该有规律,随后在查看正确代码后才知道,是我出发点有问题,这道题开头就说明了字符串directions只要给出了就固定不变,也就是说压根不用在意移动方向,最重要的应该是解决在固定移动情况下于[l,r]范围内的整数应该怎么去判断其是否为好整数。
- 当时一直难以看懂的是ai给出的正确解法里的三维数组各个维度的用法,虽然三个维度含义光看解释很容易懂,但运用这三个维度的难度很高,怎么使用此三维数组dp来推导出好整数的个数才是关键。
- 整道题目有一个非常重要的隐藏要点------边界,不管是用三维数组dp还是二维数组dp来解决,但都无法绕开边界问题,为什么需要考虑边界?因为这道题目是按照整数位数来判断好整数,在判断其中一位时,如果前一位处于边界上,那么当前位遍历会有所限制,比如34这个整数,如果前一位遍历到了3,那么此时个位上从零开始遍历最大不能超过4,否则就比原先的整数值还大,没有判断的必要,这是靠近上边界的情况,靠近下边界也是同理。这也是在做代码时容易忽略的点,题目不会主动告诉我们需要考虑边界,而是需要我们自己去发现和解决这个问题。
- 这道题目必须要分清当前遍历的数字是否在字符串directions定义的访问路径上,如果在,则需与前一个访问路径节点的数字进行对比,查看是否符合非递减要求;如果不在,则只需要考虑边界问题。
三、解题思路
对于我个人而言,首先最重要的是知道为什么这道题目使用数位dp来解,先怀疑这个解法,这个算法真的能简化时间复杂度吗?没有其他的算法能更适用于这个题目吗?对于时间复杂度,逐个数遍历每个数字,和数位DP逐位遍历,最终检查的次数是一样的,那为什么要使用数位dp呢?不是数位dp能优化时间复杂度,而是因为题目本身给出的directions字符串就是按位数移动给的访问点,通过逐位检查能更加完整。
这道题目使用三维数组是最为直观的,接下来将从数组含义、数组初始化、数组推导公式、数组遍历顺序这四个重要方面来分析三维数组dp。
1 数值含义
创建一个三维数组dp[pos][tight][prev],数组的三个维度含义:
- pos表示当前遍历的整数字符串下标,因为整数会在函数刚开始就被转化为16位的字符串,其中pos=0时表示整数的最高位。
- tight表示前一位遍历的数字是否处于边界上,是则为1,否则为0
- prev用于保存前一个处于字符串directions访问路径上数字,当前数字如果处于directions访问路径上,那么就需要和前一个路径上的数字进行非递减对比。
dp[pos][tight][prev]表示从当前第 pos 位开始,一直到最后一位,在边界状态为 tight、前一个路径数字为 prev 的条件下,后续能构造出的好整数个数。
2 数组初始化
dp数组一开始整体都初始化为-1,这样做为了判断当前的dp是否被访问过。
3 数组推导公式
此前说过这道题目是按照位数遍历,每一位都有可能出现的数字d,但按位循环会受边界影响,比如前一位已经达到边界,那么当前位就会有上限,而上一位是否处于边界则由tight来保存,tight为1则处于边界,否则相反。为了解决这个问题,会设置一个limit。
limit有两种情况:
- tight为1时,limit等于原数当前为的数字
- tight为0时,limit等于9
此时数字d就从0开始遍历到limit。
如果当前位数属于directions访问路径,那么就需要判断其数字d是否符合非递减规则,判定规则:
- d<prev && prev!=10,不符合非递减,忽略。
- d>=prev,符合非递减,prev=d。
注意:为什么要判断prev是否等于10?整个循环是依附于一个递归函数内部的,且prev需要默认初始化10开始进入递归,因为pos==0前面没有数字,prev也就无意义,一个数位只能在[0,9]范围内,只要不初始化在这个范围内,其他数值都允许作为初始化值。
前面提到过整个数字d的遍历依附于一个递归函数里的,那么这个递归函数又是什么操作呢?
首先我们知道遍历数字d是将当前位的所有可能数字都判断一遍,那么也只是遍历完了这一位,那其它位怎么办呢?所以我们需要设置一个递归,递归pos+1,也就是递归下一位,那么问题就在于遍历下一位的tight和prev怎么设置呢?prev在此前说过初始值为10,后面通过与数字d的比较来进行相应改变,那么问题就是tight怎么改变。tight是指前一位是否处于上界,那么递归下一位,tight就得保存当前位是否处于上界的真假,如果当前处于上界,那么tight就以真传入下一个递归,否则反之。
现在,递归的所有参数都确定了,设置一个变量res来累加每次递归的结果,res+=dp[pos+1][nextTight][nextPrev]
最后每一个递归函数最后return dp[pos][tight][prev]=res。
4 数组遍历顺序
本题使用记忆化递归(DFS)的方式,从pos为零(原整数最高位)开始递归,递归终止条件为pos==16,表示已是完整的好整数,返回数字1。
在一层递归里,当前数位按照范围[0,limit]遍历找到合适的数字判断当前整数是否符合继续递归的条件,使用此循环继续进行深度递归。
总体代码顺序
首先遍历字符串directions,将访问路径先用一个数组保存,随后调用count函数,count函数功能是找[0,x]范围内的好整数总数,找到[0,l]和[0,r]这两个范围内的好整数数量,做差得到[l,r]区间内的好整数总数。
count函数内部,首先将整数前导零转化为16位的字符串,然后创建一个dfs函数,用来计算当前范围内的好整数总数。
四、代码实现
三维数组:
cpp
class Solution {
public:
vector<int> dir; //设置一个数组用来记录字符串directions访问的路径
long long count(long long x) {
if(x < 0) return 0;
string s = to_string(x);
s = string(16 - static_cast<int>(s.size()), '0') + s; //前导零转化为16位的字符串
//第一个维度遍历位数,第二个维度表示是否处于边界,第三个维度保存上一次处于访问路径上的数字
vector<vector<vector<long long>>>dp(17, vector<vector<long long>>(2, vector<long long>(11,-1)));
//创建递归函数
function<long long(int, bool, int)> dfs = [&](int pos, bool tight, int prev) -> long long {
if(pos==16) return 1;
int Tight = tight ? 1 : 0;
//减枝操作
if(dp[pos][Tight][prev] != -1) return dp[pos][Tight][prev];
long long res = 0;
int limit = tight ? (s[pos] - '0') : 9; //找到边界数字,前一位处于上界,则取原整数的当前位上的数作为上界,否则为9
//以下操作判断当前位数是否处于访问路径上
int dix = -1;
for(int i=0;i<7;++i) {
if(dir[i] == pos) {
dix = i;
break;
}
}
//从零开始向上界遍历
for(int i = 0; i <= limit; ++i) {
int nextTight = tight && (i == limit);
int nextPrev = prev;
//如果当前位处于访问路径,需要判断是否符合非递减
if(dix != -1) {
//这个需要i<prev的条件是为了不排除最高位的计算
if(prev != 10 && i < prev) continue;
nextPrev = i;
}
res += dfs(pos+1, nextTight, nextPrev);
}
return dp[pos][Tight][prev] = res;
};
return dfs(0,true,10);
}
long long countGoodIntegersOnPath(long long l, long long r, string directions) {
dir.clear();
dir.push_back(0);
//遍历字串串directions,找到访问路径
for(char c : directions) {
if(c=='D') dir.push_back(dir.back()+4);
if(c=='R') dir.push_back(dir.back()+1);
}
//使用count函数对[0.l]和[0,r]区间的好整数进行计算并做差
return count(r) - count(l-1);
}
};
五、上下界数位dp优化
以上三维数组做法还有很多优化空间
- 三维数组里tight本身就只有两个值,没有必要作为数组的一个维度,可以舍去,保留pos和prev两个维度。
- 当前代码计算好整数数量是通过[0,l]和[0,r]范围内好整数总数做差所得,做了很多重复的遍历,如果直接判断[l,r]这个区域可以省去很多重复遍历。
- 当前三维数组只需判断是否处于上界,如果直接对[l,r]进行处理,那么下界也需要考虑,上界为r当前位数的数字,下界则为l的当前位数的数字,tight就需要分成两个变量,分别记录上界和下界。
- 本题可完全可以不进行前导零的操作,因为高位的零没有实际意义,即使前面的零处于访问路径上,零也是最小的数,不可能不符合非递减,所以直接记录有效位置的数字即可,整个操作也不考虑前导零的无效位数。
- 三维数组里设置的dp都是把最大位数固定还的,如果题目给出要求位数不限制,那么这个代码就不成立,在不限制位数的情况下,需要动态的去计算位数大小。
不过需要注意的是二维数组dp[pos][prev]在上一位处于上下边界时和不处于边界时的值是不一样的,所以在处于边界的情况下,dp[pos][prev]不需要被赋值,三维数组里因为记录了tight的维度,所以不需要特殊处理。
三位数组里有一段:
cpp
if(dp[pos][Tight][prev] != -1) return dp[pos][Tight][prev];
这一段在二维数组里还需要判断是否处于上下界,因为处于上下界的值本身会和非边界的值不一样,不能直接返回dp,需要单独计算。
六、复杂度分析
1 时间复杂度
整个三维数组的代码最坏的情况下时间复杂度就是162 1010,都是已知数,所以可以说时间复杂度就是O(1),16 2*10为遍历三维数组的次数,最后的10是因为三维数组每个元素都会在递归内部遍历10次。这都是针对于题目给出固定最大位数的情况下确定的。
如果动态获取有效位数,n作为位数,最坏情况下时间复杂度为100n,常数去掉就是O(n)。
2 空间复杂度
因为三维数组dp值固定不变的,所以空间复杂度可以是O(1)。
七、收获总结
- 本题将整数转化为字符串,按照位数遍历找到所有符合要求的好整数,因为访问路径也是按照位数排布,所以按位遍历更加直观。数位dp重要的不是优化时间复杂度,最重要的是更符合题目的要求。
- 通过这道题目的自我解析,了解到了数位dp的隐藏要点------边界。处于边界下的遍历会收到一定的限制,合理处理好边界问题才能正确使用数位dp。
- 掌握了三维数组针对数位dp的各种操作,怎么判断是否处于边界,怎么判断是否符合非递减,怎么去合理递归整个数组。