汉明重量算法深度解析
会话时间: 2025-11-12 02:48:56 UTC
主题: Hamming Weight (popcount) 算法原理与实现对比
📚 目录
算法概述
问题定义
计算一个整数的二进制表示中 1 的个数(Hamming Weight / Population Count)
算法代码
public int hammingWeight(int n) {
n = n - ((n >>> 1) & 0x55555555);
n = (n & 0x33333333) + ((n >>> 2) & 0x33333333);
n = (n + (n >>> 4)) & 0x0f0f0f0f;
n = n + (n >>> 8);
n = n + (n >>> 16);
return n & 0x3f;
}
基础知识
位移操作符区别
>> 算术右移
- 右移时左边补符号位(正数补0,负数补1)
- 保持数的正负性
int a = -8; // 11111111 11111111 11111111 11111000
a >> 2; // 11111111 11111111 11111111 11111110 (补1)
>>> 逻辑右移
- 右移时左边永远补0
- 不管正负数都补0
int a = -8; // 11111111 11111111 11111111 11111000
a >>> 2; // 00111111 11111111 11111111 11111110 (补0)
为什么使用 >>>?
因为只关心位的模式,不关心正负号。
算法原理详解
核心思想:分治法(Divide and Conquer)
将32位分组,逐层合并统计:
32位 → 16组(2位) → 8组(4位) → 4组(8位) → 2组(16位) → 1组(32位)
第1步:每2位一组,统计1的个数
n = n - ((n >>> 1) & 0x55555555);
魔法数字
0x55555555 = 0101 0101 0101 0101 0101 0101 0101 0101
数学原理
对于任意2位 ab(a是高位,b是低位):
原始值 = a×2 + b (二进制位值公式)
n >>> 1 = a (右移1位得到高位)
n - (n>>>1) = (a×2 + b) - a = a + b ✅
示例
11 → 原值=3, 右移=1, 相减=2 ✅ (2个1)
10 → 原值=2, 右移=1, 相减=1 ✅ (1个1)
01 → 原值=1, 右移=0, 相减=1 ✅ (1个1)
00 → 原值=0, 右移=0, 相减=0 ✅ (0个1)
完整过程(8位示例)
原始: 11 01 10 01
步骤1: 01 10 01 10 (n >>> 1)
掩码后: 01 00 01 00 ((n>>>1) & 0x55)
相减: 10 01 01 01 ✅ 每2位的1的个数
第2步:每4位一组,累加
n = (n & 0x33333333) + ((n >>> 2) & 0x33333333);
魔法数字
0x33333333 = 0011 0011 0011 0011 0011 0011 0011 0011
作用
将相邻的2位组分离后相加,避免进位干扰。
示例
当前: 10 01 01 01
分离低2位组:
10 01 01 01
& 00 11 00 11
= 00 01 00 01
分离高2位组 (右移2位后):
10 01 01 01 >>> 2 = 00 10 00 10
& 00 11 00 11
= 00 10 00 10
相加:
00 01 00 01
+ 00 10 00 10
= 00 11 00 11 (前4位有3个1,后4位有3个1)
第3步:每8位一组
n = (n + (n >>> 4)) & 0x0f0f0f0f;
魔法数字
0x0f0f0f0f = 0000 1111 0000 1111 0000 1111 0000 1111
示例
当前: 0011 0011
右移4位: 0000 0011
相加: 0011 0110
掩码: 0000 0110 (6个1) ✅
第4-5步:继续合并至32位
n = n + (n >>> 8); // 16位一组
n = n + (n >>> 16); // 32位一组
为什么不需要掩码?
关键洞察:
- 32位整数最多有32个1
- 32 =
0b100000,只需6位表示 - 最终结果不会溢出6位范围
- 高位的"垃圾数据"不影响低位正确性
完整过程
第3步后(4组8位计数):
[0000 0011] [0000 0010] [0000 0011] [0000 0010]
第4步 (n + n>>>8):
低16位 = 组0+组1的和
高16位 = 组2+组3的和
第5步 (n + n>>>16):
低位 = 所有组的总和 ✅
第6步:提取结果
return n & 0x3f; // 0x3f = 0011 1111 (63)
- 32位最多32个1,需要6位存储(2^6 = 64)
- 只取低6位,丢弃所有高位数据
核心疑问解答
Q1: 为什么不能直接累加?
问题:第1步得到每2位的计数后,为什么不直接全部相加?
回答:
❌ 错误方式1:当作普通数字
10 01 01 01 (二进制) = 149 (十进制) ❌ 完全错误
❌ 错误方式2:循环提取
int sum = 0;
sum += (n & 0b11); // 提取每2位
sum += ((n >> 2) & 0b11);
// ... 重复16次
问题:回到了循环,失去了O(1)的优势!
✅ 正确方式:层层合并
原因:
- 避免进位冲突:直接加会导致进位串到隔壁组
- 并行处理:每步同时处理所有组,不需要循环
- 保证独立性:每组有独立空间,不会互相干扰
Q2: 为什么要合并到32位?
回答:确实合并到了32位,步骤如下:
步骤1: 2位 → 16组独立计数
步骤2: 4位 → 8组独立计数
步骤3: 8位 → 4组独立计数
步骤4: 16位 → 2组独立计数
步骤5: 32位 → 1组总计数 ✅
步骤6: 提取低6位
为什么后面看起来简单了?
| 步骤 | 需要掩码? | 原因 |
|---|---|---|
| 1-3步 | ✅ 需要 | 防止组间进位干扰 |
| 4-5步 | ❌ 不需要 | 结果≤32,只需最后提取低6位 |
Q3: 为什么后面不需要掩码?
核心原因:最终结果有界(≤32),只需6位。
每步的最大值分析
| 步骤 | 组数 | 每组最大值 | 需要位数 |
|---|---|---|---|
| 第1步 | 16组 | 2 | 2位 |
| 第2步 | 8组 | 4 | 3位 |
| 第3步 | 4组 | 8 | 4位 |
| 第4步 | 2组 | 16 | 5位 |
| 第5步 | 1组 | 32 | 6位 |
结论:
- 即使高位有"垃圾数据"
- 即使有重复累加
- 最后
& 0x3f提取低6位,保证结果正确
算法对比分析
算法1: Brian Kernighan 算法
public int hammingWeight(int n) {
int count = 0;
while (n != 0) {
n = n & (n - 1); // 消除最右边的1
count++;
}
return count;
}
原理:n & (n-1) 消除最右边的1
n = 1011000
n-1 = 1010111
n&(n-1) = 1010000 ✅ 最右边的1被消除
算法2: 分治算法(本文讨论的)
public int hammingWeight(int n) {
n = n - ((n >>> 1) & 0x55555555);
n = (n & 0x33333333) + ((n >>> 2) & 0x33333333);
n = (n + (n >>> 4)) & 0x0f0f0f0f;
n = n + (n >>> 8);
n = n + (n >>> 16);
return n & 0x3f;
}
复杂度对比
| 算法 | 时间复杂度 | 空间复杂度 | 实际执行 |
|---|---|---|---|
n & (n-1) | O(k) | O(1) | k次循环(k=1的个数) |
| 分治算法 | O(1) | O(1) | 固定5次运算 |
性能对比
| 输入数据 | n & (n-1) | 分治算法 |
|---|---|---|
0b00000001 (1个1) | 1次循环 | 5次运算 |
0b10101010 (4个1) | 4次循环 | 5次运算 |
0b11111111 (8个1) | 8次循环 | 5次运算 |
0xFFFFFFFF (32个1) | 32次循环 | 5次运算 |
结论:
- 1很少:
n & (n-1)更快 - 1很多:分治算法更稳定
- 最坏情况:分治算法快6倍+
实际应用建议
🎯 日常开发(推荐95%场景)
// ✅ 直接使用标准库
int count = Integer.bitCount(n);
理由:
- JVM会优化成硬件指令(POPCNT)
- 性能最优
- 代码简洁
📝 学习/面试场景
优先掌握:n & (n-1)
int count = 0;
while (n != 0) {
n &= n - 1;
count++;
}
优势:
- ✅ 代码简洁(5行)
- ✅ 容易理解和解释
- ✅ 面试常考
- ✅ 平均性能足够
进阶理解:分治算法
优势:
- ✅ 体现算法深度
- ✅ 理解位操作精髓
- ✅ 了解JDK实现原理
🚀 性能关键场景
选择分治算法的情况:
- 高性能计算(图像处理、密码学)
- 需要固定时间(实时系统、防时序攻击)
- 大量调用(百万级循环)
- 底层库开发
算法选择流程图
需要统计1的个数?
↓
生产代码?
↓ 是
Integer.bitCount(n) ✅
↓ 否(学习/面试)
需要展示深度理解?
↓ 是
分治算法 ✅
↓ 否
n & (n-1) ✅(优先推荐)
总结与复盘
核心知识点
-
位操作基础
>>>逻辑右移(无符号右移)>>算术右移(保留符号位)
-
算法思想
- 分治法:将问题分解为子问题
- 并行处理:每步同时处理所有组
- 空间换时间:用固定运算代替循环
-
数学原理
- 第1步:利用
(a×2+b) - a = a+b - 后续步骤:层层合并,保证不溢出
- 第1步:利用
-
工程权衡
- 代码可读性 vs 性能
- 平均情况 vs 最坏情况
- 标准库 vs 自实现
关键理解
✅ 为什么层层合并?
- 避免进位冲突
- 实现并行处理
- 保证O(1)复杂度
✅ 为什么后面不需要掩码?
- 结果有界(≤32)
- 只需6位存储
- 最后提取低位即可
✅ 实际开发选择?
// 99%的情况
Integer.bitCount(n);
// 学习推荐
n & (n-1);
// 性能极致
分治算法
延伸思考
-
硬件支持
- x86:
POPCNT指令 - ARM:
CNT指令 - JVM会自动优化
- x86:
-
应用场景
- 位图索引(数据库)
- 布隆过滤器
- 棋盘游戏(位棋盘表示)
- 网络协议(校验和计算)
-
相关算法
- 找第一个1的位置:
n & -n - 判断2的幂:
n & (n-1) == 0 - 翻转所有位:
~n
- 找第一个1的位置:
学习收获
- ✅ 理解了分治算法的位操作应用
- ✅ 掌握了两种主流算法的优劣
- ✅ 学会了根据场景选择合适算法
- ✅ 理解了工程实践中的权衡
参考资料
- JDK源码:
java.lang.Integer.bitCount() - 算法名称:
- Hamming Weight
- Population Count (popcount)
- Sideways Addition
- 相关技术: SWAR (SIMD Within A Register)
文档生成时间: 2025-11-12
学习者: MaryDQ
版本: v1.0
附录:完整代码对比
方法1: Brian Kernighan
public int hammingWeight(int n) {
int count = 0;
while (n != 0) {
n &= n - 1;
count++;
}
return count;
}
方法2: 分治算法
public int hammingWeight(int n) {
n = n - ((n >>> 1) & 0x55555555);
n = (n & 0x33333333) + ((n >>> 2) & 0x33333333);
n = (n + (n >>> 4)) & 0x0f0f0f0f;
n = n + (n >>> 8);
n = n + (n >>> 16);
return n & 0x3f;
}
方法3: 标准库(推荐)
public int hammingWeight(int n) {
return Integer.bitCount(n);
}
END