数据结构学习 数位dp

发布时间:2024年01月15日

关键词:数位dp 记忆化搜索 dfs

数位dp属于比较难的题目,所有数位dp在leetcode都是hard。

因为没有做出jz43.里面用到了数位dp,所以去学习了一下,学习看了这位大神的基础知识

题目基本上是跟着这位灵大哥的题单做的。

学完数位dp之后,我发现数位dp是一个非常套路化的过程难点是确定dp需要记忆的内容。要结合实际例子来理解这个套路化的过程。

数位dp的套路:

关键思想:

从高到低给每位数填数字

比如:要填四位数,从xxxx开始,填了2xxx,再填23xx。

需要两个循环:

第一个循环(是一个递归,调用dfs函数,每次pose+1):遍历每一位数字,给每一位填数字。(第一位2xxx,第二位21xx,这样的)

第二个循环:遍历每一位数字的可选项,每一种可选项都填一遍。(要填第一位,可以填1,2,3,4,5...)

islimit:

可选项的确定依靠这个bool,表示这一位的数字是否收到上限的限制。如果这一位数没受到限制,我们就可以填0-9(十进制)或者0-1(二进制)。如果受到限制,那么就只能填这一位数的上限。

如果确定这一位数是否收到限制:如果它的前一位不受限制,那么这一位一定不受限制。如果它的前一位受到限制,那么得看我们前一位填的是否等于前一位数的上限,如果等于,那么这一位依然受到限制。

比如:我们只能从2345-0000这一段填数字,我们从高到低填数字。(初始化默认islimit==true,因为前几位填的数字都是000,和上限一样。)

假如目前状态是xxxx,第一位受到限制,只能填2-0。

????????1、填了2,2xxx,那么第二位继续受到限制只能填3-0,我们不可能填24xx 25xx 26xx这些了,大了。

? ? ? ? 2、填了1,1xxx,那么第二维就自由了,它填0-9都可以,不论是19xx还是10xx都不会超过2345.

数位dp的函数参数:

数位dp里的dfs的函数一般需要一下这些参数,不一定是所有参数都被需要:

?

题目一:面试题 17.06. 2出现的次数?

?

思路:

逐位填写数字,每一位枚举要填入的数字。对于本题来说,由于前导零对答案无影响,isNum可以省略。

dp状态:

dp[pose][count]:构造到从左往右第pose位,已经出现了 count个2,在这种情况下,继续构造最终会得到的2的个数。初始化为-1。

中止条件:

if(pose<0) return count;//中止条件

说明这一条已经填完了,返回的是这一路的数字二的个数,比如1222,返回3;1221,返回2

调取记忆:

if(!islimit&&dp[pose][count]>=0) return dp[pose][count];

//注意只有在islimit==0而且之前有记载过的时候才可以调取。

求上限:

int up=islimit?dig[pose]:9;

求上限,即求可选值。如果有上限,那么取上限值。

开始填数,temp计录每一位可选值的结果:

? ? ? ? int temp=0;//用来计数,记录在这个状态下,继续填数,一共可以得到的2的个数
? ? ? ? for(int i=0;i<=up;++i)
? ? ? ? {
? ? ? ? ? ? temp=temp+fun(pose-1,islimit&&i==dig[pose],count+(i==2),dp);
? ? ? ? }

i<=up//这一位填的数不能超过up

pose-1//填下一位数

islimit&&i==dig[pose]//是否有限制的确定

count+(i==2)//如果这一位填的数为2,计数+1

画了个示意图,方便理解temp记的是啥,返回的又是啥。

?
?

记忆化:

注意前提是没有限制。

? ? ? ? if(!islimit)//记忆化
? ? ? ? ? ? dp[pose][count]=temp;

复杂度计算:

时间复杂度O(log^2 n)

时间复杂度 = 状态个数 * 单个状态的转移次数,状态个数就是 dp 数组的长度,即 O(log^2 n) ,而单个状态的转移次数 = O(10) = O(1),所以时间复杂度为 O(log^2 n)

空间复杂度O(log^2 n)?

代码:

class Solution {
public:
    int fun(int pose,bool islimit,int count,vector<vector<int>>& dp)
    {
        if(pose<0) return count;//中止条件
        if(!islimit&&dp[pose][count]>=0) return dp[pose][count];//记忆化
        int up=islimit?dig[pose]:9;//求上限
        int temp=0;//用来计数
        for(int i=0;i<=up;++i)
        {
            temp=temp+fun(pose-1,islimit&&i==dig[pose],count+(i==2),dp);
        }
        if(!islimit)//记忆化
            dp[pose][count]=temp;
        return temp;
    }
    int numberOf2sInRange(int n) {
        while(n)//把数分成一位一位
        {
            dig.push_back(n%10);
            n=n/10;
        }
        vector<vector<int>> dp(dig.size(),vector<int>(dig.size()+1,-1));//dp状态,初始化为-1
        return fun(dig.size()-1,true,0,dp);//dfs
    }
    vector<int> dig;
};

