news 2026/10/17 6:43:22

位运算四题吃透:二进制补码与算法面试的降维技巧

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
位运算四题吃透:二进制补码与算法面试的降维技巧

之前在面试现场,我问过一位候选人 LeetCode 191 这道题:统计一个整数的二进制表示里有多少个 1。他很快写了个循环右移的版本,但当我追问“能不能把 32 次循环降到只循环 1 的个数那么多次”时,空气安静了几秒。位运算就是这样,看起来像是冷门技巧,实际上每一次刷题都绕不开,而且面试官特别喜欢往里挖一层。它考的不是背 API,而是你有没有真正理解整数在计算机里的样子。

这篇不打算泛泛讲位运算理论,直接围绕四道经典题目展开:191. 位1的个数、231. 2的幂、190. 颠倒二进制位、371. 两整数之和。这四题难度都不高,但每道题都对应一个非常核心的位运算性质,吃透了它们,后面再碰位运算相关的题目基本都能一眼看出方向。无论你是刚准备面试,还是刷题刷到瓶颈想换个角度突破,这四题都值得花一个晚上认真搞明白。

1. 位运算这关躲不掉:先把底料备齐

1.1 面试为什么爱考位运算

位运算题目看起来花样很多,但底层考察的点其实特别集中:第一,你是否了解整数在内存中是以二进制补码形式存在的;第二,你能不能跳出“十进制思维”,用位视角去看一个数;第三,你有没有能力在常数时间内完成某些判断或统计,把时间复杂度从 O(log n) 甚至 O(n) 压到 O(1)。

举个例子,判断一个数是不是 2 的幂,常规思路是不断除 2,时间复杂度 O(log n)。但如果用n & (n-1),一次位运算就能出结果。这种“降维打击”式的解法,正是很多公司在算法面试里想看到的思维层次。另外,位运算在底层系统里频繁出现:状态压缩、权限位标记、Hash 散列、图像处理、树状数组,甚至数据库的位图索引,全都建立在同一套机制上。所以面试官爱考位运算,不是因为它偏,而是因为它真的很实用。

1.2 基础运算符与补码常识

先把工具表列出来,后面所有题目都离不开这几个操作符。

运算符含义记忆要点
&按位与两位都是 1 才为 1,有 0 则 0
|按位或两位有一个 1 就为 1
^按位异或相同为 0,不同为 1
~按位取反0 变 1,1 变 0
<<左移右边补 0,相当于乘 2
>>算术右移Java 中符号位填充,相当于除以 2 取整
>>>无符号右移高位补 0,Java 专属

这里重点说补码。Java 的int是固定 32 位的,负数以补码形式存储,比如-1在 32 位视角下就是111...111(32 个 1)。Python 不同,它的整数没有固定长度,负数在概念上可以一直向左延伸出无穷多个 1,所以处理负数的位运算时经常要主动加一个掩码,模拟 32 位环境。这也是很多人写 Python 版位运算题会卡住的原因。

需要随手记下的四组核心性质,后面会反复用到:

  • x & (x - 1):消除最低位的 1。
  • x & -x:提取最低位的 1。
  • x ^ x = 0、x ^ 0 = x:自己异或自己清零。
  • x ^ 0xFFFFFFFF:对低 32 位全部取反。

这四组性质就像是位运算世界的“乘法口诀”,背熟它们,做这四道题会很顺。

2. 191. 位1的个数:三种套路一次吃透

2.1 题目要点与零基础入口

题目要求很简单:输入一个无符号整数,返回它的二进制表示中有多少个 1。比如00000000000000000000000000001011里有 3 个 1,所以答案是 3。

有一个容易绕弯的地方:Java 里其实没有无符号int,但位模式是完全一样的。换句话说,就算传入一个负数,你也只需要把它当成 32 位的二进制串来看,内存里是什么样就统计什么样。比如-1的 32 位补码是全 1,结果应该是 32。这个理解到位了,代码就不容易写偏。

2.2 逐位检查法:先保证对

最直白的思路是:每次看最低位是不是 1,然后右移一位,重复 32 次。右移的操作在 Java 里要用>>>,因为如果是负数,普通的>>会在高位补 1,虽然当前这位的取值不受影响,但要养成用无符号右移的习惯。

public int hammingWeight(int n) { int count = 0; while (n != 0) { count += n & 1; n >>>= 1; } return count; }

