CSP-J真题解析:二进制位运算实现“优秀的拆分”算法
发布时间:2026/8/23 21:39:29 作者:尧图编辑部 阅读量:1,286

1. 项目概述从一道真题看信息学竞赛的思维训练如果你接触过信息学奥赛的普及组CSP-J那么“优秀的拆分”这道题绝对是一个绕不开的经典。作为2020年CSP-J的第一题T1它看似简单却精准地考察了选手对计算机基础——二进制表示——的理解以及将数学思维转化为代码逻辑的能力。很多新手第一次看到题目可能会懵拆分怎么拆优秀的标准是什么其实这道题的本质是要求你将一个给定的正整数表示为若干个互不相同的2的正整数次幂之和。如果能够做到就输出这些幂次如果不能就输出-1。这听起来有点像把一个数字“翻译”成2的幂次方的“单词”组合并且每个“单词”只能用一次。为什么是2的幂次方因为这是计算机世界的“母语”。计算机内部存储和处理数据最底层就是二进制的0和1。每一个2的幂次方在二进制里就对应着某一位上的一个“1”。所以“优秀的拆分”问题实质上就是将一个十进制整数转换为其二进制表示中所有值为1的位所对应的2的幂次方并且要从大到小输出。这道题的价值远不止于让你通过一次考试。它像一把钥匙帮你打开理解数据在计算机中如何存储、如何高效运算的大门。无论是准备竞赛的学生还是希望夯实编程基础的开发者深入理解这个问题背后的原理和实现技巧都大有裨益。2. 核心思路解析二进制是唯一的钥匙要解决“优秀的拆分”最核心、最高效的思路就是利用二进制。我们不需要去暴力枚举所有2的幂次方的组合那样效率太低。我们需要理解十进制数和二进制数之间深刻的联系。2.1 二进制表示与拆分的等价关系让我们从一个具体的例子开始。假设题目给出的数字n 11。我们首先将11转换为二进制11十进制 1011二进制。这个二进制数1011从右向左从低位到高位每一位的权重分别是 2^01, 2^12, 2^38。因此11 1*8 0*4 1*2 1*1 8 2 1。看我们自然而然地得到了一个由2的幂次方8, 2, 1组成的和。这正是题目要求的“拆分”。并且由于二进制表示中每个位上的值只能是0或1这意味着每个2的幂次方在求和时最多出现一次完美满足了“互不相同”的条件。所以算法思路就非常清晰了如果输入的整数n是奇数那么它的二进制最低位2^0位一定是1。这意味着拆分结果中必然包含2^0也就是1。但根据题目定义2的正整数次幂是从2^12开始的不包括1。因此任何奇数都不可能有“优秀的拆分”直接输出-1。如果n是偶数我们将其转换为二进制然后找出所有值为1的位记录这些位对应的2的幂次方从高位到低位即为答案。2.2 方案选型位运算 vs. 数学运算在代码实现时我们有两种主流方式来处理这个二进制分解过程。方案一数学除余法这是最直观的方法模拟手算二进制的过程不断地将数字除以2记录余数。余数为1的位就对应一个2的幂次方。int n 10; // 举例 vectorint powers; int bit_position 0; // 当前位的位置0代表2^0 while (n 0) { if (n % 2 1) { // 当前二进制位是1 // 注意题目要求输出的是2^k而不是k。所以需要计算 2^bit_position // 但更常用的是通过左移运算1 bit_position powers.push_back(1 bit_position); } n / 2; // 相当于二进制数右移一位 bit_position; } // 得到的powers是从低位到高位的需要反转后从大到小输出 reverse(powers.begin(), powers.end());这种方法逻辑清晰易于理解但需要进行除法和取模运算在极端大量数据时效率略低于位运算。方案二位运算法这是更贴近计算机底层、效率更高的方法。我们直接检查整数n的每一个二进制位。int n 10; vectorint powers; // 我们从高位向低位检查以满足从大到小输出的要求 // 先找到最高位。例如10的二进制是1010最高位是2^38 for (int i 30; i 1; i--) { // 2^30 10^9普及组数据范围足够 if (n (1 i)) { // 检查第i位是否为1 powers.push_back(1 i); } } // 循环从i1开始跳过了i0即2^01因为题目要求正整数次幂这里的关键是n (1 i)这个操作。1 i生成了一个只有第i位是1其他位都是0的数。按位与操作会检查n的第i位是否也为1。如果结果为真非零则说明该位是1。为什么首选位运算位运算如是CPU最基本的指令通常在一个时钟周期内就能完成速度远快于除法/取模运算。在竞赛编程中养成使用位运算的习惯能在处理大量数据或复杂算法时带来可观的性能提升。对于这道题两种方法都能轻松AC通过但位运算方案更优雅、更“程序员”。3. 关键实现细节与避坑指南思路清晰了但要把代码写得健壮、准确还需要注意以下几个关键细节这些都是从无数次提交错误中总结出来的经验。3.1 奇数情况的快速判断与处理这是题目最大的一个“坑”也是区分是否理解题意的重要一点。题目明确要求拆分是“2的正整数次幂”即2, 4, 8, 16... 不包括12^0。而任何奇数的二进制表示最低位一定是1这意味着其拆分必然包含1。if (n % 2 1) { cout -1 endl; return 0; // 直接结束程序 }避坑点千万不要试图去拆分奇数。有些初学者可能会想那我把奇数减1变成偶数再拆分行不行不行因为题目要求就是拆分这个数本身。奇数就是无解没有例外。3.2 从大到小输出的实现技巧题目要求输出从大到小排列。我们的算法逻辑自然保证了这一点。如果采用“从高位向低位”遍历的位运算法那么我们每次找到的幂次方本身就是从大到小的直接存入数组或输出即可。如果采用“从低位向高位”的除余法那么收集到的幂次方顺序是从小到大的需要在最后进行反转reverse操作。个人心得我强烈推荐使用从高位向低位遍历的位运算法。理由有三第一无需额外的反转操作逻辑更简洁第二遍历的上限可以预估比如对于CSP-J的数据范围n ≤ 10^72^23约800万2^24约1600万所以从i24开始向下检查就足够了效率更高第三更能体现对二进制位操作的掌握。3.3 边界条件与数据范围考量虽然题目样例可能很简单但我们必须考虑通用情况。输入为2二进制是10拆分结果就是2。正确。输入为0或负数根据题目描述n是正整数所以无需处理。但在自己测试时要确保程序对1奇数能正确输出-1。大数情况当n很大时比如接近10^7计算2的幂次方1 i要确保不超出整数范围。在C中对于int类型32位1 31会导致溢出因为最高位是符号位。因此我们的循环条件i的上限应设为30130约10亿或者使用更大的数据类型如long long。一个实用的技巧在循环内部可以先判断(1 i) n。如果当前2的幂次已经比n本身还大那么n的二进制表示中不可能在这一位为1可以直接break跳出循环减少不必要的迭代。for (int i 30; i 1; i--) { int power 1 i; // 计算2^i if (power n) continue; // 这一位肯定为0跳过 if (n power) { // 等价于 (n (1 i)) ! 0 cout power ; n - power; // 可选减去已找到的幂次有时能简化逻辑 } }4. 完整代码实现与逐行解读下面我将给出一个C的完整AC代码并附上详细的注释。这份代码采用了效率最高的位运算方法并包含了上述的所有注意事项。#include iostream using namespace std; int main() { int n; cin n; // 关键点1奇数直接输出-1 if (n % 2 1) { cout -1 endl; return 0; } // 关键点2从可能的最大幂次开始向下遍历 // 2^30 1e9对于普及组数据完全足够。使用1i计算2的幂次。 bool hasOutput false; // 标记是否输出了至少一个数用于控制空格 for (int i 30; i 1; i--) { // i从1开始排除了2^01 int current_power 1 i; // 计算2^i // 如果当前的2^i比n还大则n的这一位肯定是0跳过 if (current_power n) { continue; } // 按位与运算检查n的第i位是否为1 if (n current_power) { if (hasOutput) { cout ; // 不是第一个数先输出空格 } cout current_power; hasOutput true; // n - current_power; // 可以减去但不必须因为我们是按位判断不影响后续位判断 } } // 关键点3如果n是偶数但循环后什么都没输出理论上只有n0时会发生但n是正整数 // 为了代码健壮性可以加上但本题保证n1所以可以省略。 if (!hasOutput) { // 这种情况对于正整数n不会发生除非n0。 // cout -1 endl; } cout endl; // 最后换行 return 0; }代码解读与技巧奇数判断if (n % 2 1)是最高效的判断方式。也可以用位运算if (n 1)含义完全相同。循环起点i 30是一个安全且足够大的起点。你也可以根据数据范围估算一个更小的值比如i 24。current_power n判断这是一个重要的优化。当2^i已经大于当前的n时n的二进制表示中第i位及更高位绝对为0后续的i都可以跳过。虽然对于单次计算提升不大但在某些需要频繁调用的场景或追求极致效率时是个好习惯。输出格式控制使用hasOutput标志来控制空格避免了末尾多一个空格的常见格式错误。这是竞赛编程中处理输出格式的经典技巧。n - current_power这行代码被注释掉了。它的作用是在找到一个幂次方后从n中减去它。这样后续循环中n的值会变小current_power n的判断会更快生效。两种写法都是正确的不减去也不影响按位与的判断逻辑因为每一位是独立的。5. 常见错误与问题排查实录即便思路正确在实现时也容易掉进一些陷阱。下面是我在辅导学生和自己刷题中遇到的几个典型错误案例。5.1 错误类型一遗漏奇数判断或判断错误这是最常见的错误。没有理解“正整数次幂”不包括1。错误代码示例// 错误没有处理奇数 cin n; for (int i 30; i 0; i--) { // i从0开始包含了1 ... }输入3错误输出2 1正确输出-1排查方法首先单独测试输入1, 3, 5等奇数看输出是否为-1。5.2 错误类型二输出顺序错误或格式错误题目要求从大到小输出用空格隔开。错误代码示例顺序错误// 错误从低位向高位遍历且未反转 while (n 0) { if (n % 2 1) cout (1 bit) ; bit; n / 2; }输入10(二进制1010)错误输出2 8先输出2后输出8正确输出8 2错误代码示例格式错误末尾多空格// 错误每次输出都带空格 for (...) { if (n (1i)) { cout (1i) ; // 最后一个数后面也会跟空格 } }虽然很多评测系统如OI系列会自动忽略行末空格但这是一个不好的习惯在某些严格系统上会导致格式错误。排查方法使用hasOutput标志或先收集到数组再统一输出可以有效避免格式问题。5.3 错误类型三整数溢出在计算1 i时如果i过大如i31对于32位int会导致溢出结果是未定义的通常是负数。错误代码示例for (int i 31; i 0; i--) { // i可能为31 if (n (1 i)) { // 当i31时131是负数-2147483648 ... } }解决方案确保循环上限合理。对于int类型的ni最大取30。更稳妥的做法是使用long long类型来存储current_power。for (int i 30; i 1; i--) { long long power 1LL i; // 使用LL后缀确保是long long类型 if (power n) continue; ... }5.4 问题排查速查表问题现象可能原因解决方案输入奇数输出了一串数未进行奇数判断或判断条件错误在程序开始处添加if (n%21) { cout-1; return 0; }输出结果顺序是反的遍历二进制的方向是从低到高改为从高位向低位遍历for(i30; i1; i--)输出结果包含数字1循环变量i从0开始了确保循环从i1开始排除2^0对大一点的数据输出错误或异常整数溢出检查1i是否可能溢出降低i的上限或使用long long感觉代码效率低使用了除余法且未做优化改用位运算法并添加if(power n) continue提前跳出6. 从“优秀的拆分”延伸的编程思维训练这道题的价值不仅仅在于解决一个问题更在于它训练了几种非常重要的编程和算法思维。思维一数学建模与转化将“拆分”问题转化为“二进制表示”问题这是一种重要的建模能力。在竞赛和实际开发中很多问题表面复杂但换一个数学视角就会变得清晰简单。遇到问题时先思考其数学本质往往能事半功倍。思维二位运算的熟练应用这道题是指引你深入学习位运算的绝佳入口。除了按位与、左移还有按位或|、异或^、右移、取反~等。掌握它们你就能写出更高效、更简洁的代码。例如判断奇偶用n 1除以2的幂用n k设置某位为1用n | (1 k)。思维三边界条件与鲁棒性思考必须考虑奇数、偶数、1、大数等边界情况。编写代码时养成首先考虑输入数据的合法范围和各种极端情况的习惯这能极大提高代码的鲁棒性Robustness减少BUG。思维四空间与时间的权衡虽然这道题不需要复杂的数据结构但它暗示了一种思想我们通过一个循环在“时间”上遍历了所有可能的2的幂次而没有预先在“空间”里存储一个2的幂次表。在算法设计中时间和空间常常需要权衡Time-Space Tradeoff。对于这个问题用时间换空间即时计算2^i是更优解。如果你想进一步挑战自己可以尝试这些变种问题如果允许重复使用2的幂次方呢这就变成了经典的“换硬币”问题可以用动态规划求解。如果不是2的幂而是3的幂、5的幂呢思路类似但进制转换变成了三进制、五进制。如何找出“最少数量的2的幂次方”来表示一个数这其实就是求该数二进制表示中“1”的个数popcount有非常巧妙的位运算技巧如n (n-1)。这道“优秀的拆分”就像一颗种子它包含的二进制、位运算、循环控制、条件判断等概念是构建更庞大算法知识体系的基石。吃透它你收获的将不仅仅是一道题的分数。