?题目二:不含连续1的非负整数

这道题我是看了答案才会的。

?

思路:

逐位填写数字,每一位枚举要填入的数字。对于本题来说,由于前导零对答案无影响,isNum可以省略。

?dp状态:

std::vector<vector<int>> dp(dig+1,vector<int>(2,-1));

dp[pose][pre]:第pose-1位为pre时,构造从左往右第pose位及其之后数位的合法方案数

中止条件:

if(pose<0) return 1;//中止条件

说明这一条已经填完了,返回的是1,表示这一路的已经被统计了,比如10101(二进制),返回1;10100(二进制),又返回1

?调取记忆:

if (!islimit && dp[pose][pre] >= 0) return dp[pose][pre];//之前已经记录了

注意这里要!islimit

求上限:

int up = islimit ? (n >> pose)&1 : 1;

开始填数,temp计录每一位可选值(合法组合数)的结果:

这里只能填0或者0和1。要看上限是0还是1。

        //temp 统计合法的组合数
        int temp = fun(pose - 1, 0, islimit && up==0, n, dp);//如果下一个数字填0
        if(up==1&&pre==0) temp+=fun(pose-1,1,islimit&&up==1,n,dp);//如果下一个数字填1:前提是前一个数字不能为1而且可以填1

记忆化:

if (!islimit) dp[pose][pre] = temp;//记忆化

复杂度计算:

时间复杂度O(logn) //2*logn

空间复杂度O(logn) //存dp

代码:

class Solution {
public:
    int findIntegers(int n) {
        int dig = 0;
        int temp = n;
        while (temp)
        {
            dig++;
            temp = temp >> 1;
        }
        std::vector<vector<int>> dp(dig+1,vector<int>(2,-1));
        return fun(dig - 1, 0, true, n, dp);
    }
    int fun(int pose, int pre, bool islimit, int n, std::vector<std::vector<int>>& dp)
    {
        //dp状态:第pose-1位为pre时,构造从左往右第pose位及其之后数位的合法方案数
        if (pose < 0) return 1;//中止条件
        if (!islimit && dp[pose][pre] >= 0) return dp[pose][pre];//之前已经记录了
        int up = islimit ? (n >> pose)&1 : 1;
        //temp 统计合法的组合数
        int temp = fun(pose - 1, 0, islimit && up==0, n, dp);//如果下一个数字填0
        if(up==1&&pre==0) temp+=fun(pose-1,1,islimit&&up==1,n,dp);//如果下一个数字填1:前提是前一个数字不能为1而且可以填1
        if (!islimit) dp[pose][pre] = temp;//记忆化
        return temp;
    }
};

?题目三:这位灵大哥的题单

?[ 用时: 23 m 30 s ] 自己写出来了

?

思路:

逐位填数字,可以填digits的数字,也可以填0(前导零),为了区分前导零和其他数字,所以需要hasnum(isnum)来控制前导零。

dp状态:

题解是没有记录hasnum状态的,我记录了。

dp[pose][hasnum]:在hasnum的状态下,构造从左往右第pose位及其之后数位的合法方案数

vector<vector<int>> dp(dig.size(),vector<int>(2,-1));

中止条件:

if(pose<0) return 1;

hasnum状态:

? ? ? ? //hasnum==1:前面有非零的数字

? ? ? ? //hasnum==0:前面全是零(前导零)?

?比如n=9999,那么就是有四个空可以填数字。我们可以只填一位:1,也可以填两位:13,也可以填三位:135,还可以填四位:1351。

如果前面不填,比如只填了两位:0013,那么前面的0就是前导零。

注意:hasnum的状态会影响到dp,所以我们才要把dp状态里面加入hasnum状态。

? ? ? ? //比如:n=1000,当pose=1,也就是我们第一个数已经填了

? ? ? ? //第一个数填了digits里的数(比如7),即hasnum==1时,现在的填数状态:7???,那么第二位就不能填0了

? ? ? ? //第一个数填了0(前导零),即hasnum==0时,现在的填数状态:0???,那么第二位既可以填digits也可以填0

调取记忆:

if(!islimit&&dp[pose][hasnum]>=0) return dp[pose][hasnum];//之前记录了

求上限:

int up=islimit?dig[pose]:9;//上限

开始填数,temp计录每一位可选值(合法组合数)的结果:

