力扣周赛难题 3906.统计网格路径中好整数的数目——自我拆解学习与分析(数位dp上下界的奇妙)

力扣3906.统计网格路径中好整数的数目

一、题目要求

题目设定一种整数------好整数,判断好整数的步骤:

  • 首先将当前整数用前导零补齐为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的各种操作,怎么判断是否处于边界,怎么判断是否符合非递减,怎么去合理递归整个数组。
相关推荐
空中海1 小时前
Git-01:基础篇 — 版本控制与日常操作
git·学习
wangl_921 小时前
初探 C# 15 的 Union Types
java·开发语言·算法·c#·.net·.net core
Smile灬凉城6661 小时前
逻辑回归数据集
算法·机器学习·逻辑回归
笑虾1 小时前
dotnet 8 实现 XXTEA 解密核心算法
算法
happymaker06261 小时前
Spring学习日记——DAY06(事务管理)
java·学习·spring
龙佚1 小时前
噪声抑制技术:让语音更清晰
算法·架构
兰令水1 小时前
topcode【随机算法题】【2026.5.14打卡-java版本】
java·算法·leetcode
故事和你911 小时前
洛谷-【图论2-1】树2
开发语言·数据结构·c++·算法·动态规划·图论