1. 项目概述:从一道经典面试题说起
“请写一个函数,输入一个整数,输出该数二进制表示中1的个数。” 这道题,但凡你刷过LeetCode、牛客网,或者参加过任何一场技术面试,大概率都遇到过。它就像算法世界的“Hello World”,看似简单,却暗藏玄机,是检验一个程序员对计算机底层、位运算以及编程思维理解深度的绝佳试金石。我当年第一次在面试中被问到这道题时,下意识地就想到了“除2取余”法,结果被面试官追问了时间和空间复杂度,以及是否有更优解,当场就有点懵。后来在无数次的刷题和实际项目优化中,我才真正体会到,这道题背后串联的是从最基础的数学逻辑到最高效的位运算技巧的完整知识链。今天,我们就以C++为例,抛开那些花哨的框架和库,回归到最本质的0和1的世界,把这道题里里外外、从笨办法到巧办法彻底讲透。无论你是正在备战秋招的学生,还是希望夯实基础的在职工程师,这篇文章都将带你重新认识这个“老朋友”,并让你在下次被问及时,能从容地给出至少三种解法,并清晰地说出各自的优劣。
2. 核心需求与问题本质解析
2.1 问题定义与输入输出边界
题目要求非常明确:给定一个整数(在C++中,我们通常考虑int类型),返回其二进制表示形式中‘1’位的数量。这个数量在计算机科学中有一个专门的术语——Population Count或Hamming Weight。
首先,我们必须明确几个关键边界,这些是写出健壮代码的前提:
- 整数类型:通常指有符号32位整数(
int32_t)。在C++中,int的具体位数由编译器和平台决定(通常是32位),但为了严谨,我们可以使用std::int32_t(需要<cstdint>头文件)。 - 负数处理:这是本题的第一个陷阱。整数在内存中以二进制补码形式存储。例如,
-1在32位系统中表示为0xFFFFFFFF(全1),其1的个数是32。我们的算法必须能正确处理负数。 - 输入范围:对于32位有符号整数,输入范围是
[-2^31, 2^31-1]。算法需要覆盖整个范围。
2.2 从数学方法到计算机思维的转变
最直观的解法来源于我们小学就学过的“进制转换”:将一个十进制数不断除以2,记录余数,直到商为0。余数序列的逆序就是二进制表示,其中1的个数就是余数为1的次数。
int countBits_Naive(int n) { int count = 0; while (n != 0) { if (n % 2 != 0) { // 或者 if (n & 1) count++; } n = n / 2; // 等价于 n >>= 1,但对于负数有区别! } return count; }注意:这个方法对于正整数是有效的。但对于负数,
n = n / 2和n >>= 1(算术右移)在C/C++中的行为是不同的。除法向零取整,而算术右移会保持符号位。对于负数,n /= 2最终会得到0,从而结束循环,但这并没有遍历其补码表示的所有位,因此结果是错误的。这是我们遇到的第一个坑。
所以,我们需要将思维从“数学除法”转换到“计算机位操作”。我们不应该关心这个数的数学值如何变化,而应该直接将其视为一个固定长度的二进制位序列,然后检查其中每一位。这就引出了我们的第一种可靠解法。
3. 核心解法深度剖析与C++实现
3.1 解法一:逐位检查法(使用无符号类型或固定移位)
为了解决负数问题,一个核心技巧是使用无符号整数来接收或转换我们的输入。这样,右移操作就会变成逻辑右移(高位补0),而不是算术右移(高位补符号位)。
思路:将输入整数转换为无符号数。准备一个掩码mask,初始值为1(二进制...0001)。在循环中,将无符号数与掩码进行按位与操作,如果结果不为0,则说明最低位是1,计数器加1。然后将掩码左移一位(mask <<= 1),检查下一位。重复直到检查完所有位(例如32次)。
#include <cstdint> // 为了使用固定宽度整数类型 int hammingWeight_ShiftMask(uint32_t n) { // 参数直接使用uint32_t int count = 0; uint32_t mask = 1; for (int i = 0; i < 32; ++i) { if ((n & mask) != 0) { ++count; } mask <<= 1; } return count; } // 调用时,如果需要处理int,可以这样转换: int num = -1; int result = hammingWeight_ShiftMask(static_cast<uint32_t>(num));另一种等价的逐位检查法是固定移动输入数本身,而不是移动掩码。这种方法更常见。
int hammingWeight_ShiftInput(int n) { int count = 0; unsigned int un = n; // 关键:转换为无符号数,确保右移是逻辑右移 while (un != 0) { if (un & 1) { // 检查最低位 count++; } un >>= 1; // 逻辑右移 } return count; }复杂度分析:
- 时间复杂度:O(k),k是整数的位数(例如32)。无论数字大小,都要循环固定的32次。
- 空间复杂度:O(1),只使用了几个固定变量。
- 优点:逻辑极其清晰,易于理解和实现,能正确处理负数。
- 缺点:循环次数固定,即使对于很小的数(如1,二进制
...0001)也需要检查所有32位,效率不是最优。
3.2 解法二:n & (n-1)魔法技巧
这是面试官最期望看到的解法,因为它巧妙利用了二进制运算的一个特性,能将时间复杂度优化到只与数字中1的个数相关。
核心原理:对于一个整数n,运算n & (n-1)的结果,会把n的二进制表示中最低位的1变成0。
让我们举个例子,假设n = 12,二进制为1100。
n - 1 = 11,二进制为1011。n & (n-1) = 1100 & 1011 = 1000。 可以看到,原来n中最低位的1(从右数第3位)被消除了。再迭代一次:n = 1000(8),n-1 = 0111(7)。n & (n-1) = 1000 & 0111 = 0000。 迭代停止。我们进行了2次操作,正好是12的二进制中1的个数。
C++实现:
int hammingWeight_BrianKernighan(int n) { int count = 0; unsigned int un = n; // 同样转换为无符号,确保减法溢出等行为符合预期 while (un != 0) { un &= (un - 1); // 消除最低位的1 count++; } return count; }复杂度分析:
- 时间复杂度:O(m),其中m是整数
n的二进制表示中1的个数。对于1很少的数(如2的幂,只有一个1),只需一次循环。这比固定32次的循环优秀得多。 - 空间复杂度:O(1)。
- 优点:效率高,代码简洁,是位运算技巧的经典体现。
- 缺点:原理需要稍加理解,对初学者不够直观。
实操心得:
n & (n-1)这个技巧必须刻在脑子里。它不仅是解决“二进制中1的个数”的关键,还是解决其他一系列位操作问题的核心技巧,例如:
- 判断一个数是否是2的幂(
n > 0 && (n & (n-1)) == 0)。- 计算两个整数的汉明距离(先异或,再计算异或结果中1的个数)。
3.3 解法三:查表法(Table Lookup)与分治思想
当追求极致性能,或者需要处理大量数据时,查表法是一个选择。其思想是“空间换时间”:预先计算好所有可能的小数据块(例如8位)中1的个数,存储在一个数组中。然后对于一个32位数,将其拆分成4个8位块,分别查表并累加结果。
步骤:
- 建表:创建一个大小为256(2^8)的数组
table,table[i]存储字节i(0-255)中1的个数。 - 拆分与查表:将32位无符号整数
n解释为4个字节。通过移位和掩码操作,依次取出这4个字节的值,作为索引去查表,累加结果。
C++实现:
int hammingWeight_LookupTable(uint32_t n) { // 静态表,只需初始化一次 static const unsigned char table[256] = { #define B2(n) n, n+1, n+1, n+2 #define B4(n) B2(n), B2(n+1), B2(n+1), B2(n+2) #define B6(n) B4(n), B4(n+1), B4(n+1), B4(n+2) B6(0), B6(1), B6(1), B6(2) }; // 分别查4个字节 unsigned char* p = (unsigned char*)&n; return table[p[0]] + table[p[1]] + table[p[2]] + table[p[3]]; }上面的建表代码使用了一个巧妙的宏展开来生成表,其本质是动态规划思想:一个字节中1的个数等于其高半字节和低半字节中1的个数之和。
复杂度分析:
- 时间复杂度:O(1),仅进行几次固定次数的内存访问和加法运算。
- 空间复杂度:O(256),需要一个256字节的查找表。
- 优点:在需要反复调用该函数的场景下,速度极快。
- 缺点:占用额外内存,代码可读性稍差,且性能优势在现代CPU的缓存和指令集优化下可能不那么明显。
3.4 解法四:利用编译器内置函数或标准库
在实际工程中,我们通常不重复造轮子。许多编译器和标准库提供了计算Population Count的高效实现。
- GCC/Clang内置函数:
__builtin_popcount(unsigned int n)。这个函数会被编译器翻译为底层最高效的机器指令(如x86的POPCNT指令)。 - C++20标准库:
<bit>头文件提供了std::popcount(T n)函数模板。
// 方法1:使用GCC/Clang内置函数 int hammingWeight_Builtin(int n) { return __builtin_popcount(static_cast<unsigned int>(n)); } // 方法2:使用C++20标准库 (需要编译器支持-std=c++20) #include <bit> int hammingWeight_STL(int n) { return std::popcount(static_cast<uint32_t>(n)); }这是生产环境的首选方法,因为它简洁、高效、正确。
4. 性能对比与场景选择
我们编写一个简单的测试程序,在循环中调用上述不同方法数百万次,来直观感受性能差异(结果因机器和编译器优化而异,但相对关系有参考价值)。
| 方法 | 时间复杂度 | 空间复杂度 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|---|
| 逐位检查 | O(32) | O(1) | 逻辑简单,绝对稳定 | 循环次数固定,效率非最优 | 教学、理解原理、对性能不敏感的简单场景 |
| n & (n-1) | O(1的个数) | O(1) | 效率高,技巧经典 | 原理需理解 | 面试首选、常规代码优化、中等性能要求 |
| 查表法 | O(1) | O(256) | 理论速度最快 | 占用内存,代码稍复杂 | 极致性能优化、嵌入式系统(如果内存允许)、处理海量数据 |
| 内置/标准库 | O(1) | O(1) | 最简单,效率最高(硬件指令) | 依赖编译器和标准 | 生产代码绝对首选、任何需要此功能的实际项目 |
注意事项:性能测试时务必关闭编译器优化进行对比,否则编译器可能会将简单的循环优化到和内联函数一样快。在实际开启优化(
-O2)后,内置函数和查表法的优势会非常明显,因为它们直接对应或逼近单条CPU指令。
5. 相关扩展问题与实战应用
掌握了核心解法,我们可以轻松应对一些变种问题,这也是面试中常见的追问环节。
5.1 扩展问题一:判断一个整数是否是2的幂
问题:不使用循环/递归,判断一个整数是否是2的幂(如1, 2, 4, 8...)。解法:利用n & (n-1)。2的幂的二进制表示中只有一位是1。因此,对于正整数n,如果n & (n-1) == 0,那么它就是2的幂。需要额外判断n > 0,因为0也满足这个等式但不是2的幂。
bool isPowerOfTwo(int n) { return n > 0 && (n & (n - 1)) == 0; }5.2 扩展问题二:计算两个整数的汉明距离
问题:汉明距离是指两个等长字符串(或数字)在对应位置上不同字符(或位)的个数。对于整数,就是其二进制表示中,对应位不同的数量。解法:先对两个数进行异或操作x ^ y,异或结果为1的位就是原数不同的位。然后问题就转化为了计算异或结果中1的个数。
int hammingDistance(int x, int y) { return hammingWeight_BrianKernighan(x ^ y); // 可以用任意一种方法实现 }5.3 实战应用场景
- 位图(Bitmap)与布隆过滤器(Bloom Filter):在这些数据结构中,我们经常需要快速统计特定位区间中1的个数,或者判断某一位的状态。
popcount是基础操作。 - 信息检索与相似度计算:在计算文档的SimHash或处理特征向量时,汉明距离是衡量相似度的常用指标,其核心就是计算1的个数。
- 游戏开发与状态压缩:许多棋盘类游戏(如围棋、黑白棋)的状态可以用位棋盘表示,快速计算棋盘上的棋子数(1的个数)对于评估局面至关重要。
- 密码学与纠错码:一些加密算法和纠错码(如奇偶校验、汉明码)需要计算数据中1的奇偶性或数量。
6. 常见“坑点”与调试技巧实录
即使理解了算法,实现时也可能掉进一些坑里。下面是我和同事们在实际编码和面试中总结的几个常见问题。
6.1 坑点一:负数的右移与循环终止
这是最大的一个坑,前面已经提到。永远记住:在C/C++中,对有符号整数进行右移(>>)是算术右移,符号位会被保留并填充到高位。这会导致对于负数,使用while (n) { ... n >>= 1; }的循环可能无法终止(如果符号位一直是1),或者无法正确统计所有位。解决方案:在操作前,先将输入转换为无符号类型(unsigned int)。
6.2 坑点二:运算符优先级
位运算符的优先级通常低于比较运算符。例如,在写判断条件时:
if (n & 1 == 1) { ... } // 错误!因为==的优先级高于&,所以这行代码实际等价于if (n & (1 == 1)),即if (n & 1),虽然在这个特例里结果巧合正确,但逻辑混乱且危险。解决方案:给位运算加上括号,这是一个良好的编程习惯。
if ((n & 1) == 1) { ... } // 正确且清晰6.3 坑点三:忽略整数宽度
在查表法或需要精确位操作时,使用int可能导致不可移植。在32位平台上是32位,在64位平台可能是64位。解决方案:使用固定宽度的整数类型,如uint32_t、uint64_t(定义在<cstdint>中)。
6.4 调试技巧:打印二进制表示
当你的算法结果不符合预期时,最直接的调试方法就是把整数的二进制形式打印出来看看。
#include <bitset> #include <iostream> void printBinary(int n) { std::cout << std::bitset<32>(n) << std::endl; // 打印32位表示 } // 或者自己实现一个简单的 void printBinarySimple(unsigned int n) { for (int i = 31; i >= 0; --i) { std::cout << ((n >> i) & 1); if (i % 4 == 0) std::cout << ' '; // 每4位加个空格,方便阅读 } std::cout << std::endl; }这个小工具能帮你直观地验证输入和中间步骤,对于理解位运算非常有帮助。
7. 从这道题延伸出的学习路径
一道简单的“二进制中1的个数”,其实是一扇通往计算机系统基础的大门。如果你对此感兴趣,我建议可以沿着以下路径深入学习:
- 深入位运算:掌握与(&)、或(|)、异或(^)、非(~)、左移(<<)、右移(>>)的所有常用技巧,如设置位、清除位、切换位、检测位等。
- 理解原码、反码、补码:这是计算机表示有符号整数的基石,理解了它,你才能明白为什么
-1 & 0xFF的结果是0xFF,以及算术右移和逻辑右移的根本区别。 - 学习更多的“魔法”位操作:比如不用临时变量交换两个数(
a ^= b; b ^= a; a ^= b;),快速判断奇偶,快速乘除2的幂等。 - 探索CPU指令集:了解像
POPCNT(Population Count)这样的专用指令,理解编译器内置函数是如何映射到这些高效指令的,这能让你写出更贴近机器效率的代码。 - 应用到具体算法和数据结构:学习位图、布隆过滤器、状态压缩动态规划等高级主题,你会发现位运算在这些领域能发挥出惊人的空间和时间效率。
回到开头,下次面试再遇到这道题,你可以从容地从最基础的逐位检查讲起,然后引出高效的n & (n-1)技巧,再提到查表法和内置函数,并对比它们的优劣。如果能再聊一两个相关的扩展问题和应用场景,面试官对你的基础扎实程度和知识广度一定会留下深刻印象。刷题不只是为了记住答案,更是为了构建起这种由点及面、融会贯通的知识网络。这道关于0和1的小题,值得你花时间把它彻底吃透。