Hamming Weight的算法分析

转载至:
http://www.cnblogs.com/jawiezhu/p/4395063.html 参考:
wikipedia
Hamming Weight的算法分析

最容易想到的算法是除余法,继而考虑到除法的代价较高,而且除数是2,

会想到使用向右移位来代替除法,并使用&0x1操作来取末位的值,这样提高了算法的效率。

然而,这样仍然进行了63次&操作、63次移位操作和63次+操作。若假设字长大小不限,记作N,那么上述算法的时间复杂度都为O(N)。

 

具体实现方法:

1.除以2,逐个数,遍历。

《Hamming Weight的算法分析》

 1 class HammingWeight{
 2 public:
 3     int HammingWeight(uint32_t n){
 4         int cnt=0;
 5         while(n!=0)
 6         {
 7             if(n%2==1) //满足尾数为1
 8                 cnt++;
 9             n/=2;//除以2,右移一位
10         }
11 
12         return cnt;
13     }
14 
15 };

《Hamming Weight的算法分析》

 

2.位操作:

《Hamming Weight的算法分析》

 1 class HammingWeight{
 2 public:
 3     int HammingWeight(uint32_t n){
 4         int cnt=0;
 5         while(n!=0)
 6         {
 7             cnt+=n&1;//尾数按位与1
 8             n>>=1;//右移一位
 9         }
10         return cnt;
11     }
12 }

《Hamming Weight的算法分析》

 

因此,对于任何一个整数n(对应的二进制数有《Hamming Weight的算法分析》位),它都要进行《Hamming Weight的算法分析》次判断。可以说,算法效率比较低,每一位都进行了判断。

 

3.n&=n-1

n&=n-1, 只与二进制中1的位数相关的算法

考虑每次找到最低位开始遇到的第一个1,计数,再把它清零,清零的位运算操作是&0,

但是在有1的这一位&0的操作,要同时不影响未统计过的位数和已经统计过的位数,因此有 n&=n-1这样的方法。

该操作对比当前操作位高的位没有影响,对低位则完全清零。

比如:

6(110),

第一次 110&101=100,这次操作成功的把从低位起第一个1消掉了,同时计数器加1。

第二次100&011=000,同理又统计了高位的一个1,此时n已变为0,不需要再继续了,于是110中有2个1。

《Hamming Weight的算法分析》

 1 class HammingWeight{
 2 public:
 3     int HammingWeight(uint32_t n){
 4         int cnt=0;
 5         whilie(n!=0)
 6         {
 7             n&=n-1;
 8             cnt++;
 9         }
10         return cnt;
11     }
12 }

《Hamming Weight的算法分析》

精髓:n&=n-1,该操作对比当前操作位高的位没有影响,对低位则完全清零。

 

4.wiki上的解法

《Hamming Weight的算法分析》

 1 class HammingWeight{
 2 public:
 3     int HammingWeight(uint32_t n){
 4         n=(n&0x55555555)+((n>>1)&0x55555555);
 5         n=(n&0x33333333)+((n>>2)&0x33333333);
 6         n=(n&0x0f0f0f0f)+((n>>4)&0x0f0f0f0f);
 7         n=(n&0x00ff00ff)+((n>>8)&0x00ff00ff);
 8         n=(n&0x0000ffff)+((n>>16)&0x0000ffff);
 9         return n;
10     }
11 };

《Hamming Weight的算法分析》

主要思想: 错位分段相加,然后递归合并的过程。

0x55555555 ->0101 0101 0101 0101 0101 0101 0101 0101

0x33333333 ->0011 0011 0011 0011 0011 0011 0011 0011

0x0f0f0f0f    ->0000 1111 0000 1111 0000 1111 0000 1111

0x0000ffff    ->0000 0000 0000 0000 1111 1111 1111 1111

因此可以看出:

整个数按照上述的周期被分成了n段,

每段里面的前半截都被清零,

后半截保留了数据。

不同在于这些数分段的长度是2倍增长的。于是我们可以姑且命名它们为“分段截取常数”。

 

这样,如果我们按照分段的思想,每个周期分成一段的话,

你或许就可以感觉到这个分段是二分法的倒过来——类似二段合并一样的东西!

 

现在回头来看问题,我们要求的是1的个数。

这就要有一个清点并相加的过程(查表法除外)。

