2. 开篇:为什么今天我非要聊位运算
先说个我自己的经历。前两年在调一个底层图像解码的模块,数据按位打包,一个像素占 10 bit,跨字节边界又是大端存储。当时排查一个问题,日志打到怀疑人生,最后发现就是一处右移操作没考虑符号位,一个>>把高位补成了 1,色彩通道直接错乱。那次之后我悟了一件事:位运算这东西,看起来是面试题,实际上它藏在协议解析、图像处理、权限系统、性能优化这些日常业务的每一个角落。
我当时在项目里做的最多的事情之一,就是跟二进制数据打交道。你别看平时写业务 CRUD 用不上几个&、|,但只要你碰到底层 SDK、嵌入式逻辑、网络协议包解析,或者哪怕只是用一个开关配置项,位运算都会主动来找你。更不用说进制转化,你发现没有,网上随手搜“位运算”,第一屏出现的几乎都是a ^ a = 0这类花活,但真正能落地的案例少之又少。
这篇文章我打算换个讲法,不整虚的。我会从位运算的底层逻辑讲起,把它掰开揉碎,然后带你看清楚进制转化到底在转什么东西,最后全部落到你能直接抄走的实操代码里。不管你是刚学数据结构的在校生,还是工作几年想补基础的开发,这篇内容都能帮你在最短时间内把这些概念焊死在脑子里。
2. 先搞清楚位运算的本质
2.1 位运算到底在算什么
位运算之所以让很多人觉得“绕”,是因为我们平时用的十进制思维根深蒂固。你看5 + 3 = 8,脑子里自动反射出十进制加法的过程。但位运算不是,它操作的直接就是二进制位,每一位只有 0 和 1 两种状态。
举个例子,5 & 3这个表达式,你要先把它转换成二进制:
5的二进制是01013的二进制是0011- 按位与(
&)的规则是:两个位都为 1 时结果才为 1,否则为 0
所以0101 & 0011的结果就是0001,也就是十进制1。
这个过程你一旦在脑子里形成画面,整个位运算就豁然开朗了。它不是在“算数”,它是在“按规则逐位比较”。这就像你在处理两个开关面板,每个面板上有 4 个开关,按位与就是你同时观察两个面板的同一个开关位,只有当两个开关都是闭合状态时,输出才是 1。这个类比虽然简单,但非常本质。
再比如左移运算num << n,本质是把num的所有二进制位整体向左移动n位,低位空出来的位置补 0。右移分两种:逻辑右移高位补 0,算术右移高位补符号位。这个区别在设计底层协议时尤其关键——你解析到一个有符号整数时,如果用了逻辑右移,正负数一反转,数值就全错了。
2.2 六种基本位运算逐一拆解
我们把这六种操作符逐个过一遍,注意这里我会把底层行为和实际应用场景绑定在一起,这样记起来才不容易忘。
按位与(&)
规则是“同 1 才 1”。它的经典应用场景是提取特定位。比如你想拿到一个整数低 8 位的数据,直接num & 0xFF就搞定了。这在处理 RGB 颜色值时特别常用,一个 32 位颜色值中,R、G、B、A 各占 8 位,你想单独拿绿色通道,就(color >> 8) & 0xFF。这个操作的本质是:先右移把目标位段移到最低位,然后通过与操作把高位置零。
按位或(|)
规则是“有 1 就 1”。它的核心用途是把某些位置为 1,同时不影响其他位。比如num | 0x0F就是把低 4 位置为 1。在权限系统里,这种操作非常常用。比如读权限是1 << 0,写权限是1 << 1,执行权限是1 << 2,你给用户授权时直接把这些值或起来,一个整数就存下了三种权限的组合。
按位异或(^)
规则是“相同为 0,不同为 1”。很多人觉得异或是位运算里最花哨的一个,其实不然。异或最大的特性是自反性:a ^ b ^ b = a。这意味着用a ^ b可以加密数据,再用b异或回去就能解密。在嵌入式通信里,CRC 校验也大量使用异或运算。另一个常用技巧是不用临时变量交换两个数:a = a ^ b; b = a ^ b; a = a ^ b;。这个技巧面试爱考,但实际工程里我建议还是老老实实用临时变量,因为可读性太差了。
按位取反(~)
规则是把 0 变 1,1 变 0。注意它操作的是补码表示下的所有位。很多人第一次接触会愣住:~5为什么结果是-6?因为5的二进制是0000 0101,逐位取反后是1111 1010。在补码表示法里,最高位是符号位,为 1 表示负数。负数用补码表示,所以1111 1010要转成十进制,需要先减 1 再取反得到0000 0110,也就是 6,加上负号就是 -6。这个特性在做掩码翻转时经常用到。
左移(<<)
num << n等价于num * 2^n,但要注意溢出。比如一个 8 位无符号数1111 1111,左移 1 位变成1111 1110,等于 254,原来的 255 变成 254 相当于乘以 2 后溢出低 8 位。左移不会保留符号位,也就是说,负数左移可能变成正数,这取决于溢出的情况。
右移(>>)
num >> n等价于num / 2^n向下取整。但正如前面说的,对于有符号整数,不同语言对高位补位的行为不同。C/C++ 和 Java 的>>是算术右移,高位补符号位;而 JavaScript 的>>>是逻辑右移,高位补 0。在 Python 中只有>>,但在部分场景下它的行为更接近算术右移,不过因为 Python 的整数是任意精度的,左移右移的表现跟固定宽度语言会有细微差异。这些语言差异是很多 bug 的来源,后面我会专门讲。
3. 进制转化的核心逻辑
3.1 为什么是二进制、八进制、十六进制
进制转化的本质,是把同一个数值在不同计数制下的表示形式进行转换。你口袋里有 10 块钱,你可以说“我有十块钱”,也可以说“我有 1010 块钱”(二进制),说得都没错,只是计数方式不同。
计算机为什么用二进制?因为物理上最容易实现两个稳定状态:高电平和低电平。但二进制写出来太长了,比如十进制255二进制要写 8 位11111111,如果数值大一点,比如65535,二进制就是1111111111111111,16 位,眼都看花。所以工程里引入了八进制和十六进制作为二进制的“缩写形式”。
八进制每 3 位二进制对应 1 位,十六进制每 4 位二进制对应 1 位。为什么是 3 位和 4 位?因为 2^3=8,2^4=16,数学上刚好可以一一映射、无歧义地转换。所以你在看 Linux 权限、内存地址、颜色值、文件标志位时,基本都是十六进制表示,因为一个十六进制位就能精确表达 4 个比特位,效率提升立竿见影。
3.2 十进制转 N 进制,通用的办法只有这一个
网上教进制转化时经常列一堆规则,什么“除 2 取余法”“乘 2 取整法”,本质其实是一个东西:整数的 N 进制转化用短除法,小数的 N 进制转化用乘法取整法。
整数部分的规矩是:不断除以 N,倒序读余数。比如十进制的29转二进制:
- 29 ÷ 2 = 14 余 1
- 14 ÷ 2 = 7 余 0
- 7 ÷ 2 = 3 余 1
- 3 ÷ 2 = 1 余 1
- 1 ÷ 2 = 0 余 1
从下往上读余数,得到11101,所以29的二进制是11101。这个方法对任何 N 进制都通用,你把 2 换成 8、16,过程一模一样。
小数部分的规矩是:不断乘以 N,顺序读整数位。比如十进制0.625转二进制:
- 0.625 × 2 = 1.25,取整数 1,小数部分剩下 0.25
- 0.25 × 2 = 0.5,取整数 0,小数部分剩下 0.5
- 0.5 × 2 = 1.0,取整数 1,小数部分为 0
从上往下读整数位,得到101,所以0.625的二进制是0.101。
很多人会背口诀“整数往右写,小数往左写”,但我建议你理解背后的原因:整数部分每次除以 N,本质是看这个数能拆成多少个 N 的幂;小数部分每次乘以 N,本质是看这个数里包含了多少个 N 的负数次方。
3.3 二进制转十六进制,工程里最常用
为什么我特别强调二进制转十六进制?因为在协议分析、内存调试、算法日志打印时,十六进制几乎是无处不在的。你打印一个二进制数据包时,如果按二进制打印,一行几百位根本没法看;但按十六进制打印,每 4 位合并成一个字符,瞬间就清爽了。
转换规则极其简单:从右往左,每 4 位二进制一组,直接映射到十六进制字符。不足 4 位的在高位补 0。例如10111011:
- 拆成两组:
1011和1011 1011对应十六进制的B- 所以结果是
BB
反向转换也一样,每个十六进制字符拆成 4 位二进制。
二进制转十六进制的映射表我建议直接背下来,虽然你说“我能算”,但实际调试时看到0x3F能马上反映出来是0011 1111,会快很多。这张表你在无数跟网络协议和编码相关的代码里都会用到。
| 十六进制 | 二进制 | 十进制 |
|---|---|---|
| 0 | 0000 | 0 |
| 1 | 0001 | 1 |
| 2 | 0010 | 2 |
| 3 | 0011 | 3 |
| 4 | 0100 | 4 |
| 5 | 0101 | 5 |
| 6 | 0110 | 6 |
| 7 | 0111 | 7 |
| 8 | 1000 | 8 |
| 9 | 1001 | 9 |
| A | 1010 | 10 |
| B | 1011 | 11 |
| C | 1100 | 12 |
| D | 1101 | 13 |
| E | 1110 | 14 |
| F | 1111 | 15 |
4. 位运算与进制转化的实战应用
4.1 权限系统的经典实现:一个整数存多个开关
权限系统大概是位运算最经典的落地场景了。很多刚工作的同学看到一堆1 << 2这样的代码会觉得莫名其妙,其实背后的逻辑非常清晰。
假设你有一个系统需要管理用户的读、写、执行三种权限。最笨的办法是定义三个布尔变量canRead、canWrite、canExecute。但这样存储和传参都很麻烦,尤其是在数据库里存权限列表时,三个字段和一个字段的区别是明显的。用位运算,你只需要一个整数:
public class Permission { public static final int READ = 1 << 0; // 1 public static final int WRITE = 1 << 1; // 2 public static final int EXECUTE = 1 << 2; // 4 public static void main(String[] args) { // 授权:读 + 写 int perm = READ | WRITE; // 检查是否有读权限 boolean hasRead = (perm & READ) != 0; // 追加执行权限 perm |= EXECUTE; // 移除写权限 perm &= ~WRITE; System.out.println(perm); } }注意这几个关键操作:
perm |= READ:把读权限对应的位置为 1,其他位不受影响。因为 READ 的二进制位只有一个位置是 1,其余是 0,OR 操作等价于“把这个位置成 1”。perm &= ~WRITE:先取反 WRITE,得到一个“除了写权限位为 0,其他位全为 1”的掩码,然后做 AND,写权限位就被清零了。(perm & READ) != 0:判断某位是否为 1。这是权限校验的核心逻辑,也是 Linux 系统权限模型的原型。
通过这种方式,权限信息可以只占一个 integer 字段,数据库里存一个数字,业务层用位运算解析,性能高、存储省、逻辑统一。你去看很多开源项目里的配置项,比如Options参数,基本都是这种玩法。
4.2 用位运算做奇偶判断和 2 的幂判断
n % 2和n & 1在功能上等价,但n & 1的底层执行速度更快,因为它直接跳过除法指令。现代编译器对于n % 2在编译器层面也会优化成位运算,所以性能差异并不明显,但在某些解释型语言或嵌入式中,位运算写法仍然有优势。
判断一个数是不是 2 的幂,位运算一行解决:
bool isPowerOfTwo(int n) { return n > 0 && (n & (n - 1)) == 0; }这个技巧的原理也值得记住:一个 2 的幂的二进制表示,恰好只有一位是 1,其余全是 0。比如8是0000 1000,8 - 1是0000 0111。两者做 AND,刚好每一位都不同,结果是 0。而任何非 2 的幂的数,二进制中至少有两个 1,n & (n - 1)的结果不可能为 0。
顺带一提,n & (n - 1)本身也是一个非常常用的操作——它能把n最右边的那个 1 清除掉。这在统计二进制中 1 的个数时特别高效,每执行一次就去掉一个 1,统计次数等于 1 的个数。
4.3 使用异或寻找唯一出现一次的数字
这是面试题里被问烂了的题目,但每一次我都会推荐它,因为它是异或自反性的绝佳演示。题目是这样的:一个数组中只有一个数字出现一次,其他数字都出现两次,找出这个数字。
常规解法是用哈希表计数,时间复杂度 O(n)、空间复杂度 O(n)。但用异或,空间复杂度直接降到 O(1):
def find_single(nums): result = 0 for num in nums: result ^= num return result原理就是a ^ a = 0和0 ^ a = a。所有成对出现的数字会互相抵消,最后剩下的就是唯一出现一次的那个数字。这个操作的执行过程在二进制层面特别直观,你可以拿[2, 3, 2]手动推一遍:
0 ^ 2 = 2,二进制0000 ^ 0010 = 00102 ^ 3 = 1,二进制0010 ^ 0011 = 00011 ^ 2 = 3,二进制0001 ^ 0010 = 0011
多推几次你就能体会到,异或在位层面的互换和抵消逻辑。这个技巧在协议校验、数据恢复、磁盘阵列校验等场景中都有直接应用,不只是面试题。
4.4 RGB 颜色通道的拆解与合成
图像处理里,位运算和进制转化几乎是标配操作。最常见的例子是对 32 位 ARGB 颜色值的解析与合成。一个像素颜色通常用一个 32 位整数表示,从高到低依次是 Alpha、Red、Green、Blue,每通道 8 位。
从整数中拆出各通道:
int color = 0xAABBCCDD; // Alpha=0xAA, Red=0xBB, Green=0xCC, Blue=0xDD int alpha = (color >> 24) & 0xFF; int red = (color >> 16) & 0xFF; int green = (color >> 8) & 0xFF; int blue = color & 0xFF;这里& 0xFF的作用就是只保留低 8 位。因为右移之后,目标通道正好落在最低 8 位,而高 24 位还是旧数据,必须用按位与把高位置零。
反向合成时用按位或和左移:
int newColor = (alpha << 24) | (red << 16) | (green << 8) | blue;这个过程本质上就是进制转化的一个实际应用:把 4 个 8 位数值拼接成一个 32 位数值。如果你把每个通道当成一个“两位十六进制数”,那合成过程就是把AA、BB、CC、DD拼接成AABBCCDD。搞懂这个,你再看图像编码、音频采样数据打包,思路都是一样的。
4.5 位图去重:布隆过滤器的应用
位运算在大数据场景还有一个非常重要的应用:位图(Bitmap)和布隆过滤器(Bloom Filter)。布隆过滤器的核心思想是设计一个位数组,每个元素通过多个哈希函数映射成多个位置,将这些位置置 1。判断某个元素是否存在时,看映射位置是否全部为 1,只要有一个为 0,肯定不存在;如果全部为 1,大概率存在。
位运算在布隆过滤器中承担的是什么工作?就是给位数组的某个位赋值和取值。假设底层是一个byte[]数组,你要把第N位设为 1:
byte[] bits = new byte[1024]; // 把第 n 位置为 1 void setBit(int n) { int byteIndex = n / 8; int bitIndex = n % 8; bits[byteIndex] |= (byte) (1 << bitIndex); } // 判断第 n 位是否为 1 boolean testBit(int n) { int byteIndex = n / 8; int bitIndex = n % 8; return (bits[byteIndex] & (1 << bitIndex)) != 0; }这里的byteIndex和bitIndex计算,本质上就是一次十进制到二进制位号的转化。n / 8表示这个位落在哪个字节里,n % 8表示它在字节内的第几位。你看,进制转化和位运算是非常紧密地结合在一起的。
5. 避免位运算写法的那些坑
5.1 有符号数和右移导致的符号扩展问题
我在开篇提到的图像解码 bug,就是符号右移引起的。C/C++ 里,>>对于有符号数是算术右移,二进制高位补符号位;对于无符号数则是逻辑右移,高位补 0。Java 也一样,>>是有符号右移,>>>是无符号右移。
在实际业务中最容易出现的问题就是把一个byte类型先右移再与0xFF做 AND。很多人会写成:
byte b = (byte) 0x80; // b 的二进制是 1000 0000,但因为符号位为1,实际值是 -128 int result = b >> 4; // 这里会得到 0xF8,而不是 0x08原因就是b先被提升为int类型(符号扩展为0xFFFFFF80),再右移 4 位,高位补 1,得到0xFFFFFFF8,转成十六进制就是F8。如果希望得到不含符号扩展的结果,必须先做b & 0xFF再右移,或者直接用>>>。
这个坑几乎每个写底层代码的人都踩过一次。我的排查习惯是:一旦遇到跟字节解析相关的异常数据,第一反应就是看符号扩展。这也是为什么我建议在 C/C++ 中解析协议数据时,有符号和无符号要分得清清楚楚,能声明无符号就声明无符号。
5.2 运算符优先级:&比==优先级低
这是另一个高频踩坑点。在 C/C++、Java 里,==的优先级高于&,所以你写:
if (perm & READ == 1) { ... }编译器会先执行READ == 1,然后拿perm去 AND 这个布尔结果。这行代码实际的行为完全不是你想要的。正确写法是:
if ((perm & READ) == READ) { ... }我见过很多新手在这个问题上栽跟头,甚至一些老手在代码 review 时也会盯着看两秒。最保险的解决方案是:位运算表达式一律加括号,不要依赖优先级。尤其在混合了!、==、&、|的表达式里,括号多一点不是坏事,可读性更好,也避免隐患。
5.3 不同语言的整数宽度差异
C/C++ 的int通常是 32 位,Java 的int固定 32 位,Java 的long是 64 位,Python 的整数是任意精度。这意味着同样一段位运算代码,在不同语言里的溢出行为可能完全不同。
比如左移溢出:
int a = 0x7FFFFFFF; int b = a << 1; // 结果变成负数,因为最高位被置 1Java 里也同样会得到负数。但在 Python 中:
a = 0x7FFFFFFF b = a << 1 print(b) # 4294967294,不会溢出因为 Python 不会限制整数宽度,它自动扩展。这个差异在日常写算法题时影响不大,一旦涉及底层协议实现或跨语言对接,就很容易出问题。我建议在涉及固定位宽的协议实现时,统一使用 Python 的struct模块或者 C 语言的uint32_t、int8_t等明确类型,尽量不要隐式依赖语言的默认宽度。
5.4 进制转化浮点的精度陷阱
小数转二进制时容易产生无限循环。比如十进制的0.1,转成二进制是0.000110011001100...无限循环。这也就是为什么你在 Python 里执行0.1 + 0.2得到0.30000000000000004而不是0.3。
这个问题的根源在于二进制无法用有限位数精确表示 0.1。计算机里的浮点数是有限位数的,它只能取近似值。理解了进制转化的这一层,你就能理解浮点数误差的真正来源,而不是每次遇到0.1 + 0.2的问题就只会背“不要直接用浮点数比较相等”的结论。
处理方案不外乎几种:用Decimal、用整数表示最小单位、比较时允许误差范围。但重点是,知道为什么会出现这样的误差,比记住解决方案更有价值。
6. 工具与调试技巧
6.1 在代码中快速打印二进制
调试位运算问题时,最快的办法就是把数值以二进制形式打出来。不同语言各有语法:
# Python print(bin(29)) # 0b11101 print(f"{29:08b}") # 00011101// Java System.out.println(Integer.toBinaryString(29)); System.out.println(String.format("%08d", Integer.parseInt(Integer.toBinaryString(29))));// C printf("%08b\n", 29); // 注意:%b 不是标准 C99,GCC 支持但可移植性差 // 更稳妥的方式是手动循环实际调试时我习惯直接用一个十六进制数字做输入,比如0xF0,打印二进制后一眼就能看出位的位置。如果你在处理一个 32 位数值,建议格式化打印时把高位 0 补齐,否则0101和101容易看混。
6.2 我常用的一个换算了表工具
人在现场时没有 IDE 也能算,但出错概率太高。我自己的方法是:平时写代码时把二进制到十六进制到十进制的映射表记熟,但真到调试时,直接用系统自带的计算器切到“程序员”模式。macOS 的计算器、Windows 的计算器都有这个模式,可以直接输十进制看二进制、十六进制、八进制,也可以反向输入。省时省力,避免心算出错。
6.3 快速心算技巧:几个常见值
有些数值的二进制形态出现频率太高,我建议直接背下来:
0xFF=1111 1111= 255,8 位全 1,可以当 8 位掩码用0x0F=0000 1111,低 4 位掩码0x80=1000 0000,最高位为 1,常用来探测符号位0x80000000= 32 位整数的符号位(1 << n) - 1= 低 n 位全是 1
其中(1 << n) - 1这个公式尤其好用。比如你想生成一个低 8 位全为 1 的掩码,(1 << 8) - 1 = 255,直接就能得出0xFF,完全不用手动写 8 个 1。
7. 从位运算和进制转化延伸出去
位运算和进制转化不是孤立的知识点,它们像地基,上面盖着很多高楼。我把几个常见的延伸方向列出来,你顺着这个脉络往下学,会特别顺。
网络协议解析:IP 地址、端口、TCP 头、UDP 头全部是按位定义的。比如一个 16 位的端口号,在字节流里拆分时就要考虑大端小端。你解析一个 IPv4 头,版本号占 4 位,IHL 占 4 位,这两个字段甚至共用一个字节,必须用位运算分别取出来。这就是最典型的“位级协议”应用。
图像与音视频编码:JPEG、PNG、H.264 这些编码格式里,大量数据是按位打包的。一个 10 bit 的像素值可能横跨两个字节,解码时要用移位和掩码拼接。我之前做的那个图像解码模块,核心工作就是拆位和装位。
压缩算法与哈希:布隆过滤器、位图索引、Zstd、LZ4 这些算法,底层都是对二进制位的高效操作。你理解了位运算,再看这些算法的核心代码,会有一种“原来如此”的感觉。
国密算法与加密:SM2、SM3、SM4 这些密码算法,大量使用循环移位、异或、按位与等操作。密码学对位运算的依赖程度极高,你去看 SM4 轮的轮函数,几乎全是位操作。当然,实际工程中不要自己实现加密算法,直接用现成库,但理解原理时位运算是必修课。
嵌入式与单片机:寄存器操作基本就是位操作。置位、清零、翻转、连续赋值,每一行都是在跟具体位打交道。嵌入式工程师写代码时,脑子里几乎时刻有一张寄存器的位分配图。
这些方向都指向同一个结论:位运算和进制转化不是面试用的花架子,而是底层开发绕不开的核心能力。
我个人还有一个体会:当你真正熟练了二进制思考方式之后,解决问题的速度会明显加快。以前我排查一个问题需要各种打印和推理,现在看到某一段数据异常,脑子里会本能地猜测“是不是位偏移了一位”“是不是符号扩展错了”“是不是字节序搞反了”,定位速度比之前快了很多。这就像学骑自行车,没学会之前总觉得难,学会之后它就成了你身体的一部分,想忘都忘不掉。希望这篇文章能帮你跨过这个拐点。