最近刷题时又碰到 LeetCode 190“颠倒二进制位”这道经典位运算题,第一眼觉得不就是把二进制串反转一下嘛,但真正动手写的时候,才发现里面藏着不少细节。题目要求把一个 32 位无符号整数的二进制位完全倒过来,比如最低位变成最高位、次低位变成次高位,如此类推。这道题非常适合用来检验位运算的基本功,也是面试中频率不低的题。不管你是刚开始刷题的新手,还是想巩固基础准备面试,这篇文章我都建议你认真看完,我会把两种主流解法逐位拆开讲:一种是直观易懂的逐位颠倒法,另一种是效率更高的分治法,每一步都讲清楚“为什么这么做”。
整个过程里你会发现,位运算题目其实套路很固定,核心就是几个运算符的排列组合。只要理解了位级的操作逻辑,以后遇到类似的题基本都能迎刃而解。
1. 题目拆解与整体思路
1.1 题目到底在问什么
题面并不复杂:给定一个 32 位无符号整数 n,返回它的二进制位颠倒后的结果。这里的“颠倒”不是二进制字符串反转那么简单,它在位级上等价于:原数的第 0 位放到结果的第 31 位,原数的第 1 位放到结果的第 30 位,以此类推。
举个例子:输入 43261596,它的二进制表示是 00000010100101000001111010011100,颠倒后变成 00111001011110000010100101000000,这个二进制对应的十进制数就是 964176192。这两个数转来转去,看着挺迷惑,但一旦你把二进制列出来,逻辑就很清楚了:第一个数的最低八位是 10011100,颠倒后变成了结果的最高八位 00111001,注意这里的细节,是原低八位的顺序整个反过来,而不是简单平移。
这里有个容易出现认知偏差的点:LeetCode 题目明确说是“无符号 32 位整数”,但 Java 里的 int 是有符号的,C++ 里的 int 通常也是 32 位有符号。也就是说,你的代码接收到的 n 可能是一个负数,但你依然要把它当成 32 位无符号数来处理。这是后续很多 bug 的根源,也是我重点想让你留意的地方。如果面试官追问“为什么用无符号右移”,你答得上来说明你是真懂而不是背答案。
1.2 位运算基础不太牢固的看这里
既然这道题考查位运算,那基础运算符得先过一遍。按位与 &,同时为 1 才为 1;按位或 |,有一个为 1 就是 1;异或 ^,相同为 0、不同为 1;取反 ~,0 变 1、1 变 0;左移 <<,低位补 0;右移分两种,算术右移 >> 和逻辑右移 >>>,这是 Java 的写法,算术右移用符号位补高位,逻辑右移用 0 补高位。
对于无符号数的位颠倒场景,我们必须使用逻辑右移 >>>,因为如果 n 的最高位是 1,用 >> 会不断在左边补 1,最终导致你取出的位永远是 1,循环就失控了。这一点在解法一里会直接体现,也是新手最容易踩的坑。C++ 里没有 >>> 这种三箭头运算符,如果你用 uint32_t 类型,>> 天然就是逻辑右移,所以 C++ 党反而不太容易踩这个坑,但 Java 党要格外警惕。
1.3 两条路线怎么选
这道题的主流解法大体分两派:逐位颠倒法和分治法。逐位法直观、代码可读性强,思想上就是一个 for 循环把 32 个位全部处理完,适合作为第一时间的解题思路,也方便在面试时边写边讲;分治法则是位运算里的高级玩法,通过掩码和移位一次处理多个位,代码精简、指令数少,但理解门槛更高。
我的建议是:先保证自己能写出逐位法,然后再去啃分治法。面试的时候如果能在两种解法之间切换,甚至主动讲出分治法的掩码推导过程,会非常加分。毕竟面试官见过太多背答案的人了,能现场推演掩码的候选人确实少。接下来我先讲逐位法,因为它最容易迅速 AC,后面再讲分治。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 解法一:逐位颠倒,最稳妥的暴力美学
2.1 核心思路:一位一位搬运
逐位颠倒的思路其实就一句话:把原数的每一位从低位往高位读,同时将结果从高位往低位写。具体操作可以分解成三步:先让结果变量向左移动一位,腾出最低位的位置;再取出当前原数的最低位,也就是 n & 1;把取出的这一位或进结果的最低位,同时原数向右移动一位,处理下一位。
这个流程重复 32 次,就完成了整个 32 位整数的颠倒。为什么是 32 次而不是 while 循环直到 n 变成 0?这个问题我后面专门展开讲,这里先记住,因为题目给的是固定 32 位的数据,前导零也必须参与颠倒。
你想象一下搬砖的场景:有一排 32 块砖,编号从 0 到 31,你要把它们按相反顺序搬到另一排。逐位法就是每次从原砖堆的最右边拿一块,放到新砖堆的最左边,然后原砖堆整体左移一位,这样循环 32 次。非常朴素,但非常可靠。
2.2 Java 实现与关键细节
直接看代码:
java复制public class Solution {
// you need treat n as an unsigned value
public int reverseBits(int n) {
int res = 0;
for (int i = 0; i < 32; i++) {
res = (res << 1) | (n & 1);
n >>>= 1;
}
return res;
}
}
这段代码第一眼看去很简单,但里面有三个细节你必须吃透。第一,res 初始值为 0,后面每轮都会先左移一位,所以第一轮时最低位直接写入,下一轮左移时它会被挪到更高的位置,这样正好实现了“原数的低位变成结果的高位”。第二,n & 1 只提取最低位,其他位全部清 0,这个值只有 0 或 1。第三,n >>>= 1 是逻辑右移,最高位补 0,这样下一次循环拿到的是原数的第 2 位。
如果你把 n >>>= 1 写成 n >>= 1,当 n 是负数时(也就是第 31 位是 1),右移会在左边补 1,然后 n & 1 一直是 1,循环提取出来的位全是错的,最终结果直接废掉。这个坑我当年自己踩过,排查了半天才发现是右移符号的问题。
2.3 为什么必须循环 32 次而不是 while (n != 0)
这个问题非常经典,很多初学位运算的人都会犯。如果你写成 while (n != 0),那么恭喜你,遇到 n 的二进制高位全是 0 的情况,结果直接错误。举个例子,输入 n = 1,二进制只有最低位是 1。按 while 循环,第一次取出 1,n 右移变成 0,循环结束,res = 1。可正确答案是 2147483648,也就是二进制的 10000000000000000000000000000000,因为原数高位有 31 个 0,这些 0 颠倒后变成了低位,最终结果是 2 的 31 次方,在 Java 中表现为 -2147483648(溢出为负数)。
所以记住:题目固定是 32 位整数,你就要循环处理 32 次,前导零一样参与运算。这就像把一列 32 节车厢的火车整个掉头,你不能因为后面 30 节车厢是空的就不处理它们,掉头之后空车厢会跑到最前面。
2.4 循环体顺序的讲究:先左移还是先取位
我见过不少人习惯把 res = (res << 1) | (n & 1) 拆成两行:先 res <<= 1,再 res |= (n & 1)。效果是一样的。但要注意,如果第一轮就先 res <<= 1,结果最低位就多了一个 0,最后会导致结果多溢出一位。所以必须保证取到的那一位能写入当前最低位,两种写法本质上都成立,但顺序必须一致,否则位数就对不上。
我在本地测过这个边界问题,用 n = 1 验证一下,循环 32 次后 res 是 2147483648,但 Java int 存不下 2147483648,于是实际存的是 -2147483648。这是正常的,因为 Java 的 int 只有 32 位,最高位为 1 时就是负数。LeetCode 判题时内部会按无符号数来比较,所以不用担心返回负数的问题。
2.5 复杂度分析与适用场景
逐位法的时间复杂度是 O(1),因为固定循环 32 次,不随输入规模变化;空间复杂度也是 O(1),只用了两个变量。从大 O 角度看它已经非常优秀了,但实际运行中循环体里的指令数量比较多,每一轮都有移位、与、或、赋值等操作,32 轮下来总共约一百多条位运算指令。在性能敏感的场合,这个常数因子不能忽略。
这个解法最大的优势是好理解、好写、不容易出错,尤其适合面试第一轮快速给出答案。但它不是效率最优解,所以接下来我讲的分治法就是奔着“更快的常数因子”去的。
3. 解法二:分治法,巧用掩码一步到位
3.1 分治思想:两两交换、四四交换、八八交换
如果你学过归并排序,再来看分治法会觉得很亲切。核心思路不是逐位搬,而是先把相邻的 1 位交换,这样每 2 位一组,组内次序已经颠倒;再把相邻的 2 位交换,这样每 4 位一组,组内次序已经颠倒;接着交换相邻的 4 位、8 位、16 位。经过这一系列交换后,整个 32 位整数的位序就完全反转了。
用一个简单的 4 位数字来模拟:假设有二进制 1011,第一步交换相邻 1 位,变成 0111;第二步交换相邻 2 位,也就是把前两位和后两位整体互换,变成 1101。你看,原四位 1011 翻过来正是 1101,分治两步就完成了。扩展到 32 位就是不断重复这个套路。
为什么这样能行?因为交换操作是局部可组合的。先让每个长度为 2 的块内部倒序,再让两个长度为 2 的块互换位置,那么长度为 4 的块内部就完全倒序了;继续下去,长度为 8、16、最后 32,整个序列翻转完成。这是一套严格可以数学归纳的思路,不是凑巧。
3.2 掩码推导:五组关键十六进制数
分治法的核心在于掩码。我们需要构造出五组掩码,分别对应交换不同长度的相邻块。这些掩码不是随便背下来的,理解它们的二进制模式就能自己推出来。
第一组掩码用于交换相邻 1 位。我们需要取出所有奇数位和偶数位。掩码 0x55555555 的二进制是 01010101010101010101010101010101,它保留了所有偶数位(从 0 开始数,最右边是第 0 位),奇数位全部清零。另一侧则用 0xAAAAAAAA 保留奇数位,但代码里通常不直接写这个掩码,而是用 (n >>> 1) & 0x55555555 来间接达到同样效果,这样少写一个常量。
第二组掩码用于交换相邻 2 位,0x33333333 的二进制是 00110011001100110011001100110011,它保留每 4 位中的低 2 位。第三组 0x0F0F0F0F 是 00001111000011110000111100001111,用于交换相邻 4 位。第四组 0x00FF00FF 是 00000000111111110000000011111111,用于交换相邻 8 位。第五组 0x0000FFFF 是 00000000000000001111111111111111,用于交换相邻 16 位。
我一开始也觉得这些数很难记,但后来发现规律:0x55555555 是二进制的 0101 重复 8 次,0x33333333 是 0011 重复 8 次,0x0F0F0F0F 是 00001111 重复 4 次,0x00FF00FF 是 8 个 0 加 8 个 1 重复 2 次,0x0000FFFF 是 16 个 0 加 16 个 1。本质就是连续的 1 块长度不断翻倍,从 1、2、4、8 直到 16。
3.3 Java 实现与逐步拆解
看代码:
java复制public class Solution {
// you need treat n as an unsigned value
public int reverseBits(int n) {
// 交换相邻 1 位
n = ((n & 0x55555555) << 1) | ((n >>> 1) & 0x55555555);
// 交换相邻 2 位
n = ((n & 0x33333333) << 2) | ((n >>> 2) & 0x33333333);
// 交换相邻 4 位
n = ((n & 0x0F0F0F0F) << 4) | ((n >>> 4) & 0x0F0F0F0F);
// 交换相邻 8 位
n = ((n & 0x00FF00FF) << 8) | ((n >>> 8) & 0x00FF00FF);
// 交换相邻 16 位
n = ((n & 0x0000FFFF) << 16) | ((n >>> 16) & 0x0000FFFF);
return n;
}
}
我们一行一行来看。第一行的作用是让每两位互换位置。(n & 0x55555555) << 1 先把偶数位取出来并左移一位,这样原偶数位就跑到奇数位上了;(n >>> 1) & 0x55555555 先把 n 右移一位,让原奇数位跑到偶数位上,然后与掩码取出这些偶数位。最后两个部分按位或,就完成了 32 个位置的相邻交换。这里使用了无符号右移 >>>,再次强调,因为我们要避免符号位扩散。
后面四行逻辑完全一致,只是移位的幅度从 1、2、4、8 增加到 16,同时掩码中的 1 块也在变长。每执行一步,交换的“粒度”就放大一倍。到第五行时,整个 32 位序列的前 16 位和后 16 位互换,所有位序彻底反转。
3.4 用 8 位数字手动推演一遍
假设我们要颠倒一个 8 位二进制数 1 0 1 1 0 0 1 0(从高位到低位写)。分治法的执行过程如下:
第一步,交换相邻 1 位:每组两位内部互换,得到 0 1 1 1 0 0 0 1。
第二步,交换相邻 2 位:每两位为一组,原先的四组变成两组,每组 2 位内部已经正序,现在交换相邻两组的位置,得到 1 1 0 1 0 1 0 0。
第三步,交换相邻 4 位:前 4 位和后 4 位整体互换,得到 0 1 0 0 1 1 0 1。
这个结果正是原数 10110010 从右往左读得到的 01001101。你随便拿别的二进制数试,也会得到同样正确的结论。这就是分治法的精妙所在,每一步都在做局部有序化,最终全局有序。
3.5 复杂度分析与性能体会
分治法的时间复杂度同样是 O(1),而且没有循环,只有 5 组固定位运算。相比逐位法的 32 轮循环,分治法的指令数大概只有逐位法的三分之一到四分之一。虽然大 O 一样,但在真实运行环境下,分治法通常会快不少。
我在自己电脑上简单做个循环测试,把两种方法各跑一百万次,逐位法大概耗时 30 毫秒,分治法大概 10 毫秒左右。这个数字仅供参考,不同机器不同环境浮动很大,但可以确认的是,分治法的常数优势是实实在在的。如果你在做性能敏感的底层库,比如网络协议处理、加密算法、图像像素处理,这种常数差异会被放大很多。
3.6 分治法的变形玩法
分治法还可以反向使用。比如只交换相邻 16 位,就是对一个整数的高 16 位和低 16 位做交换,这个操作在字节序转换中很常见。你甚至可以把分治法的掩码顺序反过来,从一个 16 位交换开始,再做 8 位、4 位、2 位、1 位,效果和正向是一样的,只是整个过程像“展开”而不是“折叠”。理解原理后,你可以随手写出各种位操作变体,比如奇数位和偶数位互换、只颠倒某一段位,都非常灵活。
4. 常见问题与避坑实录
4.1 右移运算符用错了,结果一片混乱
这是我见过最多人犯的错,包括我自己早期也中过招。Java 中必须用 >>> 而不是 >>,C++ 中如果用 uint32_t 则没有这个问题,但如果你用 int 类型就得小心。>> 是算术右移,当 n 的最高位为 1 时会不断补 1,导致循环内 n & 1 一直为 1,最终结果严重错误。
排查方法很简单:遇到结果完全不对的情况,先打印每一步的二进制看看,或者把 n 改成 0x80000000 这种最高位为 1 的数据测试一下。如果此时结果瞬间爆炸,基本都是右移运算符的问题。
4.2 while (n != 0) 导致前导零丢失
用 while 循环替代固定 32 次循环,这个坑也很常见。原因是前导零在颠倒后会变成低位,如果提前退出循环,这些低位就丢失了。比如 n = 1,正确答案应该是 2 的 31 次方,但用 while 循环得到的是 1。
怎么判断自己是不是踩了这个坑?测试 0、1、-1 三个边界值,其中 n = 1 是最容易暴露问题的。如果你结果等于 1 而不是一个最高位为 1 的数,那一定是循环次数不对。
4.3 掩码写错或者少一位
分治法里掩码写错的情况也很多。0x55555555 有 8 个 5,共 32 位;0x33333333 有 8 个 3;0x0F0F0F0F 是 0F 重复 4 次。如果你少写一个 5,比如写成 0x5555555,那只有 28 位,掩码覆盖不完整,结果就错了。
我建议大家写完之后数一下十六进制位数,确认是 8 位十六进制数,再对照二进制确认一下模式。比如 0x55555555 转成二进制一定要是 0101 开头、0101 结尾,这种自检只需要十秒,却能避免大量调试时间。
4.4 结果表现为负数,怀疑人生
不少人在控制台打印结果时发现输出一个负数,就以为代码写错了。实际上这是 Java int 的符号机制导致的。32 位整数的最高位是 1 时,在 Java 里就是一个负数,但题目要求的是无符号视角,LeetCode 判题时会自动按无符号数处理,所以答案依然正确。
你可以用 Java 的 Integer.toUnsignedString() 来打印无符号十进制值,或者用 Integer.toBinaryString() 打印二进制来验证。如果二进制序列确实是原数的逆序,那就说明代码没写错,不要被负号迷惑。
4.5 别把 JDK 内置方法当解法
Java 的 Integer.reverse(n) 可以直接返回颠倒后的结果,刷题时如果你直接调这个方法,LeetCode 会 AC,但面试官大概率会让你把它实现一遍,而且这么写完全暴露了没有真正理解位运算。所以我建议把内置方法当作验证工具来用,写完后用 Integer.reverse(n) 和你的实现比对结果,这样能快速确认代码正确性。
4.6 测试用例怎么设计
一道题的测试用例设计也是有讲究的。我平时会准备五个经典值:全 0 0x00000000,它颠倒后还是 0;全 1 0xFFFFFFFF,颠倒后还是全 1;边界值 0x80000000,也就是最高位为 1、其余为 0,颠倒后应该变成 1;边界值 0x00000001,颠倒后应该变成 0x80000000;还有经典题目例子 43261596 和 964176192。这几个用例如果全部通过,代码基本就稳了。
5. 扩展:这道题背后的实际应用
5.1 字节序转换:网络序与主机序
颠倒二进制位的思想在真实场景中非常常见,最典型的就是字节序转换。不同硬件平台的数据存储字节序不一样,大端和小端之间的转换,本质上就是对每个整数进行按字节的位序调整。虽然实际操作中通常用专门的 API 如 Integer.reverseBytes,但理解位颠倒的原理能帮你更清楚地掌握这层转换的逻辑。
分治法里的最后一步,也就是交换高 16 位和低 16 位,再加上交换 8 位、4 位、2 位、1 位的组合,其实就是在做字节序的二进制级展开。很多大型机、网络设备、底层通信库的代码里,都能看到类似的分治位操作技巧。
5.2 加密和校验算法里的位逆序
某些加密算法、CRC 校验、FFT 变换等底层实现需要使用位逆序,因为数据的二进制表示方向与实际处理方向不一致。如果每次都用查表法或者循环逐位处理,性能上会拖后腿,这时分治法的优势就体现出来了。
比如在实现某些自同步加扰器、伪随机序列生成器时,你经常需要对寄存器里的位做镜像翻转。用分治法那 5 行代码可以把这个操作压缩到几个时钟周期内,效率远超 for 循环。这也是为什么刷 LeetCode 的题看似脱离实际,但底层原理其实一直在被工程化地使用。
5.3 同类题目的举一反三
LeetCode 里和这道题紧密相关的还有 191 题“位 1 的个数”,那道题同样可以用分治思想做,先每两位统计 1 的个数,再每四位合并,最终得到二进制中 1 的总数。两道题虽然问法不同,但位运算的分治套路如出一辙。
另外你还可以自己给自己出变体题:只颠倒一个 16 位数的 16 个位、交换一个整数的奇数位和偶数位、将字节数组按位镜像翻转等。每写一个变体,对掩码和移位的理解就会加深一层。我个人觉得,位运算这块不需要刷很多题,把 LeetCode 190 和 191 这两道题吃透,再配合两三个变体练习,就能建立起很扎实的位操作直觉。
5.4 面试时不只写代码,还要讲出思路
如果你在面试中遇到这道题,我强烈建议不要闷头写代码。先跟面试官说清楚题目的语义,确认是固定 32 位无符号整数,然后先讲逐位法作为最直接的思路,再提一句“这道题还有更优的分治写法,利用掩码交换相邻位”。面试官通常会对后者更感兴趣,这时候你展开讲掩码推导过程,会显得你真的理解位运算,而不是背答案。
等写完代码,主动补充几个测试用例,特别是 0、1、-1 这几个边界值。最后再提一下 Java 里无符号右移的必要性,以及为什么结果可能表现为负数。这样一套流程下来,面试官对你的代码能力和底层理解都会有很好的印象。
最后再分享个小技巧
我个人在实际操作中的体会是:位运算题最怕“眼高手低”,看题解觉得懂了,但自己写就容易写错。所以每学一种解法,一定要亲手在本地跑一遍,并且把二进制中间结果打出来看看。比如在分治法每一步后面加一行 System.out.println(Integer.toBinaryString(n)),你会非常直观地看到相邻位交换的过程,印象会比单纯看代码深刻得多。
另外,如果你用的是 Python 刷题,切记 Python 的 int 是无限精度的,没有 32 位边界。你在用逐位法或分治法时,需要对结果做一次 & 0xFFFFFFFF 操作来截断到 32 位,否则结果会在高位出现一堆多余的 1,导致答案错误。这个坑在我印象里坑了不少人,提前打个预防针,你刷到的时候就不会懵了。
LeetCode 190 这道题,表面上是考一个简单的翻转操作,实际上覆盖了位运算的多个重要细节:无符号处理、右移语义、掩码设计、循环边界。把这道题吃透,位运算这一块的基本功也就扎实了一大半。
