1. 项目概述:从一道国赛真题看二进制问题的深度
最近在复盘蓝桥杯国赛的历年真题,2021年第十二届国赛的这道“二进制问题”让我印象尤为深刻。它不像一些纯数学题那样抽象,也不像某些工程题那样繁琐,而是巧妙地站在了计算机科学最底层的基石——二进制表示——之上,考察了我们对数位、组合以及动态规划的综合运用能力。很多刚接触算法竞赛的同学,一看到“二进制”可能首先想到的是简单的进制转换或位运算,但国赛的题目往往会把一个基础概念挖得很深,这道题就是一个典型。
简单来说,题目给定一个范围[1, N],要求我们找出这个范围内,有多少个正整数的二进制表示中恰好有K个 1。例如,N=13, K=2,那么在1到13之间,二进制表示恰好有两个1的数有:3 (11), 5 (101), 6 (110), 9 (1001), 10 (1010), 12 (1100),共6个。题目核心就是高效地计算出这个数量。N 可以非常大(比如10^18),K 相对较小,这就排除了我们直接从1遍历到N逐个判断的暴力解法,必然要求我们寻找一种基于数位特征的组合计数方法。
这道题的价值在于,它是理解“数位动态规划”(简称数位DP)一个近乎完美的入门案例。数位DP是解决此类“在某个区间内,满足特定数字特征的数有多少个”问题的利器,在竞赛和面试中都很常见。通过拆解这道题,我们不仅能学会如何计算二进制中1的个数,更能掌握一种将大范围计数问题转化为对小规模数位状态进行动态规划的通解思路。无论你是正在备赛蓝桥杯、ACM的同学,还是希望巩固动态规划与组合数学基础的开发者,相信这篇深度的拆解都能给你带来实实在在的收获。
2. 核心思路拆解:为什么暴力枚举行不通?
拿到题目,最朴素的想法就是写一个循环,从 1 遍历到 N,对每个数用位运算或除二取余法计算其二进制中 1 的个数,如果等于 K 则计数器加一。这个方法直观且不易出错,对于小规模的 N 完全可行。但是,一旦 N 的规模上升到 10^9、10^15 甚至 10^18,这个 O(N) 的时间复杂度就完全不可接受了。程序可能运行几天几夜都得不到结果。
那么,高效的出路在哪里?关键在于我们不需要关心每一个具体的数,我们只需要关心“符合条件”的数的“数量”。而二进制表示提供了一种非常结构化的视角:我们可以把数字看成是由一个个“位”组成的。对于一个上界 N,我们可以先将其转化为二进制字符串。例如 N=13,二进制是1101。
我们的目标变成了:统计所有二进制形式不超过1101(即十进制不超过13)且恰好包含 K 个1的数字的个数。这里“不超过”是关键约束。数位DP的核心思想就是:从高位到低位,依次决定每一位填0还是填1,在确保整体数字不超过上界 N 的前提下,统计满足其他条件(此处是1的个数为K)的方案数。
这个过程类似于构造数字。我们从最高位开始:
- 如果 N 的当前位是 1,那么我们可以选择在这一位填 0。如果填 0,那么后续的所有位无论怎么填(0或1),构造出的数字都一定小于 N(因为最高位已经比 N 小了)。这时,后续位就是一个“无限制”的排列组合问题。
- 如果选择在这一位填 1(前提是 N 的当前位是 1),那么我们就“贴合”了上界 N 的这一位,构造出的数字有可能等于 N,也有可能小于 N(取决于后续位)。此时,我们需要继续考察下一位,并传递一个“前面几位已经和 N 完全一致”的状态(通常称为
limit或tight状态)。
这样,我们就把一个庞大的遍历问题,转化为了一个按位决策的问题。状态由以下几个维度定义:
- 当前处理到的数位位置
pos:从最高位向最低位处理。 - 当前已经使用的 1 的个数
cnt:记录到当前位置为止,我们填了多少个1。 - 是否受到上界 N 的限制
limit:这是一个布尔值。如果为true,表示前面所有位填的数字都和 N 的对应位完全相同,那么当前位能填的最大数字受限于 N 在当前位的值(0或1)。如果为false,表示前面至少有一位我们已经填了比 N 对应位小的数,那么当前位可以自由填 0 或 1(在二进制下就是0或1)。
最终,我们要求的状态就是dp[pos][cnt][limit],表示在pos位置,已经使用了cnt个1,在limit限制下,从当前位置往后继续构造数字,最终能得到多少个满足条件(总1的个数为K)的有效数字。通过记忆化搜索(Memoization)来递归计算这个dp数组,我们可以避免大量重复的子问题计算,将复杂度降低到 O(位数 * K * 2)。对于 N <= 10^18,其二进制位数不超过60位,K通常也很小,这个复杂度是瞬间完成的。
注意:这里有一个非常重要的细节,题目要求统计的是
[1, N]区间。而我们的数位DP常规写法通常更容易处理[0, N]区间。有两种处理方式:一是先计算[0, N]的结果,然后如果 K>0 则减去0这个数(0的二进制1的个数为0);二是初始化时做一些调整。在下面的实操中,我们会采用更清晰的第一种方式。
3. 算法核心:数位动态规划的状态设计与转移
理解了思路,我们来具体设计动态规划的状态和转移方程。这是整个解法最核心的部分,我会尽量拆解得细致一些。
3.1 状态定义
我们定义一个记忆化搜索函数dfs(pos, cnt, limit):
pos(int): 当前正在处理的二进制位索引。通常我们从最高位(最左边)开始,向最低位(最右边)递归。初始化时pos为 0。cnt(int): 从最高位到pos-1位(即已经处理完的位)中,我们填了1的个数。limit(bool): 布尔标志。为true表示之前所有位填的数字恰好和 N 的对应位相同,当前位的选择受到 N 在该位值的限制;为false表示之前至少有一位填的数小于 N 的对应位,当前位可以自由选择 0 或 1。
函数的返回值是一个整数:表示在当前位置pos,已有cnt个1,处于limit限制状态下,继续向下(向低位)构造数字,最终能得到的、总1的个数恰好为 K的数字的个数。
我们用一个三维数组dp[pos][cnt][limit]来缓存这个结果,避免重复计算。由于limit只有两种状态,我们可以用 0 和 1 表示,或者直接使用两个二维数组dp0[pos][cnt]和dp1[pos][cnt]。在实现中,通常将limit作为参数,并用一个单独的dp数组,初始化时为-1表示未计算。
3.2 状态转移与递归过程
递归过程是深度优先搜索(DFS):
- 递归边界:当
pos到达最低位之后(即所有位都处理完毕),递归结束。此时,我们需要检查整个数字构造是否满足条件:即cnt是否等于 K。如果相等,说明我们成功构造了一个符合条件的数,返回 1;否则返回 0。 - 记忆化检查:如果当前状态
dp[pos][cnt][limit]已经被计算过(不为-1),则直接返回缓存的值。 - 确定当前位可选的上界:
- 如果
limit为true(受到限制),那么当前位最大能填的数字就是 N 在pos位的二进制值,我们记为up = bits[pos](bits是 N 的二进制位数组)。 - 如果
limit为false(不受限制),那么当前位可以填 0 或 1,即up = 1。
- 如果
- 枚举当前位的选择:从 0 到
up进行枚举(在二进制下,其实就是 0 和 1,所以最多两次循环)。对于每一种选择i(0 或 1):- 计算新的
cnt_next = cnt + (i == 1),即如果当前位填了1,则已使用的1的个数加1。 - 确定新的
limit_next状态:只有当旧的limit为true并且当前位填的数字i等于 N 在该位的最大值up时,新的限制状态才为true。否则为false。- 用代码表示就是:
limit_next = limit && (i == up)
- 用代码表示就是:
- 然后,递归调用
dfs(pos+1, cnt_next, limit_next),将结果累加到当前状态的答案中。
- 计算新的
- 缓存并返回结果:将计算出的结果存入
dp[pos][cnt][limit],然后返回。
3.3 一个具体的例子:N=13 (1101), K=2
让我们手动推演一下,加深理解。N=13,二进制为1101,共4位。我们将其存入数组bits = [1, 1, 0, 1](索引0是最高位)。
我们从dfs(pos=0, cnt=0, limit=true)开始,处理最高位(bits[0]=1):
- 因为
limit=true,当前位上限up = bits[0] = 1。 - 我们有两种选择:
- 选择 i=0:填0。
cnt_next = 0 + 0 = 0limit_next = true && (0 == 1) = false(因为填0小于上限1,后续位不再受限制)- 递归计算
dfs(1, 0, false)。这个子问题意味着:在剩下的3位中自由填充,需要总共凑出 K=2 个1。
- 选择 i=1:填1。
cnt_next = 0 + 1 = 1limit_next = true && (1 == 1) = true(因为填1等于上限,后续位仍然受限制)- 递归计算
dfs(1, 1, true)。这个子问题意味着:在剩下的3位中,填充的数字不能超过 N 对应的后三位101,且已有一个1,还需要再凑1个1。
- 选择 i=0:填0。
dfs(1, 0, false)这个状态非常关键。它表示我们已经处理完第一位(且填了0),并且不再受 N 的限制。那么,在剩下的3个自由位上,我们需要凑出2个1。这完全是一个组合问题:从3个位置中选2个位置放1,其余放0。组合数 C(3,2)=3。数位DP的记忆化搜索会高效地算出这个结果,而不需要真正枚举所有3位二进制数。
dfs(1, 1, true)则还需要继续带着限制往下递归。通过这样一层层分解,最终所有路径的结果加起来,就得到了总数6。
3.4 初始化与最终计算
在开始递归前,我们需要:
- 将数字 N 转换为二进制位数组。
- 初始化
dp数组所有元素为 -1。 - 调用
dfs(0, 0, true)计算[0, N]区间内满足条件的数的个数。 - 因为题目要求
[1, N],所以如果 K > 0,最终答案就是dfs(0, 0, true);如果 K == 0,则需要减去数字0(因为0不在范围内),即答案为dfs(0, 0, true) - 1。但注意,N 本身可能为0,需要特判。
4. 代码实现与逐行解析
理论清晰之后,我们来看具体的代码实现。这里以 C++ 为例,因为蓝桥杯竞赛主要使用 C++。代码会包含详细的注释。
#include <iostream> #include <cstring> #include <vector> using namespace std; // 定义全局变量和dp数组 long long dp[70][70][2]; // dp[pos][cnt][limit], 位数最多70位足够应对10^18 int bits[70]; // 存储N的二进制位 int K; // 目标1的个数 int len; // N的二进制长度 /** * 数位DP记忆化搜索函数 * @param pos 当前处理到的位索引(从最高位0开始) * @param cnt 当前已经使用的1的个数 * @param limit 当前是否受到上界N的限制 * @return 从当前状态开始,能构造出的满足条件的数字个数 */ long long dfs(int pos, int cnt, bool limit) { // 递归边界:所有位都处理完毕 if (pos == len) { // 如果当前累计的1的个数等于K,则找到一个有效数字 return cnt == K ? 1 : 0; } // 记忆化:如果当前状态已经计算过,直接返回结果 // 将limit转换为整数索引,0表示false,1表示true if (dp[pos][cnt][limit] != -1) { return dp[pos][cnt][limit]; } long long ans = 0; // 确定当前位可以填的最大数字 int up = limit ? bits[pos] : 1; // 二进制下,每位只能是0或1 // 枚举当前位所有可能的选择 for (int i = 0; i <= up; ++i) { // 计算新的已使用1的个数 int next_cnt = cnt + (i == 1); // 计算新的限制状态 bool next_limit = limit && (i == up); // 递归处理下一位,并累加结果 ans += dfs(pos + 1, next_cnt, next_limit); } // 将当前状态的结果保存到dp数组中,然后返回 dp[pos][cnt][limit] = ans; return ans; } /** * 主计算函数,统计[0, N]中二进制表示恰好有K个1的数字个数 * @param N 上界 * @return 满足条件的数字个数 */ long long solve(long long N) { if (N < 0) return 0; // 处理边界 // 1. 将N转换为二进制数组,bits[0]是最高位 len = 0; long long temp = N; // 注意这里循环条件用do-while,保证N=0时也能正确处理 do { bits[len++] = temp & 1; // 取最低位 temp >>= 1; // 右移一位 } while (temp > 0); // 反转数组,使得bits[0]存储最高位 for (int i = 0; i < len / 2; ++i) { swap(bits[i], bits[len - 1 - i]); } // 2. 初始化DP数组为-1(未计算状态) memset(dp, -1, sizeof(dp)); // 3. 从最高位开始进行记忆化搜索,初始状态:位置0,已用1的个数0,受到限制(true) return dfs(0, 0, true); } int main() { long long N; cin >> N >> K; // 计算[0, N]区间内的答案 long long ans = solve(N); // 题目要求[1, N],所以如果K>0,0(二进制无1)不会被计入,ans就是答案 // 如果K==0,那么0也被包含在solve(N)的结果中,需要减去 // 但注意,当K==0时,数字1(二进制为1)有一个1,也不符合条件,所以[1,N]区间内符合条件的数只有0?不,0不在区间内。 // 实际上,当K=0时,[1, N]区间内没有任何数的二进制表示有0个1(因为正整数至少有一个1),所以答案应为0。 // 而我们的solve(N)在K=0时,会包含数字0。因此需要修正。 if (K == 0) { // 区间[1,N]中,没有数的二进制1的个数为0,所以答案是0 cout << 0 << endl; } else { // 区间[1,N]的答案就是[0,N]的答案,因为0不符合K>0的条件 cout << ans << endl; } // 更通用的写法,兼容K=0和K>0: // long long ans = solve(N) - (K == 0 ? 1 : 0); // cout << ans << endl; // 但需要额外判断N>=0,且当N=0时,答案应为0。 return 0; }代码关键点解析:
dp数组大小:dp[70][70][2]。第一个维度70对应数位位置,因为2^60约等于1.15e18,所以10^18以内的数二进制位数不超过60,取70足够安全。第二个维度70对应已使用的1的个数,K最大可能接近位数,所以也取70。第三个维度2对应limit的两种状态。- 二进制转换:使用
do...while循环而不是while循环,是为了正确处理N=0的情况。N=0时,二进制表示就是0,len应为1。 - 记忆化搜索的驱动:
solve(N)函数完成了初始化工作,并启动递归dfs(0, 0, true)。 limit的传递逻辑:next_limit = limit && (i == up)是状态转移的精髓。只有“之前一直紧贴上限”并且“当前位也填到了允许的最大值”,后续才会继续受到限制;否则,一旦某一位填小了,后面就彻底自由了。- 区间处理:主函数中对
K==0的特殊处理是必要的。因为solve(N)计算的是[0, N]。当K>0时,0(0个1)不会被计入,所以结果就是[1, N]的答案。当K==0时,solve(N)的结果包含了0,但题目区间是[1, N],且该区间内没有符合条件的数(正整数至少有一个1),所以答案应为0。
5. 算法优化与边界情况探讨
基础的数位DP已经能完美解决问题,但我们还可以思考一些优化和边界情况,这能体现对问题的深入理解。
5.1 空间与时间优化
我们的dp数组状态是dp[pos][cnt][limit]。实际上,当limit为true时,其对应的状态是与特定的上界 N 绑定的,不同 N 计算出的结果不同,因此对于limit=1的状态,记忆化只在当前 N 的本次计算中有效,在不同 N 的多组查询间无法复用。而limit=0的状态则不同,它表示“无限制”的情况,其值只与剩余位数 (len-pos) 和还需要填的1的个数 (K-cnt) 有关,与具体的 N 无关!这是一个非常重要的优化点。
我们可以将dp数组改为dp[pos][cnt],仅用于缓存limit=0(无限制)的状态。在dfs函数中,如果limit为false,我们才查询和存储dp[pos][cnt];如果limit为true,则直接计算不缓存,因为这部分状态不可复用。这样可以节省近一半的空间,并且概念上更清晰。
long long dp[70][70]; // 只缓存limit=false的状态 long long dfs(int pos, int cnt, bool limit) { if (pos == len) return cnt == K ? 1 : 0; // 只有在无限制状态下,才使用记忆化 if (!limit && dp[pos][cnt] != -1) { return dp[pos][cnt]; } long long ans = 0; int up = limit ? bits[pos] : 1; for (int i = 0; i <= up; ++i) { ans += dfs(pos + 1, cnt + (i == 1), limit && (i == up)); } // 只有在无限制状态下,才缓存结果 if (!limit) { dp[pos][cnt] = ans; } return ans; }5.2 组合数学的直接应用(无限制情况)
当limit为false时,问题退化为:在remain_len个二进制位中,恰好放入need个 1,有多少种放法?这就是经典的组合数问题,答案直接是C(remain_len, need)。我们可以在递归中直接计算,避免进一步的递归调用,这是最强的优化。
我们需要预处理组合数C[n][m]。在dfs函数中:
long long dfs(int pos, int cnt, bool limit) { if (cnt > K) return 0; // 剪枝:如果已经用的1超过K,后续无论如何都不可能,直接返回0 if (pos == len) return cnt == K ? 1 : 0; // 无限制状态下的组合数优化 if (!limit) { int remain_len = len - pos; // 剩余位数 int need = K - cnt; // 还需要放置的1的个数 if (need < 0 || need > remain_len) { return 0; // 需要的1的个数不合法,无法达成 } // 直接返回组合数 C(remain_len, need) return C[remain_len][need]; } // ... 剩余limit=true的逻辑不变 }这种优化将无限制子问题的计算从 O(remain_len) 降到了 O(1),极大地提升了效率,尤其是在 N 很大、二进制位数很多时。
5.3 边界情况与陷阱
- N=0 的情况:题目范围是
[1, N],但输入可能给 N=0?按照题意,N 应该是正整数。但为了代码健壮性,可以处理 N=0,此时答案显然为0(无论K是多少,因为区间为空)。我们的solve函数中if (N < 0) return 0;和二进制转换的do...while能处理 N=0。 - K=0 的情况:如前所述,
[1, N]区间内没有二进制表示包含0个1的正整数。所以当 K=0 时,答案恒为0。这是最容易忽略的边界条件。 - K 大于二进制位数:如果 K 大于 N 的二进制位数,那么显然
[1, N]区间内也不可能有符合条件的数,答案也是0。可以在递归开始前或递归中进行剪枝。 - 大整数与溢出:结果可能非常大。N 最大为
10^18,符合条件的数可能很多。dp数组和返回值应使用long long(C++)或long(Java)等至少64位的整数类型。 - 多组数据输入:如果题目是多组测试数据,记得在每组数据开始前,重置
dp数组和bits数组。采用优化后的只缓存无限制状态的dp数组,重置起来更简单。
5.4 从数位DP到其他变体
掌握这道题的解法,就掌握了数位DP的基本范式。这个范式可以解决大量类似问题:
- 十进制下的数字计数:统计
[L, R]内有多少个数包含偶数个数字7,或者不含数字4,或者各位数字之和为特定值等。只需将二进制位bits数组换成十进制位,每位可选数字从0-9,up的计算相应调整即可。 - 更复杂的条件:条件不仅是1的个数,可能是1的个数模3余1,或者是0和1的某种特定模式。这时只需要增加
dp的状态维度,例如dp[pos][cnt][mod][limit],其中mod记录当前1的个数对3取模的结果。 - 求第K小的满足条件的数:这需要结合数位DP和二分查找。先用数位DP计算某个数
mid之前有多少个满足条件的数,然后通过二分找到第K个。
6. 实战调试与常见问题排查
即便理解了算法,第一次实现时也难免遇到各种问题。这里我分享几个常见的“坑”和调试技巧。
6.1 问题一:答案总是偏大或偏小
- 可能原因1:区间端点处理错误。最可能的就是
[0, N]和[1, N]没搞清楚。务必确认最终答案是否减去了数字0(当K>0时,0不影响;当K=0时,必须处理)。- 检查:用 N=1, K=1 测试。正确答案应为1(只有数字1)。如果你的程序算出2,那很可能包含了0。
- 可能原因2:
limit状态传递逻辑错误。next_limit = limit && (i == up)是核心。如果写成next_limit = limit && (i == bits[pos])在limit=false时会有问题,因为此时up=1,但bits[pos]可能是0或1。所以必须用up。- 检查:用 N=5 (101), K=1 测试。手动列举:1 (001), 2 (010), 4 (100)。答案是3。逐步调试看
limit的变化。
- 检查:用 N=5 (101), K=1 测试。手动列举:1 (001), 2 (010), 4 (100)。答案是3。逐步调试看
- 可能原因3:二进制位数组
bits存储顺序错误。确保bits[0]是最高位。常见的错误是在转换二进制后忘记反转数组。- 检查:打印出
bits数组,看是否与 N 的二进制表示一致。
- 检查:打印出
6.2 问题二:程序运行超时或递归深度过大
- 可能原因:没有使用记忆化搜索,或者记忆化状态设计错误。确保
dp数组被正确初始化(例如置为-1),并且在递归函数开头检查状态是否已计算。- 检查:对于中等大小的 N(如 10^6),程序应该在毫秒级完成。如果很慢,基本可以确定是暴力递归。
- 优化建议:务必实现上一节提到的“组合数优化”。对于无限制 (
limit=false) 的状态,直接返回组合数,这是性能提升的关键。 - 递归深度:N最大
10^18,二进制深度约60,递归深度很小,不会栈溢出。
6.3 问题三:结果溢出
- 可能原因:使用
int类型存储结果或中间状态。dp数组和函数返回值必须使用long long。组合数C(60, 30)的值非常大,远超int范围。- 检查:使用大一点的 N 和 K(如 N=10^18, K=30)进行测试。
6.4 调试技巧
- 小数据对拍:写一个暴力枚举的程序,用于 N 较小(比如 N<10000)时,与你的数位DP程序的结果进行对比。这是验证算法正确性最有效的方法。
- 打印递归树:在
dfs函数入口打印pos, cnt, limit状态,观察递归过程。这能帮你理解状态是如何转移的,特别是limit的变化。 - 重点关注边界:单独测试 N=0, N=1, K=0, K=1, K等于位数等边界情况。
- 使用静态分析:在提交前,自己心里过几组数据:
- N=0, K任意 -> 0
- N任意, K=0 -> 0 (除非题目包含0)
- N=1, K=1 -> 1
- N=2 (10), K=1 -> 2 (1: 01, 2: 10)
- N=3 (11), K=1 -> 2 (1: 01, 2: 10) // 注意3(11)有两个1,不符合K=1
6.5 一个完整的测试用例集
| N (十进制) | N (二进制) | K | 符合条件的数 (二进制) | 答案 | 说明 |
|---|---|---|---|---|---|
| 0 | 0 | 1 | 无 | 0 | 区间[1,0]为空 |
| 1 | 1 | 1 | 1 | 1 | 基础情况 |
| 1 | 1 | 0 | 无 | 0 | K=0的特殊情况 |
| 2 | 10 | 1 | 1(1), 2(10) | 2 | |
| 3 | 11 | 1 | 1(1), 2(10) | 2 | 3(11)有2个1,不计入 |
| 5 | 101 | 2 | 3(11) | 1 | |
| 13 | 1101 | 2 | 3(11),5(101),6(110),9(1001),10(1010),12(1100) | 6 | 题目样例 |
| 100 | 1100100 | 3 | 手动计算或程序跑 | 28 | 中等规模测试 |
把这些测试用例都跑通,你的程序基本就稳了。
这道“二进制问题”从一个简单的概念出发,引出了一个强大且通用的算法框架。它考察的不仅仅是编码能力,更是将复杂问题分解、抽象并应用已知算法模型(动态规划、组合数学)的思维能力。在竞赛和面试中,这种能力远比死记硬背算法模板重要。希望这篇详细的拆解,能帮你不仅搞定这一道题,更能触类旁通,在面对其他“区间计数”问题时,能立刻想到数位DP这把利器。在实际写代码时,从最基础的无优化版本开始,确保逻辑正确,再加入记忆化和组合数优化,步步为营,调试起来也会更加清晰。