要求:对于一个字节(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
那么现在 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’的个数
就得到最后的结果了,结果为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; }