18、位操作与算法:用位运算实现汉明重量计算(popcount)

汉明重量,说白了就是一个二进制数里有多少个1。你想想看,在通信纠错、密码学、甚至机器学习里,经常要统计这个数。我早年做网络协议栈的时候,就遇到过需要快速计算数据包中比特位1的个数,那时候CPU还没有专门的popcount指令,全靠手写。

今天我们就来聊聊,怎么用纯位运算把popcount算得又快又准。

18.1 什么是汉明重量?

汉明重量(Hamming Weight)是指一个二进制串中非零符号的个数。对于二进制数来说,就是1的个数。比如:

  • 0b1010 的汉明重量是2
  • 0b11111111 的汉明重量是8
  • 0b00000000 的汉明重量是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的像素处理时,就用这个方法来统计屏幕上的亮点数量,效果很好。

小技巧:如果内存紧张,可以只建16位的表(65536个元素),这样查两次就够了。但16位表占256KB,在MCU上可能吃不消。8位表只占256字节,几乎不占什么资源。

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。

注意:SWAR算法依赖整数乘法,在一些低端MCU上乘法指令可能比较慢。如果MCU没有硬件乘法器,查表法反而可能更快。我曾经在8051上试过,SWAR比查表慢了3倍。

18.6 各方法性能对比

我整理了一个对比表,方便你根据场景选择:

方法 循环次数 内存占用 适用场景
逐位检查 32次(最坏) 0 教学演示,不推荐实际使用
Kernighan算法 等于1的个数 0 稀疏数据(1很少)
查表法(8位表) 固定4次 256字节 通用场景,内存充足时首选
SWAR算法 0(纯计算) 0 高性能场景,有硬件乘法器

18.7 知识体系图

下面这张图总结了popcount的几种实现路径和它们之间的关系:

汉明重量(popcount)实现方法 popcount 实现 逐位检查法 Kernighan 算法 循环次数 = 1的个数 适合稀疏数据 查表法 SWAR 分治法 纯寄存器运算 需要硬件乘法器 选择依据:数据密度 × 硬件能力 × 内存限制

18.8 我的建议

如果你问我实际项目中该用哪个,我的建议是:

  • 通用场景:用查表法(8位表),256字节换4次查表,性价比最高
  • 性能极致:用SWAR,前提是MCU有硬件乘法器
  • 稀疏数据:用Kernighan算法,代码简洁,1很少时飞快
  • 不要用:逐位检查法,除非你只是在写教学代码

我曾经在一个电池供电的传感器节点上,用SWAR算法做数据压缩中的popcount,把整个压缩流程从12ms降到了3ms。嗯,省下来的电量,让设备多跑了两个月。这就是位运算的魅力——不花一分钱硬件成本,纯靠算法优化就能提升性能。

好了,popcount就聊到这里。记住:位操作不是炫技,而是实实在在解决性能问题的工具。下次你遇到需要统计1的个数时,别再用循环了,试试今天讲的这些方法。


公众号:蓝海资料掘金营,微信deep3321