news 2026/9/3 13:19:45

数位DP精讲:从二进制计数到通用框架,解决蓝桥杯国赛难题

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
数位DP精讲:从二进制计数到通用框架,解决蓝桥杯国赛难题

1. 从一道国赛真题说起:二进制与数位DP的碰撞

最近在整理蓝桥杯的历年国赛真题,第十二届那道“二进制问题”让我印象挺深。这题表面上是问在1到N的整数中,有多少个数的二进制表示里恰好有K个1。乍一看,这题用暴力枚举好像也能做?但仔细一想,N的上限是10^18,这直接遍历的念头可以趁早打消了。这题明摆着是数位动态规划(数位DP)的经典应用场景。很多同学一听到“数位DP”就觉得头大,感觉是竞赛里的“高端”技巧,其实它的核心思想非常朴素,就是“按位考虑,记忆化搜索”,用来解决这类与数字的数位(无论是十进制还是二进制)性质相关的计数问题,是再合适不过的工具。今天,我就结合这道国赛真题,把二进制场景下的数位DP从思路到代码,再到容易踩的坑,给大家彻底捋清楚。无论你是正在备赛蓝桥杯,还是对算法中的这种精巧思想感兴趣,相信这篇都能给你带来可以直接“抄作业”的收获。

2. 问题重述与暴力解法的死胡同

我们先明确一下题目:给定一个非常大的正整数 N(1 ≤ N ≤ 10^18),和一个非负整数 K(0 ≤ K ≤ 60),我们需要求出区间 [1, N] 内,所有满足“其二进制表示中‘1’的个数恰好为 K”的整数 x 的个数。

为什么暴力枚举行不通?我们来算笔账。N最大是10^18,约等于2^60。也就是说,最坏情况下我们需要检查大约1e18个数。即使你的计算机每秒能处理1亿(1e8)次运算,也需要1e10秒,这超过300年。显然,这条路是走不通的。这迫使我们必须寻找一种与数字大小“无关”,而与数字的“位数”相关的算法。数位DP正是为此而生,它的时间复杂度通常为 O(位数 * 状态数),在这里就是 O(60 * 60 * 2),完全在可接受范围内。

这里就引出了数位DP的一个核心思想:我们不直接枚举数字,而是枚举构成数字的每一个数位(bit)的可能取值,并在枚举过程中动态维护我们关心的状态(在这里就是当前已经出现的‘1’的个数)。这就像我们写一个多位数的密码锁,我们不是去试每一个可能的密码数字,而是从最高位开始,一位一位地决定这个数字的构成,同时记录下到当前位为止,我们已经用了几个“1”。

3. 数位DP的核心框架与记忆化搜索

数位DP通常采用记忆化搜索(DFS + Memoization)的实现方式,因为它写起来思路清晰,易于理解。整个框架可以分解为以下几个关键部分:

3.1 状态定义与DFS函数设计

我们设计一个递归函数dfs(pos, count, isLimit)

  • pos(当前位):表示当前正在处理二进制数字的第几位。通常我们从最高位(最左边)开始处理,向最低位(最右边)递归。对于N最大为2^60,我们考虑60位二进制位(实际上可能用不到60位,但为了统一,我们可以将数字看作一个固定60位的二进制串,高位不足补0)。
  • count(当前状态):表示从最高位处理到pos位之前,已经累计出现了多少个‘1’。这是我们关心的核心状态。
  • isLimit(是否受到限制):这是一个非常关键且容易出错的参数。它表示当前位pos的取值是否受到前缀的约束。
    • 如果isLimit == true,意味着之前所有高位(pos之前)的取值,已经和我们的上界 N 的对应位完全一致。那么当前位pos能取的最大值,不能超过 N 在pos位上的值(0或1)。
    • 如果isLimit == false,意味着之前的高位中,至少有一位已经填了一个比 N 对应位小的数(比如N的该位是1,我们填了0)。那么从这一位开始,后面的所有位都可以自由地在0和1之间选择,不再受 N 的限制。理解这一点是理解整个算法为何高效的关键。

