当前位置: 首页 > article >正文

数位DP模板

0x00 模版特征

1给定我们一个整数 n n n,让我们在范围 [ 0 , n ] [0,n] [0,n] 之内求符合题目要求的数的个数





0x01 模版引入

1. 题目

统计特殊整数



2. 思路

首先,对于数位 d p dp dp 的题目,解决方法就是记忆化搜索
在明确了解决方法的前提下( d p dp dp + 记忆化搜索),我们开始套 d p dp dp 的模版。

  1. 状态表示: d p [ i ] [ m a s k ] dp[i][mask] dp[i][mask] 表示当前选择顺位为 i i i,已经选择了的数的状态为 j j j,构造第 i i i 位及后面位(直到结束)的最大合法方案数。
  2. 状态计算:根据最后一个不同点,(第 i − 1 i-1 i1 位的状态 )来选择第 i i i 位的状态(其实这里说是第 i i i 位不太准确,准确来说应该是从开始直到第 i − 1 i-1 i1 位)。如果前面 i − 1 i-1 i1 位等于给定数 m m m 的前 i − 1 i-1 i1 位,那么第 i i i 位的选择是受限制的,否则, i i i 可以任意取(其实也受限)。

下面解释一下状态计算。
假如给定数字 m m m 12345 12345 12345 m [ 0 ] = 1 , m [ 1 ] = 2 , . . , m [ 4 ] = 5 m[0]=1, m[1]=2, .. , m[4]=5 m[0]=1,m[1]=2,..,m[4]=5,我们可以选择的数字为 s s s, 当前处于第 2 2 2 位, i = 2 i = 2 i=2,即 m m m 中数字 3 3 3 所在位置。

