18、位操作与算法:用位运算实现汉明重量计算(popcount)
汉明重量,说白了就是一个二进制数里有多少个1。你想想看,在通信纠错、密码学、甚至机器学习里,经常要统计这个数。我早年做网络协议栈的时候,就遇到过需要快速计算数据包中比特位1的个数,那时候CPU还没有专门的popcount指令,全靠手写。
今天我们就来聊聊,怎么用纯位运算把popcount算得又快又准。
18.1 什么是汉明重量?
汉明重量(Hamming Weight)是指一个二进制串中非零符号的个数。对于二进制数来说,就是1的个数。比如:
0b1010的汉明重量是20b11111111的汉明重量是80b00000000的汉明重量是0
你可能会问:这玩意儿有啥用?我举个例子。在RAID存储系统中,要校验数据一致性,经常需要计算校验位的汉明重量。在嵌入式系统里,我们做低功耗通信时,也会用popcount来估算信号的活跃度。
18.2 最笨的方法:逐位检查
刚学C语言的人,可能会这么写:
int popcount_naive(unsigned int x) {
int count = 0;
while (x) {
if (x & 1) count++;
x >>= 1;
}
return count;
}
这个写法没错,但效率很低。每次循环只处理1位,32位的整数最坏要循环32次。我在一个实时性要求很高的项目里用过这个版本,结果被性能测试打脸了——太慢了。
18.3 经典优化:Brian Kernighan 算法
Brian Kernighan(就是写《C程序设计语言》那位)提出过一个很巧妙的算法。核心思想是:x & (x - 1) 可以消除最低位的1。
你想想看,每次循环消除一个1,循环次数就等于1的个数。对于稀疏数据(1很少的情况),效率极高。
int popcount_kernighan(unsigned int x) {
int count = 0;
while (x) {
x &= (x - 1); // 消除最低位的1
count++;
}
return count;
}
我曾经在一个数据压缩模块里用这个算法,处理那些大部分是0的位图数据,速度比逐位检查快了将近4倍。嗯,这里要注意:如果数据中1很多(比如接近32个1),这个算法反而会慢,因为循环次数等于1的个数。
18.4 分治思想:查表法
既然逐位算太慢,那我们可以用空间换时间。把8位二进制数的popcount预先算好,存成一张表。然后32位的数拆成4个8位段,查表求和。
// 预计算0-255的popcount
static const unsigned char popcount_table[256] = {
0,1,1,2,1,2,2,3,1,2,2,3,2,3,3,4,
// ... 省略中间数据,实际要256个
8,7,7,6,7,6,6,5,7,6,6,5,6,5,5,4
};
int popcount_table_lookup(unsigned int x) {
return popcount_table[x & 0xFF] +
popcount_table[(x >> 8) & 0xFF] +
popcount_table[(x >> 16) & 0xFF] +
popcount_table[(x >> 24) & 0xFF];
}
这个方法的优点是稳定——不管数据长什么样,都是4次查表加3次加法。我在做嵌入式GUI的像素处理时,就用这个方法来统计屏幕上的亮点数量,效果很好。
18.5 终极方案:分治加法(SWAR算法)
查表法虽然快,但还是要访问内存。有没有不用查表、纯寄存器运算的方法?有!这就是SWAR(SIMD Within A Register)算法。它利用位运算,在寄存器内部模拟并行计算。
核心思路是分治:先每2位一组统计1的个数,然后每4位一组累加,再每8位一组,最后得到32位的总个数。
int popcount_swar(unsigned int x) {
// 第一步:每2位一组,计算每组中1的个数
x = x - ((x >> 1) & 0x55555555);
// 第二步:每4位一组,累加
x = (x & 0x33333333) + ((x >> 2) & 0x33333333);
// 第三步:每8位一组,累加
x = (x + (x >> 4)) & 0x0F0F0F0F;
// 第四步:乘以0x01010101,把各组的和累加到最高8位
x = (x * 0x01010101) >> 24;
return x;
}
这段代码看起来有点吓人,我拆开解释一下:
0x55555555二进制是01010101...,用来提取奇数位0x33333333二进制是00110011...,用来提取每4位中的低2位0x0F0F0F0F二进制是00001111...,用来提取每8位中的低4位- 最后乘以
0x01010101是个巧妙的技巧,相当于把4个8位的数累加到最高8位
这个算法没有循环,没有查表,全是寄存器操作。我在做DSP音频处理时,需要实时统计音频信号的过零率,就是用这个算法,一个时钟周期就能算完一个32位字的popcount。
18.6 各方法性能对比
我整理了一个对比表,方便你根据场景选择:
| 方法 | 循环次数 | 内存占用 | 适用场景 |
|---|---|---|---|
| 逐位检查 | 32次(最坏) | 0 | 教学演示,不推荐实际使用 |
| Kernighan算法 | 等于1的个数 | 0 | 稀疏数据(1很少) |
| 查表法(8位表) | 固定4次 | 256字节 | 通用场景,内存充足时首选 |
| SWAR算法 | 0(纯计算) | 0 | 高性能场景,有硬件乘法器 |
18.7 知识体系图
下面这张图总结了popcount的几种实现路径和它们之间的关系:
18.8 我的建议
如果你问我实际项目中该用哪个,我的建议是:
- 通用场景:用查表法(8位表),256字节换4次查表,性价比最高
- 性能极致:用SWAR,前提是MCU有硬件乘法器
- 稀疏数据:用Kernighan算法,代码简洁,1很少时飞快
- 不要用:逐位检查法,除非你只是在写教学代码
我曾经在一个电池供电的传感器节点上,用SWAR算法做数据压缩中的popcount,把整个压缩流程从12ms降到了3ms。嗯,省下来的电量,让设备多跑了两个月。这就是位运算的魅力——不花一分钱硬件成本,纯靠算法优化就能提升性能。
好了,popcount就聊到这里。记住:位操作不是炫技,而是实实在在解决性能问题的工具。下次你遇到需要统计1的个数时,别再用循环了,试试今天讲的这些方法。
公众号:蓝海资料掘金营,微信deep3321