3.2 记忆化搜索的“记忆”什么?

记忆化搜索是为了避免重复计算。我们用一个数组dp[pos][count]来缓存计算结果。但是,这里有一个至关重要的细节:dp数组只能缓存当isLimit == false时的结果!

为什么?因为isLimit == true的情况是与当前具体的上界 N 紧密绑定的,它表示一条“紧贴着上界”的路径。这条路径在整个搜索过程中很可能是唯一的,或者与其他isLimit == false的路径不通用,缓存它没有意义,反而可能出错。而isLimit == false的情况代表“已经脱离上界限制”的路径,此时后续位的选择是自由的,其结果(从当前poscount状态开始,能构造出多少满足条件的数)是通用的,可以被缓存和复用。

所以,我们的记忆化逻辑是:在DFS函数开始时,如果isLimit == false,并且dp[pos][count]已经计算过,则直接返回缓存值。否则,进行计算,并在返回前(同样仅在isLimit == false时)将结果存入dp数组。

3.3 递归的流程与决策

在每一层递归中(即处理第pos位时),我们需要决定这一位填0还是填1。

  1. 确定当前位能取值的上限up:如果isLimit为真,则up等于 N 在pos位上的值(0或1);否则up为1(二进制位最大就是1)。
  2. 枚举当前位i从 0 到up
    • 计算新的状态next_count = count + (i == 1 ? 1 : 0)。即如果这一位填了1,则已使用的‘1’的个数加1。
    • 计算新的限制状态next_isLimit = isLimit && (i == up)。这意味着,只有当前位也“顶格”取了上限值,且之前的状态本来就是受限的,传递给下一位的限制状态才继续为真。否则,只要当前位没取到上限,或者之前已经不受限了,那么下一位就自由了。
  3. 递归调用dfs(pos-1, next_count, next_isLimit),将所有可能的后续路径的结果累加,即为当前状态下的方案数。

3.4 递归边界与结果返回

pos变为 -1(或0,取决于你的起始定义)时,意味着所有位都已经处理完毕。此时,我们检查状态count是否等于目标 K。如果相等,则找到一种合法数字,返回1;否则返回0。

最终,我们调用dfs(start_pos, 0, true)start_pos是最高有效位的位置,初始计数为0,并且初始状态是受到限制的(isLimit = true),因为我们一开始构造的数字不能超过N。

4. 针对“二进制问题”的代码实现与逐行解析

理论说完了,我们来看具体代码。这里提供一个清晰的C++实现,并加上详细注释。

