【一】“求二进制数中1的个数”的几种方法
转自[http://blog.youkuaiyun.com/justpub/article/details/2292823]
求二进制中1的个数。对于一个字节(8bit)的变量,求其二进制表示中"1"的个数,要求算法的执行效率尽可能的高。
先来看看样章上给出的几个算法:
解法一,每次除二,看是否为奇数,是的话就累计加一,最后这个结果就是二进制表示中1的个数。
解法二,同样用到一个循环,只是里面的操作用位移操作简化了。
int Count(int v)
{
int num = 0;
while (v)
{
num += v & 0x01;
v >>= 1;
}
return num;
}
解法三,用到一个巧妙的与操作,v & (v -1 )每次能消去二进制表示中最后一位1,利用这个技巧可以减少一定的循环次数。
解法四,查表法,因为只有数据8bit,直接建一张表,包含各个数中1的个数,然后查表就行。复杂度O(1)。
int countTable[256] = { 0, 1, 1, 2, 1, ..., 7, 7, 8 };
int Count(int v)
{
return countTable[v];
}
好了,这就是样章上给出的四种方案,下面谈谈我的看法。
首先是对算法的衡量上,复杂度真的是唯一的标准吗?尤其对于这种数据规模给定,而且很小的情况下,复杂度其实是个比较次要的因素。
查表法的复杂度为O(1),我用解法一,循环八次固定,复杂度也是O(1)。至于数据规模变大,变成32位整型,那查表法自然也不合适了。
其次,我觉得既然是这样一个很小的操作,衡量的尺度也必然要小,CPU时钟周期可以作为一个参考。
解法一里有若干次整数加法,若干次整数除法(一般的编译器都能把它优化成位移),还有几个循环分支判断,几个奇偶性判断(这个比较耗时间,根据CSAPP上的数据,一般一个branch penalty得耗掉14个左右的cycle),加起来大概几十个cycle吧。
再看解法四,查表法看似一次地址计算就能解决,但实际上这里用到一个访存操作,而且第一次访存的时候很有可能那个数组不在cache里,这样一个cache miss导致的后果可能就是耗去几十甚至上百个cycle(因为要访问内存)。所以对于这种“小操作”,这个算法的性能其实是很差的。
这里我再推荐几个解决这个问题的算法,以32位无符号整型为例。
这里用的是二分法,两两一组相加,之后四个四个一组相加,接着八个八个,最后就得到各位之和了。
还有一个更巧妙的HAKMEM算法
int Count(unsigned x)
{
x = x - ((x >> 1) & 0x55555555);
x = (x & 0x33333333) + ((x >> 2) & 0x33333333);
x = (x + (x >> 4)) & 0x0F0F0F0F;
x = x + (x >> 8);
x = x + (x >> 16);
return x & 0x0000003F;
}
首先是将二进制各位三个一组,求出每组中1的个数,然后相邻两组归并,得到六个一组的1的个数,最后很巧妙的用除63取余得到了结果。
因为2^6 = 64,也就是说 x_0 + x_1 * 64 + x_2 * 64 * 64 = x_0 + x_1 + x_2 (mod 63),这里的等号表示同余。
这个程序只需要十条左右指令,而且不访存,速度很快。
由此可见,衡量一个算法实际效果不单要看复杂度,还要结合其他情况具体分析。
关于后面的两道扩展问题,问题一是问32位整型如何处理,这个上面已经讲了。
问题二是给定两个整数A和B,问A和B有多少位是不同的。
这个问题其实就是数1问题多了一个步骤,只要先算出A和B的异或结果,然后求这个值中1的个数就行了。
【二】MIT HAKMEM算法分析
转自[http://blog.youkuaiyun.com/msquare/article/details/4536388]
今天学习了一种很有趣的BitCount算法——MIT HAKMEM算法。
本文中^表示乘方
问题需求:计算32位整型数中的'1'的个数
思路分析:
1.整型数 i 的数值,实际上就是各位乘以权重——也就是一个以2为底的多项式:
i = A0*2^0+A1*2^1+A2*2^2+...
因此,要求1的位数,实际上只要将各位消权:
i = A0+A1+A2+...
所得的系数和就是'1'的个数。
2.对任何自然数n的N次幂,用n-1取模得数为1,证明如下:
若 n^(k-1) % (n-1) = 1 成立
则 n^k % (n-1) = ((n-1)*n^(k-1) + n^(k-1)) % (n-1) = 0 + n^(k-1) % (n-1) = 1 也成立
又有 n^(1-1) % (n-1) = 1
故对任意非负整数N, n^N %(n-1)=1
3.因此,对一个系数为{Ai}的以n为底的多项式P(N), P(N)%(n-1) = (sum({Ai})) % (n-1) ;
如果能保证sum({Ai}) < (n-1),则 P(N)%(n-1) = (sum({Ai})) ,也就是说,此时只要用n-1对多项式取模,就可以完成消权,得到系数和。
于是,问题转化为,将以2为底的多项式转化为以n为底的多项式,其中n要足够大,使得n-1 > sum({Ai})恒成立。
32位整型数中Ai=0或1,sum({Ai})<=32。n-1 > 32 ,n需要大于33。
因此取n=2^6=64>33作为新多项式的底。
4.将32位二进制数的每6位作为一个单位,看作以64为底的多项式:
i = t0*64^0 + t1*64^1 + t2*64^2 + t3*64^3 + ...
各项的系数ti就是每6位2进制数的值。
这样,只要通过运算,将各个单位中的6位数变为这6位中含有的'1'的个数,再用63取模,就可以得到所求的总的'1'的个数。
5.取其中任意一项的6位数ti进行考虑,最简单的方法显然是对每次对1位进行mask然后相加,即
(ti>>5)&(000001) + (ti&>>4)(000001) + (ti>>3)&(000001) + (ti>>2)&(000001) + (ti>>1)&(000001) + ti&(000001)
其中000001位2进制数
由于ti最多含有6个1,因此上式最大值为000110,绝不会产生溢出,所以上式中的操作完全可以直接对整型数 i 进行套用,操作过程中,t0~t6将并行地完成上式的运算。
注意:不能将&运算提取出来先+后&,想想为什么。
因此,bit count的实现代码如下:
int bitcount(unsigned int n)
{
unsigned int tmp;
tmp = (n &010101010101)
+((n>>1)&010101010101)
+((n>>2)&010101010101)
+((n>>3)&010101010101)
+((n>>4)&010101010101)
+((n>>5)&010101010101);
return (tmp%63);
}
但MIT HAKMEM最终的算法要比上面的代码更加简单一些。
为什么说上面的式子中不能先把(ti>>k)都先提取出来相加,然后再进行&运算呢?
因为用&(000001)进行MASK后,产生的有效位只有1位,只要6位数中的'1'个数超过1位,那么在"先加"的过程中,得数就会从最低位中向上溢出。
但是我们注意到,6位数中最多只有6个'1',也就是000110,只需要3位有效位。上面的式子实际上是以1位为单位提取出'1'的个数再相加求和求出6位中'1'的总个数的,所以用的是&(000001)。如果以3位为单位算出'1'的个数再进行相加的话,那么就完全可以先加后MASK。算法如下:
tmp = (ti>>2)&(001001) + (ti>>1)&(001001) + ti&(001001)
(tmp + tmp>>3)&(000111)
C代码:
int bitcount(unsigned int n)
{
unsigned int tmp;
tmp = (n &011111111111)
+((n>>1)&011111111111)
+((n>>2)&011111111111);
tmp = (tmp + (tmp>>3)) &030707070707;
return (tmp%63);
}
注:代码中是使用8进制数进行MASK的,11位8进制数为33位2进制数,多出一位,因此第一位八进制数会把最高位舍去(7->3)以免超出int长度。
从第一个版本到第二个实际上是一个“提取公因式”的过程。用1组+, >>, &运算代替了3组。并且已经提取了"最大公因式"。然而这仍然不是最终的MIT HAKMEM算法,不过已经非常接近了,看看代码吧。
MIT HAKMEM算法:
又减少了一组+, >>, &运算。被优化的是3位2进制数“组”内的计算。再回到多项式,一个3位2进制数是4a+2b+c,我们想要求的是a
+b+c,n>>1的结果是2a+b,n>>2的结果是a。
于是: (4a+2b+c) - (2a+b) - (a) = a + b + c
中间的MASK是为了屏蔽"组间""串扰",即屏蔽掉从左边组的低位移动过来的数。