返回首页
## 引言
这是第二章的第 8 篇。上一篇给出了完整优先级表,位运算符(`& | ^ ~ << >>`)占据等级 5、8~11 的位置——它们比算术低、比比较高,处于表达式中段的"敏感地带"。本篇从最底层讲起:二进制是什么、负数在计算机里如何表示(补码)、六个位运算符各自的语义,然后用奇偶判断、XOR 交换、位掩码权限、RGB 颜色通道提取四个实战把它们用起来。修完这一篇,你再看二进制协议报文、哈希长度 or 对齐技巧、或后续课程 03 里 CPU 与指令集的补码讨论,都会有豁然开朗的感觉。先决条件:第 2 篇的整数类型与 `bin()`、第 7 篇的优先级表。
## 概念与原理:二进制与补码
一切位运算都在二进制位(bit)上发生。第 2 篇讲过 `bin()` 的输出:`bin(5)` 是 `0b101`,即 1×4 + 0×2 + 1×1。正整数在二进制下直观,真正需要小心的是负数——现代计算机用**二进制补码(two's complement)**表示负数,Python 的整数也是补码语义,只是位宽无限。
补码的定义一句话:**-x 的补码 = x 的二进制按位取反再加 1**。以 8 位为例,5 是 `00000101`,取反得 `11111010`,再加 1 得 `11111011`,这就是 -5 的 8 位补码。为什么这样设计?因为补码让"加法和减法共用一套电路":5 + (-5) = `00000101 + 11111011` = `1 00000000`,溢出位丢弃,正好是 0。同时补码还有一个漂亮的边界性质:8 位补码表示范围是 -128 ~ 127,`-128`(`10000000`)到 `127`(`01111111`)首尾相接,没有"负零"的浪费。
Python 的整数位宽无限的直接后果是:`bin(-5)` 显示 `-0b101`,负号留在表示层之外;要观察"真正的补码位模式",需要拿掩码把无限长的高位 1 截断成有限位——`-6 & 0xFF` 给出 -6 的 8 位补码视图:
```python
print(bin(5)) # 0b101
print(bin(-5)) # -0b101:表示层仍然只显示数值
print(-6 & 0xFF, bin(-6 & 0xFF)) # 250 0b11111010:8 位补码视图
```
`-6 & 0xFF` 得到 250,二进制 `11111010`,正是"按位取反加 1"推导出的 8 位补码。这个"掩码截断"技巧在调试、协议字节解析中会反复用到。
补码还有一个漂亮的代数性质,值得亲手推一遍:对任意位模式 x,`x + (~x)` 的每一位都是 1(1+0 或 0+1,且没有进位链),结果恒为全 1,即 -1。于是 `x + (~x) == -1`,移项得 **`~x == -x - 1`**——这正是后面 `~` 一节要用的公式,它不是一个需要背诵的怪癖,而是补码定义的必然推论。同理可证 `-x == ~x + 1`,与第一节"取反加一"的定义互相印证。
## 操作与实现:六个位运算符
### 按位与、或、异或:& | ^
三者都是按位独立运算:`&` 两者都为 1 才得 1,`|` 任一为 1 就得 1,`^`(异或,exclusive OR)两者不同才得 1。用 4 位例子直观看:
```python
print(5 & 3) # 1:101 & 011 = 001
print(5 | 3) # 7:101 | 011 = 111
print(5 ^ 3) # 6:101 ^ 011 = 110
```
它们的工程角色各不相同:`&` 用来**提取/保留**特定位(掩码提取,见实战三),`|` 用来**置位/合并**标志,`^` 用来**翻转**与**比较差异**——`x ^ y` 结果里位为 1 的位置,正是 x 与 y 不同的位置。
异或还有三条铁律:`x ^ x == 0`、`x ^ 0 == x`、异或满足交换律与结合律。这三条组合出的著名应用是"数组里唯一出现一次的数":把所有数全部异或,成对的全部抵消,剩下就是那个落单者——一个 O(n) 时间、O(1) 空间的解法。
### 按位取反:~
`~x` 把每一位翻转:0 变 1,1 变 0。在无限位宽的 Python 里,`~x` 的代数形式是 **`~x == -x - 1`**——`~5` 是 -6,`~0` 是 -1,`~(-1)` 是 0:
```python
print(~5, ~0, ~(-1)) # -6 -1 0
```
直觉解释:`~5 = -5 - 1 = -6`。为什么"取反"会捣鼓出负数?因为按位取反不是"反转符号"——`~5` 的无限位模式是 `...11111010`,这是 -6 的补码。`~` 的真正用处是生成"全 1 掩码的补集":`~READ` 在清除位掩码的某一位时是天然搭档(实战三)。
### 左移与右移:<< >>
`x << k` 等价于乘以 2 的 k 次方,`x >> k` 对正数等价于整除 2 的 k 次方:
```python
print(1 << 4) # 16,等于 2**4
print(3 << 2, -3 << 2) # 12 -12:左移对正负数都是乘 2**k
print(256 >> 4) # 16
print(7 >> 1, -7 >> 1) # 3 -4:负数右移是向下取整(地板除)
print(1 << 10 == pow(2, 10)) # True:左移即幂运算
```
三个要点:其一,左移在 Python 里永远不会溢出——整数任你随便左移,位宽跟着长;其二,负数右移沿用第 7 篇的 `//` 语义(向负无穷),`-7 >> 1` 是 -4 而不是 -3,这与 C/Java 的行为不同,是跨语言移植的暗雷;其三,Python 没有 Java/C# 的 `>>>` 无符号右移——无限位宽下"符号位"的概念不存在,也就没有无符号一说。
## 实战一:奇偶判断
判断奇偶通常写 `n % 2 == 0`。用位运算则是 `(n & 1) == 0`——只看最低位:最低位为 1 必为奇数。对负数同样成立(补码最低位与绝对值一致):
```python
def is_even(n: int) -> bool:
return (n & 1) == 0
for n in (0, 1, 2, 17, -1, -2):
print(n, is_even(n)) # 0 True;1 False;2 True;17 False;-1 False;-2 True
```
性能上两者处于同一量级,现代解释器对 `% 2` 也有专门优化,**不要为"更快的奇偶判断"而牺牲可读性**——位版本的价值在于训练直觉,以及后续在底层代码、算法竞赛中读懂别人写法的能力。
## 实战二:XOR 交换
不使用中间变量,仅用异或交换两个数:
```python
a, b = 7, 12
a ^= b # a = 7 ^ 12
b ^= a # b = 12 ^ (7 ^ 12) = 7,结合律与 x^x=0
a ^= b # a = (7 ^ 12) ^ 7 = 12
print(a, b) # 12 7
```
中间过程:`b ^= a` 后 b 变成原来的 a,`a ^= b` 后 a 变成原来的 b。虽然 Python 里交换两值有更地道的 `a, b = b, a`(元组解包),但 XOR 交换展示了异或三条铁律的组合威力;在嵌入式 C 中它还是经典的省内存手法。理解它,等于理解了异或的全部代数性质。
## 实战三:位掩码(bitmask)权限系统
位掩码的思想:用整数的一位表示一个开关。文件系统权限就是教科书案例——读、写、执行各占一位:
```python
READ, WRITE, EXEC = 1 << 0, 1 << 1, 1 << 2 # 1, 2, 4
perm = READ | WRITE # 赋予“读 + 写” = 3
print(perm) # 3
print(bool(perm & READ)) # True,可读
print(bool(perm & EXEC)) # False,不可执行
perm |= EXEC # 追加执行权限:3 | 4 = 7
print(perm) # 7
perm &= ~WRITE # 收回写权限:7 & ~2 = 5
print(perm) # 5
perm ^= READ # 翻转读权限:5 ^ 1 = 4
print(perm) # 4
```
四个操作的对应关系值得背下来:**置位用 `|`,清位用 `& ~`,检测用 `&`,翻转用 `^`**。真实工程里,Linux 文件权限、数据库用户组权限、配置开关集合都用这一模式;相比存一列布尔值,一个整数占用极小、可以整体存储与传输、还能用 `==` 精确比较整组权限。
同一思想放大就是**状态压缩(state compression)**:8 个独立开关不再需要 8 个变量,一个字节全部装下:
```python
lights = 0 # 8 盏灯,初始全灭(低 8 位表示)
lights |= 1 << 3 # 打开第 3 盏
lights |= 1 << 5 # 打开第 5 盏
print(bin(lights)) # 0b101000
print(bool(lights & (1 << 3))) # True:第 3 盏亮着
lights &= ~(1 << 3) # 熄灭第 3 盏(清位)
print(bin(lights)) # 0b100000:只剩第 5 盏
```
把"第几盏灯"翻译成"第几位",代码量的下降立竿见影——这在大规模状态模拟、棋盘博弈(每格一位)、以及后续课程的数据结构压缩中都是常用招数。
## 实战四:RGB 颜色通道提取
24 位真彩色把一个像素塞进 32 位整数:`0xFF3366` 中前 8 位是红(FF)、中间 8 位是绿(33)、末 8 位是蓝(66)。提取各通道 = 右移到目标段 + 掩码截断,反向合成 = 各通道左移后 `|` 拼装:
```python
rgb = 0xFF3366
r = (rgb >> 16) & 0xFF # 红色通道:255
g = (rgb >> 8) & 0xFF # 绿色通道:51
b = rgb & 0xFF # 蓝色通道:102
print(hex(rgb), r, g, b) # 0xff3366 255 51 102
restored = (r << 16) | (g << 8) | b
print(restored == rgb) # True,往返无损
```
这解释了许多图像库的像素格式:`#RRGGBB` 十六进制字符串与整数互转,本质就是移位与掩码。同理,网络字节序解析、状态标志打包(如 `(channel << 8) | code`)都是同一套手法。
### 位打包与字节序的边界
位移打包描述的是"逻辑上的位次序",与内存里的**字节序(endianness)**是两回事。下面的整数 `0x07FF3366` 中,7 占据最高 8 位;至于这 4 个字节在内存里按大端还是小端存放,Python 整数层面不关心——只有把整数序列化进文件或网络(如 `struct.pack`)时字节序才登场,那是后面「文件与网络」章节的主题。现阶段记住:**位运算处理的是整数在逻辑层的位布局,跨进程传输时再考虑字节序**,两者别混为一谈。
### 位运算的性能真相
位运算在不少语言里被当作"快的算术",但这是 C/汇编时代的遗产。在 CPython 中,`x & 1` 与 `x % 2`、`x << 1` 与 `x * 2` 都经过解释器分发,性能处于同一量级,`% 2` 甚至被解释器专门优化过。所以用位运算**不是为了快**,而是为了语义精确:写协议、压缩存储、控制硬件时,位就是数据的原生形态;而普通业务代码里,可读的算术写法优先。判断标准很简单——当你需要表达"这一位"时用位运算,当你需要表达"这个数"时用算术。
## 易错点与陷阱
**1. `~x` 不是"按位取绝对值反过来"。** `~5` 是 -6。拿 `~x` 想得到 0b010 的"反码 0b101"在无限位宽下不成立——补码语义下取反必然跨越符号。真正想要"截断到 n 位的反码",要写 `(~x) & ((1 << n) - 1)`。
**2. 优先级暗雷:`1 << 2 + 3` 是 32 不是 16。** 移位(等级 8)低于加减(等级 7),表达式被解析为 `1 << (2 + 3)`。第 7 篇的等级表在这里是救命稻草:位运算与算术/比较混写时,一律加括号。`(x & 0x0F) == 0x0F` 不加括号虽然结果碰巧正确(`&` 高于 `==`),但读代码的人必须先查表——不值得。
**3. 负数右移的地板除语义。** `-7 >> 1` 是 -4。若你期望"除以 2 向零截断",需要 `int(-7 / 2)`。而当需求是"对正数整除 2 的幂",`x >> k` 与 `x // (1 << k)` 等价,可以放心换用——哈希表、二分中点 `(lo + hi) >> 1` 是竞赛里的常见写法,但**只在保证非负时使用**。
**4. 模 2 的幂与负数。** `x & 15` 与 `x % 16` 对所有整数都相等(运行验证:`-17 & 15 == -17 % 16 == 15`),因为 Python 的 `%` 余数恒非负。但注意这依赖 Python 语义:在 C 与 JavaScript 里 `-17 % 16` 会是 -1,`&` 与 `%` 不再等价。跨语言移植时,位运算的"取模"用法必须重新验证。附带一提:掩码取模只对 `2**k - 1` 这类"低位全 1"的数成立,`x % 10` 没有任何优雅的位运算替代——十进制不是二进制的亲戚。
## 小结
位运算直接操作二进制位,负数走补码表示:`~x == -x-1` 是无限位取反的代数形式,观察补码位模式要用掩码截断(`-6 & 0xFF`)。六个运算符各有分工:`&` 提取、`|` 置位、`^` 翻转与抵消、`~` 生成掩码补集、`<<` 乘 2 的幂、`>>` 对正数整除 2 的幂、对负数地板除。四个实战——奇偶判断、XOR 交换、位掩码权限、RGB 通道——覆盖了位运算 90% 的日常用途。写位运算代码的铁律只有一条:优先级拿不准就加括号,可读性永远优先于"看起来很炫"。
## 练习与思考题
1. 用位运算实现 `is_power_of_two(n)`:`n > 0 and (n & (n - 1)) == 0`。解释为什么 `n & (n-1)` 能消掉最低位的 1,并验证 `n = 8` 与 `n = 12`。
2. 实现 `count_ones(n)`:统计正整数 n 的二进制里有几个 1。提示:循环执行 `n &= n - 1` 直到归零,循环次数就是答案;验证 `count_ones(255) == 8`。
3. 用位移与掩码把四个字节 `7, 255, 51, 102` 打包进一个 32 位整数,再解包还原,验证往返一致(参照实战四的合成顺序)。
4. 思考题:`x ^ y` 的结果中为 1 的位代表什么?用这个性质,能否在不比较的情况下判断两个整数是否相等?给出表达式并验证。