可以填两种数,一种是digits里的数,一种是前导零。

        for(int i=0;i<=up;++i)
        {
            if(digits_hash.find(i)!=digits_hash.end())//填digits里面的数
            {
                temp+=fun(pose-1,true,islimit&&i==up,digits_hash,dig,dp);//填了所以hasnum==true
            }
            if(i==0&&hasnum==false&&pose!=0)//如果&hasnum==false,就可以继续填0
            {//pose!=0是为了防止00000全零也被算进去的情况:如果到了pose==0的时候,前面还是全零,最后一个数就不能继续填0了
                temp+=fun(pose-1,false,islimit&&i==up,digits_hash,dig,dp);//继续填0所以hasnum==false
            }
        }

记忆化:

if(!islimit) dp[pose][hasnum]=temp;//记忆化

复杂度计算:

时间复杂度O(logn)

空间复杂度O(logn)

代码:

class Solution {
    //填数字,可以填digits的数字,也可以填0(前导零)
public:
    int atMostNGivenDigitSet(vector<string>& digits, int n) {
        vector<int> dig;//把n逐位拆分
        while(n)
        {
            dig.push_back(n%10);
            n=n/10;
        }
        unordered_map<int,int> digits_hash;//把digits转为字典
        for(const string&s:digits)
        {
            digits_hash[s[0]-'0']=1;
        }
        //dp状态:在hasnum的状态下,构造从左往右第pose位及其之后数位的合法方案数
        //hasnum==1:前面有非零的数字
        //hasnum==0:前面全是零(前导零)
        //hasnum的状态会影响到dp。
        //比如:n=1000,当pose=1,也就是我们第一个数已经填了
        //第一个数填了digits里的数(比如7),即hasnum==1时,现在的填数状态:7???,那么第二位就不能填0了
        //第一个数填了0(前导零),即hasnum==0时,现在的填数状态:0???,那么第二位既可以填digits也可以填0
        vector<vector<int>> dp(dig.size(),vector<int>(2,-1));
        return fun(dig.size()-1,false,true,digits_hash,dig,dp);
    }
    int fun(int pose,bool hasnum,bool islimit,const unordered_map<int,int>& digits_hash,const vector<int>& dig,vector<vector<int>>& dp)
    {
        if(pose<0) return 1;
        if(!islimit&&dp[pose][hasnum]>=0) return dp[pose][hasnum];//之前记录了
        int up=islimit?dig[pose]:9;//上限
        int temp=0;//记录该阶段(该pose)的合法数
        for(int i=0;i<=up;++i)
        {
            if(digits_hash.find(i)!=digits_hash.end())//填digits里面的数
            {
                temp+=fun(pose-1,true,islimit&&i==up,digits_hash,dig,dp);//填了所以hasnum==true
            }
            if(i==0&&hasnum==false&&pose!=0)//如果&hasnum==false,就可以继续填0
            {//pose!=0是为了防止00000全零也被算进去的情况:如果到了pose==0的时候,前面还是全零,最后一个数就不能继续填0了
                temp+=fun(pose-1,false,islimit&&i==up,digits_hash,dig,dp);//继续填0所以hasnum==false
            }
        }
        if(!islimit) dp[pose][hasnum]=temp;//记忆化
        return temp;
    }
};

题目四:至少有 1 位重复的数字

看了答案才会的 主要是把求重复变成求非重复这里没想到,而且vis用int来表示我没想到。

思路:

? ? //关键思想:求有重复了太困难了,求不重复的比较简单

? ? //先求不重复的,然后总数-不重复的=重复的

? ? //所以:逐位填入不重复的数字

? ? //注意这题需要isnum来控制前导零的

dp状态:

vector<vector<int>> dp(dig.size(),vector<int>(1<<10,-1));

dp[pose][vis]在前面已经被用了vis的情况下,构造第pose位及其之后数位的合法方案数

为什么第二维是1<<10呢?请看vis状态。

vis状态:

vis记录了前面选过的数字。

注意:这里有一个技巧,这里的vis不是一个长度为10的vector,而是int,节省空间了。

  • 问:那么怎么记录呢?
  • 答:比如vis=(00000000101)(2进制)即(5)(10进制),意味着0和2被用过了
  • 问:为什么dp第二维是1<<10呢?
  • 答:在构造dp的时候,为了装下vis=(0000000000)~(1111111111)(2进制),所以要用(10000000000)(2进制)的大小,所以大小写成1<<10

?isnum状态:

前面是否填了除了前导零以外的数字。这个状态可以记可以不记,上一题我记了,这一题我没记是因为如果记就三维了,太多了。

中止条件:

表示这一分支已经填数字填到底了,完成了1组数字组合,返回1

if(pose<0) return 1;

调取记忆:

if(!islimit&&dp[pose][vis]>=0) return dp[pose][vis];

求上限:

int up=islimit?dig[pose]:9;

开始填数,temp计录每一位可选值(合法组合数)的结果:

这里分成两种情况,前面全是0和前面有非零的数字:

? ? ? ? 1、如果前面全为0,即if(!isnum)