这段代码里n & 1只保留最低位,是 1 就加进去,不是 1 就加 0。循环次数是固定的 32 次,因为 Java 的int只有 32 位。时间复杂度严格说是 O(32),也就是常数时间,空间复杂度 O(1)。这个版本的好处是几乎不可能出错,适合作为面试时的兜底答案。

2.3 Kernighan 位计数法:把循环次数降为 1 的个数

逐位检查虽然正确,但有点“笨”:如果整数只有 1 个 1,它依然要循环 32 次。Kernighan 位计数法就是冲着这个问题去的,核心就一句话:n &= n - 1会消除掉最低位的那个 1。

为什么能消除?举个例子,n = 6,二进制是110。n - 1 = 5,二进制是101。两者相与,得到100,也就是 4。原本最低位的那个 1 被消掉了。再对 4 执行一次,4 & 3 = 0,两个 1 全部消除,所以 6 的二进制里有 2 个 1。

public int hammingWeight(int n) { int count = 0; while (n != 0) { n &= n - 1; count++; } return count; }

循环次数不再是固定的 32,而是二进制中 1 的个数。极端情况全是 1 时依然是 32 次,但平均下来快很多。这个技巧在真实项目中非常常见,比如统计一个集合中有几个元素被选中、判断权限标志位组合等。

2.4 工程爽快方案:内置 bitCount / popcount

实际工程里,Java 直接调用Integer.bitCount(n),C++ 用__builtin_popcount,Python 用bin(n).count('1')也能实现。这些内置实现通常会查表或使用分治算法,性能很高。但面试的时候不建议一开始就抛内置函数,因为面试官想看的是你能否手写原理。比较稳妥的做法是:先讲清楚 Kernighan 的原理,然后补充一句“在工程上我会直接用内置方法,因为它经过充分优化且可读性更好”。

解法循环次数可读性适用场景
逐位检查固定 32 次高面试兜底
Kernighan1 的个数次高手写面试题、嵌入式
内置 bitCount常数极高生产环境

3. 231. 2 的幂:一个二进制特征解决两种写法

3.1 先分清边界:0、负数、大数

判断一个整数是不是 2 的幂,看起来是数学题,其实是二进制特征题。2 的幂在二进制里有一个明显的身份标识:只有一个位是 1,其他位全是 0。比如 1 是1,2 是10,4 是100,8 是1000。

边界条件必须一次想清楚。0 不是 2 的幂,负数也不是,所以判断条件里n > 0不可缺少。很多人在n & (n - 1) == 0这条路上翻车,就是因为忽略了 0 也满足这个等式,最后返回了错误结果。

3.2 方案一:n & (n-1) 消除唯一的 1

既然 2 的幂只有一个 1,那就用上一题学到的技巧:n & (n - 1)消除最低位的 1。如果消除之后变成 0,说明原来只有一个 1,那就一定是 2 的幂。

public boolean isPowerOfTwo(int n) { return n > 0 && (n & (n - 1)) == 0; }

比如n = 8,二进制1000,n - 1 = 0111,相与得到0000,返回 true。n = 12,二进制1100,n - 1 = 1011,相与得到1000,不是 0,返回 false。这个写法已经是最优解,时间复杂度 O(1)。

3.3 方案二:n & -n 提取最低位再比较

另一种思路来自 lowbit 操作。n & -n能提取出最低位的 1。如果n本身就是 2 的幂,那么它唯一的那个 1 就是最低位的 1,提取出来应该等于它自己。

public boolean isPowerOfTwo(int n) { return n > 0 && (n & -n) == n; }

拿n = 16验证,-16的补码表示与 16 相与后仍得 16,条件成立。拿n = 24验证,24的二进制是11000,最低位的 1 是1000,也就是 8,不等于 24,返回 false。两种方案都很好,区别只在于一个消除最低位,一个提取最低位。面试时如果你能说出两种写法,并解释它们的等价性,会显得对位运算非常熟练。

3.4 语言差异提醒:Python 无限长与 JS 32 位限制

Python 里写这个题目时,n > 0条件同样不能省。Python 的整数是无限长的,负数位运算时会表现为无限长的补码,但在这个题里只要先用n > 0拦住负数和 0,后面的位运算就不会出问题。JavaScript 则要注意,位运算符会先把操作数转成 32 位有符号整数,超出范围的部分会被截断,所以如果输入接近2^31附近,某些写法可能踩坑。稳妥起见,可以提前用常规判断拦截,或者直接用Math.log2这类接口来佐证。