#include <iostream> #include <cstring> #include <vector> using namespace std; typedef long long ll; ll N; int K; // dp[pos][count] 记忆化数组,pos范围[0, 64], count范围[0, 60] ll dp[65][65]; // 存储数字N的二进制位,bits[0]是最低位(个位),方便循环处理 vector<int> bits; // 记忆化搜索函数 // pos: 当前处理到的位索引(从最高位向最低位走,初始为最高位索引) // cnt: 当前已经使用的‘1’的个数 // limit: 当前是否受到上界N的限制 ll dfs(int pos, int cnt, bool limit) { // 递归边界:所有位都处理完了 if (pos < 0) { // 如果使用的‘1’的个数恰好等于K,则这是一个合法数字 return cnt == K ? 1 : 0; } // 记忆化:只有在不受限制时,结果才是通用的,可以缓存 if (!limit && dp[pos][cnt] != -1) { return dp[pos][cnt]; } // 计算当前位能取的最大值 int up = limit ? bits[pos] : 1; // 二进制位,不受限时最大为1 ll res = 0; // 枚举当前位取0或1 for (int i = 0; i <= up; ++i) { // 计算新的‘1’的计数 int next_cnt = cnt + (i == 1); // 如果新的计数已经超过K,后续无论如何填都不可能满足条件,剪枝 // 这是一个重要的优化,可以提前结束无效分支 if (next_cnt > K) { continue; } // 计算传递给下一位的限制状态 // 只有当前位也取到了上限值,且之前是受限的,下一位才继续受限 bool next_limit = limit && (i == up); // 累加后续所有位的方案数 res += dfs(pos - 1, next_cnt, next_limit); } // 只有在不受限时,才将结果存入记忆化数组 if (!limit) { dp[pos][cnt] = res; } return res; } // 主求解函数,计算[1, N]中满足条件的数的个数 ll solve(ll n, int k) { N = n; K = k; // 初始化记忆化数组为-1,表示未计算 memset(dp, -1, sizeof(dp)); bits.clear(); // 将数字N分解为二进制位,存入bits // 这里bits[0]存的是最低位,方便索引 ll temp = N; while (temp > 0) { bits.push_back(temp & 1); // 取最低位 temp >>= 1; // 右移一位 } // 如果N是0,bits为空,需要特殊处理。但题目N>=1,所以这里不考虑。 // 注意:此时bits的最后一个元素是N的最高有效位。 // 例如 N=5 (101),bits = [1, 0, 1],索引0是低位1,索引2是高位1。 // 从最高位开始搜索。最高位索引是 bits.size() - 1 // 初始计数为0,初始状态是受限的(limit = true) return dfs(bits.size() - 1, 0, true); } int main() { ll n; int k; // 题目输入 cin >> n >> k; // 调用求解函数 ll ans = solve(n, k); cout << ans << endl; return 0; }

代码关键点解析:

  1. 二进制位存储bits向量存储N的二进制表示,bits[0]是最低位。这样在递归时,pos从最高位索引 (bits.size()-1) 开始递减到0,符合我们从高到低思考的习惯。
  2. dfs参数pos:它表示当前处理的是bits容器中的第pos个元素(即从低到高数的第pos位)。在递归调用时传递pos-1,就是处理下一位(更低一位)。
  3. 剪枝优化if (next_cnt > K) continue;这行代码非常关键。一旦当前路径累积的‘1’已经超过了K,那么无论后面怎么填0,总数都会超过K,这条路径不可能产生合法结果,直接跳过,节省了大量不必要的递归。
  4. 记忆化的条件if (!limit) dp[pos][cnt] = res;再次强调,只有不受限的状态才能被缓存。

5. 从理解到精通:数位DP的易错点与实战技巧

理解了框架和代码,不代表实战中就能一次写对。下面是我在多次做题和教学中总结的几个容易踩坑的地方和对应的技巧。

5.1 关于“前导零”的处理

问题:在我们这道二进制题里,前导零(即二进制表示中高位的0)会影响‘1’的计数吗?比如数字5(101)和数字5看作4位二进制的0101,其中‘1’的个数都是2个,所以在这个特定问题里,前导零不影响结果。因此我们的代码没有特殊处理前导零。

陷阱与扩展:但是,在很多其他数位DP问题中,前导零是必须处理的。例如,统计数字中“非零数字”的个数、处理数字回文、或者某些数字的数值特性时,前导零的存在会干扰状态定义。通常的处理方法是:

  • 在DFS函数中增加一个状态isLead,表示当前位之前是否全是前导零。
  • isLead为真且当前位填0时,isLead继续保持为真,并且count状态不更新(因为前导零不计入统计)。
  • isLead为真且当前位填了非零数时,isLead变为假,开始正式计数。
  • 记忆化时,需要将isLead也作为一个维度(通常只缓存isLead==false的状态,因为前导零状态也是与具体路径相关的)。

5.2 记忆化数组的维度与初始化

维度:我们的dp[pos][cnt]是二维的。pos的维度至少要等于最大位数(这里取65很安全)。cnt的维度至少要等于可能的最大‘1’的个数,二进制下就是最大位数,所以也取65。如果问题有更多状态(比如是否包含某个数字、奇偶性等),就需要增加维度。