使用&运算和移位运算可以帮我们找到1,但是却无法计算1的个数,需要由加法来完成。最传统的逐位查找并相加,每次只加了1位,显然比较浪费,我们能否一次用加法来计算多次的位数呢?

再考虑问题,找到了1的位置,如何把这个位置变成数量。

最简单的情况,一个2位的数,比如11,只要把它的第二位和第一位相加,不就得到了1的个数了吗?!所以对于2位的x,有x中1的个数=(x>>1)+(x&1)。是不是和上面的式子有点像?

再考虑稍复杂的,一个字节内的情况。
一个字节的x,显然不能用(x>>1)+(x&1)的方法来完成,

但是我们受到了启发,如果把x分段相加呢?

把x分成4个2位的段,然后相加,就会产生4个2位的数,每个都代表了x对应2位地方的1的个数。

 

对于n位二进制数,最多有n个1,而n必定能由n位二进制数来表示,因此我们在求出某k位中1的个数后,

可以将结果直接存储在这k位中,不需要额外的空间。

以4位二进制数abcd为例,最终结果是a+b+c+d,循环的话需要4步加法

让abcd相邻的两个数相加,也就是 a+b+c+d=[a+b]+[c+d]

[0 b 0 d]

[0 a 0 c]

——–

[e f] [ g h]

ef=a+b   gh=c+d 而 0b0d=(abcd)&0101,0a0c=(abcd)>>1 &0101

[ef]  [gh]再相邻的两组相加

[00 ef]

    [gh]

——–

i j k l

ijkl=ef+gh  gh=(efgh)&& 0011 ,ef=(efgh)>>2 & 0011

依次入次递推。需要log(N)次

 

例子:

若求156中1的个数,156二进制是10011100

最终:

[1][0][0][1][1][1][0][0] //初始,每一位是一组

|0  0 |0  1 |0  1 |0  0|  //与01010101相与的结果,同时2个一组分组
+
|0  1 |0  0 |0  1 |0  0|  //右移一位后与01010101相与的结果
=
[0  1][0  1][1  0][0  0]  //相加完毕后,现在每2位是一组,每一组保存的都是最初在这2位的1的个数
—-
|0  0  0  1 |0  0  0  0|  //与00110011相与的结果,4个一组分组
+
|0  0  0  1 |0  0  1  0|  //右移两位后与00110011相与的结果
=
[0  0  1  0][0  0  1  0] //相加完毕后,现在每4位是一组,并且每组保存的都是最初这4位的1的个数
—-
|0  0  0  0  0  0  1  0|
+
|0  0  0  0  0  0  1  0|
=
[0  0  0  0  0  1  0  0] //最终合并为8位1组,保存的是整个数中1的个数,即4。

 

比如这个例子,143的二进制表示是10001111,这里只有8位,

高位的0怎么进行与的位运算也是0,所以只考虑低位的运算,按照这个算法走一次

+—+—+—+—+—+—+—+—+
| 1 | 0 | 0 | 0 | 1 | 1 | 1 | 1 |   <—143
+—+—+—+—+—+—+—+—+
|  0 1  |  0 0  |  1 0  |  1 0  |   <—第一次运算后
+——-+——-+——-+——-+
|    0 0 0 1    |    0 1 0 0    |   <—第二次运算后
+—————+—————+
|        0 0 0 0 0 1 0 1        |   <—第三次运算后,得数为5
+——————————-+

这里运用了分治的思想,

先计算每对相邻的2位中有几个1,

再计算每相邻的4位中有几个1,下来8位,16位,32位,因为2^5=32,所以对于32位的机器,5条位运算语句就够了。

像这里第二行第一个格子中,01就表示前两位有1个1,00表示下来的两位中没有1,其实同理。

再下来01+00=0001表示前四位中有1个1,同样的10+10=0100表示低四位中有4个1,

最后一步0001+0100=00000101表示整个8位中有5个1。

 

 

64位的机子:

《Hamming Weight的算法分析》

 1 class HammingWeight{
 2     typedef unsigned __int64 uint64;  //assume this gives 64-bits
 3     const uint64 m1  = 0x5555555555555555; //binary: 0101...
 4     const uint64 m2  = 0x3333333333333333; //binary: 00110011..
 5     const uint64 m4  = 0x0f0f0f0f0f0f0f0f; //binary:  4 zeros,  4 ones ...
 6     const uint64 m8  = 0x00ff00ff00ff00ff; //binary:  8 zeros,  8 ones ...
 7     const uint64 m16 = 0x0000ffff0000ffff; //binary: 16 zeros, 16 ones ...
 8     const uint64 m32 = 0x00000000ffffffff; //binary: 32 zeros, 32 ones
 9     const uint64 hff = 0xffffffffffffffff; //binary: all ones
10     const uint64 h01 = 0x0101010101010101; //the sum of 256 to the power of 0,1,2,3...
11  
12 //This is a naive implementation, shown for comparison,
13 //and to help in understanding the better functions.
14 //It uses 24 arithmetic operations (shift, add, and).
15 public:
16     int HammingWeight(uint64_t x){
17         x = (x & m1 ) + ((x >>  1) & m1 ); //put count of each  2 bits into those  2 bits 
18         x = (x & m2 ) + ((x >>  2) & m2 ); //put count of each  4 bits into those  4 bits 
19         x = (x & m4 ) + ((x >>  4) & m4 ); //put count of each  8 bits into those  8 bits 
20         x = (x & m8 ) + ((x >>  8) & m8 ); //put count of each 16 bits into those 16 bits 
21         x = (x & m16) + ((x >> 16) & m16); //put count of each 32 bits into those 32 bits 
22         x = (x & m32) + ((x >> 32) & m32); //put count of each 64 bits into those 64 bits 
23         return x;
24     }
25 };

《Hamming Weight的算法分析》

算法是使用了分治的思想,每步将问题划分成子问题,然后合并来减小问题的规模,求解问题的过程像是一棵倒置的二叉树。

先将n位的二进制相邻的两位两两分为一组,并巧妙的利用移位和掩码来使其利用自身来表示所得到的和,这样从宏观上来看,问题就被简化成规模为n/2bit(这里的bit其实已经是虚指了,其实理解为unit更好)的问题求解了,

同样的,继续两两划分成一组分治求解。经过lg(n)步,得到最终的解。

由以上分析可见,算法的复杂度为O(lgn)。
对于64位的字串来说 ,只使用了24次算数操作,比起前面的算法来说要明显减少了。

 

下面的实在有点不懂了,先留着。。。

优化:

《Hamming Weight的算法分析》

 1 //This uses fewer arithmetic operations than any other known  
 2 //implementation on machines with slow multiplication.
 3 //It uses 17 arithmetic operations.
 4 int popcount_2(uint64 x) {
 5     x -= (x >> 1) & m1;             //put count of each 2 bits into those 2 bits
 6     x = (x & m2) + ((x >> 2) & m2); //put count of each 4 bits into those 4 bits 
 7     x = (x + (x >> 4)) & m4;        //put count of each 8 bits into those 8 bits 
 8     x += x >>  8;  //put count of each 16 bits into their lowest 8 bits
 9     x += x >> 16;  //put count of each 32 bits into their lowest 8 bits
10     x += x >> 32;  //put count of each 64 bits into their lowest 8 bits
11     return x & 0x7f;
12 }

《Hamming Weight的算法分析》

popcount2在popcount1的基础上进行了优化。
第一步基于了这样一个事实:ab-0a得到的值为ab中1的个数。
简单证明:若a为0,那么0a=0,减0无变化,那么b就是结果。
若a位1,那么只有两种情况,10-01 = 01, 11-01 = 10.都符合上述事实。
这样x -= (x >> 1) & m1和 x = (x & m1 ) + ((x >> 1) & m1 )的结果相同,却节省了1个操作。(这里我有个疑问,有符号数使用补码进行减法操作等于加法操作,效率相当,然而这里x为无符号数,即原码加减法,原码的减法在机器级如何实现,即一个源码减法的操作的代价与加法和与操作的代价和比较,哪个更大?有时间的话要去看下原码减法的实现)

第二步第三步同popcount1,此时x=[a]8[b]8[c]8[d]8[e]8[f]8[g]8[h]8

第四步后x = [H8|a+b]16[H8|c+d]16[H8|e+f]16[H8|g+h]16,这里H8代表高8位,由于我们不关心高8位的值(当然H的值是明显知道的),这里就用H代替。由于使用低8位完全可以表示0~64范围内的值,因此不用担心低八位溢出。