4. 190. 颠倒二进制位:从逐位搬到分治

4.1 最直观的字符串法不是不能用

看到“颠倒二进制位”,脑子里第一个反应通常是:把数字转成二进制字符串,翻转,再转回整数。这个思路没有错,但需要处理好两个细节:前导零补齐和语言对负数的字符串表示。

可以用伪代码描述这个过程:

bin = 转成二进制字符串 bin = 左侧补零到 32 位 result = reverse(bin) 转成整数

代码很简单,但面试时我建议把它当作“理解题意”的辅助,而不是最终提交方案。一是转换过程涉及多次字符串操作,性能一般;二是不同语言对负数转二进制的处理不一致,容易踩坑。作为第一思路和面试官讲清楚,再引导到逐位操作上,是比较自然的答题节奏。

4.2 常规逐位翻转:循环 32 次

真正的常规解法是逐位搬移:每次取出原数的最低位,放进结果的最低位,然后把结果左移一位腾出位置,原数右移一位准备取下一位。循环 32 次后,原数的最高位就跑到了结果的最低位,实现颠倒。

public int reverseBits(int n) { int result = 0; for (int i = 0; i < 32; i++) { result = (result << 1) | (n & 1); n >>>= 1; } return result; }

核心步骤拆开看:n & 1取出当前最低位;result << 1给新位腾位置;|运算把该位放进去;n >>>= 1用无符号右移处理原数。注意这里千万不能用>>,否则遇到负数时高位补 1,取位逻辑就全乱了。这个方法复杂度 O(32),已经能通过全部测试,工程上也够用。

4.3 分治交换:面向 32 位的并行手法

逐位翻转没问题,但 32 次循环仍然可以优化成常数条指令。思路很像归并排序的逆过程:先把相邻的 1 位交换,再交换相邻的 2 位,再交换相邻的 4 位,然后是 8 位,最后交换前后 16 位。

public int reverseBits(int n) { n = ((n & 0x55555555) << 1) | ((n >>> 1) & 0x55555555); n = ((n & 0x33333333) << 2) | ((n >>> 2) & 0x33333333); n = ((n & 0x0F0F0F0F) << 4) | ((n >>> 4) & 0x0F0F0F0F); n = ((n & 0x00FF00FF) << 8) | ((n >>> 8) & 0x00FF00FF); return (n << 16) | (n >>> 16); }

要理解它,重点看第一行:0x55555555的二进制是01010101...,偶数位是 1,奇数位是 0。(n & 0x55555555) << 1把偶数位的 1 左移到奇数位;(n >>> 1) & 0x55555555把奇数位的 1 右移到偶数位。两半拼起来,相邻两位就完成了交换。后面每行把块大小翻倍,逻辑完全一样。最终经过 5 轮,整个 32 位就逆序了。

这个写法第一次看会觉得很烧脑,但它其实是很多底层优化库里的标准套路,例如 CPU 指令集优化、Z-order 曲线映射等场景。面试时能写出来是加分项,但前提是先把逐位法写对,否则容易因一个小掩码错误陷入调试泥潭。

4.4 现场怎么选:先稳后快

我个人面试答题的顺序建议是:先快速给出逐位翻转版本,确认边界条件,让代码跑通;如果面试官追问“能不能更快”,再升级到分治交换。直接上手分治的问题在于,那 5 行掩码一旦有一个数字写错,整个结果就乱了,而且很难一眼定位错误。先把正确的基线版本放在那儿,心理压力会小很多,优化时也更从容。LeetCode 的测试用例里,逐位翻转完全够用,没必要一开始就追求炫技。

5. 371. 两整数之和:用加法器思路解掉“禁用加法”

5.1 半加器核心:异或是不进位加法,与是进位标记

这道题要求不用加法运算符实现两数相加。听起来很魔幻,但如果从数字电路底层看,加法本来就是用异或和与运算拼出来的。

