位运算基础:与、或、异或、取反、左移、右移

位运算,说白了就是直接操作二进制位。很多刚入行的同学觉得这东西离自己很远,觉得现代编译器会优化,没必要手动搞。嗯,我以前也这么想,直到有一次在嵌入式项目里,一个整型标志位判断拖慢了整个中断响应……从那以后,我再也不敢小看位运算了。

位运算最大的魅力在于:它能在常数时间内完成很多看似复杂的判断。没有分支预测失败,没有内存访问延迟,就是几条 CPU 指令的事。

六种基本位运算操作

先过一遍基础。这六种操作,我建议你背到条件反射的程度。

运算符 名称 规则 示例(8位)
& 按位与 同1为1,否则为0 0b1100 & 0b1010 = 0b1000
| 按位或 有1为1,否则为0 0b1100 | 0b1010 = 0b1110
^ 按位异或 相同为0,不同为1 0b1100 ^ 0b1010 = 0b0110
~ 按位取反 0变1,1变0 ~0b1100 = 0b0011(仅低4位)
<< 左移 高位丢弃,低位补0 0b0001 << 2 = 0b0100
>> 右移 低位丢弃,高位补符号位 0b1100 >> 2 = 0b1111(算术右移)
注意:右移分算术右移和逻辑右移。C/C++ 对有符号数是算术右移(补符号位),对无符号数是逻辑右移(补0)。Java 里 >> 是算术右移,>>> 是逻辑右移。我见过有人在这上面栽跟头,移位后符号位扩散,结果完全不对。

位运算的常见套路

光知道操作符不够,得会用。下面这几个套路,我在代码审查里几乎每周都能用到。

1. 判断奇偶性

最经典的用法。不用取模,直接看最低位。

// 判断 n 是否为奇数
bool isOdd = (n & 1) == 1;

// 判断 n 是否为偶数
bool isEven = (n & 1) == 0;

为什么快?n % 2 编译器可能会优化成 n & 1,但手动写出来,语义更清晰,也省去了对编译器优化能力的依赖。

2. 判断是否为 2 的幂

这个技巧我经常在内存分配器里用。2 的幂在二进制里只有一个 1。

// 判断 n 是否为正的 2 的幂
bool isPowerOfTwo = (n > 0) && ((n & (n - 1)) == 0);

原理很简单:n - 1 会把最低位的 1 变成 0,后面的 0 全变成 1。如果 n & (n - 1) == 0,说明 n 只有一个 1。

避坑:这个判断对 n = 0 会返回 true(因为 0 & (-1) == 0),所以一定要加 n > 0 的前置条件。我曾经在哈希表扩容逻辑里忘了这个,结果 size 为 0 时也走了 2 的幂优化路径,死循环了半小时。

3. 交换两个数(不用临时变量)

这个技巧面试常考,但实际项目里我建议少用——可读性差,而且现代编译器对临时变量的优化已经很好了。

a ^= b;
b ^= a;
a ^= b;

原理:异或满足交换律和结合律,且 x ^ x = 0,x ^ 0 = x。三步之后,a 和 b 的值就互换了。

4. 取最低位的 1

这个操作在树状数组(Fenwick Tree)里是核心操作。

int lowbit = n & (-n);

负数的补码表示是取反加一。-n 和 n 只在最低位的 1 处对齐,其他位都相反。与运算后,就只保留了最低位的那个 1。

如何用位运算实现 O(1) 判断

好了,重点来了。为什么位运算能实现常数时间判断?因为位运算是 CPU 的原语操作,一条指令搞定,不依赖循环、不依赖分支、不依赖内存访问。

我总结了一个核心思路:把「判断条件」编码成「位模式」,然后用位运算一次性提取结果。

案例:判断一个数是否在集合中

假设我们要判断一个字符是不是元音字母(a, e, i, o, u)。常规做法是写个 switch 或者查哈希表,但用位运算可以做到 O(1)。

// 用 32 位整数表示元音字母的位图
// 'a' - 'a' = 0, 'e' - 'a' = 4, 'i' - 'a' = 8, 'o' - 'a' = 14, 'u' - 'a' = 20
const int VOWEL_BITMAP = (1 << 0) | (1 << 4) | (1 << 8) | (1 << 14) | (1 << 20);

bool isVowel(char c) {
    // 只处理小写字母
    int idx = c - 'a';
    // 越界检查
    if (idx < 0 || idx > 25) return false;
    // O(1) 判断:检查对应位是否为 1
    return (VOWEL_BITMAP >> idx) & 1;
}

你看,没有循环,没有分支(除了越界检查),就是两次移位加一次与运算。这就是 O(1) 判断的本质。

案例:判断两个区间是否重叠

这个技巧我在做区间调度时用过。假设区间用 [start, end) 表示,判断两个区间是否重叠,常规做法是 a.start < b.end && b.start < a.end。但如果你把区间编码成位掩码,可以一次判断。

// 假设区间长度不超过 64,用 64 位整数表示
uint64_t rangeToMask(int start, int end) {
    // 生成从 start 到 end-1 的位都为 1 的掩码
    return ((1ULL << (end - start)) - 1) << start;
}

bool overlap(uint64_t a, uint64_t b) {
    // 两个掩码按位与,非零则重叠
    return (a & b) != 0;
}

当然,这个技巧有局限性——区间长度不能超过位宽。但在很多嵌入式场景里,这个限制是可以接受的。

核心思想总结:位运算实现 O(1) 判断的三种模式
  • 位图法:用整数的每一位表示一个布尔值,一次与运算判断多个条件
  • 掩码法:用连续的位段表示数值范围,通过移位和与运算提取信息
  • 奇偶法:利用二进制最低位的特性,判断奇偶、符号等简单属性

位运算的避坑指南

我踩过的坑,列出来给你参考。

  • 移位位数不要超过类型位宽。在 C/C++ 里,移位位数等于或超过类型位宽是未定义行为。比如 1 << 32 在 32 位 int 上是 UB,编译器可能给你任何结果。
  • 有符号数的右移是算术右移。负数右移高位补 1,可能导致你预期的 0 没有出现。如果要做逻辑右移,用无符号类型。
  • 取反操作要小心类型提升。~0 在 32 位系统上是 0xFFFFFFFF,但如果你把它赋值给 char,会截断成 0xFF。我见过有人用 ~0 做全 1 掩码,结果类型不匹配,高位被截掉了。
  • 位运算优先级容易搞错。比如 if (n & 1 == 0) 会被解析成 if (n & (1 == 0)),结果永远是 0。记住:比较运算符优先级高于位运算符,加括号保平安。

知识体系总览

下面这张图,把位运算的核心知识点串起来了。你可以把它当作速查表。

位运算知识体系 位运算基础 按位与 & 按位或 | 异或 ^ 取反 ~ 左移 << 右移 >> 判断奇偶性 判断2的幂 交换两数 取最低位1 位图法:一次与运算 掩码法:移位+与运算 奇偶法:最低位判断 避坑:移位越界 / 符号位扩散 / 类型提升 / 优先级陷阱 应用场景:集合判断 / 区间重叠 / 标志位管理 / 哈希表扩容

这张图从六种基本操作出发,延伸到常见套路,再收敛到三种 O(1) 判断模式,最后落到避坑和应用场景。你可以把它当作学习路线图,也可以当作面试前的速查。

我的建议:别死记硬背位运算的公式。理解二进制位的本质,然后多写多练。我每次做代码审查,看到能用位运算简化逻辑的地方,都会顺手改掉——不是为了炫技,而是为了减少分支、提升可预测性。常数时间,有时候就藏在这些细节里。