【位操作】比特位计数(bit counting)之三【分治法】【2bit分组】右移位法【Integer类bitCount()方法】【彻底打通任督二脉】【位运算】

在Java的核心类库中,Integer类有一个专门用于比特位计数的bitCount()方法,用于统计二进制整数中 bit 位为 1 的位数。其代码非常经典,我们今天将对其进行详细剖析,请看源代码:

	/***分治法的右移位法【2bit】分组版本二***/
/***Integer类bitCount()方法***/
	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;
}   /***分治法的右移位法【2bit】分组版本二***/

在上一篇“【2bit分组】右移位法”博客中
【位操作】比特位计数(bit counting)之二【分治法】【2bit分组】右移位法【彻底打通任督二脉】【位运算】
我们已经介绍了分治法,用分治法解决问题有三个步骤:
(1)问题可分解:问题可以分解为一组规模更小的相同或类似的子问题。
(2)子问题求解:子问题容易求解;求解子问题。
(3)问题解的合并(收集):对子问题解进行合并和收集,最后得到问题的解。

Integer类的 bitCount()这个方法,与上一篇博客中介绍的一样,采用的都是分治算法。bitCount()方法,实际上也是对上篇博客介绍的“【2bit分组】右移位法”版本的优化。

我们选一个最相似的版本作为“版本一”来进行分析对比,请看版本一的源码:

	/***分治法的右移位法【2bit】分组,版本一***/
	public static int bitCountShiftRB (int i)
	{
	    i = (i & 0x55555555) + ((i >>> 1) & 0x55555555); //分解16组2bit
	    i = (i & 0x33333333) + ((i >>> 2) & 0x33333333); //每4bit合并为2bit
	    i = (i & 0x0F0F0F0F) + ((i >>> 4) & 0x0F0F0F0F); //每8bit合并为4bit
	    
	i = (i + (i >>> 8)) & 0x00FF00FF; //把每16bit合并为8bit
    i = (i + (i >>> 16)) & 0x0000FFFF; //把每32bit合并为16bit(实际用到6bit)
	    return i;
}   //分治法的右移位法【2bit】分组,版本一。

这个版本一是最中规中矩的写法,可实现完全相同的功能,但效率有所降低。

对二个版本的代码可分成前后二部分来进行代码对比,我们可以发现:
(一)前面一部分各三行代码,实际上只有第一行不同;第二行完全相同;第三行使用分配律分配后也完全相同。我们只要分析第一行代码功能是否相同即可。
版本一算式:i = (i & 0x55555555) + ((i >>> 1) & 0x55555555);
版本二算式:i = i - ((i >>> 1) & 0x55555555);

这里0x55555555 的二进制编码表示为 0101 0101 0101 0101 0101 0101 0101 0101,一共是16组长度为2bit的二进制编码0B01。这实际上是一种特殊的掩码。

一个比特位只能有二种取值:0或1。
2bit的二进制编码共有四种情形:00,01,10,11
分别用这二种版本的算法,分别对四种情形进行推演分析。先看【版本一】的演算的解。
在这里插入图片描述
再来看【版本二】的演算结果。
在这里插入图片描述
从以上推演分析可知,对于四种不同情形,两种版本的演算结果完全相同,是等价的。
通过目测,我们就能发现:经过优化的版本二代码 i = i - ((i >>> 1) & 0x55555555);
比版本一代码 i = (i & 0x55555555) + ((i >>> 1) & 0x55555555);
能减少一次移位操作。可提高效率,同时节省一些算力。

前面讨论的第一行代码,实现的是问题分治和子问题求解功能。上文已经验证了两种版本的算法完全等价。因为优化算法没有简明易懂的算法原理支撑,所以我们借助版本一的算法来分析。因此,为了方便读者阅读和理解,我把上篇博客的问题分治和子问题求解部分剖析内容复制过来。

我们用一个实际例子来演示算法:输入参数 i=2052399602
正整数i=2052399602 用二进制编码表示:int i = 0B01111010010101010010000111110010;
详细的演算分析如下图所示:
在这里插入图片描述
我们可以考测每一组的解,如i的最左边的4组(8位二进制码)是:“0111 1010”其子问题的解分别为01 10 01 01,各自转换为整数为1、2、1、1,正好是比特位为1的位数。
表格列出了16组子问题的解,S1表示第1组解,S2是第2组解,共16组。
在这里插入图片描述
第二行、第三行代码,两个版本完全相同。这二行代码和后面部分代码,实现的功能都是“问题解的合并”。为方便读者阅读和理解,我也直接从上篇博客中复制分析内容如下:
解的合并(前二步骤)
对于版本二,解的合并共有5行代码,这里是其中的2行,也就分二个步骤:
i = (i & 0x33333333) + ((i >> 2) & 0x33333333); //每4bit合并为2bit
i = (i & 0x0F0F0F0F) + ((i >> 4) & 0x0F0F0F0F); //每8bit合并为4bit

这2行代码,其处理方法类似,只是粒度不同。

步骤一:i = (i & 0x33333333) + ((i >> 2) & 0x33333333);
i的初始值:0110 0101 0101 0101 0001 0001 1010 0001
0x33333333的二进制值:0011 0011 0011 0011 0011 0011 0011 0011
0x33333333可看作8组0x3(0011)掩码。每组掩码处理2组2bit数据,处理的数据粒度为2bit。
下图是把奇数组的解给掩掉,从而得到“结果①”。下图是掩码的示意
在这里插入图片描述

然后i>>2先移位,再把偶数组解给掩掉,得到“结果②”。最后2个结果进行合并(相加)。
在这里插入图片描述

“解的合并”步骤一的具体分析演示如下图所示:
在这里插入图片描述