两个二进制位相加的结果有两部分:本位和进位。a ^ b能算出不考虑进位的“本位和”,因为异或的规则是相同为 0、不同为 1,恰好对应 0+0=0、0+1=1、1+0=1、1+1=0。那 1+1 产生的进位去哪里了?就是a & b,只有两个都是 1 时才会进位。进位还要向左移动一位,因为二进制里要向高一位进 1,所以完整公式是:

  • 本位和:a ^ b
  • 进位:(a & b) << 1

然后用这个进位继续和本位和相加,直到进位变成 0。可以把它想象成做竖式加法:先算当前位,再把进位加到上一位,反复迭代。

5.2 Java 场景的循环实现

Java 版本很干净,因为int本身就是 32 位补码,负数也天然兼容这个逻辑。

public int getSum(int a, int b) { while (b != 0) { int carry = (a & b) << 1; a = a ^ b; b = carry; } return a; }

每次循环里,a被更新为无进位和,b被更新为进位。当进位为 0 时,加法完成。负数为什么没问题?比如-1 + 1,在 32 位补码下就是0xFFFFFFFF ^ 0x00000001得到 0,进位也是 0,直接返回 0。这正好说明补码设计有多优雅,加法和减法在底层共用一套电路。

5.3 Python 的 32 位补码模拟

Python 版本麻烦在整数无限长。负数左移会产生无穷无尽的进位位,循环永远不会停。解决思路是强行把整数限制在 32 位范围内:每一步都做& 0xFFFFFFFF。

class Solution: def getSum(self, a: int, b: int) -> int: MASK = 0xFFFFFFFF a &= MASK b &= MASK while b: carry = ((a & b) << 1) & MASK a = (a ^ b) & MASK b = carry if a > 0x7FFFFFFF: return ~(a ^ MASK) return a

最后那个if是把 32 位无符号表示还原成 Python 有符号整数。当结果大于0x7FFFFFFF时,说明最高位是 1,按 32 位补码来看它是个负数。~(a ^ MASK)的作用是:先对低 32 位取反得到补码的绝对值减一,再整体取反恢复成 Python 里的负数。这段逻辑会绕一点,我的个人技巧是拿0xFFFFFFFF当作 -1 的模拟值,亲手在纸上走一遍-1 + 1,马上就能理解。

5.4 面试追问图谱:负数、递归、无符号

这道题特别容易被追问。面试官通常会在你写完循环版后接着问:

  • 负数怎么处理?Java 直接依赖补码;Python 靠掩码模拟 32 位。
  • 循环为什么一定会终止?因为进位每次左移一位,最多 32 次后就变成 0。
  • 能不能用递归写?可以,但 Java 没有尾递归优化,递归过深可能栈溢出,循环更稳。
  • 减法怎么做?加法是add(a, ~b + 1),补码的本质就是用加法统一加减法。

如果这些追问都能答上来,这道题基本就拿满分了。我个人还建议自己私下扩展一下:用位运算实现乘法、判断两个数符号是否相同。这些扩展题能帮你把异或和与的性质用得更熟。

6. 四道题串成一张考点地图

6.1 每道题钉住一个位运算性质

做完这四道题,回头看会发现它们各自主打一个核心性质,合起来正好是一张位运算考点地图。

题号核心性质一句话方法论
191x & (x - 1)消除最低位的 1统计消耗 1 的次数
2312 的幂只有一个 1n & (n - 1) == 0或n & -n == n
190位与位之间可以并行交换逐位翻转是基线,分治交换是优化
371a ^ b不含进位和,(a & b) << 1是进位循环合并直到进位为 0

另外还隐藏了两条贯穿始终的线索:x ^ x = 0可以用来找出现一次的元素,x & -x可以提取最低位的 1。这两条线索分别通到 LeetCode 136(只出现一次的数字)和树状数组的 lowbit 操作,以后碰到相关题目会非常敏感。

6.2 复习顺序与扩展题清单

这四道题我建议按 231 -> 191 -> 190 -> 371 的顺序刷。231 是热身,用一行位运算找到感觉;191 开始引入循环和计数;190 升级到分治;371 挑战加法器思维。每一题都比前一题多一点复杂度,但知识是连贯的。

扩展题可以继续加:136 只出现一次的数字、268 丢失的数字、338 比特位计数、342 4的幂。这些题用到的性质基本都能在本文这四题里找到影子。复习的时候,我强烈建议隔几天用另一门语言重写一遍,特别是 371,Java 版和 Python 版的味道完全不同,这种语言差异本身就是对位运算理解的检验。