同理,第五步后x=[H24|a+b+c+c]32[H24|e+f+g+h]32
第六步后x=[H56|e+f+g+h]64
第七步使用掩码0x7f获得低8位的值(0xff效果应该一样的吧..?)

 

《Hamming Weight的算法分析》

1 //This uses fewer arithmetic operations than any other known  
2 //implementation on machines with fast multiplication.
3 //It uses 12 arithmetic operations, one of which is a multiply.
4 int popcount_3(uint64 x) {
5     x -= (x >> 1) & m1;             //put count of each 2 bits into those 2 bits
6     x = (x & m2) + ((x >> 2) & m2); //put count of each 4 bits into those 4 bits 
7     x = (x + (x >> 4)) & m4;        //put count of each 8 bits into those 8 bits 
8     return (x * h01)>>56;  //returns left 8 bits of x + (x<<8) + (x<<16) + (x<<24) + ... 
9 }

《Hamming Weight的算法分析》

popcount3进一步进行了优化,只看最后一步:return (x * h01)>>56;
此步之前的x=[a]8[b]8[c]8[d]8[e]8[f]8[g]8[h]8
x*h01 = x*0x0101010101010101 = x+(x<<8)+(x<<16)…+(x<<56)
即x=[a+b+c+d+e+f+g+h|L56], L56指低56位
右移56位获得a+b+c+d+e+f+h的值,得解。

 

《Hamming Weight的算法分析》

1 //This is better when most bits in x are 0
2 //It uses 3 arithmetic operations and one comparison/branch per "1" bit in x.
3 int popcount_4(uint64 x) {
4     int count;
5     for (count=0; x; count++)
6         x &= x-1;
7     return count;
8 }

《Hamming Weight的算法分析》

上面这个算法在已知0的数目比较多时候很高效。
此算法基于这样一个事实:x-1使得以二进制表示的x,从低向高位开始包括第一个1在内的值,都由0变成1,由1变成0。如11-01 = 10, 10 – 01 = 01, 01 – 01 = 00, 100 – 001 = 011。而&操作使得发生变化的位置都变成0,这样就去除了1个1,从而有几个1就&几次,最终x必变成0.

下面算法消除了popcount4的循环

《Hamming Weight的算法分析》

 1 //This is better if most bits in x are 0.
 2 //It uses 2 arithmetic operations and one comparison/branch  per "1" bit in x.
 3 //It is the same as the previous function, but with the loop unrolled.
 4 #define f(y) if ((x &= x-1) == 0) return y;
 5 int popcount_5(uint64 x) {
 6     if (x == 0) return 0;
 7     f( 1) f( 2) f( 3) f( 4) f( 5) f( 6) f( 7) f( 8)
 8     f( 9) f(10) f(11) f(12) f(13) f(14) f(15) f(16)
 9     f(17) f(18) f(19) f(20) f(21) f(22) f(23) f(24)
10     f(25) f(26) f(27) f(28) f(29) f(30) f(31) f(32)
11     f(33) f(34) f(35) f(36) f(37) f(38) f(39) f(40)
12     f(41) f(42) f(43) f(44) f(45) f(46) f(47) f(48)
13     f(49) f(50) f(51) f(52) f(53) f(54) f(55) f(56)
14     f(57) f(58) f(59) f(60) f(61) f(62) f(63)
15     return 64;
16 }
17  
18 //Use this instead if most bits in x are 1 instead of 0
19 #define f(y) if ((x |= x+1) == hff) return 64-y;

《Hamming Weight的算法分析》

 

最有趣的是查表法,当有足够的内存时,我们可以用空间换时间,从而得到O(1)的最优算法。
以4bit的串为例,可以构造一个数组int counts[16]={0,1,1,2,1,2,2,3,1,2,2,3,2,3,3,4}.
对于4bit的x,x的hamming weight即为:counts[x].
对于32bit的串,也可以使用分成两部分查表的方法来节省一点内存:

1 static unsigned char wordbits[65536] = { bitcounts of ints between 0 and 65535 };
2 static int popcount(uint32 i)
3 {
4     return (wordbits[i&0xFFFF] + wordbits[i>>16]);
5 }
点赞