分类: C/C++
2012-03-27 13:54:42
Henry S. Warren, Jr.
种群计数(Population Count)或横列和(Sideways Sums)表示一个计算机字长所包含的“1”的总位数。有些计算机有相应的指令,但仅支持RISC或CISC的计算机并没有,它们所支持的指令为:移位 (shift)、加(add)、与(and)、取数(load)、条件分支(branch)等等。本章就解决了基于支持RISC或CISC的计算机上的种 群计数问题。
基本方法
最容易想到的方法就是下面的C程序:
上面的代码可以改进。首先,在大多数计算机上,从31倒计数到0比从0累加到31更高效,因为前者可以少一条比较指令。(博主:没搞懂为什么……)
继续改进:我们并不需要对所有的位都逐个计数,当x为0的时候,循环就可以结束了,这样当高位是0时就可以省去一些迭代。此外,if条件判断也可以去掉:
表达式x&(x-1)可以将x的最低位清零,所以我们可以用如下方式来改进代码:
如果1位数目的期望值很大,可以用表达式x = x | (x + 1)来把x最右边的0位翻转为1位,直到所有的比特位都变成1为止(x值为-1)。这时迭代次数n就是0位的数目,32-n就是1位的数目。
最后还可以用查表法,把x中的1个字节转换成该字节所包含的1位的数目:
分治法
我们可以把字长的32位分为两部份,每部分16位,最后的结果为两个部份中1位的数目的总和。同样,对于16位的部分,也可以分成8位的两部分。以此类推,直到每个部分只包含一个比特位。
如果不能对各部分并行计算的话,这样的分治毫无意义,因为额外的一个加操作(把两部分的1位数目相加)会增加开销。(博主:之前的循环方法复杂度为 O(n),分治法同样为O(n))多处理器或支持SIMD指令的计算机上可以通过并行可以很好地加速分治算法(单指令多数据源(SIMD):可以并行操作 计算机字中的多个字段,比如字节或者半字)。
下面给出一个在单处理器的RISC或CISC计算机上并行分治的算法。
给出如下一个32位的字:10111100011000110111111011111111,用分治法来计算它的1位数目。
10 11 11 00 01 10 00 11 01 11 11 10 11 11 11 11 -- 分治后的最末端
01 10 10 00 01 01 00 10 01 10 10 01 10 10 10 10 -- 2个比特位中1位数目的和(二进制表示)。
0011 0010 0010 0010 0011 0011 0100 0100 -- 4个比特位中1位数目的和(二进制表示)。
00000101 00000100 0000110 00001000 -- 8个比特位中1位数目的和(二进制表示)。
0000000000001001 0000000000001110 -- 16个比特位中1位数目的和(二进制表示)。
00000000000000000000000000010111 -- 32个比特位中1位数目的和(二进制表示)。
这里再对上面的分治算法作补充说明:
1、第一行是32位字本身的值,根据分治法,最后会分为位数仅为1的32个部分。在此基础上,便是开始对各部份的1位数目进行求和了。
2、第二行求出每2个比特位中1位的数目。比如00中没有1位,得到的也是00;01或10中有1个1位,得到的便是01,而11有2个1位,得到的便是10。
3、以此类推,将求得的各个小部分依次合并成更大的部分,直到最后求出32位字长中1位数目的总和(为23)。
对应于第一行到第二行的转换,我们可以这样实现:
x = (x & 0x55555555) + (x & 0xAAAAAAAA) >> 1;
0x55555555对应的二进制表示为
01 01 01 01 01 01 01 01 01 01 01 01 01 01 01 01
x & 0x55555555便求出奇数位(从右往左)的1的数目。
相应的0xAAAAAAAA对应的二进制表示为
10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10
(x & 0xAAAAAAAA ) >> 1便求出了偶数位的1的数目(并把结果放在奇数位)。
之后把奇数位1的数目与偶数位1的数目相加,便得到了第二行的结果。(通过这种移位(shifting)和屏蔽(masking)的方式可以并行地把分治
的最末端的值求出。这里基于一个特性:两个位1位数目的和最多只为2(即二进制的10),只占两位,所以不会出现进位的情况。)
对于表达式(x & 0xAAAAAAAA) >> 1,我们可以改写成(x >> 1) & 0x55555555。之所以这样改写,是为了避免在寄存器中产生两个巨大的常量。(博主:不是很明白为什么)
对于其它行的转换,也可以进行类似的处理,所以前面的转换过程用C语言描述为:
改进后的代码如下:
上面的代码只执行了21条指令,并且没有分支(branches)和内存引用(memory references)。不过高效的代价就是把它扩展到64位机器时,并不是直接明了的事情。仅管如此,这种节约指令的收益还是很大的。
种群计数的公式如下:
pop(x) = x - floor(x/2) - floor(x/4) - floor(x/8) - ... - floor(x/2^31)
对于上面这个公式,可以这样理解:floor(x/2)是x进行右移1位的结果,floor(x/2^n)是x右移n位的结果。上式相当于循环地将x减去
x右移的结果,总共31次。在x对应的二进制序列中的第m位(从低到高),如果它是"1",它对应的十进制值为2^m,第一次右移,对应该位被减掉的值为
2^(m-1),...,第m-1次右移后,对应该位被减掉的值为2,之后该位就不再作为减数中的一部分了。如此,第m位的“1”最终结果为
2^m-(2^(m-1) + 2^(m-2) + ... + 2 + 1) =
1。也就是说,每多一个值为“1”的位,最终结果就能加1。这样产生的最后结果就是我们要求的种群计数。
其实上面的代码的第一行x = x - ((x>>1) & 0x5555555);便是并行地对16个2比特位使用公式的前两项:2个比特位b的1位数目和为b-floor(b/2) = b - (b>>1)。
其他方法
另一种类似于分治法的方法是求出相邻的3位字段中1位的数目,再把相邻3位字段合并成6位字段,之后对字x模63便能得到所有6位字段的位数总和。这个算法原本是为36位字长的计算机设计的,但是很容易适用于32位字。算法的C代码如下(长串的常量是8进制的):
这种算法在DEC PDP-10计算机上只需要10条指令,因为这种计算机支持这种指令:通过直接引用内存全字得到的值,将其余为余数计算的第二个操作数使用。在一台 RISC计算机上,大约需要15条指令,并且假设这台计算机提供无符号模指令。这条指令可能运行得不是很快,因为除法总是较慢。另外,这种算法最大适用于 62位字长,较难扩展到64位字长。
另一种令人惊奇的方法是把x逐次循环左移1个位置,总共31次,然后求32项的和:
pop(x) = -∑<0→31>(x rot<< i) (rot<<表示循环左移,即左移时最高位的值填到最低位上来)
举个4比特位1001的例子:循环左移三次,得到的值分别为0011, 0110, 1100。这样各项和为1001+0011+0110+1100 = 1110即-2的补码,也即表示原来有2个1位。其实这种方式很好理解,对于每个1位,都为最后的和贡献出1111的值,即-1。所以字长中有多少个1 位,最后的和就有多少个-1。
这种算法只是比较新奇而已,在大多数计算机上并没有什么实用价值,因为循环要执行31次,从而需要63条指令外加循环控制的开销。
两个种群的和与差
计算pop(x)+pop(y)的值,有一种方法可以节省部分时间:首先用分治法中的前两行分别作用于x和y(分别把x,y中的相邻1位合并成2位字段,再合并为4位字段),之后把x与y的值相加,再用分治法的后几行把它们的和从4位字段合并成32位字。
对于pop(x) - pop(y),我们可以把它转换为pop(x) - (32 - pop(~y)) = pop(x) + pop(~y) - 32。~y是y的反码,pop(~y)是y中0的个数,所以32-pop(~y)是y中1的个数。这样就可以和pop(x)+pop(y)使用一样的方法 来计算结果。下面是pop(x) - pop(y)的C代码:
两个字的种群计数比较
有时我们只想知道两个字中哪一个的种群计数更大,而不考虑实际的计数值是多少。基本思想是对每个字中的单个比特位轮流清零,直到其中某个字的比特位全为0,那剩下的非零种群计数值的字就是较大者:
在没有种群计数指令的情况下,计算一个全字数组的1位数目最简单的方法是:求出每个字的1位数目,再把结果相加。这种方法被称为“幼稚的方法”。
另一种方法,是先对数组中的三字组分别进行1位到2位,2位到4位的合并,之后再把这三个字相加。因为3个4位字段的和不会超过12,所以不会溢出到左边的相邻字段。这种思想同样适用于8位和16位字段。这种方法大概比幼稚方法减少20%的指令。
一种更优的方法由Robert Harley和David
Seal在1996年左右发明的。它是一种基于进位保留器(CSA)或者3-2编码器(3:2
compressor)的电路。简单来说,CSA就是一组独立的全加法器(由3个1位输入(用于相加)和2个1位输出(和与进位)所组成的电路)。
下面是使用布尔代数符号(并置表示与,+表示或,⊕表示异或)所表示的每个全加法器的逻辑:
h ← ab + ac + bc = ab + (a + b)c = ab + (a ⊕ b)c
l ← (a ⊕ b) ⊕ c
其中a,b,c都是1位的输入,l是低位输出(和),h是高位输出(进位)。第一行a+b之所以可以改成a⊕b是经过论证的:a和b都为1时,ab值为1,所以两种形式下值都为1;当a和b都为0时,两种形式的值都为0;当a和b中只有一个1时,ab值为0,a+b和a⊕b的值都为1。
一种利用CSA的方法是将三个字减为两个字,下面是16位字的处理过程:
a = 0110 1001 1110 0101 -- 9
b = 1000 1000 0000 0111 -- 6
c = 1100 1010 0011 0101 -- 8
----------------------------------------------
l = 0010 1011 1001 0111 -- 9
h = 1100 1000 0111 0101 -- 7*2 = 14
高位的值是进位,所以最后算1位数总和时,要乘以2。
使用上述的方法,可以把数组中的三个一组地处理,把每组中的三个字减为两个字,再对这两个字应用种群计数 操作(高位中的总数最后要乘以2)。得到的结果在循环体中累计。在典型的RISC计算机上,CSA步骤的指令数nc = 5,为某个字进行种群计数的指令数np = 15。如果忽略数组取数和循环控制,上述方法处理数组中的每个字大约花费(nc + 2*np + 2) / 3约为12.33条指令(式中的+2表示循环体中的2次加法)。
另一种运用CSA的操作方法如下:
我们之前只是用ones来记录低位的值,把twos的值累加到计数器tot上,从而一次数理两个字。现在,我们增加几个CSA单元,这样可以一次处理8个 字。整个数组处理完毕后,最后的结果为8 x pop(eights) + 4 x pop(fours) + 2 x pop(twos) + pop(ones)。忽略数组取数和循环控制,每个字的循环体的执行时间为(7*nc+np+1)/8 = 6.375条指令。下面是对应的C代码:
程序 | 取数和循环控制除外的指令 | 循环中的所有指令(编译器输出) | |
公式 | 令nc = 5,np = 15 | ||
幼稚方法 | np+1 | 16 | 21 |
分组大小为2 | (nc + np + 1) / 2 | 10.5 | 14 |
分组大小为4 | (3*nc + np + 1) / 4 | 7.75 | 10 |
分组大小为8 | (7*nc + np + 1) / 8 | 6.38 | 8 |
分组大小为16 | (15*nc + np + 1) / 16 | 5.69 | 7 |
分组大小为32 | (31*nc + np + 1) / 32 | 5.34 | 6.5 |
分组大小为2^n | nc + (np - nc + 1) / 2^n | 5 + 11 / 2^n |
应用
种群计数指令的用途很广泛,比如:
1、计算位串(bit strings)所表示的集合大小;
2、计算两个位向量之前的“Hamming距离”,这一应用来自于错误校验码理论。Hamming距离指的是向量间对应位取不同值的位数,即:dist(x, y) = pop(x⊕ y)
3、计算字中后缀0的数目,使用关系式:ntz(x) = pop(┐x & (x - 1)) = 32 - pop(x | -x)可以得到。
这里x作为无符号数。x-1可以把离最低位最近的“1”位变为“011111...”序列,而┐x与x-1的交集正是这些最后的“11111...”序 列,也即x中后缀0的数目。另一方面,根据补码的特性,-x = ┐x + 1 ,这个+1操作将使得从低位开始的连续1序列(对应着x中的后缀0序列)变为0序列,x | -x的值除了+1操作产生的低位连续的0序列外,其余都为1,所以pop(x | -x)就是除了后缀0序列的位数,32 - pop(x | -x)便是x中后缀0的个数。
ntz(x)函数用途同样非常广泛,比如中断处理。早期计算机一旦发生中断,就会在一个特殊的寄存器中存储一个表示“中断原因”的位。优先级越高的中断位离最低位越近。可以通过计算后缀0个数的方式找到最高优先级的中断并处理。
4、快速访问使用特定压缩方式表示的中等稀疏数组。
稀疏数组可以被压缩,在压缩的数组里仅存储已定义的或者非零的数组元素。为了维护原数组的信息,需要额外一个位串。如果该位串的第i位是1,就说明原数组 A[i]有定义。这个位串通常很长,所以它会被分成一组32位的字,这样就会有一个bits数组,数组中每个元素是一个32位的字。另外维护一个 bitsum的数组,其中元素bitsum[j]表示bits中前j个字里的1位数目的总和,它用来加速索引。下表描述了一个将95个元素压缩成6个元素 的结果:
bits |
bitsum |
data |
0x00000005 | 0 | A[0] |
0x00018001 | 2 | A[2] |
0x80000000 | 5 | A[32] |
A[47] | ||
A[48] | ||
A[95] |
5、用来生成符合二项式分布的随机整数。
6、计算机界传言,种群计数对于美国国家安全局(National Security Agency)而言非常重要。除了NSA自己的人,没有人知道他们用种群计数做什么,但很有可能是关于加密的工作或者海量材料的搜索。
:《高效程序的奥秘》(Hacker's Delight)的作者,他在IBM有过40年的工作经验。