位运算与二进制补码完全指南
📚 目录
1. 位运算基础
1.1 六大基本运算符
| 运算符 | 名称 | 规则 | 示例 | 用途 |
|---|---|---|---|---|
& | 按位与 | 都为1才为1 | 5 & 3 = 1 | 判断奇偶、取某位 |
| | 按位或 | 有1就为1 | 5 | 3 = 7 | 设置某位为1 |
^ | 按位异或 | 不同为1 | 5 ^ 3 = 6 | 交换变量、找唯一数 |
~ | 按位取反 | 0变1,1变0 | ~5 = -6 | 计算补码 |
<< | 左移 | 左移n位 | 5 << 1 = 10 | 乘以2^n |
>> | 右移 | 右移n位 | 5 >> 1 = 2 | 除以2^n |
1.2 异或的重要性质
a ^ a = 0 (自己异或自己等于0)
a ^ 0 = a (任何数异或0等于本身)
a ^ b = b ^ a (满足交换律)
(a ^ b) ^ c = a ^ (b ^ c) (满足结合律)
2. 位运算技巧详解
2.1 交换两个数(无需中间变量)
# 原理:利用异或的性质
a ^= b # a = a ^ b
b ^= a # b = b ^ (a ^ b) = a
a ^= b # a = (a ^ b) ^ a = b
# 详细推导示例(a=5, b=3):
# 初始:a=5(101), b=3(011)
# a ^= b: a=6(110), b=3(011)
# b ^= a: a=6(110), b=5(101) ← b得到原来的a
# a ^= b: a=3(011), b=5(101) ← a得到原来的b
2.2 常用技巧
n & (n-1) - 消除最右边的1
应用:计算二进制中1的个数
示例:6 & 5 = 4
110 & 101 = 100
n & (-n) - 保留最右边的1
应用:树状数组(Fenwick Tree)
示例:6 & (-6) = 2
0110 & 1010 = 0010
判断2的幂
def is_power_of_two(n):
return n > 0 and (n & (n - 1)) == 0
计算平均值(避免溢出)
# 普通方法可能溢出
avg = (a + b) / 2
# 位运算方法(不会溢出)
avg = (a & b) + ((a ^ b) >> 1)
# 原理:
# a + b = 2×(a & b) + (a ^ b)
# 所以 (a + b) / 2 = (a & b) + (a ^ b) / 2
为什么不溢出?
a & b:提取两数都为1的位(共同部分)a ^ b:提取两数不同的位(差异部分)- 先将差异部分除以2,再加上共同部分,避免先相加导致溢出
3. 二进制补码深度理解
3.1 为什么需要补码?
问题:如果用原码(最高位表示符号),计算会出错
6 + (-6) 用原码计算:
0000 0110 (6)
+ 1000 0110 (-6的原码)
-----------
1000 1100 ❌ 不等于0!
解决方案:补码让减法变成加法
3.2 补码的计算方法
方法一:万能公式
负数的补码 = 2^n - |负数|
示例(8位):
-6 的补码 = 256 - 6 = 250 = 1111 1010
方法二:取反加1(快捷方法)⭐
步骤:
1. 写出正数的二进制:6 = 0000 0110
2. 按位取反: 1111 1001
3. 加1: 1111 1010 ← 这就是-6
3.3 补码验证
6 + (-6) = 0(用补码):
0000 0110 (6)
+ 1111 1010 (-6的补码)
-----------
1 0000 0000
↑ 溢出丢弃
= 0000 0000 = 0 ✅
3.4 反推:如何快速计算负数的值
看到补码 → 求原来的负数值
方法:取反加1(或减1取反)
示例:1111 1010 表示多少?
1. 取反:0000 0101 = 5
2. 加1: 0000 0110 = 6
3. 答案:-6
或者:
1. 减1: 1111 1001
2. 取反:0000 0110 = 6
3. 答案:-6
3.5 特殊情况:1000 0000 = -128
为什么特殊?
8位有符号数范围:-128 到 127
按"取反加1"规则:
-127 = ~127 + 1 = 1000 0001 ✅
-128 = ~128 + 1 = ?
❌ 128需要9位,8位装不下!
解决方案:
直接规定 1000 0000 = -128
(这个位置放着也是浪费,充分利用8位)
验证合理性:
127 + (-128) = -1
0111 1111
+ 1000 0000
-----------
1111 1111 = -1 ✅
理解要点:
- 1000 0000 无法通过"取反加1"从正数得到
- 但它确实表示 -128
- 这是补码系统中唯一的"特殊绑定"
4. 有符号数与无符号数
4.1 最高位不一定表示符号!
取决于类型:
8位二进制 1000 0001 的值:
作为无符号数(unsigned):
= 128 + 1 = 129
作为有符号数(signed):
= -127(补码)
4.2 不同位数的取值范围
n位有符号数
范围:-2^(n-1) 到 2^(n-1) - 1
4位:-8 到 7
8位:-128 到 127
16位:-32768 到 32767
32位:-2147483648 到 2147483647
n位无符号数
范围:0 到 2^n - 1
4位:0 到 15
8位:0 到 255
16位:0 到 65535
32位:0 到 4294967295
4.3 快速判断正负
有符号数:
最高位 = 0 → 正数或0
最高位 = 1 → 负数
示例(8位有符号):
0000 0000 到 0111 1111 → 0 到 127
1000 0000 到 1111 1111 → -128 到 -1
5. 编程语言差异
5.1 各语言对比
| 语言 | 有符号 | 无符号 | 溢出处理 | 设计哲学 |
|---|---|---|---|---|
| C/C++ | ✅ | ✅ | 无警告,未定义行为 | 信任程序员,接近硬件 |
| Rust | ✅ | ✅ | Debug panic, Release可配置 | 安全第一,性能第二 |
| Java | ✅ | ❌ | 截断(wrap around) | 简单安全,避免混淆 |
| Python | ✅ | ❌ | 自动扩展(无限大) | 对用户友好 |
5.2 详细说明
C/C++:完全控制
int a = -100; // 有符号
unsigned int b = 100; // 无符号
// 优点:性能好,灵活
// 缺点:容易出错,溢出无警告
Rust:安全的系统语言
let a: i32 = -100; // 有符号
let b: u32 = 100; // 无符号
// 明确溢出行为:
b.wrapping_add(1); // 明确要溢出
b.checked_add(1); // 检查溢出,返回Option
b.saturating_add(1); // 饱和运算,到最大值停止
Java:简化设计
byte a = -128; // 只有有符号
// 没有 unsigned 关键字
// 原因:
// 1. 简化语言(避免有符号/无符号混用的错误)
// 2. 大部分业务不需要无符号
// 3. 需要时用更大的类型或特殊方法
Python:无限大整数
a = 99999999999999999999999999999999 # 完全OK
# 原理:小整数直接存储,大整数用数组存储多个"块"
# 优点:不用担心溢出
# 缺点:性能损失
5.3 Java的截断机制
// 编译时检查 → 报错
byte b = 130; // ❌ 编译错误
// 运行时或强制转换 → 截断
int x = 130;
byte b = (byte) x; // b = -126(截断)
// 截断原理:
130 = 0000 0000 1000 0010(int)
↓ 只保留低8位
1000 0010(byte)= -126
// 公式:
// value > 127: result = value - 256
// value < -128: result = value + 256
// 130 - 256 = -126
6. 核心知识点总结
6.1 必须记住的公式
1. 负数补码 = 取反加1
2. 看到补码求负数 = 取反加1(相同操作)
3. n & (n-1) = 消除最右边的1
4. n & 1 = 判断奇偶(0为偶,1为奇)
5. 1 << n = 2^n
6. x << n = x * 2^n
7. x >> n = x / 2^n(向下取整)
6.2 重要概念
✅ 补码不是"符号+绝对值",而是特殊的编码方式
✅ 1000 0000 = -128 是特殊绑定,不能用取反加1从正数得到
✅ 最高位是否表示符号取决于有符号/无符号类型
✅ 不同语言对溢出的处理完全不同
✅ 位运算速度快,但要注意可读性
6.3 常见误区
❌ 以为负数就是"符号位+绝对值"(那是原码,不是补码)
❌ 以为最高位是1就一定是负数(要看类型)
❌ 以为所有语言都有无符号数(Java没有)
❌ 以为溢出都会报错(大部分语言不报错)
6.4 实用技巧速查
# 1. 判断奇偶
def is_even(n):
return (n & 1) == 0
# 2. 交换两数
a ^= b; b ^= a; a ^= b
# 3. 计算1的个数
def count_ones(n):
count = 0
while n:
n &= (n - 1)
count += 1
return count
# 4. 找唯一不重复的数
def single_number(nums):
result = 0
for num in nums:
result ^= num
return result
# 5. 判断2的幂
def is_power_of_two(n):
return n > 0 and (n & (n - 1)) == 0
# 6. 获取第n位
def get_bit(num, n):
return (num >> n) & 1
# 7. 设置第n位为1
def set_bit(num, n):
return num | (1 << n)
# 8. 清除第n位
def clear_bit(num, n):
return num & ~(1 << n)
# 9. 计算平均值(避免溢出)
def avg(a, b):
return (a & b) + ((a ^ b) >> 1)
6.5 8位有符号数对照表(常用值)
二进制 十进制
0000 0000 0
0000 0001 1
0111 1111 127 ← 最大正数
1000 0000 -128 ← 最小负数(特殊)
1000 0001 -127
1111 1110 -2
1111 1111 -1 ← 全1
📝 复盘建议
- 重点理解:补码的"取反加1"规则和为什么需要补码
- 多练习:手算几个补码转换,加深记忆
- 实践应用:
- LeetCode 位运算题目
- 实现上面的实用技巧
- 注意陷阱:
- 不同语言的溢出行为
- 有符号/无符号混用
- 下次学习:
- 位掩码(Bitmask)在状态压缩DP中的应用
- 更高级的位运算技巧
🎯 测试题(检验理解)
练习题
1. 1111 1100(8位有符号)表示多少?
2. -1 的二进制为什么是全1?
3. 为什么 -128 + (-128) 会溢出?
4. Java 中 byte b = (byte)200; b的值是多少?
5. 如何用位运算判断一个数是否是4的幂?
答案
1. -4
取反:0000 0011 = 3
加1:4, 所以是 -4
2. -1 = ~1 + 1 = ~(0000 0001) + 1
= 1111 1110 + 1 = 1111 1111
3. -128 + (-128) = -256
超出8位有符号范围(-128到127)
4. b = -56
200 = 1100 1000(低8位)
补码 = -56(或 200 - 256 = -56)
5. n > 0 && (n & (n-1)) == 0 && (n & 0xAAAAAAAA) == 0
或:n > 0 && (n & (n-1)) == 0 && __builtin_ctz(n) % 2 == 0
附录:关键问题深度解析
Q1: 为什么交换两数用异或可以不需要临时变量?
核心原理:
1. a ^ a = 0(自己异或自己等于0)
2. a ^ 0 = a(任何数异或0等于自己)
3. 满足交换律和结合律
推导过程:
a ^= b → a' = a ⊕ b
b ^= a' → b' = b ⊕ (a ⊕ b) = a ⊕ b ⊕ b = a ⊕ 0 = a
a' ^= b' → a'' = (a ⊕ b) ⊕ a = a ⊕ a ⊕ b = 0 ⊕ b = b
完成交换!
Q2: 为什么负数用补码而不是原码?
原因1:统一加减法运算
- 用补码后,CPU只需要加法器,不需要单独的减法器
- a - b = a + (-b),直接加上b的补码即可
原因2:避免两个零
- 原码有 +0 (0000 0000) 和 -0 (1000 0000)
- 补码只有一个 0 (0000 0000)
原因3:硬件实现简单
- 取反加1的电路非常简单
- 所有运算都是加法,硬件设计统一
Q3: 为什么 1000 0000 绑定给 -128?
设计考虑:
1. 8位共256个组合,要充分利用
2. 正数0-127(128个)+ 负数-128到-1(128个)= 256个,刚好!
3. -128没有对应的正数128(超出8位范围)
4. 1000 0000这个位置不能浪费,直接定义为-128
5. 数学验证:所有运算都正确(除了-128 + -128会溢出)
这是一个工程上的最优选择!
Q4: 平均值算法为什么不溢出?
传统方法:(a + b) / 2
问题:a + b 可能超过类型最大值
位运算方法:(a & b) + ((a ^ b) >> 1)
数学证明:
a + b = (a & b) + (a & b) + (a ^ b)
= 2×(a & b) + (a ^ b)
所以:
(a + b) / 2 = (a & b) + (a ^ b) / 2
= (a & b) + ((a ^ b) >> 1)
为什么不溢出?
- (a & b) ≤ min(a, b)
- (a ^ b) ≤ max(a, b)
- (a ^ b) >> 1 更小
- 两个较小数相加不会超过原范围
Q5: Java 为什么只有有符号数?
设计者的考虑(James Gosling团队):
1. 简化语言
- 减少关键字和概念
- 降低学习曲线
2. 避免常见错误
- C语言中有符号/无符号混用常导致bug
- 类型转换规则复杂,难以理解
3. 跨平台一致性
- 所有平台行为完全一致
- 不依赖硬件特性
4. 够用
- 大部分业务场景不需要无符号
- 需要时用 long 或特殊API(如 Integer.toUnsignedLong)
哲学:宁可限制灵活性,也要保证安全和简单
学习完成时间: 2025-11-10 22:57
建议复习时间: 3天后、1周后、1个月后
祝学习进步!🚀