逐位法
int bit_count_by_bit(unsigned int n){ int ans = 0; while(n){ ans += n & 1; n >>= 1; } return ans; }
这个算法需要注意的就是Java中无符号右移运算符的问题,如果使用的是右移运算符,处理负数时,会出现死循环。虽然C或者C++没有无符号右移这个操作符,但它们可以直接使用unsigned int实现此操作。我们在单元测试的时候一定注意正负数都要测试到,否则很容易出现问题。
查表法
在逐位法中,我们是逐位进行计算,效率不是很高,如果我们能够一次计算多位中1的数量,那效率将成倍提升,如何实现呢?抛开二进制转换为十进制时利用了1这个信息,一般而言二进制的值和1的数量并没有直接关系,因此难点就在于如何建立二进制值和1的数量之间的关系。在数学中,想要建立两个不相干变量a和b的关系,一般我们都会找一个和两者都有关系的变量c,来间接确定a和b的关系。首先二进制的值和十进制值有关系,但是1的数量好像找不到它和其他的关系,因此该问题转换为十进制值和1的数量关系,即十进制值如何映射1的数量。在我们学过的数据结构中,涉及到映射的有Map(键值映射)和Array(下标和值映射)。具体选哪一个呢,其实针对这个问题,两者都可以,但是数组查询效率以及空间利用率都大于Map,因此我们选择使用数组来映射十进制值和1数量的关系,即二进制值和1数量的关系,其中二进制的值作为数组下标,1的数量作为数组中的值,这种处理方式也叫查表法。
如果大家对Integer源码比较熟悉的话,里面就有一些查表法的操作,如DigitOnes和DigitTens。查表法是用空间换时间的典型代表,数组开得太大会占用内存空间,开的太小对效率的提升又太小,因此需要根据程序的实际需求来制定合适的方案。为了兼顾空间和效率,我选择开辟长度为256的数组来存储8位二进制中1的数量值。Talk is cheap,代码如下:
const int table[] = { 0,1,1,2,1,2,2,3,1,2,2,3,2,3,3,4,1,2,2,3,2,3,3,4,2,3,3,4,3,4,4,5, 1,2,2,3,2,3,3,4,2,3,3,4,3,4,4,5,2,3,3,4,3,4,4,5,3,4,4,5,4,5,5,6, 1,2,2,3,2,3,3,4,2,3,3,4,3,4,4,5,2,3,3,4,3,4,4,5,3,4,4,5,4,5,5,6, 2,3,3,4,3,4,4,5,3,4,4,5,4,5,5,6,3,4,4,5,4,5,5,6,4,5,5,6,5,6,6,7, 1,2,2,3,2,3,3,4,2,3,3,4,3,4,4,5,2,3,3,4,3,4,4,5,3,4,4,5,4,5,5,6, 2,3,3,4,3,4,4,5,3,4,4,5,4,5,5,6,3,4,4,5,4,5,5,6,4,5,5,6,5,6,6,7, 2,3,3,4,3,4,4,5,3,4,4,5,4,5,5,6,3,4,4,5,4,5,5,6,4,5,5,6,5,6,6,7, 3,4,4,5,4,5,5,6,4,5,5,6,5,6,6,7,4,5,5,6,5,6,6,7,5,6,6,7,6,7,7,8 }; int bit_count_by_table(unsigned int n){ int ans = 0; for (int i = 0; i < 32; i += 8){ ans += table[n >> i & 0xFF]; } return ans; }
虽然这方法看起来笨笨的,但如果使用得当,就能极大提升程序运行效率。
Brian Kernighan法
在逐位法中,我们是逐位判断1,比如二进制为0b1000,就需要判断三次0一次1,如何一步到位直接判断第四位的1呢?我们知道 ,这个1刚好就是可以当做n+1位中1的数量,再利用x & (x - 1)将n位及以下的位全置0,这样我们每次的操作都能获取含1的位,提升了逐位法的效率。具体代码如下:
int bit_count_by_brain_kernighan(unsigned int n){ int ans = 0; while(n){ ++ans; n = n & (n-1); } return ans; }
如果n & (n - 1) = 0,则说明此时的n值并无含1的位,因此我们也可以用n & (n - 1) == 0来判断一个数是否是2的幂次方。
分治法
前面的三种方法虽然看起来不同,但都是逐位法的变种,其思想都是逐个计算。对于归并排序肯定大家都不陌生,它就是将待排数组分为多块,然后排序多块,最后进行合并操作。那么对于32位的二进制,我们能不能也把它分成多块,然后计算每一块他们所含1的数量,最后将多块的1数量累加起来,进而得到二进制数中1的个数呢?要实现这种操作,就有二个问题需要解决,一是如何计算每一块中所含1的数量,二是如何合并已被计算的块。我在Brian Kernighan法提到过这个式子,如果我们要计算第n+1位二进制值的十进制值,其结果为,其中a就是第n位二进制值,即0或1。对于0b1101,我们记其四位的二进制值分别为a、b、c、d。
0b1101中第四位的二进制值:
0b1101中第三位的二进制值:
0b1101中第二位的二进制值:
0b1101中第一位的二进制值:
转换为位操作,即:a + b + c + d = 0b1101 - ((0b1101 >>> 1) & 0b0111)
- ((0b1101 >>> 2) & 0b0011)
- ((0b1101 >>> 3) & 0b0001)。
其中红体数字是掩码,避免高数位对结果产生影响。掩码的选取必须和块数量对应,如果数据是32位,块长度为4,则掩码0b0111也必须变为扩大八倍,即0x77777777。
有了上面的理论基础,则对于二位的情况,a + b = x - ((x >>> 1) & 0b01)
对于三位的情况,a + b + c = x - ((x >>> 1) & 0b011) - ((x >>> 2) & 0b001)
以此类推,我们可以仅仅通过减、移位和掩码操作,就能计算任意位数块中所含1的数量。这也就解决了第一个问题。
前面我们已经计算了指定位数块中1的数量了,如何累加相邻两位数块,使其合并成两倍的位数块呢?其实只需要右移相应位数,再相加,利用两倍的位数掩码消除脏数据即可。比如对于二位数块0b1001,(0b1001 + (0b1001 >>> 2) ) & 0b0011 = 0b0011,其结果就是两位数块累加的结果。此时,第二个问题也被解决了。
综合前面所述,我们完全可以使用分治法来计算二进制数中1的个数,其中还包含一些优化操作,也将在下面的具体算法中得以说明。
Hamming Weight法
该方法的初始位数块长度为2,然后分治为4位数块,接着8位数块,以此类推,最终扩大到32位数块,其结果就是1的数量。如果大家完全理解我前面的分治法原理,其实可以直接写出相应代码。在学习该算法时,我还是看看了关于它的维基百科,放个传送门:Hamming weight,解释还是比较详细的。这些东西说起来比较抽象,还是代码中理解吧。
int bit_count_by_hamming_weight(unsigned int n){ n = (n & 0x55555555) + ((n >> 1) & 0x55555555); n = (n & 0x33333333) + ((n >> 2) & 0x33333333); n = (n & 0x0F0F0F0F) + ((n >> 4) & 0x0F0F0F0F); n = (n & 0x00FF00FF) + ((n >> 8) & 0x00FF00FF); n = (n & 0x0000FFFF) + ((n >> 16) & 0x0000FFFF); return n; } 0x55555555 = 01010101010101010101010101010101 0x33333333 = 00110011001100110011001100110011 0x0F0F0F0F = 00001111000011110000111100001111 0x00FF00FF = 00000000111111110000000011111111 0x0000FFFF = 00000000000000001111111111111111
看到这些数值的二进制值,就比较清晰了,因为它初始位数块长度为2,所以第一步就是解决a + b的问题,这也是第一行代码的作用,第二行代码分治为4位,第三行代码分治为8位,一直分治到32位即可。理解了分治法思想,上面的代码看起来是非常之简单的。
Hamming Weight优化法
Hamming Weight法的第一步操作,虽然是实现a + b,但我在介绍分治法时是使用的减法,实际操作能节约一次与操作,其实后续还有很多地方需要优化,这里我们先给出优化后的代码,再详细说明优化的思路。
int bit_count_by_hamming_weight_new_one(unsigned int n){ n = n - ((n >> 1) & 0x55555555); n = (n & 0x33333333) + ((n >> 2) & 0x33333333); n = (n + (n >> 4)) & 0x0F0F0F0F; n = n + (n >> 8); n = n + (n >> 16); return n & 0x3F; }
第一步优化(少了一次与操作)我们在前面已经解释了,此处就不在赘言。第二步没变化,因为现在的n = 8a + 4b + 2c + d,单纯的减操作不能把n转换为a + b + c + d,因此,这里就照旧。第三步,直接把两者相加,然后掩码操作,我们知道四位里面最多有4个1,记作0100,就算两个块里面都是4个1,加起来也就是1000,结果没有溢出块限制,所以就可以直接加,但前面两步相加后的结果超出了块范围,比如11 + 11 = 110,就超过了二位的限制范围,所以必须分开加,再将结果放到分治后的块里面,这样又少了一次与操作。第四步,结果没有进行掩码处理,我们知道掩码处理是防止块里面有“脏”数据,因为32位整型数据最多就32个1,即最大值为00100000,所以绝对要保证后六位的绝对正确。打个比方,相邻的四个位桶里面分别装着0100和0010的数据,相加以后为0110,此时两个桶共同的位数为00100110,如果此时不用掩码0x0F0F0F0F处理,那么八个位桶的数值就是00100110= 38,这个数据弄脏了第六位,因此需要用掩码剔除它,使计算结果为0110= 6。第四步是八位数块,其最大值就是00001000 = 8,就算两个一样数值的块相加,其结果是00001000 00010000,分治后的16位数块也能保证最后六位数据的绝对正确,所以就无需利用掩码剔除脏数据,节约了两次与操作。第五步也如第四步一般,又节约了两次与操作。最后再用六位掩码获取最后六位的数值即可。因此该算法相比上面的算法,少了5次与操作。Integer的bitCount方法就是采用的该算法。
Hamming Weight乘法优化法
小学我们都学过乘法,也知道乘法交换律,即c * (a + b) = a * c + b * c,这里面的加号就隐含了能计算c各位之和的可能性,比如12 * (10 + 1) = 132,其中第二位的3就是12的个位和十位之和;121 * (100 + 10 + 1) = 13431,其中第三位的3就是121的个位、十位和百位之和,此时我们大胆推测,假设一个数字x有n位,则x * (11111...)的结果的第n位等于数字x所有位数值的累加和,但是这个推测有一些不足,比如198 * 111 = 21978,第三位的9 != 1 + 9 + 8,这是因为1 + 9 + 8 = 18 > 9,需要进位,且1 + 9 = 10 > 9也需要进位,它的进位值1也加到位数累加值上,这两种情况都会“污染”累加值,因此上面的推测若想合理,则还需要加一个限制条件:所有位数值的累加和不能大于9,即不能出现进位情况。
既然十进制乘法能通过乘法来累加所有位数值,那么我们也能将其推广到二进制。首先保证所有位数累加值不能出现进位,32位的累加值最大为32 = 0b100000,因此我们必须用大于或等于6位的的位数块来保存所有位的累加值,为了计算的方便,位数块的大小M最好是2的幂次方,这样能保证计算的累加值在32位的最左边,然后通过无符号右移(32 - M)位就可以直接获取累加值。假设我们使用八位的位数块来计算所有位的累加值,则32位数据可以分为4个位数块,四个位数块的十六进制的值分别记作0x0a、0x0b、0x0c和0x0d,则相应的乘法操作如下所示:
红色部分是溢出的数据,蓝色部分相加正是四个位数块的累加值,其值通过无符号右移32 - 8 = 24位即可得到。该算法的详细思路也可以参考维基百科的Hamming weight。具体代码如下:
int bit_count_by_hamming_weight_new_two(unsigned int n){ n = n - ((n >> 1) & 0x55555555); n = (n & 0x33333333) + ((n >> 2) & 0x33333333); n = (n + (n >> 4)) & 0x0F0F0F0F; return (n * 0x01010101) >> 24; }
方法的优化需要CPU中基本运算部件乘法器的给力。如果CPU执行乘法操作指令比较慢的话,这样优化可能会适得其反。但一般来说不会这样,比如说AMD在两个时钟周期里就可以完成乘法运算。至于Java的bitCount为什么没有选这种,可能也有这方面的考虑吧。
MIT HAKMEM 169算法
在前面累加所有位数值时,我们采用了直接累加法和利用乘法交换律的性质来实现累加,那么还有没有其他方法来实现数值累加呢?定理:任意n进制的数值x对(n - 1)求余都等于x各位数值之和对(n - 1)求余。将假设转换为数学模型,其核心就是证明,a、b、m、p均为正整数,这里很容易证明,使用等价代换后,r = m - 1,就很容易做了。
32位数值中各位值之和最大为32,因此,我们可以选择大于等于64(64是大于32的最小2的幂次方)的2的幂次方(选择2的幂次方,是为了与Hamming Weight法对应。)作为进制的权。根据前面的结论来实现计算二进制数中1的个数的操作。假设选取64位作为进制的权,则我们只需分治到6位数块,就可对(64 - 1) = 63求余来计算二进制数中1的个数。代码如下:
C++代码:
int bit_count_by_mit_hakmem(unsigned int n){ n = n - (((n >> 1) & 0xDB6DB6DB) + ((n >> 2) & 0x49249249)); n = (n + (n >> 3)) & 0xC71C71C7; return n % 63; } 0xDB6DB6DB = 11011011011011011011011011011011 0x49249249 = 01001001001001001001001001001001 0xC71C71C7 = 11000111000111000111000111000111
Java代码:
public static int bit_count_by_mit_hakmem_for_java(int n){ n = n - (((n >>> 1) & 0xDB6DB6DB) + ((n >>> 2) & 0x49249249)); n = (n + (n >>> 3)) & 0xC71C71C7; return n < 0 ? ((n >>> 30) + ((n << 2) >>> 2) % 63) : n % 63; } 0xDB6DB6DB = 11011011011011011011011011011011 0x49249249 = 01001001001001001001001001001001 0xC71C71C7 = 11000111000111000111000111000111
第一行代码就是分治法中a + b + c的操作,第二行就是把三位数块分治成六位数块,因为负数求余也等于负数,且32 % 6 = 2,因此对于最终结果为负数的情况,先将前两位中1的数量直接通过移位计算出来,再把后面的30位中1的数量通过求余计算出来,两者相加即可得32位负数中1的数量。该算法也就是传说中的MIT HAKMEM 169算法。
江峰求一算法
既然学透了MIT HAKMEM 169算法,那么我也来按照它的思路写个自己的版本,就叫做江峰求一算法,也证明了MIT HAKMEM 169算法并不是该思路的唯一算法。这里我选取256作为进制权数,分治到16位数块,再对255求余。具体代码如下:
int bit_count_by_jf(unsigned int n){ n = n - ((n >> 1) & 0x55555555); n = (n & 0x33333333) + ((n >> 2) & 0x33333333); n = (n + (n >> 4)) & 0x0F0F0F0F; n = (n + (n >> 8)) & 0x00FF00FF; return n % 255; }
这上面的几个十六进制值在原始Hamming Weight法里已经介绍过了,这里就不赘述了。前面四步来自于Hamming Weight优化法,主要是将位数块分治到16位数块。因为32 % 16 = 0,且数量32只保存在后六位中,所以不会出现负数情况,因此可以直接对(256 - 1) = 255求余,进而得到二进制数中1的个数。
效率测试
double estimate_single(int (*f)(unsigned int)){ double start = clock(); for(int i = -1000000; i <= 1000000; ++i){ (*f)(i); } double end = clock(); return (end - start) * 1000. / CLOCKS_PER_SEC; } double estimate(int (*f)(unsigned int)){ return (estimate_single(f) + estimate_single(f) + estimate_single(f)) / 3.; } int main(){ printf("bit_count_by_bit: %fms\n", estimate(bit_count_by_bit)); printf("bit_count_by_table: %fms\n", estimate(bit_count_by_table)); printf("bit_count_by_brain_kernighan: %fms\n", estimate(bit_count_by_brain_kernighan)); printf("bit_count_by_hamming_weight: %fms\n", estimate(bit_count_by_hamming_weight)); printf("bit_count_by_hamming_weight_new_one: %fms\n", estimate(bit_count_by_hamming_weight_new_one)); printf("bit_count_by_hamming_weight_new_two: %fms\n", estimate(bit_count_by_hamming_weight_new_two)); printf("bit_count_by_mit_hakmem: %fms\n", estimate(bit_count_by_mit_hakmem)); printf("bit_count_by_jf: %fms\n", estimate(bit_count_by_jf)); return 0; } bit_count_by_bit: 86.084667ms bit_count_by_table: 13.616333ms bit_count_by_brain_kernighan: 50.191000ms bit_count_by_hamming_weight: 9.947000ms bit_count_by_hamming_weight_new_one: 9.413667ms bit_count_by_hamming_weight_new_two: 7.019000ms bit_count_by_mit_hakmem: 8.102000ms bit_count_by_jf: 10.573333ms
转载于:https://blog.csdn.net/haiyoushui123456/article/details/83997517