位运算与二进制补码完全指南

72 阅读11分钟

位运算与二进制补码完全指南


📚 目录

  1. 位运算基础
  2. 位运算技巧详解
  3. 二进制补码深度理解
  4. 有符号数与无符号数
  5. 编程语言差异
  6. 核心知识点总结

1. 位运算基础

1.1 六大基本运算符

运算符名称规则示例用途
&按位与都为1才为15 & 3 = 1判断奇偶、取某位
|按位或有1就为15 | 3 = 7设置某位为1
^按位异或不同为15 ^ 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        (自己异或自己等于0a ^ 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位:-87
8位:-128127
16位:-3276832767
32位:-21474836482147483647
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++无警告,未定义行为信任程序员,接近硬件
RustDebug 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 0010int)
      ↓ 只保留低81000 0010byte)= -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. 重点理解:补码的"取反加1"规则和为什么需要补码
  2. 多练习:手算几个补码转换,加深记忆
  3. 实践应用
    • LeetCode 位运算题目
    • 实现上面的实用技巧
  4. 注意陷阱
    • 不同语言的溢出行为
    • 有符号/无符号混用
  5. 下次学习
    • 位掩码(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(自己异或自己等于02. a ^ 0 = a(任何数异或0等于自己)
3. 满足交换律和结合律

推导过程:
a ^= ba' = ab
b ^= a'    → b' = b ⊕ (ab) = abb = a0 = a
a' ^= b'   → a'' = (ab) ⊕ a = aab = 0b = 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个月后

祝学习进步!🚀