前言
最近在刷力扣题时,刷到了一道统计数字二进制位里面1的数量的题,用常规方法做出来后,一看评论区才知道原来java的Integer类自带统计数字二进制表示里面1数量的方法——Integer.bitCount (int i)
。但是Integer.bitCount (int i)
的源码长得非常古怪,出于兴趣,我对它的源码好好研究了一番,并且特地在此记录。
这是Integer.bitCount (int i)
的源码,后面会对这个源码进行详细解析:
public static int bitCount(int i) {
// HD, Figure 5-2
i = i - ((i >>> 1) & 0x55555555);
i = (i & 0x33333333) + ((i >>> 2) & 0x33333333);
i = (i + (i >>> 4)) & 0x0f0f0f0f;
i = i + (i >>> 8);
i = i + (i >>> 16);
return i & 0x3f;
}
预备知识
首先在解析源码之前,需要对位运算和补码的知识有一定的了解。如果你对位运算和补码已经非常了解,可以跳过这一节。
这里用到的位运算只有两个&
和>>>
。
位与运算&
位与运算是对两个操作数按位取与。即只有两个操作数的第n位均为1时,结果的第n位才为1;两个操作数有一个的第n位为零,则结果的第n位为零。
例如,3&5==1:
3: 0B 0000 0000 0000 0000 0000 0000 0000 0011
5: 0B 0000 0000 0000 0000 0000 0000 0000 0101
3&5: 0B 0000 0000 0000 0000 0000 0000 0000 0001
根据位与运算的特点,我们可以将一个数字的某些位 置零,例如:
将31的低4位 置零:
31: 0B 0000 0000 0000 0000 0000 0000 0001 1111
-16: 0B 1111 1111 11111 1111 1111 1111 1111 0000
&: 0B 0000 0000 0000 0000 0000 0000 0001 0000
无符号右移>>>
java中的移位运算符有三种左移(<<)、右移(>>)和无符号右移(>>>)。 左移就是将二进制数左移指定位数,高位丢弃、低位补零。
右移是将二进制数右移指定位数,低位丢弃,但是高位补数这里有一点不同。我们知道java中的数字都是以补码形式存储的,正数的补码是其本身,但是负数的补码是其对应正数的原码的每一位取反结果再加一,其中最高位是符号位,正数的符号为0,负数为1。
对一个数进行右移运算时,低位丢弃,高位补的是它的符号位的值,也就是说正数的高位补0、负数的高位补1。
根据上面的内容我们知道右移是与符号相关的,而无符号右移就是剔除了右移的符号相关性,无论是正数还是负数,执行无符号右移后,高位都补零
根据右移运算的特点,右移一位就相当于将原数除以2并且向下取整,15 >> 1 == 7
,-15 >> 1 == -8
。
无符号左移一位,如果是正数或者无符号数,则跟右移同效。对于负数,结果是其所对应正数除以2并且向下取整后的数字的补数,即n >>> 1 == nteger.MAX_VALUE - (-n >> 2),仅n为负数时成立!
,至于为什么这样,跟补码表示有关,这里就不展开讲了。
三者区别如下:
移位方向 | 补数 | |
---|---|---|
左移 | 向左 | 低位补零 |
右移 | 向右 | 高位补符号位的值 |
无符号右移 | 向右 | 高位补零 |
需要注意的是,位运算是直接对二进制位的操作,源码补码这些只是对一个二进制串解释方法的不同,不会改变这个二进制串本身。
补码
对于源码补码的知识,大家可参阅我的另一篇文章:原码、反码与补码
源码讲解
基本原理
我们知道一个二进制数的某一位的值只可能是0或者1,我们可以换一种理解方式,可以将零或者1理解为这个数字在这一位上1的数量,为零表示这一位没有1,为零表示这一位有一个1,基于这种理解,我们将这个二进制数每一位上的值(0或1)加起来,就是这个数的各个二进制位上1的数量,好好理解这句话后面有大用。
那么问题来了,如何将这个数每一位的值相加呢?
两位二进制
先考虑只有两位的情况,例如 n=0B11
。
首先请大家思考下对于两位十进制数n,我们是如何计算它的十分位和个分位的和的,是不是count = n/10 + n%10
。
那么对于两位二进制就可以写成count = n/2 + n%2
。除以2是为了将低位的值舍弃,再将高位的值提取出来放到低位,这一步可以用n >>> 1
替代,执行时低位自动被舍弃;取余2是为了消除高位的值,或者说将高位 置零,可以用n & 0B01
替代。那么就可以改写成count = (n >>> 1) + (n & 0B01)
四位二进制
现在考虑4位的情况。以n = 0B1011
为例。有了前面的基础,我们可以先将n两两分组,每组两位,n = 0B 10 11
,利用前面两位的计算方法,将每一组的高位和低位相加,再将这两组的值加起来就是最终结果。
上面是位与上0B01
,这里有两组,所以要位与上0B 01 01
,即n1 = n & 0B0101
,这样n1每一组的值就是n每一组低位里面1的数量。
前面是左移一位,因为低位的值会被自动丢弃。但是这里有两组,直接右移会将第一组低位的值放到第二组的高位,影响后续计算,所以执行>>1
将高位放到低位后要再将每一组的高位 置零,即n2 = (n >>> 1) & 0B0101
,这样n2每一组的值,就是n对应组的高位里面1的数量。
n3 = n1 + n2
,现在n3的每一个分组的值就是n的每一个分组里面1的数量。将两个分组的值相加,过程与前面相同,现将n3的低分组置零提取出高位分组,再右移两位,n4 = (n3 & 0B1100) >> 2
,的到高分组的值,只有两个分组,右移两位低分组的值被自动舍弃,所以简写成n4 = n3 >> 2
;将n3的高位 置零,n5 = n3 & 0B0011
,得到低位分组的值,n4 + n5
就是n的所有位里面1的数量。
大家可以在草稿本上比划一下这个过程,说起来很复杂,其实写起来挺简单的。
32位的int
现在我们就可以通过上面的方式,求任何位数为2的幂的二进制数里面1的数量,对于java里面32位的int也不例外,并且只需要经过 log 2 32 \log_{2}{32} log232=5次上述过程即可。可以看出上面的过程其实就是一种分治的思想。
但是有一点要注意,java里面32位的int是有符号的,最高位为符号位,这也是为什么一直用的是无符号位移>>>
而非 有符号位移>>
。对于补码源码,对这个问题没有影响,因为为源码补码只是这个二进制串的整体所表示值的不同,对它本身每一位的值没有影响。
源码详解
源码:
public static int bitCount(int i) {
// HD, Figure 5-2
i = i - ((i >>> 1) & 0x55555555);
i = (i & 0x33333333) + ((i >>> 2) & 0x33333333);
i = (i + (i >>> 4)) & 0x0f0f0f0f;
i = i + (i >>> 8);
i = i + (i >>> 16);
return i & 0x3f;
}
首先,
0x55555555 == 0B 0101 0101 0101 0101 0101 0101 0101 0101
0x33333333 == 0B 0011 0011 0011 0011 0011 0011 0011 0011
0x0f0f0f0f == 0B 0000 1111 0000 1111 0000 1111 0000 1111
按照之前的思路,第一步应该是将32的int两位一组,分成16个分组,将每个分组高位的值与低位的值相加得出该分组里面1的数量。
(i >>> 1) & 0x55555555
这句话的作用很明确,就是将每个分组高位的值提取出来,可是i = i - ((i >>> 1) & 0x55555555);
中间为什么是减号?
我们假设n
为某一个分组的值,n
有两个二进制位,假定两个二进制位的值分别为x,y
,则n = 2x + y
,那么n - x == x + y
,即高位与低位之和。
上面(i >>> 1) & 0x55555555
相当于是求出了x
,所以i = i - ((i >>> 1) & 0x55555555);
与 i = (i & 0x55555555) + ((i >>> 1) & 0x55555555);
等价。
第二句话就是常规操作了,i = (i & 0x33333333) + ((i >>> 2) & 0x33333333);
,将16个分组分成8组,每组的高位分组与低位分组值相加,就是每组(4位)里面1的数量。
第三句话又张的不一样了,i = (i + (i >>> 4)) & 0x0f0f0f0f;
。我们知道现在有8个分组,每个分组4位,8个分组两两一组后为4个组,每组八位最多有8个1,而低分组的4位可存储的最大值为15>8,不会溢出。所以可以先加再置零。
第四句话是一样的道理,i = i + (i >>> 8);
,4个分组分成两组,低分组的八位足以容纳32,所以不必在将第一组置零。
第五句话跟第四句话一样,不再过多解释。
最后一句话,取低6位,五位最大31,六位最大63。
总结
可以看出jdk源码对上面的算法做了不少细节上的优化,这需要对位运算有极高的理解,将每一步都优化到极致,短短的六句话就蕴含了这么多细节。相信将这个源码完全看懂后,你会对位运算和二进制有更加深刻的理解。