讲真的,每次面试问到“位操作符相关题目”,我都能看到两种极端表现:一种是候选人盯着白板愣住,脑子里只有“按位与就是把两个数加在一起”这种模糊印象;另一种是直接秒答,能从原理讲到复杂度再讲到边界情况,顺手还能给你写两种以上的解法。
这道题之所以能成为经典中的经典,是因为它考察的不是“背没背过技巧”,而是你有没有真正理解二进制的底层逻辑、能不能在时间和空间之间做取舍、能不能从暴力解法一步步优化到技巧解法。今天我用几道最经典的位操作符面试题,完整拆解多解法的思考链路。这不是给你背答案,而是带你看懂“为什么有人能想到这种解法”。
这篇内容适合正在准备面试的开发者(不管是校招还是社招)、想补底层功底的工程师,以及单纯对位运算感兴趣的爱好者。看完之后你最大的收获不是会做几道题,而是遇到位运算问题时,你能从哪几个维度去找解法。
1. 判断一个整数是不是 2 的幂:五种解法,从循环到一行返回
这道题是位运算面试的“敲门砖”,几乎每个面试官手里都有。题目本身简单到不像一道算法题:给定一个整数 n,判断它是否是 2 的整数次幂。但正因为简单,面试官才能在你给出的代码里看清楚你的基本功。
1.1 暴力解法:循环和取模,先跑通再优化
大多数人第一次上手会写这样的代码:循环判断 n 能不能被 2 整除,能就除以 2,不能就返回 false。还有人是换一个方向,从 1 开始不停乘 2,看能不能累乘到 n。
# 解法一:循环除以 2,任一环节发现余数不为 0 就不是 2 的幂 def is_power_of_two_v1(n: int) -> bool: if n <= 0: return False while n > 1: if n % 2 != 0: return False n //= 2 return True# 解法二:从 1 开始累乘,防溢出是重点 def is_power_of_two_v2(n: int) -> bool: if n <= 0: return False factor = 1 while factor <= n: if factor == n: return True if factor > n // 2: # 防止 factor *= 2 溢出后死循环 break factor *= 2 return False这两种解法的时间复杂度都是 O(log n)。面试时你先给出这种解法完全没有问题,因为它展示了你最基本的“能跑”的能力。但注意,如果你止步于此,面试官通常会比较失望,因为这道题放在“位操作符”这个话题下,目的显然不是让你用取模和除法。
1.2 核心技巧:n & (n - 1) 等于 0,一条判断语句的事
这道题在位运算世界里有个非常著名的结论:如果一个数是 2 的幂,那么它的二进制表示中只有一位是 1。比如 8 的二进制是 1000,16 是 10000。那么 n - 1 呢?以 8 为例,8 = 1000,7 = 0111,8 & 7 = 0000 = 0。
这个规律的底层逻辑是:2 的幂在二进制里就是“一个 1 后面跟一串 0”,减 1 之后,原来最高的那一位 1 变成 0,后面的一串 0 全部变成 1。两者按位与,结果必然为 0。反过来,如果某个数不是 2 的幂,它至少有两个二进制位是 1,n 和 n - 1 相与不可能等于 0。
# 解法三:一行位运算,经典中的经典 def is_power_of_two_v3(n: int) -> bool: return n > 0 and (n & (n - 1)) == 0这里有个极其容易忽略的坑:不能只写 (n & (n - 1)) == 0。因为当 n = 0 时,0 & (-1) = 0,按这个写法会错误地返回 True。然而 0 显然不是 2 的幂。所以 n > 0 这个前置条件不但不能省,还应该放在表达式的最前面,利用短路逻辑省掉一次位运算。
1.3 另外一个等价思路:n & (-n) == n
如果你理解补码的规则,还有一个更高的视角:对一个正整数 n 来说,n & (-n) 代表的数学含义是“n 的最低位 1 所对应的权值”。比如 12 的二进制是 1100,12 & (-12) = 4,也就是从右往左数第一个 1 是第 2 位(权值为 4)。
如果 n 本身就是 2 的幂,那么它的二进制只有一个 1,这个 1 就是最高的那一位,所以 n & (-n) 必然等于 n。反之,如果一个数不是 2 的幂,它有多个 1,n & (-n) 只提取最低位的那个 1,提取出来的数必然小于 n。
# 解法四:利用 n & (-n) 提取最低位 1 的性质 def is_power_of_two_v4(n: int) -> bool: return n > 0 and (n & -n) == n1.4 查表法:面试时用来展示“工程思维”的加分项
还有一种解法值得提,虽然面试时未必用它做主答案,但可以作为补充方案说出来:查表法。因为面试题的输入一般是 32 位有符号整数(int 范围),2 的幂在这个范围内一共只有 31 个(2^0 到 2^30),如果你能快速判断 n 是否落在一张预计算好的表里,时间复杂度就是 O(1),而且不受输入多少的影响。
# 解法五:哈希表存储所有可能的 2 的幂 def is_power_of_two_v5(n: int) -> bool: power_table = {1 << i for i in range(31)} return n in power_table这种解法的缺点是需要额外的内存来存表,但对于本题来说 31 个元素的内存几乎可以忽略。我建议你把它当作发散思路提一下,然后根据面试官的追问重点来展示进一步的理解。很多候选人不知道怎么在面试里表现“工程感”,像这样在暴力解之后补充一个空间换时间的思路,就是很好的切入点。
2. 统计二进制中 1 的个数:从逐位检测到分治归并
这道题可能是位操作符里出现频率最高的题目,没有之一。它有一个正式的名字叫“汉明重量”(Hamming Weight),在信息安全、编码理论、网络传输校验场景里大量使用。面试官问你“写一个函数,输入一个整数,返回其二进制表示中 1 的个数”时,他其实给了你很大的发挥空间。
2.1 朴素的按位检测:右移加掩码
最直观的想法是:把 n 的每一位都一遍,遇到 1 就计数。用 n & 1 判断最低位是否为 1,然后用 n >>= 1 左移一位逐位检查。但这里有个语言细节必须注意:在 C/C++ 里,右移分为逻辑右移和算术右移,对有符号整数做右移时,符号位会补到高位。如果 n 是负数,比如 -8 = 11111111 11111111 11111111 11111000(补码),算术右移之后仍然是负数,最高位会一直补 1,循环就永远终止不了。
# 正确的逐位检测写法:用掩码不断左移检查每一位,避免符号位干扰 def count_ones_v1(n: int) -> int: count = 0 mask = 1 for _ in range(32): # 明确检查 32 位 if n & mask: count += 1 mask <<= 1 return count用 1 左移生成掩码,而不是直接对 n 右移,这样就不会触碰符号位的问题。在 Python 和 Java 里还有个更省事的做法:用无符号右移 >>>,但在 C/C++ 里你必须自己对逻辑右移和算术右移有清晰认知。很多候选人在这一步就被追问得冒汗了,如果你能主动说出“这里我不用右移,改用左移掩码来避免符号位干扰”,面试官会高看你一眼。
2.2 经典技巧:n & (n - 1) 甩掉最低位的 1
上面的逐位检测方法时间复杂度是 O(位数),也就是无论 n 里有多少个 1,循环次数都是固定的 32 次或 64 次。但 n & (n - 1) 这个操作可以做到“有多个 1 就循环多少次”。
原理很简单:n & (n - 1) 的作用是把 n 的二进制表示中最低位的那个 1 变成 0。比如 n = 12(1100),n - 1 = 11(1011),两者相与得到 8(1000),最低位的那个 1 确实被消掉了。每循环一次消掉一个 1,直到 n 变成 0,循环次数就等于 1 的个数。
# 经典解法:每次消掉最低位一个 1 def count_ones_v2(n: int) -> int: count = 0 while n: n &= (n - 1) count += 1 return count这种写法唯一需要小心的是负数。如果输入是负数,while n 不会退出吗?在 Python 里,n = -8,n & (n - 1) 不等于 0,循环确实会结束吗?仔细算一下,-8 的补码是 11111111 11111111 11111111 11111000,-8 & (-9) 等于 11111111 11111111 11111111 11110000,每次确实会消去最低位的 1,循环次数有限。但为了保险和通用性,面试时最好先声明“如果是无符号数或者按 32 位来处理,可以这样写;如果输入可能是负数,需要先做掩码处理”。能主动指出输入可能为负的边界条件,本身就是加分项。
2.3 查表法:空间换时间,处理超大频繁调用的场景
如果这个函数被调用几十万次,每次循环好几轮的开销就不能忽视了。这时候查表法非常合适:把 8 位(或 16 位)范围内所有数字的 1 的个数预计算成一张表,然后每 8 位查一次表,累加。
# 查表法:每 8 位查一次预计算的表 def build_table(): table = [0] * 256 for i in range(256): table[i] = table[i >> 1] + (i & 1) return table def count_ones_v3(n: int) -> int: table = build_table() mask = 0xFF return table[n & mask] + table[(n >> 8) & mask] + \ table[(n >> 16) & mask] + table[(n >> 24) & mask]如果 n 是负数,右移时最终会变成全 1,但这道题在绝大多数面试场景下都是按无符号数处理。如果你用 Python,可以用 n & 0xFFFFFFFF 先把负数变成正数的补码表示再做查表。这种处理方式本身也体现了你对“负数在内存中怎么存”的理解。
2.4 分治归并:没有循环,只需要几条位运算
最后给你看一种“炫技”级别的解法——分治法,也叫 SWAR(SIMD Within A Register)算法。思路是每次把相邻位、相邻两位组、相邻四位组的 1 的个数合并统计。整个过程没有任何循环,代码是固定的几条位运算。
def count_ones_v4(n: int) -> int: n = (n & 0x55555555) + ((n >> 1) & 0x55555555) # 每两位统计 1 的个数 n = (n & 0x33333333) + ((n >> 2) & 0x33333333) # 每四位统计 n = (n & 0x0F0F0F0F) + ((n >> 4) & 0x0F0F0F0F) # 每八位统计 n = (n & 0x00FF00FF) + ((n >> 8) & 0x00FF00FF) # 每十六位统计 n = (n & 0x0000FFFF) + ((n >> 16) & 0x0000FFFF) # 十六位合并为最终结果 return n我第一次看到这段代码时盯着看了很久才想明白。它的核心思想是:先用掩码把相邻两位的 1 的个数相加,得到的结果用两位二进制就能表示(最大值是 2);再把相邻两位组相加,结果用四位二进制表示;以此类推。每一层都是“并行地统计小块范围内的 1 的数量”。
这道题给到第四种解法的时候,面试官通常已经满意了。但要注意,分治法如果没解释清楚原理,会显得你在背代码。建议在面试里说一句“这个方案在 CPU 上没有分支跳转,适合处理超大规模的批量计算”,然后简单画一下合并过程,效果会非常好。
3. 不用临时变量交换两个数:异或三次背后的陷阱
这道题属于“一看就会,一写就错”的类型。它表面考的是技巧,实际上考的是你对异或运算本质的理解,以及对“两个变量指向同一块内存”这类极端情况的敏感度。
3.1 常规技巧:三次异或完成交换
异或运算有三大性质:交换律、结合律,以及 x ^ x = 0、x ^ 0 = x。交换两个数最经典的位运算写法是:
a = a ^ b; b = a ^ b; a = a ^ b;拆开看每一步:
- 第一行执行后,a = a ^ b。
- 第二行执行的 b = a ^ b = (a ^ b) ^ b = a ^ (b ^ b) = a ^ 0 = a。此时 b 已经被赋成了原来的 a。
- 第三行执行的 a = a ^ b = (a ^ b) ^ a = b ^ (a ^ a) = b。此时 a 被赋成了原来的 b。
整个过程利用了异或的自反性,不需要任何多余的内存空间。但如果只答到这里,这道题只能算答对了一半。
3.2 加减法也能交换,但隐患很明显
有些候选人会给出加减法版本:
a = a + b; b = a - b; a = a - b;这个版本看起来更直白,数学上也没问题。但在有符号整数溢出时,a + b 可能超出 int 的表示范围,未定义行为(在 C/C++ 里)会带来不可控的结果。即便是在 Python 这种整数可以无限大的语言里,它也只是“能跑”,底层性能并不比异或更好,而且逻辑上并不通用——如果换成浮点数,加法交换可能因为精度问题出错。
异或交换法则完全避开了溢出问题,因为位运算不涉及进制的进位与符号扩张,这也是位运算适合做这类操作的根本原因。不过这里要提醒你一句:现在实际工程里根本不会用这种技巧去交换变量,现代 CPU 的寄存器交换指令和编译器优化已经很成熟,异或交换不仅可读性差,在某些体系结构下反而可能更慢。面试时把它当作展现你思维广度的题目来理解就好。
3.3 真正的坑:a 和 b 指向同一块内存时,结果全变零
很多人在白板上写完异或交换,面试官紧接着会问一句:“如果 a 和 b 指向同一个变量,会发生什么?”
如果 a 和 b 的地址相同,那么: a = a ^ a = 0 b = a ^ a = 0 a = a ^ a = 0这块内存直接被清零,整个程序逻辑完全崩溃。这个问题在操作数组交换元素时尤其致命。比如你想交换数组里 arr[i] 和 arr[j],如果恰好 i == j,结果就不是“不交换”,而是 arr[i] 被置成 0。这个细节不留意,线上数据就会莫名其妙丢失。
我在实际项目里见过有人用异或交换法配合尽量少的内存去刷算法题,结果遇到 i == j 的情况整个排序算法直接错乱。正确的防御性写法是先判断:if (a == b) return; 或者 if (i == j) return;。你要在面试里主动说出这个坑,这会让面试官觉得你不仅有技巧,还有工程安全意识。
4. 只出现一次的数字:异或在“唯一值查找”上的魔力
如果说前面的题都在展示位运算的“操作技巧”,那这道题就是位运算在“数据结构替代”上的封神之作。题目描述是:一个整数数组里,除了一个数字以外,其余数字都出现了两次,请找出这个只出现一次的数字。
4.1 暴力解和哈希表:最常见的常规思路
第一种方案是两层循环,统计每个数字出现的次数,时间复杂度 O(n²),不推荐但确实是最容易想到的。第二种方案是哈希表,用哈希表统计每个数字出现的次数,最后找出次数为 1 的数字。时间复杂度 O(n),空间复杂度 O(n)。这两种做法不能算错,但面试官在面“位操作符”专题时,心里真正期望的是你用异或。
4.2 异或解法:全部数字异或一遍,出现两次的全部抵消
异或的核心性质是:两个相同数字异或为 0,任何数字和 0 异或等于它自己。结合交换律和结合律,把数组里所有数字异或的最终结果,就等于那个只出现一次的数字。因为出现过两次的数字全部被抵消成 0 了。
def single_number(nums): result = 0 for num in nums: result ^= num return result这个解法的时间复杂度 O(n),空间复杂度 O(1)。只用 5 行代码就解决了问题。这不是炫技,而是异或运算本质特征最自然的应用。
4.3 面试追问进阶版:出现一次的数字另外两个怎么找
面试官从来不满足于一道题。他紧接着会问:如果数组里除了两个数字以外,其余数字都出现了两次,找这两个只出现一次的数字,怎么做?
思路是这样的:先把所有数字异或一遍,得到的结果是两个目标数字的异或值。这两个目标数字不同,所以异或结果里至少有一个二进制位是 1。找到最低位的那个 1(用 n & -n 提取),然后依据这一位是 0 还是 1,把数组分成两组。每组各自做异或,就能分别求出两个目标数字。
def single_numbers(nums): xor_all = 0 for num in nums: xor_all ^= num # 提取最低位的 1,用于分组 diff = xor_all & -xor_all a = 0 b = 0 for num in nums: if num & diff: a ^= num else: b ^= num return a, b这个进阶题背后隐藏着一个非常重要的思维模式:当你面对一堆杂乱的数据时,寻找一个“二进制位上的差异”作为分组的依据。这种“按位分组”的思想,在布隆过滤器、哈希分桶、Raft 选举的节点 ID 设计里都有影子。
如果你还能更进一步,回答“如果数组里除了一个数字出现了 1 次,其余数字都出现了 3 次,怎么找这个数字”,那这道题可以延伸到“有限状态机”的概念——利用两个位来记录每一位 1 出现的次数(00 -> 01 -> 10 -> 00),循环结束后剩下的数就是那个唯一出现一次的数。这一层的深度,已经能覆盖大多数中高级岗位的面试要求了。
5. 为什么位运算面试题这么常考:藏在题目背后的底层能力考察
讲了这么多具体题目,我想跟你聊聊更本质的问题:为什么面试官偏爱位运算?真的只是为了考你几个“奇技淫巧”吗?当然不是。我做过很多次面试官,我考位运算的时候,真正在观察的是以下四件事。
5.1 候选人对“二进制表示”的敏感度
位运算题目的根基是对二进制补码的深入理解。比如“负数在内存中怎么表示”“右移对负数的影响”“掩码怎么处理符号位”,这些问题如果一问三不知,说明候选人对计算机底层原理的理解停留在 API 调用层面。写业务代码的人可以不知道这些,但做底层基础设施、高性能计算、协议解析的人必须知道。
5.2 候选人在时间和空间之间的取舍能力
同一个统计 1 的个数问题,逐位右移是 O(n) 时间、O(1) 空间,查表法是 O(1) 时间、O(256) 空间,分治法是 O(1) 时间、O(1) 空间但牺牲了可读性。没有一种方案是绝对最优的,你必须根据实际场景选择。面试官想看到的是你能不能在多个方案之间做权衡,而不是只会一个“标准答案”。
5.3 候选人是否有“边界条件”意识
n > 0这一步省掉行不行?if (i == j) return要不要写?负数怎么处理?数组为空时返回什么?这些边界条件是生产环境里最容易出 bug 的地方。位运算之所以是好的面试题,是因为它的边界情况非常隐蔽而且容易测试。你主动提出来,说明你吃过这方面的亏;想不起来,面试官就知道你经验还欠火候。
5.4 候选人能否从“背技巧”上升到“想原理”
n & (n - 1) 这个公式,网上随便一搜就是一大堆。但你能不能解释为什么它能消去最低位的 1?能不能说明它跟“借位”的本质关系?如果你只是背下来,换个条件就抓瞎。在面试里,我喜欢先让候选人写出解法,然后追问“为什么”,看他是死记硬背还是真正理解。
6. 位运算实用技巧清单:从面试桌回到工程现场
刷完了面试题,我想把这些技巧落到工程应用上。位运算绝不仅存在于面试题里,它在真实项目中几乎无处不在。下面这组实用技巧,你可能会在代码 review 或日常开发中直接用到。
6.1 权限系统和开关标志位:一个整数存 N 个布尔值
最常见的应用是权限管理。Linux 文件权限就是 rwx 三组位标志,许多开源项目用 int 来存储几十个配置开关。每个位代表一个开关,用位掩码和位运算来判断是否开启、开启某个开关、关闭某个开关。
#define READ 0x0001 #define WRITE 0x0002 #define EXEC 0x0004 int perm = READ | WRITE; // 开启读和写 if (perm & READ) { // 判断是否有读权限 // do something } perm &= ~WRITE; // 关闭写权限这种方式一个变量存了多个布尔值,序列化、传输、比较都极其高效。面试中如果聊到权限设计,用位运算来做远远比多个布尔字段或者 JSON 数组更有说服力。
6.2 取模优化:n & (k - 1) 只在 k 是 2 的幂时成立
很多高性能代码会把 x % 16 写成 x & 15,因为位与运算比取模指令快得多。但这里有个必须强调的前提:这个优化只在取模数是 2 的幂时才成立。为什么?因为 2 的幂减 1 的二进制全是低位 1,与这个数做按位与,本质上是把高位全部清 0,只保留低位的值,效果等价于取模。这个技巧在哈希表扩容、循环队列、分页分配器里非常常见。
6.3 判断奇偶、正负号、绝对值:最少指令实现基础运算
x & 1 判断奇数效率极高;利用 ~x + 1 可以快速取负;绝对值计算可以用 (x ^ (x >> 31)) - (x >> 31) 在无语分支的情况下实现,当然在大多数编译器优化下直接写 abs() 就够了,但当你在写 GPU Shader、嵌入式系统、或者对分支跳转极其敏感的代码时,无分支版本的价值就体现出来了。
6.4 计算至少为 2 的幂的数:容器扩容的常见需求
HashMap 的容量必须是 2 的幂,如果你传入的初始容量不是 2 的幂,就得手动“向上取整”到最近的 2 的幂。经典写法:
int n = cap - 1; n |= n >>> 1; n |= n >>> 2; n |= n >>> 4; n |= n >>> 8; n |= n >>> 16; return n + 1;这个写法利用了“把最高位的 1 扩散到所有低位”的思路:先减 1 防止 cap 本身就是 2 的幂时结果翻倍,然后把最高位 1 依次右移并或运算,最终所有低位全部变成 1,再加 1 就得到比原来大的最近 2 的幂。面试时如果被问到“HashMap 容量为什么总是 2 的幂”,位运算就是核心答案。
6.5 状态压缩:用一个整数表示一组状态的组合
在动态规划(尤其是状压 DP)、棋盘问题、图遍历问题里,用一个整数表示一组状态的集合非常常见。例如用 int 的每一位表示某个点是否被访问过:访问 3 号点就把第 3 位设为 1,判断是否访问过就检查第 3 位是否为 1,回溯时再把它清为 0。这种做法的内存占用比布尔数组小得多,而且在复制状态时只需要赋值一个 int,性能差异非常明显。
我是从第一次在代码评审里被人指出“你这几个标志位可以用一个 int 搞定,没必要开三个 bool”的时候,才真正开始系统补位运算的。从那以后,我在协议解析、缓存设计、并发标志控制里反复用到这些技巧。回过头来看,位运算面试题的价值不止于“刷题”,它其实在帮你建立一种“数据在底层就是一堆比特”的直觉。这种直觉,平时写 CRUD 用不上,一旦遇到性能瓶颈、内存瓶颈、协议兼容性问题,它就是一把没人跟你抢的利器。
如果你准备面试,我给你一个最实在的建议:不要只背 n & (n - 1) 和异或交换这种结论,而是拿着笔在纸上把补码、掩码、分治的每一步都推演一遍。不要怕浪费时间,我面试时见过太多背得滚瓜烂熟却一个“为什么”都答不上来的候选人。能把位运算从“技巧”变成“直觉”,你收获的不只是通过面试,而是真正理解计算机怎么在底层思考问题。