7. 高频翻车点与自查清单

7.1 常见问题速查表

刷题和面试里,有些错误出现频率高得惊人。我把它们整理成一张速查表,遇到症状直接查原因和修法。

症状原因处理办法
负数统计 1 时死循环用了>>,负数高位补 1改用>>>无符号右移
Python 写 371 时死循环整数无限长,进位左移无穷无尽全程& 0xFFFFFFFF模拟 32 位
JavaScript 下结果变负数位运算默认转 32 位有符号整数结果用>>> 0转回无符号视角
231 判断 0 返回 true忘了n > 0条件必须同时保证正数
字符串翻转时结果不对前导零没有补齐 32 位先补零再翻转,或者干脆不用字符串法
190 分治有一行出错掩码写错位宽先用逐位法作基线,再逐步替换

7.2 提交前必跑的特例

实践经验告诉我,位运算题在提交前一定要跑几个固定特例:0、1、-1、2147483647、-2147483648。0 和 1 看似简单,却能验证边界条件;-1 全是 1,能检测右移类型和取位逻辑;2147483647 是最大正数,验证高位是否被正确处理;-2147483648 只有最高位是 1,专门用来测试分治交换和符号处理。这四个值跑通了,绝大多数隐含错误都会现原形。

我个人刷完这四题的体会是:位运算考的不是记忆力,而是你对整数底层的敏感度。第一次写 371 的 Python 版时,我也在最后的符号还原那里卡了大半个小时,后来把所有数都按 32 位无符号视角画一遍,思路立刻清晰了。最后建议你别急着背最终代码,按我上面的顺序把每种解法手写一遍,遇到奇怪结果就把数字打印成十六进制观察规律。等你能一眼看出12 & -12 == 4、并且想清楚为什么的时候,这四道题就算真正吃透了。

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

作用域解析运算符 :: 的正确打开方式:变量隐藏与多态场景全解析

一份用代码踩出来的总结&#xff1a;:: 在变量隐藏和多态场景里的正确打开方式写 C 时间长了&#xff0c;几乎每个人都会碰到两件让人抓狂的事&#xff1a;一是在派生类重写了虚函数&#xff0c;想在重写函数里顺手调用基类原来的实现&#xff0c;结果一跑就栈溢出&#xff1b;…

作者头像 李华
网站建设 2026/10/16 4:30:52

手写FFmpeg音视频采集引擎:从设备枚举到PTS同步

简介&#xff1a;本资源是一套基于FFmpeg API实现摄像头视频与麦克风音频同步采集的完整C工程&#xff0c;面向多媒体开发初学者及音视频工程师&#xff0c;解决DirectShow框架学习门槛高、FFmpeg设备采集示例稀缺的实践痛点&#xff0c;适用于本地录制、推流预处理等轻量级音视…

作者头像 李华
网站建设 2026/10/16 4:30:24

章鱼优化算法OOA:仿生原理、Matlab实现与性能实测

作为经常和各类元启发式算法打交道的人&#xff0c;我在新算法复现上花的精力不在少数。章鱼优化算法&#xff08;Octopus Optimization Algorithm, OOA&#xff09;这篇今年发表在SCI一区期刊上的工作&#xff0c;我前后花了两三天时间复现、调试和跑benchmark&#xff0c;整体…

作者头像 李华
网站建设 2026/10/16 4:30:22

时滞系统下基于协方差交叉的多传感器融合估计Matlab实现

1. 项目概述&#xff1a;时滞系统下为什么要做协方差交叉融合1.1 从实际场景说起&#xff1a;传感器融合里的“时间错位”问题做过多传感器融合的人都知道&#xff0c;现实世界从来不像教科书里那样“所有测量在同一时刻、没有延迟、没有互相干扰”。我之前在做一个多源目标跟踪…

作者头像 李华
网站建设 2026/10/16 4:30:22

肌电信号处理实战:从预处理、特征提取到手势识别

1. 为什么我要写肌电信号处理这件事如果你接触过肌电信号&#xff0c;大概会有同感&#xff1a;这个东西说难不难&#xff0c;说简单也真不简单。表面肌电电极一贴、放大器一连、波形一出来&#xff0c;看着挺像那么回事。但真要把信号处理成能用的结果——比如准确判断手是握拳…

作者头像 李华