上图,请考察一下合并前i的左边4个bit位(2组):0110;合并后则为:0011。
这2组解分别为01和10相当于整数值为1和2;合并后为0011,其值刚好是1+2=3。

步骤二:i = (i & 0x0F0F0F0F) + ((i >> 4) & 0x0F0F0F0F);
现在,i的初始值为: 0011 0010 0010 0010 0001 0001 0100 0001
0x0F0F0F0F的二进制值: 0000 1111 0000 1111 0000 1111 0000 1111。
我们这儿以这行中规中矩的写法(拆分后的形式)来演示。
Integer类的 bitCount()方法中这行是优化写法:i = (i + (i >>> 4)) & 0x0f0f0f0f;
乍一看,好像这两种写法完全等价啊,只是用了一个分配率而已。实则不然,且听我来分解。
说明:
实际上,这样的分配律对于“按位与&”操作符是不等价的,不正确的。读者可自行分析验证。
那Java核心库函数这个优化写法难道错了吗?
之所以可以把掩码作为公共因子提取出来,是有前提条件的,即两组粒度为4bit的解合并后不会溢出(合并的解还是可用4bit来表示)。而比特位计数到这一步的时候,刚好满足这个前提条件。因此,核心库函数这个优化也没错。而且提取公共因子后,优点是可以减少一次按位与(&030707070707)操作,可提高效率,节省算力。

请读者自行参照上一步骤进行分析(算法完全相同,只是处理的数据粒度从2bit增加到4bit)。

步骤二完成时,i的值为:0000 0101 0000 0100 0000 0010 0000 0101
至此,版本二,前面部分的代码分析完毕。这个i的值就是后面演算中“i的初值”。

(二)后面一部分代码略有差异,但实现的功能是完全相同的。下面进行分析比对。

版本一的代码:
i = (i + (i >>> 8)) & 0x00FF00FF; //把每16bit合并为8bit
i = (i + (i >>> 16)) & 0x0000FFFF; //把每32bit合并为16bit(实际用到6bit)
这是中规中矩的写法,与版本二中最后三行代码比较相近,其实现功能相同。

版本二的代码,核心类库中Integer类的bitCount()方法中的写法:
i = i + (i >>> 8);
i = i + (i >>> 16);
return i&0x3f;

下面我们先来分析“版本一”的代码的算法演算过程:
先看i = (i + (i >>> 8)) & 0x00FF00FF; 这行我们用分配律分解为二部分:
i & 0x00FF00FF和 (i >>> 8) & 0x00FF00FF二部分。0x00FF00FF是一种掩码,下图中灰底色部分的二进制编码被掩码处理后都变成“0000 0000”。请看下图演算:
在这里插入图片描述

上图,(i >>> 8)时先“无符号右移”,左边补8个0,图中黑色圆角矩形中部分。

以下是两者相加,有效信息就是下图中矩形中的部分。
在这里插入图片描述

接下来分析:i = (i + (i>>16))&0x0000FFFF; 同样可把代码拆分为两部分:
i&0x0000FFFF和(i>>16)&0x0000FFFF两部分,请看下图演算。

下图“i&0x0000FFFF”中灰底色部分的二进制编码被掩码处理后,都变成“0000 0000 0000 0000”。
在这里插入图片描述

上图,(i >>> 16)时先“无符号右移”,左边补16个0,图中横向黑色矩形中。纵向的黑色矩形框出了两个结果数据编码的有效部分。下面是把两个结果相加。
在这里插入图片描述

最后得到问题的解,尽管是一个32bit的整型数,实际上有效部分只有最右边的6bit。如黑色矩形所示。这个解直接可用,无须矫正。
这个解因为6bit可表示64以内的整数,而一个整型数的bit位计数值最大值是32。这也是下面版本二中代码优化的基础。

下面才进入本博客后面部分的主题。上面介绍的是中规中矩的代码演算过程。目的是为了与下面版本代码的处理过程作对比。
版本二的代码,也就是Integer类的bitCount()方法中的写法:
i = i + (i >>> 8);
i = i + (i >>> 16);
return i&0x3f;

我们先来分析 i = i + (i >>> 8); 下面也用同一个数据进行演算推演:
在这里插入图片描述
上图,由于没有使用0x00FF00FF掩码进行&(按位与),所以图中的浅灰色矩形中的编码数据是有瑕疵的。下图才是无瑕疵的正确编码数据。但是我们可以在后面用掩码去除有瑕疵的脏数据以矫正最后的结果。

在这里插入图片描述
i = i + (i >>> 16);的演算推演如下图:
在这里插入图片描述
同样道理,i = i + (i >>> 16);由于没有使用0x0000FFFF掩码进行&(按位与),所以图中的浅灰色矩形中的编码数据也都是有瑕疵的。最后的“i的值”也是有瑕疵的,被灰色矩形圈定的是脏数据。但是,好在经过上述的演算,已经把有效的正确数据集中到了二进制编码的最低8bit中,因此只须过滤掉有瑕疵的脏数据就行了。前面已提及实际真正有效的数据只需最低6bit。代码“0x3f”是一个特殊的掩码,“0x3f”二进制编码是“0B111111”。
最后一行代码:return i&0x3f; 的作用就是完成过滤有瑕疵的脏数据,提取二进制编码中最右边的6bit有效数据的。最后return返回的就是问题的解。

Java核心库中Integer类的bitCount()方法,因为用度广泛,经过这样优化,减少了好几次位处理运算,既提高了性能,又节省了算力。但是经过这种优化,导致代码晦涩难懂。
因此,一般的程序员开发程序时不建议进行如此优化,这反而容易导致BUG的产生。

参考文献:
Integer中bitCount方法的源码解析

  • 15
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值