? ? ? ? ? ? ? ? a、那么如果继续填零,isnum的状态不变(注意:pose不能在==0的时候再填0了,不然整个数字全是0)

? ? ? ? ? ? ? ? b、如果填了别的数,isnum的状态改变

? ? ? ? 2、如果前面有非零的数字

? ? ? ? ? ? ? ? a、?这个数之前没有被用过(vis>>i &1)==0,那么统计

? ? ? ? ? ? ? ? b、?这个数之前被用过(vis>>i &1)!=0,那么跳过

        for(int i=0;i<=up;++i)
        {//这里分成两种情况,前面全是0和前面有非零的数字
            if(!isnum)//如果前面全是0
            {
                if(i==0&&pose!=0)//填0:前面全是0,那么再加一个0,isnum还是保持不变,相当于啥都没做。pose不能在==0的时候再填0了,不然整个数字全是0
                {//所以vis不记录,这时候vis=(0000000000)
                    temp+=fun(pose-1,vis,islimit&&i==up,isnum,dig,dp);
                }//填除了0其他的
                else if(i!=0)
                {//vis|(1<<i):比如填了个2,那么1<<i:(100)(2进制) 按位或(其实这里用+也没事):将1<<i合并进vis里面
                //isnum要改成true,因为填数了
                    temp+=fun(pose-1,vis|(1<<i),islimit&&i==up,true,dig,dp);
                }  
            }
            else if((vis>>i &1)==0)//如果前面有非零数字,那么只需要判断这个数之前有没有被用过(vis>>i &1)==0
            {//同上
                temp+=fun(pose-1,vis|(1<<i),islimit&&i==up,true,dig,dp);
            }
        }

?记忆化:

注意:要在没有任何限制的情况下(!islimit&&isnum)才记忆化

if(!islimit&&isnum) dp[pose][vis]=temp;//记忆化

复杂度计算:

代码:

class Solution {
    //关键思想:求有重复了太困难了,求不重复的比较简单
    //先求不重复的,然后总数-不重复的=重复的
    //所以:逐位填入不重复的数字
    //注意这题需要isnum来控制前导零的
public:
    int numDupDigitsAtMostN(int n) {
        int m=n;
        vector<int> dig;
        while(n)
        {
            dig.push_back(n%10);
            n=n/10;
        }//把n逐位分成一个一个数字
        //dp状态:在前面已经被用了vis的情况下,构造第pose位及其之后数位的合法方案数
        //vis记录了前面选过的数字
        //注意这里有一个技巧,这里的vis不是一个长度为10的vector,而是int,那么怎么记录呢?
        //比如vis=(00000000101)(2进制)即(5)(10进制),意味着0和2被用过了
        //在构造dp的时候,为了装下vis=(0000000000)~(1111111111)(2进制),所以要用(10000000000)(2进制)的大小,所以大小写成1<<10
        vector<vector<int>> dp(dig.size(),vector<int>(1<<10,-1));
        return m-fun(dig.size()-1,0,true,false,dig,dp);
    }
    int fun(int pose,int vis,bool islimit,bool isnum,const vector<int>& dig,vector<vector<int>>& dp)
    {
        if(pose<0) return 1;//中止条件,表示这一分支已经填数字填到底了,完成了1组数字组合,返回1
        if(!islimit&&dp[pose][vis]>=0) return dp[pose][vis];//之前已经记录过了
        int up=islimit?dig[pose]:9;
        int temp=0;
        for(int i=0;i<=up;++i)
        {//这里分成两种情况,前面全是0和前面有非零的数字
            if(!isnum)//如果前面全是0
            {
                if(i==0&&pose!=0)//填0:前面全是0,那么再加一个0,isnum还是保持不变,相当于啥都没做。pose不能在==0的时候再填0了,不然整个数字全是0
                {//所以vis不记录,这时候vis=(0000000000)
                    temp+=fun(pose-1,vis,islimit&&i==up,isnum,dig,dp);
                }//填除了0其他的
                else if(i!=0)
                {//vis|(1<<i):比如填了个2,那么1<<i:(100)(2进制) 按位或(其实这里用+也没事):将1<<i合并进vis里面
                //isnum要改成true,因为填数了
                    temp+=fun(pose-1,vis|(1<<i),islimit&&i==up,true,dig,dp);
                }  
            }
            else if((vis>>i &1)==0)//如果前面有非零数字,那么只需要判断这个数之前有没有被用过(vis>>i &1)==0
            {//同上
                temp+=fun(pose-1,vis|(1<<i),islimit&&i==up,true,dig,dp);
            }
        }
        if(!islimit&&isnum) dp[pose][vis]=temp;//记忆化
        return temp;
    }
};
文章来源:https://blog.csdn.net/rainssssss/article/details/135587692
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。