初始化:务必在每次求解一个新的问题(即新的N和K)时,重新初始化dp数组为-1(或其他未计算标记)。因为dp缓存的是!limit状态下的结果,这个结果是通用的,但只针对相同的数字上限N的二进制长度和相同的K吗?仔细看,dp[pos][cnt]的含义是:在不受原始数字N限制的情况下,从第pos位开始,当前已有cnt个1,后续能组成的所有数字中,满足总‘1’的个数为K的方案数。这个结果实际上与具体的N值无关,只与剩余位数(pos)和当前计数(cnt)有关。所以,如果我们连续求解多个不同N但相同K的问题,理论上可以不清空dp数组,因为状态是通用的。但为了避免混淆和潜在错误(比如K变了),最稳妥的做法还是在solve函数内初始化。

5.3 递归边界的多样性

我们的边界是pos < 0。有时也可以定义pos == 0时处理最低位,然后边界是pos == -1。关键是保持一致。在边界处,要根据题目要求返回正确的值。本题是计数,所以返回1或0。如果是求满足条件的数字之和,边界就可能需要返回数字本身或0,并在递归过程中拼接数字。

5.4 如何调试数位DP

数位DP的递归树可能很深,直接跟踪比较困难。我的调试技巧是:

  1. 小数据暴力对拍:写一个朴素的暴力程序,枚举1到一个小范围的M(比如1000),统计答案。然后用你的数位DP程序去计算solve(M, K),对比结果是否一致。这是最有效、最根本的调试方法。
  2. 打印递归日志:在DFS函数入口打印pos, cnt, limit的值,在返回前打印计算结果。观察哪些状态被重复计算了(记忆化生效),哪些路径被剪枝了。这能帮你理解算法的执行流程。
  3. 检查记忆化逻辑:重点确认!limit的条件判断是否正确。可以尝试去掉记忆化,用小数据看结果是否一样(速度会慢,但可用于验证逻辑正确性)。

6. 性能分析与算法扩展思考

对于本题,N最大为10^18,二进制位数最多约为60位。我们的状态数是pos(60) *cnt(60) ≈ 3600。每个状态计算时需要枚举0和1两种可能。所以总的时间复杂度大约是 O(60 * 60 * 2) = O(7200),忽略常数后就是 O(m^2),其中m是位数。空间复杂度是 O(m^2)。对于现代计算机来说,这几乎是一瞬间的事情。

扩展思考:如果问题变成十进制呢?比如,求1到N之间,各位数字之和为S的数的个数。思路完全一样!

  1. 数位变成十进制(0-9)。
  2. 状态count变为当前数字之和。
  3. 递归枚举时,当前位i从0枚举到upup在受限制时为N的当前位数字,否则为9)。
  4. 剪枝条件变为next_sum > S
  5. 记忆化数组dp[pos][sum]。 框架完全通用,这体现了数位DP作为一种“方法论”的强大之处。

再扩展:求满足条件的数字之和,而不仅仅是个数。这时状态需要携带更多信息。通常,我们让DFS函数返回一个结构体或pair,里面包含两个值:(count, sum),即从当前状态出发,能构成的合法数字的个数,以及这些数字的总和。

  • 在递归边界,如果合法,返回(1, 0)(个数为1,但当前数字和为0,因为还没数字)。
  • 在递归过程中,当枚举当前位填i时,得到子状态的(sub_count, sub_sum)。那么当前状态通过填i得到的贡献是:个数为sub_count,而总和需要加上i放在当前位所代表的值(即i * (10^pos)i * (2^pos))乘以sub_count,因为i这个值会在sub_count个数字中出现。
  • 最后汇总所有i的贡献。记忆化也需要相应地缓存这个pair

7. 总结与个人心得

数位DP的本质是一种基于数位的、带状态记忆的深度优先搜索。它通过“逐位构造数字”的方式,将指数级的大范围枚举问题,转化为关于“位数”的多项式时间问题。其核心难点和精髓在于状态的设计限制(limit)的处理

