位运算这个东西,说实话,很多写了几年业务代码的程序员对它都停留在"面试前背一背、笔试时用一用"的层面。但你真正去读那些高性能的开源库、嵌入式驱动、网络协议栈,甚至很多框架的状态标志设计时,会发现位运算无处不在。它不是炫技,而是用最贴近机器的方式表达逻辑——一个整数里塞下多个布尔状态,一条指令完成判断和置位,这种效率优势和表达张力,业务代码里确实很难体会到。
这篇文章我从头梳理位运算的基础应用,不装高深,直接用代码和场景说话。适合刚接触位运算、想搞懂它到底怎么用的朋友,也适合想系统回顾一下、把自己知识补全的开发者。读完你可以把常见的奇偶判断、交换两个数、取绝对值、判断2的幂、统计二进制1的个数这些经典操作全部手写出来,并且知道它们为什么能这样写。
1. 位运算的本质:先从二进制说起
1.1 计算机里只有0和1,位运算就是直接操作它们
所有高级语言最终都要编译成机器指令,而机器指令操作的对象就是寄存器里的一串二进制位。位运算之所以"快",是因为它直接映射到CPU的ALU(算术逻辑单元)指令,一条and、or、xor、shl、shr指令就能完成一次操作,不需要经过乘除法那种多周期计算。
我们平时写a % 2判断奇偶,编译器确实也可能优化成位运算,但你自己掌握原理后,写出来的代码意图更明确,而且在某些无法依赖编译器优化的场景(比如写内核代码、嵌入式裸机程序、加密算法)里,这是基本功。
二进制的核心规则只有几条:每一位只能是0或1,逢二进一。一个8位二进制数0000 0000到1111 1111对应十进制的0到255。位运算作用于这些独立的位,每一位的结果只和对应位置的输入位有关,不存在"进位"这种跨位影响(位移除外)。
1.2 理解"位权"才能正确换算
很多人卡在二进制和十进制的换算上,其实抓住"位权"就够了。从右往左数,第0位的权值是2^0=1,第1位是2^1=2,第2位是2^2=4,依次翻倍。
比如0b1011,从右往左拆开:第0位是1(贡献1),第1位是1(贡献2),第2位是0(不贡献),第3位是1(贡献8),加起来就是11。这个换算不用背,多算几次就熟了。我在实际中喜欢用十六进制做中间媒介,因为4个二进制位正好对应1个十六进制位:0b1011就是0xB,写代码、看调试器输出时都方便很多,后面遇到复杂的位掩码,十六进制表示几乎是必须的。
1.3 位运算为什么值得学:性能和表达的权衡
先说性能。现代CPU对位运算的执行通常是单周期,而整数除法动辄几十个周期,乘法也相对更慢。虽然高级语言编译器已经做了很多优化,但在循环热点里使用位运算依然有肉眼可见的收益。我早年在某嵌入式项目里优化一个CRC校验模块,把查表索引和状态位的更新全部改成位操作后,整个中断处理时间缩短了将近三分之一。
再说表达力。一个byte可以同时存放8个开关状态,配合掩码和移位,读代码的人一眼就能看出"你是在操作状态位",而不是维护8个独立的布尔变量。这种表达方式在配置项多、结构紧凑的场景下极大减少了内存占用和代码冗余。
2. 六大基本运算逐一拆解
2.1 按位与(&)、按位或(|)、按位异或(^)、按位取反(~)
这四个运算符是位运算的基石,我用一张表先列出它们的规则。
| 运算 | 规则 | 记忆口诀 |
|---|---|---|
a & b | 两位都是1才为1 | 有0则0 |
a | b | 两位只要有一个1就是1 | 有1则1 |
a ^ b | 两位不同为1,相同为0 | 相同则0 |
~a | 0变1,1变0 | 逐位翻转 |
举个直观的例子。假设a=0b1100,b=0b1010:
a & b = 0b1000:只有第3位两边都是1,所以结果只有这一位是1。a | b = 0b1110:只要任一边有1的结果位就是1。a ^ b = 0b0110:第3位两边相同(都是1)所以是0,第2位不同(1和0)所以是1,第1位不同(0和1)所以是1,第0位相同(都是0)所以是0。~a = 0b0011(取低4位看),整体看则是各位翻转。
这里要特别强调一个新手极其容易踩的坑:取反~是对整个整数类型的全部位进行翻转,而不是只看你关心的那几位。如果a是8位整数0b0000 1100,~a的结果不是0b1111 0011吗?是,但如果你在32位环境里用int存储,a实际是0b0000 0000 0000 0000 0000 0000 0000 1100,~a就变成了一大串1开头的负数。所以取反配合掩码使用时,一定要记得用&限定范围,比如(~a) & 0xFF才是对低8位取反。
2.2 左移(<<)与右移(>>):位移就是乘除2的幂
左移n位,等价于把每个二进制位向左移动n个位置,低位补0。右移则稍微复杂一点,分逻辑右移和算术右移:逻辑右移高位补0;算术右移高位补符号位(负数补1,正数补0)。绝大多数语言里,无符号整数用逻辑右移,有符号整数用算术右移。Java里还专门区分了>>(算术右移)和>>>(逻辑右移),C/C++里对负数的>>行为是实现定义的,跨平台代码要小心。
位移最直接的应用就是快速乘除2的幂。x << 3等价于x * 8,x >> 2等价于x / 4(对正数是整除)。但我要提醒一句:现代编译器自己就会做这种优化,你写x * 8编译器生成的指令很可能就是shl,所以不要为了"优化"而刻意把乘法改成移位——除非你是在写极致的性能敏感代码,或者需要明确表达这个语义。可读性永远是第一位的,这一点后面我还会反复说。
2.3 有符号数与补码:为什么右移负数会出"怪事"
有符号整数在计算机里以补码存储。正数的补码就是原码;负数则是"原码取反加1"。以8位为例,-1的原码是1000 0001,反码是1111 1110,补码是1111 1111。所以-1的二进制表示全是1,这解释了为什么~0等于-1(在32位int里)。
理解了补码,很多位运算的"怪现象"就不怪了。比如x >> 1对负数来说,相当于除以2后向下取整,-3 >> 1的结果是-2,而不是很多人直觉里的-1。因为-3的二进制是...1111 1101,右移一位变成...1111 1110也就是-2。这个细节在算法题里经常坑人,后续讲应用时会再提到。
我建议基础还不牢的朋友,在自己电脑上用Python或C写个脚本,打印几个负数的二进制表示和移位结果,亲眼看一遍印象会深很多。Python里可以用bin(x & 0xFF)这种技巧把负数限定到指定字节宽度来看位模式。
3. 经典应用模式:这些技巧你必须会
3.1 奇偶判断:最低位说了算
判断一个整数是奇数还是偶数,最朴素的方式是n % 2 == 0。用位运算则是(n & 1) == 0。原理很简单:二进制的最低位(第0位)就是1时代表这个数是奇数(因为只有2^0这一项贡献了奇数1),否则是偶数。n & 1只保留最低位,其它位全部清零。
这个判断在循环遍历、分治算法、哈希选择桶位时非常常用。我自己写并查集或者树状数组时也经常会用到,比如从一个索引推导它的父亲结点、区间端点等,idx & 1这类判断比idx % 2在语义上更贴近"操作二进制索引"的上下文。
3.2 不借助临时变量交换两个数
经典的异或交换法:
void swap(int *a, int *b) { *a ^= *b; *b ^= *a; *a ^= *b; }第一步a = a ^ b后,a存的是两者的异或结果。第二步b = b ^ a等价于b ^ (a ^ b),由于异或满足交换律和结合律,且x ^ x = 0、x ^ 0 = x,化简后就是b = a。第三步同理a = a ^ b化简为a = b。三步下来,两数互换,没有用额外变量。
但是,这个炫技方法在实际工程里我并不推荐。原因有两个:第一,现代编译器和CPU对临时变量的优化非常成熟,使用临时变量的交换不会更慢;第二,如果a和b指向同一个地址,比如调用swap(&x, &x),第一步就会把值异或成0,结果两个变量全变0,逻辑错误。面试中你能写出异或交换是加分项,但生产代码里还是老老实实用临时变量。这个例子最好的价值是帮助你理解异或的三条重要性质:归零律(x^x=0)、恒等律(x^0=x)、自反性(a^b^b=a)。
3.3 判断一个数是不是2的幂
这个技巧是位运算应用里辨识度最高的之一:
bool isPowerOfTwo(int n) { return n > 0 && (n & (n - 1)) == 0; }为什么n & (n - 1)能把n的最低位的1消掉?因为n - 1会让n的最低有效位变为0,并将它右边的所有0变成1,向上借位。比如n = 12 = 0b1100,n - 1 = 11 = 0b1011,两者相与:1100 & 1011 = 1000,可以看到原来第3位的1被消除了。
如果n是2的幂,它的二进制表示里只有一个1,比如8 = 0b1000,n - 1 = 0b0111,两者相与必然为0。反过来,只要n不是2的幂,它至少有两个1,n & (n-1)的结果一定非0。再加上n > 0排除0和负数(0满足(n & (n-1)) == 0但0不是2的幂),就得到了完整判据。
这个方法在很多地方有变体:用n & (n-1)消去最低位的1,可以用来统计二进制中1的个数,后面会讲。在内存分配器、哈希容量计算、Java的HashMap扩容判断里,"容量必须是2的幂"这类检查就是用它完成的。
3.4 统计二进制中1的个数
最直观的办法是循环每位判断:
int countOnes(int n) { int count = 0; while (n) { count += n & 1; // 检查最低位 n >>= 1; // 右移一位 } return count; }这个写法简单,但时间复杂度是O(位数),也就是32或64次循环。如果只看性能,可以改用"消1法":
int countOnesFast(int n) { int count = 0; while (n) { n &= (n - 1); // 每次消去最低位的1 count++; } return count; }这个循环只执行"1的个数"次,比如n = 0b1000,一次循环就结束,效率显著提升。原理就是上一节说的n & (n - 1)会消去最低位的一个1。这个方法在图形处理、稀疏集合表示、多重背包状态压缩DP里都有广泛使用。
如果你追求极致性能,还可以查表法:一个256项的popcount表,每8位查一次表求和。或者直接用CPU指令,比如x86的POPCNT指令,多数语言的运行时库也已经内置了类似函数(Java的Integer.bitCount、C++的__builtin_popcount),工程中优先用库函数就好。
3.5 提取、设置、清除、翻转指定位
这是位运算里最实用的"四件套",不管是操作硬件寄存器、解析网络报文,还是设计功能开关,都离不开它们。假设我们要操作整数x的第k位(k从0开始计数):
// 提取第k位:把第k位移动到最低位,再与1 int bit = (x >> k) & 1; // 设置第k位为1:把1左移到第k位,然后或运算 x |= (1 << k); // 清除第k位为0:构造第k位为0、其余位为1的掩码,再与运算 x &= ~(1 << k); // 翻转第k位:用异或,1和0互换 x ^= (1 << k);这四行代码看着简单,但每一行都值得仔细琢磨。提取时先右移再与1,相当于把目标位移到最低位,其它位被掩码清掉;设置时用或运算,因为或运算的特点是"有1则1",所以只影响第k位;清除时先构造一个"只有第k位是0、其它位全是1"的掩码(~(1<<k)),再用与运算,因为与运算"有0则0",正好把第k位清零;翻转时利用异或的自反性,该位原来是0会变1,原来是1会变0。
我当年读单片机寄存器操作代码时,第一次看到REG |= (1 << 3)这种写法还愣了一下,后来明白了"1 << 3"就是0b1000,也就是第3位,瞬间豁然开朗。硬件寄存器的手册里经常会写"bit3: 使能位,1为开启",对应的代码就是这么一行。理解这四件套后,你读这类代码就像读中文一样自然。
3.6 掩码与位字段:一个变量塞多个状态
把多个布尔开关打包进一个整数,是位运算最重要的工程应用。比如一个配置项可能有读、写、执行、隐藏、归档5个开关状态,用5个布尔变量显得松散,用一个int加掩码就非常紧凑:
// 定义状态位掩码 #define FLAG_READ (1 << 0) // 0b0001 #define FLAG_WRITE (1 << 1) // 0b0010 #define FLAG_EXEC (1 << 2) // 0b0100 #define FLAG_HIDDEN (1 << 3) // 0b1000 int flags = 0; // 同时开启读和执行:按位或 flags |= FLAG_READ | FLAG_EXEC; // 判断是否可写:与掩码 if (flags & FLAG_WRITE) { ... } // 关闭读权限:与非掩码 flags &= ~FLAG_READ; // 切换写权限:异或掩码 flags ^= FLAG_WRITE;这个模式在无数真实项目中出现过:Linux文件权限、消息队列的消息标志、TCP协议头里的标志位(SYN、ACK、FIN各占一位)、图形API的状态管线、游戏引擎的实体开关。它的优雅之处在于,多状态组合的语义用一个整数就表达了,比较、拷贝、存储、序列化都非常方便。
我还见过更进阶的用法:用一小段连续的位存储数值,而不只是布尔值。比如"用4位存一个0到15的数字、用3位存另一个0到7的数字",这就是位字段。C语言里甚至有原生的位域语法,但我觉得掌握手工移位和掩码更本质,因为它不依赖具体语言特性,可迁移性强。
4. 实战:从状态标志到二进制枚举
4.1 状态标志系统的完整设计
下面我用一个具体的例子把上一节的内容串起来。假设我们在写一个图片处理模块,需要记录一张图片的5种处理状态:是否已加载、是否已缩放过、是否已增强对比度、是否已转灰度、是否已标注过,同时还要记录一个0到3的"质量等级"用2位二进制来存。
// 状态位定义 #define IMG_LOADED (1 << 0) #define IMG_SCALED (1 << 1) #define IMG_CONTRAST (1 << 2) #define IMG_GRAYSCALE (1 << 3) #define IMG_ANNOTATED (1 << 4) // 质量等级占第5、6位,mask是这两位的掩码 #define QUALITY_SHIFT 5 #define QUALITY_MASK (0x3 << QUALITY_SHIFT) // 0b0110 0000加载图片后设置状态:
int state = 0; state |= IMG_LOADED;把质量等级设为3:
// 先清除原有质量等级位,再写入新值 state &= ~QUALITY_MASK; state |= (3 << QUALITY_SHIFT);读取质量等级:
int quality = (state >> QUALITY_SHIFT) & 0x3;这段代码就是"写入先清、读出尽力"的典型范式。初学者容易犯的错是直接state |= (3 << QUALITY_SHIFT),如果之前质量等级已经是别的值,新旧值会叠加在一起,得到错误结果。所以对连续多位区域写值前,清一清掩码区域是必须的步骤。这套方法我在处理设备寄存器、配置报文时反复使用,已经形成肌肉记忆。
4.2 二进制枚举:用位表示"选或不选"
还有一个特别妙的场景是枚举所有子集。假设有n个元素,每个元素可以选或不选,那么所有可能的子集就有2^n个。用n位二进制数恰好可以一一枚举:第i位是1表示选择了第i个元素。
for (int mask = 0; mask < (1 << n); mask++) { // 处理一个子集mask for (int i = 0; i < n; i++) { if (mask & (1 << i)) { // 第i个元素被选中 } } }外层循环从0到2^n-1,正好覆盖全部子集。这种技巧在状态压缩动态规划里是核心操作,比如旅行商问题的DP状态dp[mask][i]表示"已经访问过的城市集合是mask,当前在城市i",这里mask就是用一个整数描述整个集合,节省一整个维度的状态空间。
我用这个技巧解决过一个模拟项目X里的任务分配问题:有7个任务,每个任务可分配给不同的小组,为了枚举所有分组方案,直接用位掩码遍历,代码比我最初用多层循环嵌套的写法短一截,运行效率也高得多。后来我总结一个经验:凡是"每个对象只有两个状态"的枚举搜索,优先想二进制枚举,几乎不会错。
4.3 位运算在哈希与取模中的妙用
当哈希表的容量是2的幂时,x % capacity可以用x & (capacity - 1)替代。例如容量为16时,x & 15等价于x % 16。原理是:一个数的低4位正好表示它除以16的余数,高位部分都是16的倍数。
日常开发里,很多语言的标准库都默认让哈希表容量是2的幂,正是为了用位运算加速散列取模。但注意,普通业务代码里我不建议直接写x & 15代替x % 16,因为会让读到代码的人困惑"这里的15是哪来的"。除非你把capacity - 1留给个命名清晰的变量,比如:
int index = hash & (capacity - 1); // 假设capacity是2的幂这里面还有一层更深的考量:为什么取模之前哈希值要尽量"打散"?因为直接拿低几位做索引,会丢失高位信息,导致分布不均匀。所以很多哈希函数会先做一次"扰动",把高位信息扩散到低位,这就是另一个话题了。位运算在系统设计里从来不是孤立的技巧,它是和数据结构设计绑在一起的。
5. 避坑指南与常见问题排查
5.1 位移位数不能越界
对32位整数,左移或右移的位数不能超过31,否则是未定义行为(在C/C++里)或者结果不可预期。比如1 << 32在不少环境下会得到1而不是0,因为这等价于移动0位(CPU只取移位计数的低5位)。这个坑我在一次笔试题目里见过,也在真实代码Review里见过。写循环移位或者动态计算移位次数时,先对位数取模是稳妥的做法。
5.2 优先级问题:位运算远低于比较运算
运算符优先级是位运算的大坑。==、!=的优先级高于&,所以下面的代码并不会做你想做的事:
// 问题代码:真正执行的是 x & (y == 0),而不是 (x & y) == 0 if (x & y == 0) { ... }正确写法是加括号:
if ((x & y) == 0) { ... }这个坑特别隐蔽,因为编译器通常不会报错,只会悄悄改变语义。我对团队的要求是:位运算操作数中间一律加括号,不给优先级留任何机会。尤其是==、!=、&&、||和位运算混写时,不要依赖记忆,显式括号是零成本的安全保障。
5.3 有符号右移的负数陷阱
前面提到-3 >> 1得到-2,这是因为算术右移会保留符号位,相当于向下取整的除法。如果你需要"趋近0的除法",就不能简单用右移。在算法题里,计算中位数时(low + high) / 2遇到负数会有同样问题。位运算版本的正确写法之一是low + ((high - low) >> 1),这样即使high - low为正数,避免了加法溢出,再用移位加速。这里要特别注意:不要写成(low + high) >> 1,因为low + high可能溢出。
5.4 位运算与逻辑运算别混淆
&、|、~、^是位运算,作用于每一位;&&、||、!是逻辑运算,只返回布尔值,并且有短路行为。比如if (p != NULL & *p > 5)如果p是空指针,位运算版本不会短路,解引用照样崩溃;但if (p != NULL && *p > 5)则安全。我在代码里见过有人为了"少打一个字符"把&&写成&,结果引入了线上空指针问题。这种低级错误成本很高,但排查时又特别隐蔽,因为逻辑上常常90%的情况是对的。
5.5 调试技巧:把整数当成一堆开关来看
排查位运算问题时,我最大的经验是:不要盯着十进制数字硬想,转成二进制看。工欲善其事,必先利其器,几个常用工具:
- Python里
bin(x)、hex(x)快速查看位模式; - 调试器里把变量显示格式切换为二进制;
- C/C++用
printf配合%x看十六进制; - 写个一次性脚本打印
x & (1 << k)逐位判断结果。
我遇到过一个典型bug:某个状态标志被反复或运算累加后,某位一直清不掉,排查半天,最后发现是清除掩码写错了,~(1 << 3)被随手写成了(1 << 3) ^ 0xFFFFFFFF,但0xFFFFFFFF在不同平台上的位数不同,导致掩码扩展成脏位。后来我统一写成~(1 << 3),配合调试脚本逐位检查,才彻底解决。这个教训让我养成了一个习惯:涉及位掩码的代码,改动后一定做一次"逐位断言",比如assert((flags & MASK) == expected),把潜在问题挡在测试阶段。
6. 问题速查表与经验总结
最后把常用技巧和最容易出错的地方整理成一张速查表,方便你放到收藏夹里随时翻阅。
| 操作 | 代码 | 说明 |
|---|---|---|
| 判断奇偶 | (n & 1) == 0 | 偶数最低位是0 |
| 判断2的幂 | n > 0 && (n & (n-1)) == 0 | 消去最低位1后为0 |
| 提取第k位 | (x >> k) & 1 | 先移位再掩码 |
| 设置第k位 | x |= (1 << k) | 或运算置1 |
| 清除第k位 | x &= ~(1 << k) | 与运算清0 |
| 翻转第k位 | x ^= (1 << k) | 异或翻转 |
| 消去最低位的1 | x &= (x - 1) | 统计1、判断2的幂的基础 |
| 取2的幂的余数 | x & (capacity - 1) | 要求capacity为2的幂 |
| 无临时变量交换 | a ^= b; b ^= a; a ^= b; | 面试理解为主,工程不推荐 |
| 位字段写入 | 先清掩码区域,再或上新值 | 防止新旧值叠加 |
因为在二进制里,第k位是1代表2^k,把所有1位对应的2^k相加就是十进制值。反过来,想快速算1 << 20是多少,记住2^10约等于1000,2^20约等于100万,就够估算很多内存和容量问题了。
我个人在实际项目中的体会是,位运算最忌"为了用而用"。它真正的价值场景集中在性能热点、资源受限、底层硬件交互、算法状态压缩这几类;普通业务逻辑里,可读性比那几条指令的节省重要得多。你要是能在合适的地方自然写出(x & (x-1)) == 0,而不需要注释来解释,说明已经真正理解了;反过来,如果写完还得停下来想半天为什么,那不如写一行普通代码。这就像工具在工具箱里的位置——好的工匠不是每件工具都用,而是知道何时该用哪把。
如果你刚接触位运算,我的建议是找10道位运算相关的练习题,每一道都把二进制展开手算一遍,再写代码验证。这个"手算一遍"的过程特别重要,它能帮你把抽象的运算符和具体的位模式对应起来。下一篇我会继续写位运算在更复杂场景里的应用,比如子集生成、状态压缩DP、位图索引、以及用位运算实现高效集合操作,如果你对哪一块特别感兴趣,可以先去翻翻这些方向,会有更多惊喜等着你。