如果前面 i − 1 i-1 i1 如果是 12 12 12,则 k [ 2 ] = 0 , 1 , 2 , 3 k[2]=0,1,2,3 k[2]=0,1,2,3,即, k k k 的第 i i i 为最高只能取 3 3 3,否则最高可以取到 9 9 9。(很好理解)
但是,我们无法通过第 i − 1 i - 1 i1 位就得知前 i − 1 i-1 i1 位的信息,所以我们需要记录的最后的状态应该是前 i − 1 i-1 i1 位是否等于 m m m 的前 i − 1 i-1 i1 位。( i s _ l i m i t is\_limit is_limit


需要用到的变量

好了,现在我们知道我们至少需要两个变量:一个表示当前选择的数 k k k 的前 i − 1 i-1 i1 位是否等于 m m m 的前 i − 1 i-1 i1 位。( i s _ l i m i t is\_limit is_limit)。如果等于,即受限制,则为真。
另一个则表示当前遍历到第几个数了( u u u)。
另外,由于题目要求不能出现重复的数字,所以我们还需要判重,由于 0 − 9 0-9 09 一共才 10 10 10 个数字,所以我们不必使用额外数组,哈希表之类,那太大材小用了,我们只需要一个整数( m a s k mask mask)即可,充分利用二进制的性质,用整数二进制下第 i i i 位是否为 1 1 1 来表示第 i i i 位是否被用,这样我们只需要 O ( 1 ) O(1) O(1) 的空间。
别以为这样就完了,通过题目给出的实例可以发现,最后的数 k k k 不一定和 m m m 的位数相同, k = 12 , 123 , 12345 k=12,123,12345 k=12,123,12345 都是可以的,我们应该增加一个变量( i s n u m is_num isnum)表示我们是否已经开始放置数字了,如果放置数字了,那么接下来的所有位都必须放置数字,不能跳过。 12 [ ] 45 12[]45 12[]45,这种在 12 12 12 45 45 45 之间跳过了一位肯定是不合理的。如果跳过,则为真,只有该状态为真时,才可以继续跳过。


为啥可以用记忆化

例如, m = 54321 m=54321 m=54321,当前 k = 12 ? ? ? k=12??? k=12??? 或者 k = 21 ? ? ? k=21??? k=21???,? 表示还没选,那么这两个 k k k 构成的方案数肯定是一样的。所以说我们可以保存其中一个的方案数,下一次用到直接使用,而不是再计算一次。
上例也间接说明了,方案数只与 u u u m a s k mask mask 有关,也就是我们定义的 d p dp dp 数组的两维。
但是,这里有一个限制,那就是当 i s _ l i m i t is\_limit is_limit 为真时,不可以用记忆化得到的答案。
例如还是上面的 m m m k = 54 ? ? ? k=54??? k=54??? k = 45 ? ? ? k=45??? k=45??? 得到的方案数是不一样的(显然, k = 54 ? ? ? k=54??? k=54???受约束),所以我们不能用 k = 45 ? ? ? k=45??? k=45??? 得到的答案更新 k = 54 ? ? ? k=54??? k=54??? 的方案数,


一段代码的理解

int up = is_limit ? s[u] - '0' : 9;
int low = is_num ? 0 : 1;

这里的 u p up up 容易理解,如果没有限制,可以取到9。
当我们跳过 c n t cnt cnt 个数之后,下一个数如果不跳过,肯定不能选 0 0 0,否则和跳过没啥区别。所以当 i s n u m is_num isnum 为真时,我们最小只能取 1 1 1
注意!我们不能合并代码:

if(!is_num)     res += f(u + 1, mask, false, false);
int up = is_limit ? s[u] - '0' : 9;
int low = is_num ? 0 : 1;
for(int i = low; i <= up; i ++ )         

为:

int up = is_limit ? s[u] - '0' : 9;
for(int i = 0; i <= up; i ++ )         

因为这样会出现 12 [ ] 45 12[]45 12[]45 的情况,即数字的中间被跳过了。
总之,我们要区分跳过和填充 0 0 0 在什么情况下是合适的:

  1. 如果是 [ ] [ ] [ c u r ] [ ] [ ] [][][cur][][] [][][cur][][] 的形式,此时 i s n u m is_num isnum f a l s e false false,那么 c u r cur cur 不能为 0 0 0,否则等价于跳过,但是跳过的情况我们已经特殊处理了。
  2. 如果是 [ ] [ a ] [ c u r ] [ ] [ ] [][a][cur][][] [][a][cur][][] 的形式,此时 i s n u m is_num isnum t r u e true true,那么 c u r cur cur 可以为 0 0 0,但是不能跳过。

代码解释

if(!is_limit)  dp[u][mask] = res;

为啥要加 i s l i m i t ! = 0 is_limit != 0 islimit!=0,因为有 l i m i t limit limit 限制的数的情况我们只会搜索一次,因为不需要记忆化。我们只需要记忆化那些被重复搜索的。



3. 自己理解写的代码

class Solution {
public:
    int countSpecialNumbers(int m) {
        string s = to_string(m);
        int n = s.length();
        int dp[n + 1][1 << 10];
        memset(dp, -1, sizeof dp);
        
        function<int(int, int, bool, bool)> f = [&](int u, int mask, bool is_limit, bool is_num) -> int
        {
            if(u == n)  return is_num;
            if(dp[u][mask] != -1 && !is_limit)  return dp[u][mask];
            
            int res = 0;
            if(!is_num)     res += f(u + 1, mask, false, false);
            int up = is_limit ? s[u] - '0' : 9;
            int low = is_num ? 0 : 1;
            for(int i = low; i <= up; i ++ )
            {
                if((mask >> i & 1) == 1)    continue;
                res += f(u + 1, mask | (1 << i), is_limit && i == up, true);
            }
            if(!is_limit)  dp[u][mask] = res;
            return res;
        };
        
        return f(0, 0, true, false);
    }
};


4. 题解带注释代码

class Solution {
public:
    int countSpecialNumbers(int n) {
        // 返回 1~n之间特殊整数的数目
        // 特殊整数:数字中各位互不相同
        string s = to_string(n);
        int m = s.length();
        int dp[10][1 << 10]; /* 1<<10很有说法 */
        // dp[i][j] 标识当前选择顺位为i,已经选择状态为j,构造第i位及后面位的合法方案数
        // 例如对于 n=12345
        // a=10***
        // b=01***
        // a和b能构造的特殊数字是相同的,所以说我们我们对a搜索一次,对b搜索一次,就会造成一次重复搜索
        // 因此我们需要用记忆化来消除重复搜索
        memset(dp, -1, sizeof dp);
        
        function<int(int, int, bool, bool)> f = [&](int i, int mask, bool is_limit, bool is_num) -> int
        {
            
            // 越过最后一位了,此时如果不是全部跳过(为空),就是一个合法解
            if(i == m) return is_num; 
            
            // 是否已经搜索过了
            // 记忆化去掉重复计算 **关键所在**
            // 如果已经计算过该状态:dp[i][mask] >= 0,这一点好理解,只有计算过才能修改数值
            // 并且该状态是有效的:!is_limit && is_num,重点是为啥加上这个
            // 首先,当前位是不能有限制的,例如还是n=54321
            // 我们不可能对形如a=1****,12***,123**,1234*的形式进行重复搜索
            // 通过上面的10***,01***你应该可以看出来,10和01之后的***具有相同的**选择特征**,即可以随便选
            // 所以说它们的搜索是相同的,但是对a=1****,12***,123**,1234*的形式
            // a的搜索是受限制的,也就是说后面的***不能随便选1-9
            // 另外,当前数字必须是有效的,为啥呢?
            // 。。。
            // 其实受限制和为空(无效)这两种情况整个搜索只会出现一次,不存在和他同类型的重复搜索
            // 如果我们利用其他搜索得出的结果代替他,显然是不对的。
            
            // 例如n=54321
            // a=543**,b=345**
            // 此时虽然i和mask相等,但不能代替
            
            // 去掉is_num也能过???
            // 
            
            // if(dp[i][mask] != -1 && !is_limit && is_num)    return dp[i][mask];
            if(dp[i][mask] != -1 && !is_limit)    return dp[i][mask];
            
            int res = 0;
            if(!is_num) res = f(i + 1, mask, false, false); // skip to next
            for(int d = 1 - is_num, up = is_limit ? s[i] - '0' : 9; d <= up; d ++ )
            {
                if((mask >> d & 1) == 0)    // not used
                {
                    res += f(i + 1, mask | (1 << d), is_limit && d == up, true);
                }
            }
            if(!is_limit && is_num)    dp[i][mask] = res;
            return res;
        };
        // 一开始最高位是有限制的,如果没有限制的话,那么第0位可以任选,这肯定是不合理的。
        return f(0, 0, true, false);
    }
};


5. 别人写的注释

class Solution {
    int[][] memo;   // memo[i][mask]记录当前选择顺位为i,已选状态为mask时,构造第i位及后面位的合法方案数
    char[] s;

    public int countSpecialNumbers(int n) {
        /*
        参考灵神の数位DP记忆化DFS模板:
        注意这题与LC1012是一样的,不过这题更直接求每一位都不相同数字
        dfs(i, mask, isLimit, hasNum) 代表从左到右选到第i个数字时(i从0开始),前面数字已选状态为mask时的合法方案数
        各个参数的含义如下:
        i:当前选择的数字位次,从0开始
        mask:前面已择数字的状态,是一个10位的二进制数,如:0000000010就代表前面已经选了1
        isLimit:boolean类型,代表当前位选择是否被前面位的选择限制了;
            如n=1234,前面选了12,选第3位的时候会被限制在0~3,isLimit=true;否则是0~9,isLimit=false
        hasNum:表示前面是否已经选择了数字,若选择了就为true(识别直接构造低位的情况)
        时间复杂度:O(1024*M*10) 空间复杂度:O(1024*M)
        记忆化DFS的时间复杂度=状态数*每一次枚举的情况数
        **记忆化本质就是减少前面已选状态一致的情况,将1eM的时间复杂度压缩至1<<M,效率非常高**
         */
        s = String.valueOf(n).toCharArray();    // 转化为字符数组形式
        int m = s.length;
        memo = new int[m][1 << 10];     // i∈[0,m-1],mask为一个10位二进制数
        // 初始化memo为-1代表该顺位下该已选状态还没进行计算
        for (int i = 0; i < m; i++) {
            Arrays.fill(memo[i], -1);
        }
        // 注意一开始最高位是有限制的,isLimit=true
        return dfs(0, 0, true, false);
    }

    // dfs(i, mask, isLimit, hasNum) 代表从左到右选第i个数字时,前面已选状态为mask时的合法方案数
    private int dfs(int i, int mask, boolean isLimit, boolean hasNum) {
        // base case
        // i越过最后一位,此时前面选了就算一个,没选的就不算,因为不选后面也没得选了
        if (i == s.length) return hasNum ? 1 : 0;
        // 已经计算过该状态,并且该状态是有效的,直接返回该状态
        // 这一步是降低时间复杂度的关键,使得记忆化dfs的时间复杂度控制得很低
        // !isLimit表示没有被限制的才可以直接得出结果,否则还要根据后面的数字进行计算子问题计算
        if (!isLimit && hasNum && memo[i][mask] != -1) return memo[i][mask];
        int res = 0;    // 结果
        // 本位可以取0(可直接构造低位数)的情况,此时要加上构造低位数0xxx的方案数
        // 将是否选了数字作为分类条件是为了避免出现00010这样有多个0的就不能统计了
        if (!hasNum) res = dfs(i + 1, mask, false, false);
        // 构造与当前顺位相同位数的数字就要枚举可选的数字进行DFS
        // 枚举的起点要视hasNum而定,如果前面选择了数字,那么现在可以选0;否则只能从1开始
        // 枚举得终点视isLimit而定,若被限制了只能到s[i],否则可以到9
        for (int k = hasNum ? 0 : 1, end = isLimit ? s[i] - '0' : 9; k <= end; k++) {
            // 如果该数字k还没有被选中,那猫就可以选该位数字
            if (((mask >> k) & 1) == 0) {
                // 方案数遵循加法原理
                // i:进行下一位的DFS,因此为i+1
                // mask:由于该位选中了k,mask掩膜传下去就要更新,已选状态加上k
                // isLimit:当且仅当前面的被限制了且该位被限制
                // hasNum:该位选了必定为true
                res += dfs(i + 1, mask | (1 << k), isLimit && k == end, true);
            }
        }
        if (!isLimit && hasNum) memo[i][mask] = res;    // 如果前面没有限制,表明后面都是同质的,可以记录进memo中
        return res;
    }
}




0x02 例题

1. 数字1的个数

1.1 思路

由于本题前导 0 0 0 不影响结果,所以不再需要这个参数。
这里的 d p [ i ] [ j ] dp[i][j] dp[i][j] 表示当前顺位到第 i i i,已经有 j j j 1 1 1 的方案数。第二维是必要的,例如 n = 55555 55555 55555,那么 11 ? ? ? 11??? 11??? 34 ? ? ? 34??? 34??? 的方案数肯定是不同的。
参考



1.2 代码
class Solution {
public:
    int countDigitOne(int m) {
        string s = to_string(m);
        int n = s.length();
        int dp[n][n]; // dp[i]:当前位即之后所有位能构成的所有方案中 1 的个数
        memset(dp, -1, sizeof dp);
        
        function<int(int, int, bool)> f = [&](int u, int cnt, bool is_limit) -> int {
            if(u == n)  return cnt;
            
            if(dp[u][cnt] != -1 && !is_limit)    return dp[u][cnt];
            
            int res = 0;
            
            // if(!is_num) res = f(u + 1, cnt, false, false);
                
            int up = is_limit ? s[u] - '0' : 9;
            for(int i = 0; i <= up; i ++ )
            {
                res += f(u + 1, cnt + (i == 1), is_limit && (i == up));
            }
            
            if(!is_limit)  dp[u][cnt] = res;
            return res;
        };
        
        int res = f(0, 0, true);
        return res;
    }
};


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

2.1 思路

这一题由于题目要求我们不能在二进制的角度上包含连续的 1 1 1,所以说我们不能再按照数字的十进制下可以选( 0   9 0~9 0 9)的数转移状态,而是要按照二进制下可以选的数( 0   1 0~1 0 1)转移状态。
另外再说一下,我们的数字都是由限制的( i s l i m i t is_limit islimit),我们需要记录限制是否存在才能判断这个数起码时合法的( < = n <=n <=n),我们只有从数字的高位开始遍历,才能知道这个数是否受限制,从低位遍历是无法判断的,所以说,这里我们要从二进制的高位开始遍历。
另外,很显然我们需要一个变量来记录上一位是否为 1 1 1,这样我们才能判断该位能否选 1 1 1
剩下的就看代码吧。



2.2 代码
class Solution {
public:
    int findIntegers(int m) {
        // 得到 m 的最高位是第几位
        int n = 0;
        for(int i = 30; i >= 0; i -- ) {  
            if(m >> i & 1)  {
                n = i;
                break;
            }
        }
        cout << "n: " << n << ' ' << __lg(m) << endl;
        
        // dp[n][pre] 表示当前顺位到第n位,选择第 n 位及后面所有位并且第 n - 1 位选择的数位 pre 的方案数
        // 这里不需要判断前导 0,因为 0 对答案没有影响
        int dp[n + 1][2];   // 多开一点总没问题
        memset(dp, -1, sizeof dp);
        
        function<int(int, bool, bool)>f = [&](int u, bool pre, bool is_limit) -> int {
            // 使用 bool 类型存储上一位是 0/1 可以节省空间,虽然没啥用
            if(u < 0)   return 1;
            if(!is_limit && dp[u][pre] != -1) return dp[u][pre];
            
            int res = 0;
            bool up = is_limit ? (m >> u & 1) : 1;
            res = f(u - 1, 0, is_limit && (up == 0));
            if(!pre && up)  // 只有不超过限制 (<=m) 并且上一位不是 1 才可以选 1
                res += f(u - 1, 1, is_limit && (up == 1));
            if(!is_limit)   dp[u][pre] = res;
            return res;
        };
        
        return f(n, 0, true);
    }
};


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

3.1 思路

这道题还是比较考验思维能力的,直接求至少有一位重复数字并不好求,例如 m = 55555 m = 55555 m=55555 k = 12 ? ? ? k=12??? k=12??? k = 11 ? ? ? k = 11??? k=11???,我们不能用前面的 k k k 得到的方案数取更新后面的 k k k,因为后面的 k k k 本身就符合条件,后三个数任选就行,但是对于前面的 k k k 就不可以任选了。
但是!求不包含重复数字的个数还是很好求的。因此,我们逆向思维。 用总数字个数减去不包含重复的就是包含重复的了。



3.2 代码
class Solution {
public:
    int numDupDigitsAtMostN(int m) {
        string s = to_string(m);
        int n = s.length();
        int dp[n][1 << 10];
        memset(dp, -1, sizeof dp);
        
        function<int(int, int, bool, bool)> f = [&](int u, int mask, bool is_limit, bool is_num) -> int {
            
            if(u == n)  return is_num;
            if(dp[u][mask] != -1 && !is_limit && is_num)  return dp[u][mask];
            
            int res = 0;
            if(!is_num) res += f(u + 1, mask, false, false);
            
            int up = is_limit ? s[u] - '0' : 9;
            int low = is_num ? 0 : 1;
            for(int i = low; i <= up; i ++ )
            {
                if(mask >> i & 1)  continue;
                res += f(u + 1, mask | (1 << i), is_limit && (i == up), true);
            }
            
            if(!is_limit && is_num)  dp[u][mask] = res;
            return res;
        };
        
        int res = f(0, 0, true, false);
        // cout << "res: " << res << endl;
        return m - res;
    }
};




0x03 参考

作者用到的C++新特性 function<>


思路&&相似题目&&模板


int stoi(string s),字符串转 i n t int int ,需要保证转换的字符串不能为空,转换后不能大于 i n t int int 的范围。

0x04 其他题目

https://www.acwing.com/activity/content/problem/content/1009/


http://www.kler.cn/a/613569.html

相关文章:

  • arm64位FFmpeg与X264库
  • 【云成本优化案例】K8s计费探针让跨境电商企业节省30%云预算
  • 视频生成的测试时Scaling时刻!清华开源Video-T1,无需重新训练让性能飙升
  • django报错:RuntimeError: populate() isn‘t reentrant
  • open-cv的安装
  • Jackson相关问题
  • 高级java每日一道面试题-2025年3月14日-微服务篇[Eureka篇]-Eureka如何保证高可用性?
  • 3D Gaussian Splatting部分原理介绍和CUDA代码解读(一)——3D/2D协方差和高斯颜色的计算
  • MLP(Multilayer Perceptron, 多层感知机)
  • Supabase 匿名密钥与服务角色密钥详细对比文档
  • 初识MySQl · 内置函数
  • LangChain 文档加载完全指南:从PDF到YouTube的多样化数据源处理
  • 人工智能:officeAI软件,如何调整AI对话界面的字体?
  • 【图片识别Excel表格】批量将图片上的区域文字识别后保存为表格,基于WPF和阿里云的项目实战总结
  • Jupyter Notebook :美化读取到的JSON格式的数据(以表格形式呈现)
  • 【go微服务】Golang微服务之基--rpc的实现原理以及应用实战
  • Android - 2025年安卓真的闭源了吗
  • 诠视科技MR眼镜如何安装apk应用
  • TensorFlow之sparse tensor
  • STM32 CAN学习(一)