要求:对于一个字节(8bit)的无符号×××变量,求其二进制表示中“1”的个数,要求算法的执行效率尽可能高。


书中由浅入深地给出了几种解法。

解法1:

我们最容易想到的做法可能会是一个一个数的遍历,因为对于二进制数的操作,除以一个2,原来的数字会减少一个0,若除的过程中有余,表示当前位置有一个1。


例:10100010

第一次除以2时,商为1010001,余0。

第二次除以2时,商为101000,余1。

因此,可以考虑通过不断相除和判断余数的值的方法来分析二进制数中1的个数。


代码:



int Count(Byte v)
{
   int num = 0 ;
   while(v) 
  {
    if (v%2 == 1) //余1,当前位置有个1,num+1
    num ++;
  }
   v = v/2;
   return num;
 }



解法2:

用效率比‘除余’操作高的位移操作来解决问题,在向右位移的过程中判断最后一位是否为1,“与”操作可以达到目的。

例如有一个八位二进制数:10100001,可以将它与00000001进行与操作,若结果为1则表示最后一位为1,反之为0。


例:

10100001 & 0x01 = 1

1010000 & 0x01 = 0 #右移

.......

1 & 0x01 = 1


代码:

int Count(Byte v)
{
   int num = 0 ;
   while(v) 
  {
    num += v & 0x01;
    v >>= 1;
  }
   return num;
 }

同样用到一个循环,只是里面的操作用位移操作简化了。



解法3:


位操作比除、余操作效率高了很多,但是时间复杂度仍为O(log2v),

能不能再降低一些复杂度到只与“1”的个数有关?

先考虑只有一个1的情况。

将问题转化为判断一个数是否是2的整数次幂。


例如:01000000这个数字是2的整数次幂,只操作一次,得到的结果为1或者0。

解决方法如下:判断 (n &= n-1) == 0 ? (n > 0)

01000000&(01000000-00000001)= 01000000&00111111 = 0

所以是2的整数次幂。


为什么n &= (n – 1)能清除最右边的1呢?因为从二进制的角度讲,n相当于在n - 1的最低位加上1。


举个例子,

8(1000)= 7(0111)+ 1(0001),所以8 & 7 = (1000)&(0111)= 0(0000),清除了8最右边的1(其实就是最高位的1,因为8的二进制中只有一个1)。


再比如7(0111)= 6(0110)+ 1(0001),所以7 & 6 = (0111)&(0110)= 6(0110),清除了7的二进制表示中最右边的1(也就是最低位的1)。


于是这个问题可以这样解决:


int Count(BYTE V)
{
  int num = 0;
  while(v)
{   v &= (v-1);
  num ++;
}
  return num;
}

算法把时间复杂度降到了O(n),n为二进制中‘1’的个数,应该算是很不错了。


用到一个巧妙的与操作,v & (v -1 )每次能消去二进制表示中最后一位1,利用这个技巧可以减少一定的循环次数。


解法4:

用查表法来做,就是用空间换时间,在需要频繁使用这个算法的应用中,通过空间换时间是一个常用的方法。因为数据只有8bit,直接建一张表,包含各个数中1的个数,然后查表就行。复杂度O(1)。


int countTable[256] =
{ 0,1,1....    #所有八进制数中1的个数
};int Count(Byte v)
{
return CountTable[v];
}

这是典型的空间换时间的算法,把0~255中“1”的个数直接存储在数组中,v作为数组的下表,countTable[v]就是v中“1”的个数。算法的时间复杂度仅为O(1)。


查表法看上去很美,但是使用条件十分有限,至于数据规模变大,对于16位或者更多位的数来说,查表法基本是不可能的。而且看似一次地址计算就能解决,但实际上这里用到一个访存操作,而且第一次访存的时候很有可能那个数组不在cache里,这样一个cache miss导致的后果可能就是耗去几十甚至上百个cycle(因为要访问内存)。所以对于这种“小操作”,这个算法的性能其实是很差的。


解法5:


书外的一个解法,比较巧妙,其做法是采用类似归并的思想。对于相邻的两位,先计算这两位的1的个数(最大是2),比如对于32位的数来说,分成 16组,每组计算的是相邻两位的1的个数和,并且将这个和用新得到的数的两位表示(2位可以最大表示4,所以可以存得下这个和,和此时最大为2);然后对相邻四位进行操作,计算每两位两位的和(这样操作后其实是计算了原来32位数的相邻四位的1的个数);这样依次类推,对于32位的数来说,只要操作到将其相邻16位的1的个数相加就可以得到其包含的1的个数了。


举个例子:整数212的二进制数    1101 0100

求二进制数中1的个数_算法

那么现在 10,01,01,00分别代表了1101,0100中相邻两位中‘1’的个数。而且由于是计算的相邻两位,所以‘1’的个数最多为2,所以用两位足够表示。


下一步需要继续处理第一步得到的结果1001,0100,对于1001,0100,


第8位,第7位联合起来,即10,表示了原二进制数(1101,0100)中第8位与第7位所含‘1’的个数。

第6位,第5位联合起来,即01,表示了原二进制数(1101,0100)中第6位与第5位所含‘1’的个数。

.....

以此类推

现在我们就需要把新得到的二进制串1001,0100的

8、7联合位与6、5联合位相加,即把10和01相加,代表原第8,7,6,5位的‘1’的个数

(因为8、7联合起来才表示原第8位和原第7位一共有多少个‘1’,这里不能再次分开计算他们了,应该把他们当成一个整体。同理6、5位,也当成一个整体来看待)

4、3联合位与2、1联合位相加,即把01和01相加,代表原第4,3,2,1位的‘1’的个数



求二进制数中1的个数_算法_02


就得到最后的结果了,结果为4。



好了,现在剩下的问题就是怎么样让相邻的位相加,怎么样让相邻两位的相加,怎么样让相邻四位的相加。

拿相邻两位的相加举例,

(x & 0x55) + (x >> 1  & 0x55)

因为0x55展开成二进制就是0101,0101,观察1的位置,奇数位为1,偶数位为0,这样和x进行与操作的结果就是保留了x的奇数位。

然后x右移一位以后,再与0x55进行与操作,对x>>1来说是保留了奇数位,但相对于原来的x则是保留了偶数位。

最后将两式相加,就得到了x的二进制中,相邻两位的‘1’的个数。

同理:

因为0x33展开成二进制是0011,0011,所以相邻二位的相加则为

(x & 0x33) + (x >> 2 &  0x33)

因为0x0f展开成二进制是0000,1111,所以相邻四位的相加则为

(x & 0x0f) + (x >> 4 &  0x0f)


算法代码如下:




unsigned int CountX (unsigned int x)
{
    x = (x & 0x55) + (x >> 1 & 0x55);
    x = (x & 0x33) + (x >> 2 & 0x33);
    x = (x & 0x0f) + (x >> 4 & 0x0f);
    return x;
}