在实际写代码时,我个人习惯遵循以下步骤:

  1. 确定状态:题目问什么,什么信息需要在不同数位间传递和决策,这些就是状态。本题是“1的个数”,所以状态是cnt
  2. 设计DFS函数:参数至少包含pos,状态,limit。考虑是否需要isLead(前导零状态)。
  3. 确定记忆化维度:状态有哪些,dp数组就开几维。记住只记忆化!limit的状态。
  4. 明确递归边界及返回值:根据问题是求个数、和、最大值等,确定边界返回什么。
  5. 当前位的决策:根据limit确定枚举范围,进行递归调用,并更新状态。
  6. 剪枝:在枚举前或递归前,判断当前状态是否已经不可能达到目标(如cnt > K),提前返回,提升效率。

最后,再提一个容易忽略的点:题目问的是 [1, N],我们的算法通常处理的是 [0, N]。如果0不符合条件(比如本题要求二进制中1的个数为K,而0的二进制没有1,除非K=0,否则0不合法),那么我们的算法结果就是对的。如果0符合条件,而题目要求从1开始,只需要在最终结果中减去1(如果0合法)或者直接计算即可。本题中,当K=0时,0是合法的(0个1),但区间是[1, N],所以0不应该被计入。我们的算法solve(N, K)计算的是[0, N]中满足条件的数的个数。因此,当K=0时,最终答案应该是solve(N, K) - 1(减去0这个数)。当K>0时,0本身就不合法,所以solve(N, K)直接就是答案。这是一个边界情况,在比赛中需要仔细考虑。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/3 13:19:36

基于Google Earth Engine的遥感生态指数自动化计算系统构建

简介&#xff1a;遥感生态指数是综合评估区域生态环境质量的重要指标&#xff0c;它通过主成分分析等方法&#xff0c;融合绿度、湿度、干度和热度等多个基础参量&#xff0c;实现对生态状况的全面刻画。其核心原理在于利用多光谱遥感数据&#xff0c;通过缨帽变换等经典方法提…

作者头像 李华
网站建设 2026/9/1 5:20:43

音视频开发项目-1开篇

开篇&#xff1a;这个项目要做什么&#xff0c;我是怎么设计的系列目录&#xff1a; 01 开篇&#xff1a;项目与整体设计&#xff08;本篇&#xff09;02 从主函数开始看起03 RKMedia 三大模块初始化&#xff08;VI / VENC / RGA&#xff09;04 通道绑定与任务分发05 数据是怎么…

作者头像 李华
网站建设 2026/9/3 13:19:28

贪心算法与二分答案实战:从“书页”问题看最小化最大值的经典解法

1. 项目概述&#xff1a;从一道模拟赛题看贪心算法的实战拆解最近在整理过去的算法竞赛题目&#xff0c;翻到了这道“书页”题。它来自一场模拟赛&#xff0c;标签是“贪心”&#xff0c;但实际做下来&#xff0c;发现远不止一个“贪”字那么简单。很多刚接触贪心算法的朋友&am…

作者头像 李华
网站建设 2026/8/31 11:34:56

432道MySQL面试题 161 - 180 题

为方便阅读,这里整理了整个系列的索引导航。本系列共 432 道 MySQL 面试题,按每 20 题为一篇进行连载,点击下方链接即可跳转到对应章节,方便你按需查阅、系统复习。 432道MySQL面试题 1 - 20 题 432道MySQL面试题 21 - 40 题 432道MySQL面试题 41 - 60 题 432道MySQL面试题…

作者头像 李华
网站建设 2026/8/31 23:42:26

解决超声波测距稳定增长问题:从HC-SR04原理到STC15单片机代码优化

1. 项目概述&#xff1a;从“稳定增长”现象切入超声波测距核心最近在准备蓝桥杯单片机竞赛&#xff0c;特别是国赛和客观题部分&#xff0c;很多同学在调试超声波测距模块时&#xff0c;都会遇到一个经典又让人头疼的问题&#xff1a;代码烧录进去&#xff0c;超声波模块的返回…

作者头像 李华