1. 位运算为什么值得玩命吃透:从一次“算法面试翻车”说起
我先讲个真事。之前有个同事去面一家做基础架构的团队,笔试有一道题:判断一个整数是不是 2 的整数次幂。他下意识写了个 while 循环,n 每次除以 2,能除到 1 就算 true。代码没问题,复杂度 O(log n),边界也处理了。结果面试官问了一句:能不能用 O(1) 的办法?他愣了半天,最后在提示下才写出 n > 0 && (n & (n - 1)) == 0。
后来他回来和我复盘,说了一句让我印象很深的话:“我不是不会位运算,我是压根没把它当成日常工具在用。”
这就是大多数程序员对位运算的现状——上学学过,面试背过,但真到了工程里,遇到需要解析二进制协议、压缩标志位、处理颜色通道、优化低层计算的时候,很多人第一反应还是写出一堆 if、while、乘除法,完全想不到用位运算。
位运算这个东西,看着冷门,实际上是连接“数据表示”和“算法效率”的一座桥。你理解了它,才能真正理解为什么计算机里负数要用补码存,为什么 x & (x - 1) 能消掉最低位的 1,为什么哈希表求余数可以用位运算代替,为什么颜色值里的 RGB 通道能用一个 int 装下。
更重要的是,位运算和进制转化是同一件事的两面。你越懂进制,越能看透位的本质;反过来,你把位运算练熟了,看任何进制的数字都会觉得那是“一排开关”,而不是一串看不懂的符号。
这篇文章,我不会给你堆一堆百度就能搜到的定义,也不会写那种“位运算共 6 种,请背诵”的教科书内容。而是从原理、实战、坑、优化四个维度,把位运算和进制转化这件事彻底讲透。适合正在学算法准备面试的人,也适合做底层开发、嵌入式、游戏、音频视频处理、想做高性能代码的人。认真看完,你至少能解决三类问题:看懂别人代码里那些奇奇怪怪的 & 和 >> 到底在干嘛;自己遇到性能瓶颈时知道往哪个方向优化;面试时遇到位运算题不再慌。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 先从“计算机为什么要用二进制”说起:进制转化的本质是一场翻译
很多人觉得进制转化就是一个数学游戏:十进制转二进制,除 2 取余;二进制转十六进制,四位一拼。背下来就能做题,但完全不知道这有什么意义。我建议你把思路倒过来——进制转化不是“数学计算”,而是翻译,翻译的对象是“同一个数在不同计数体系下的写法”。
2.1 为什么计算机死认二进制,不认十进制
我们日常用十进制,是因为人有十根手指,但电路没有手指。电路只有两种稳定状态:高电平和低电平,对应 1 和 0。如果用十进制,你需要一个元件能稳定输出十种不同的电压,这在物理上做得到,但抗干扰性极差,成本极高。反而是“要么有电要么没电”这种最简单的状态,最容易做,也最可靠。
所以从底层开始,存储、传输、计算,全都基于二进制。但这不代表计算机不懂十进制,而是它把十进制数先翻译成二进制,算完再翻译回十进制给你看。整个过程对你是透明的。
那进制转化到底在转化什么?举个例子:十进制的 13,和二进制 1101,本质上是同一个数量的两种写法。13 的意思是 1*10^1 + 3*10^0,1101 的意思是 1*2^3 + 1*2^2 + 0*2^1 + 1*2^0。看明白了吗?位置权重从“十的幂”变成了“二的幂”,仅此而已。
2.2 三种手感必须练成肌肉记忆的手工转化法
抛开计算器,实际写代码或读二进制时,常用的手工转化方法有三套,各有适用场景。
第一套:除 2 倒取余法,适合十进制转二进制。比如把 37 转成二进制:
code复制37 / 2 = 18 余 1
18 / 2 = 9 余 0
9 / 2 = 4 余 1
4 / 2 = 2 余 0
2 / 2 = 1 余 0
1 / 2 = 0 余 1
从下往上读:100101。验证一下:32 + 4 + 1 = 37,没问题。
第二套:8421 法,适合二进制和十六进制互转。因为四位二进制恰好能表示 0 到 15,和一位十六进制完全对应。比如 1011 0110,拆成两组:1011 是 8+2+1=11,也就是十六进制的 B,0110 是 4+2=6,所以结果是 0xB6。反过来,十六进制的 0x2F,2 对应 0010,F 对应 1111,拼起来 0010 1111。
第三套:权值展开法,适合二进制转十进制。把每一位上的 1 乘上它的权值再加起来。比如 10101,从右往左的权值分别是 1、2、4、8、16,是 1 的位有第 0 位、第 2 位、第 4 位,所以结果是 1 + 4 + 16 = 21。
提示:如果你经常要手算进制,建议花 10 分钟背一下 2 的 0 到 16 次幂,从 1 到 65536。这比背九九乘法表还有用,因为很多位运算技巧本质上就是在判断“这个数落在哪个 2 的幂区间”。
2.3 一图看懂各进制间的转化路径(无图版)
很多进制转化的题容易把人绕晕,其实转化路径完全可以固定下来:任何进制转十进制,用权值展开;十进制转任何进制,用除法取余;二进制和八进制/十六进制之间,用分组法,一位八进制对应三位二进制,一位十六进制对应四位二进制。
八进制用得少了,但偶尔在 Linux 文件权限、Unix 系统调用里会碰到。0777 这种写法,就是三个八进制位分别表示 owner、group、other 的读写执行权限。你如果理解三 bit 能表示 0 到 7,就能瞬间明白为什么 chmod 755 里的 7 是 rwx,5 是 r-x——7 = 111,5 = 101,每个 bit 就是一个权限开关。
这部分讲完,一定会有个疑问冒出来:负数怎么办?二进制里怎么表示负号?如果只研究无符号数,那位运算和进制转化就是纯粹的数学游戏,但一旦放进真实编程语言里,就牵扯到补码、符号扩展这些概念,而这恰恰是位运算最容易出 bug 的地方。先记住这个疑问,后面专门开一章来拆。
3. 六个位运算符的实战视角:别再当数学符号背,要当“位级操作工具”用
C 语言和 Java 里的位运算一共有 6 种:&、|、^、~、<<、>>。教科书喜欢给你真值表,让你记住 0&1 得 0、1^0 得 1 之类的。但实战里你不会去背真值表,你需要的是条件反射——看到某种需求,立刻想到用哪个运算符。
3.1 按位与(&):掩码、清零、取特定位
& 的规则很简单:两个位都是 1,结果才是 1。但它在实战里扮演的角色是“过滤器”。
最经典的应用是取低 N 位。比如 x & 0xFF,就是把 x 的最低 8 位取出来,高位置零。为什么?因为 0xFF 的二进制是 0000 0000 1111 1111,跟它做按位与,高 24 位无论 x 是什么,和 0 相与都变成 0,低 8 位和 1 相与就保留原值。
还有判断奇偶:x & 1,如果结果是 1,说明最低位是 1,也就是奇数。这个写法比 x % 2 != 0 在很多底层代码里受欢迎,因为位运算不涉及除法,指令数更少。虽然现代编译器能把 % 2 优化成 & 1,但你自己写的时候用位运算至少能让意图更底层、更明确。
清零操作用得更广。x & ~(1 << n) 表示把 x 的第 n 位清零,其他位不变。这个形式你在状态寄存器、标志位操作里会频繁看到。理解它有个技巧:先构造一个“只有第 n 位是 0,其他位都是 1”的掩码,再和 x 相与。1 << n 是只把第 n 位置 1,取反后就变成你要的掩码了。
3.2 按位或(|):置位、拼装、权限追加
| 的逻辑是“只要有一个 1,结果就是 1”。在实战里它最常用的场景是置特定位为 1。
比如 x | 1,不管理论上是什么意思,直接看效果:把 x 的最低位置成 1,其他位不动。那如果我想把第 4 位强制置 1 呢?x | (1 << 4)。
标志位模式的权限设计就是基于这个。比如说一个文件权限用 8 bit 表示,第 0 位表示可读,第 1 位表示可写,第 2 位表示可执行。初始权限是 000,要加可写权限就是 perm |= 1 << 1,要加可执行就是 perm |= 1 << 2。检查是否有可写权限:(perm & (1 << 1)) != 0。这套模型在 Linux 文件权限、JavaScript 的 fs.constants、各种 SDK 的 option 参数里都在用。
3.3 异或(^):最“反直觉”但最强大的运算符
异或的规则是“相同为 0,不同为 1”,但它有几个性质一定要刻进 DNA 里:
x ^ x = 0,任何数和自己异或得 0x ^ 0 = x,任何数和 0 异或不变- 异或满足交换律和结合律
x ^ y ^ x = y,一个数异或两次另一个数,会回到原值
这三条性质直接推导出两个高频算法:
第一个是不引入临时变量交换两个数:
c复制int a = 3, b = 5;
a = a ^ b;
b = a ^ b; // b = (a ^ b) ^ b = a
a = a ^ b; // a = (a ^ b) ^ a = b
这个技巧面试里经常出现,但我要提醒你:工程上不要为了炫技用它,因为可读性差,而且现代 CPU 的寄存器交换指令已经足够快。但它能帮你深刻理解异或的性质。
第二个是找出数组中唯一出现奇数次的元素。一组数字里,其他数字都出现偶数次,只有一个数出现奇数次,怎么找?把所有数全部异或一遍,出现偶数次的数字两两抵消成 0,最后剩下的就是那个唯一的数字。时间复杂度 O(n),空间复杂度 O(1),代码只有一行循环。这个解法要是没彻底理解异或性质,很难自己想出来。
3.4 按位取反(~):掩码制造器
~x 就是把 x 的所有位翻转,0 变 1,1 变 0。单看没什么,但它最大的价值在于和 & 配合生成清零掩码。
比如我们要把低 4 位清零,高位移除后保留,可以直接 x & ~0xF。这里的 ~0xF 会变成一个高位置 1、低 4 位为 0 的数。这种写法比硬写 0xFFFFFFF0 要好,因为你不需要关心 int 是 32 位还是 64 位,~0xF 会自动适应当前类型的位宽。
另一个容易踩坑的点是:~ 在 C 和 Java 里都会涉及符号位,因为整数默认是有符号的。~0 不是 0,而是 -1,因为 0 的所有位取反变成全 1,而全 1 的补码表示就是 -1。这个点从二进制角度理解比从“取反加一”理解更直观。
3.5 左移(<<)和右移(>>):乘法除法的位级版本
左移 x << n 相当于把 x 的二进制位整体向左挪 n 位,右边空出的位补 0。效果是乘以 2 的 n 次方:3 << 2 = 12,也就是 3 * 4。右移 x >> n 相当于除以 2 的 n 次方,但这里有个大坑,见后面的“符号”章节。
左移最常用的场景是构造位掩码。1 << n 就是“只有第 n 位为 1”的数,然后可以配合 &、| 去操作任意特定位。这种构造方式比 int mask = (int) Math.pow(2, n) 高效得多,也更直观。刷题写回溯算法时,就经常用 1 << i 来表示“第 i 个元素已经被访问过”。
右移的场景则更多样:把高 8 位取出来时,可以先 x >> 8 再把结果 & 0xFF;处理 RGB 颜色时,红色通道如果是高 8 位,就要右移到低 8 位再取值;调试二进制协议时,靠移位来逐字段解析。总之左移右移是“位移”的本职操作,凡是需要按位取数的场景基本都会用到。
4. 位运算的常见套路与算法题实战:从一道经典题拆到底
很多人刷算法题最怕碰到位运算题,因为看起来不像动态规划那样有章法,也不像 BFS/DFS 有固定模板。但位运算题其实是有套路家族谱系的。我按“代码特征 + 核心思路”的方式,整理了最常考、最实用的几类。每类我都会给代码,并且解释“为什么能这样做”。
4.1 判断一个数是不是 2 的整数次幂
判断一个正整数 n 是否为 2 的幂,绕开循环的 O(1) 解法是:
java复制public boolean isPowerOfTwo(int n) {
return n > 0 && (n & (n - 1)) == 0;
}
关键在于 n & (n - 1) 这个表达式的含义:它会去掉 n 的二进制表示中最低位的那个 1。比如 n = 12,二进制是 1100,n - 1 = 11,是 1011,两者相与:1100 & 1011 = 1000,最低位的 1(在第三位)被消掉了。
如果 n 是 2 的幂,那么它的二进制只有一个 1,比如 8 是 1000。消掉这个唯一的 1 之后,结果必然是 0。反过来,只要 n 的二进制里有不止一个 1,相与结果就不会是 0。
这个套路太常用了,建议直接背下来,并且在纸上验证几次。我把 n = 16、n = 24 分别画出来算一遍,比听十遍解释都管用。
4.2 统计二进制中 1 的个数(汉明重量)
LeetCode 191 题。最普通的做法是一位一位数,循环 32 次。但用上面的技巧可以更快:
c复制int countOnes(int n) {
int count = 0;
while (n != 0) {
n = n & (n - 1);
count++;
}
return count;
}
每次 n & (n - 1) 都会消掉一个最低位的 1,所以循环次数等于 1 的个数。如果一个数只有 3 个 1,就循环 3 次,和位数无关。这个思路在“二进制位操作”类问题里相当于“清除一个已处理元素”的标准动作,类似 BFS 里弹出的队首元素。
还有更极端的查表法:把 0 到 255 每个数的 1 个数预处理到一个数组里,然后每 8 位查一次表。空间换时间,在性能敏感的场景里很有用。
4.3 只出现一次的数字
前面讲过用异或找唯一奇数次元素,我再补充它的变种题源:LeetCode 136 的原题。很多新手一开始会想用哈希表,但异或解法才是这道题的精髓。
java复制public int singleNumber(int[] nums) {
int result = 0;
for (int num : nums) {
result ^= num;
}
return result;
}
注意,这个解法成立的前提是“其他数字恰好出现两次”。如果题目改成“其他数字出现三次”,异或就不行了,要用“逐位统计”的思路:统计每一位上 1 出现的次数,如果某一位 1 的次数不是 3 的倍数,说明落单的数字那一位是 1。这种一脉相承的变种,考察的本质就是对二进制位的掌控力。
4.4 位运算版的加法:在不使用 + 号的情况下做加法
这个非常能考验位运算理解深度。两个二进制数相加,无非就是本位相加和进位。a ^ b 可以得到不考虑进位的加法和,(a & b) << 1 可以算出进位。两者相加就是最终结果,递归或循环直到没有进位:
java复制public int add(int a, int b) {
while (b != 0) {
int carry = (a & b) << 1;
a = a ^ b;
b = carry;
}
return a;
}
核心原理:异或得到的是“模 2 加法”的结果,也就是不考虑进位;与运算加左移得到的是“进位位”。当进位变成 0,异或的结果就是真实的和。
这道题的延伸,还能推导减法是 a + (~b + 1),因为补码里负数的表示就是取反加一,所以减法本质上是加上补码。
4.5 权限系统与状态压缩位掩码:位运算在业务里最常见的应用
别看前面都是算法题,位掩码在真实业务里其实遍地都是。比如一个后台管理系统,用户有一组权限,如果每个权限定义一个布尔字段,用户表会有几十个字段;但用位掩码的话,一个整数就能装下 32 个权限。
java复制public class Permission {
public static final int READ = 1 << 0; // 1
public static final int WRITE = 1 << 1; // 2
public static final int DELETE = 1 << 2; // 4
public static final int ADMIN = 1 << 3; // 8
}
int userPerm = Permission.READ | Permission.WRITE;
userPerm |= Permission.DELETE; // 加权限
userPerm &= ~Permission.DELETE; // 撤权限
boolean canRead = (userPerm & Permission.READ) != 0; // 查权限
这套模式的好处是,权限判断只需要一次位运算,不需要多次数据库查询;权限集合可以持久化成一个 int 字段,非常省空间。而且这种按位存储的思想还能迁移到“一个角色有哪些菜单权限”“一个课表有哪些时间段被占用”等场景。
5. 有符号与无符号:位运算里最容易翻车的“隐形杀手”
如果你做过嵌入式、网络协议开发,或者写过 C/C++、Java 代码做字节解析,一定遇到过类似问题:明明取出了一个字节,打印出来却是负数;把 char 转成 int 后高位全是 1;右移一个数,为什么左边补的是 1 而不是 0?这些都是“有符号与无符号”在背后捣鬼。
5.1 补码表示:为什么计算机用补码来存负数
先看一个最简单的问题:如果二进制直接表示负数,用最高位当符号位,那 0000 0001 是 1,1000 0001 是 -1,看起来没问题,但是算 1 + (-1) 用普通加法会得到 1000 0010,根本不是 0。这种表示法在硬件加法器里完全没法用。
补码的设计非常巧妙:负数的补码等于对应正数的二进制取反加 1,比如 -1 这样算:先写 1 的二进制 0000 0001,取反得到 1111 1110,再加 1 得 1111 1111,所以 -1 的全 1 二进制。然后你验证 1 + (-1):0000 0001 + 1111 1111 = 1 0000 0000,去掉溢出的最高位,结果正好是 0。
有了补码,加法和减法就统一成加法了,硬件电路不用考虑“两个数符号不同该不该借位”的问题。这也是前面说 ~0 = -1 的根本原因——按位取反在补码体系下自动得到了一个负数。
5.2 逻辑右移和算术右移:Java 用两个符号区分,C 语言看类型
右移运算符,在无符号整数上永远是逻辑右移,左边补 0;在有符号整数上,C 标准说“由实现定义”,但几乎所有主流编译器都做算术右移——正数左边补 0,负数左边补 1。Java 则把选择权交给你:>> 是算术右移,>>> 是逻辑右移。
为什么要补 1?举个例子,如果 -8 的二进制是 1111 1000,算术右移一位应该得到 -4,因为除以 2 嘛。如果用逻辑右移,变成 0111 1100,成了正数 124,显然不对。为了保持负数的符号,右移就得在高位补 1,相当于把符号位“传播”下去。
这带来一个经典教训:如果你在 C 语言里对一个无符号类型用 >>,永远是高位移 0;如果你声明了 int 然后对它右移,那负数会补 1。很多人写网络协议解析时,先 char 转 int 再右移,结果高位带着 1,掩码怎么和都对不上。正确的习惯是:在处理二进制协议时,先把数据转成无符号类型,再做右移和掩码。
5.3 C 语言中的实际案例:为什么 (byte << 24) 出来是负数
一位做音视频的同学踩过一个坑,他的代码要从一个字节数组里读 int,写了这样一行:
c复制int value = (data[0] << 24) | (data[1] << 16) | (data[2] << 8) | data[3];
这里的 data[] 是 char[],也就是有符号的。如果 data[0] 的二进制是 1000 0101,这个 char 其实代表负数 -123,但它被隐式转换成 int 时,高位会用符号位填充,变成 1111...1110 0000101,再左移 24 位,结果已经完全不是预想中的字节拼接了。
解决方式就是先把 data[i] 转成无符号 char,也就是 unsigned char,或者先 & 0xFF 把高位置零。很多 C 语言教程会说“char 和 int 之间的隐式转换有坑”,这个坑的具体表现就在这里——位运算面前,符号位是会作祟的。
5.4 Java 中 byte 的默认有符号:每个调底层接口的人都被坑过
Java 的 byte 是 -128 到 127,不是 0 到 255。处理流式数据时,你调 inputStream.read() 拿到的字节要和 0xFF 做与运算才能得到真正的“无符号字节值”。比如:
java复制byte b = (byte) 0xFE; // b 的实际值是 -2
int unsigned = b & 0xFF; // unsigned 才是 254
如果你直接 int x = b,得到的是 -2,因为 Java 转型时会做符号扩展。很多解析 MP3 文件头的人就在这一步把帧大小算错了。结论很简单:在 Java 里做位运算,如果牵扯到 byte 或 short,先想到低 8 位掩码 & 0xFF,否则你的 0x80 位会一路扩展到 int 的符号区域。
6. 性能优化与工程实践:位运算在现代 CPU 下还有多大事
经常听到一种说法:位运算快,是因为 CPU 直接支持这些指令,比乘除法少好几个周期。这话没错,但也不能绝对化。现代编译器和 CPU 都已经很聪明了,我们得知道位运算真正值钱的地方在哪里,以及什么时候不应该用位运算去“炫技”。
6.1 编译器的聪明程度:你以为的手写优化,编译器早就帮你做了
在 GCC 开 O2 优化的情况下,写 x * 8,编译器基本都会生成左移 3 位的指令;写 x / 8,无符号除法会优化成右移 3 位,有符号除法结合向下取整语义,也会优化成移位加修正。所以早期教材里说的“用 x << 3 代替 x * 8”,在今天已经不再是必须的优化手段了。
但位运算并没有因此失去价值。它真正的价值在三个地方:一是语义表达,当你确实想操作某个 bit 时,用位运算符比用算术运算符更能表达意图;二是复杂场景,比如做位图索引、布隆过滤器、状态压缩,这些表达式不是简单乘除能替代的;三是查表法叠加,位运算配合预计算表,能实现很多从 O(n) 降到 O(1) 的技巧。
6.2 位图(Bitmap):用 1 bit 代替 1 个字段的极致压缩
给你一个场景:系统里有 10 亿个用户 ID,需要记录每个用户今天是否登录过。如果每个用户用一个 boolean 存,Java 里 boolean 数组每个元素占 1 字节,10 亿用户要 1GB。但如果给用户 ID 分配一个 bit,10 亿用户只需要 10 亿 bit,约 125MB。这就是位图的思路。
实现也简单:一个 long[] bits,n 号用户对应的 bit 位置是 bits[n / 64] 的第 n % 64 位,代码通常这么写:
java复制public class BitSetDemo {
private long[] bits;
public BitSetDemo(int capacity) {
bits = new long[(capacity + 63) / 64];
}
public void set(int index) {
bits[index / 64] |= 1L << (index % 64);
}
public boolean get(int index) {
return (bits[index / 64] & (1L << (index % 64))) != 0;
}
}
这里面 index / 64 和 index % 64 在 index 非负的情况下都可以用移位和掩码替代,因为 64 是 2 的幂次。而且 JDK 自带的 BitSet 就是这么实现的,看源码你能学到很多位运算和扩容策略的配合。
6.3 状态压缩 DP:位运算让“集合”成为可以计算的变量
许多算法问题需要表示“某个集合中的元素是否已被访问过”。如果集合最多有 20 个元素,一个 int 的 20 个 bit 就能表示所有状态。这就是状态压缩动态规划的地基。
以旅行商问题的 DP 写法为例,dp[mask][i] 表示已经走过的城市集合是 mask,当前在城市 i 的最短路径。转移就是尝试从 i 走到一个还没去过的城市 j,新的 mask 是 mask | (1 << j)。这里判断“j 是否在集合里”用 (mask & (1 << j)) == 0,加入集合用 mask | (1 << j),比用 HashSet<Integer> 快得多,而且 DP 数组可以直接按下标访问,天然适合记忆化。
这类题目如果你不做竞赛、不打算法比赛,日常可能碰不到,但它确实是位运算“集合表达能力”的经典证明。你只要记住:当集合规模较小(一般 ≤ 20 或 ≤ 30)时,用整数表示集合是空间和时间最优的选择。
6.4 工程里何时不该用位运算:可读性优先的“反向”劝告
接下来说点反话。位运算虽好,但过度使用很容易让代码变成“只有作者自己能看懂的谜语”。比如交换两个变量用异或,在业务代码里就不如写一个临时变量来得清晰;判断奇偶在业务代码里写 x % 2 == 0 也完全没问题,不会构成性能瓶颈。
我的建议是分场景取舍:
- 业务 CRUD 代码:优先可读性。如果除法和取模不影响性能指标,老老实实用算术运算符。
- 底层系统模块(网络协议解析、序列化、编解码、驱动、嵌入式):位运算是标准操作,不能躲,该用就用,而且必须配套注释解释每个 bit 的含义。
- 算法和性能敏感路径:位运算是核心武器。但一定要加注释,说明你这步用
& (n - 1)是在消最低位 1,否则下一个接手的人可能直接重写成循环。
7. 再往深处走一步:如何把位运算内化成自己的“下意识”
从“会做位运算题”到“遇到问题能自然想用位运算”,中间隔着的不是更多刷题,而是思维模式的转变。我总结了自己练下来的三个经验,希望对你有参考价值。
第一个方法是把数字永远看成二进制在思考。看到十进制 28,别只想到 4 周有 28 天,多想一步:28 等于 16+8+4,二进制是 11100,所以它比 2 的 5 次幂 32 少 4。这种想多了之后,很多位运算题的套路你一眼就能识别:比如看到 15 就想到 0xF 是低 4 位全 1,看到 255 就想到低 8 位全 1。有这个思维习惯,你就不会再问“为什么 x & 0xFF 能取低 8 位”这种问题了。
第二个方法是拿真实场景练手。去解析一个 BMP 文件头,或者 PNG 的 IHDR 块,里面有大量的字节序、位域、通道位数,全得靠移位和掩码去处理。写完一遍之后,你对 &、|、<<、>> 的熟悉度会超过刷 100 道题。另一个好场景是自制一个小型哈希表,要求容量必须是 2 的幂,然后你用 hash & (capacity - 1) 代替取模,顺便还能理解为什么 HashMap 扩容的时候要把容量保持在 2 的幂。
第三个方法是把经典套路整理成“工具箱”。我自己的工具箱里存了这些东西:
x & (x - 1):消掉最低位的 1,用于判断 2 的幂和统计 1 个数x & (-x):取出最低位的 1,常配合树状数组用,也是 lowbit 的由来x ^ x = 0:用于找落单数字、交换变量mask | (1 << n):把第 n 位置 1mask & ~(1 << n):把第 n 位清零mask ^ (1 << n):把第 n 位取反(mask >> n) & 1:取出第 n 位的值(1 << n) - 1:生成低 n 位全 1 的掩码
这些工具看着简单,但每个都是十几个场景的提炼。比如 1 << n 减 1 这个操作,本质就是从“第 n 位为 1”变成“0 到 n-1 位全为 1”,在二分答案、位域操作里出现频率极高。
最后一个建议可能出乎意料:别忽略 Python、JavaScript 这些高级语言里的位运算。虽然这些语言不追求底层性能,但位运算在算法题、代码压缩、权限标志位里依然有一席之地。尤其 JavaScript,无符号右移 >>> 和按位或 | 0 经常用来处理超过 32 位整数的溢出问题;Python 的整数虽然是任意精度,但位运算的规则和底层一致,刷题的时候照样可以用来做状态压缩 DP。语言只是外壳,位运算的“位”是通用的。
8. 个人排查 bug 的体会:位运算出错时,别调算法,先查类型
说一个我个人在实战中体会特别深的点。一旦代码里用了位运算,如果结果不对,我第一步永远不会去改逻辑,而是去检查三件事,命中率极高。
第一,检查类型是否是有符号。尤其是 C/C++ 和 Java,char、byte、int 之间的隐式转换是否把高位填了 1。这个坑我前面已经用案例拆过,它的隐蔽性在于代码看起来完全正确,编译器也不报错,但运行时数值就是不对。解决办法也很标准:解析任何二进制的字节时,入口处统一将所有字节先做 & 0xFF 转成无符号视角。
第二,检查右移运算的语义。在 Java 里,你到底想用 >> 还是 >>>?前者会在负数情况下补 1,后者永远补 0。如果你是在解析协议、处理颜色值,多半需要的是 >>>。C 语言里则要看变量本身是不是无符号类型。一个经验法则是:只要你在和“字节”“通道”“像素”打交道,就默认用逻辑右移。
第三,检查运算符优先级。位运算符的优先级普遍偏低,尤其 & 和 == 混用是重灾区。比如 if (x & 1 == 0),在 C 语言里实际会被解析成 x & (1 == 0),因为 == 的优先级高于 &,结果变成了 x & 0,永远为假。这种 bug 特别难找,因为编译不报错,逻辑也不崩溃,就是结果不对。我现在写位运算条件统一加括号,就是被这种 bug 教育出来的。
这三个问题排查完,如果还是不对,我才开始怀疑自己的位操作逻辑。事实上我发现,真正的位逻辑错误反而很少,绝大多数 bug 都出在“语言类型系统”和“优先级”这些边角料上。这也是我在文章最后一章专门花篇幅讲有符号无符号的原因——它不容易被重视,但它是位运算工程化的门槛。位运算本身是一片海,但淹死人的往往不是深水区,而是岸边看不见的暗流。
