分治算法
分治算法,字面意思就是“分而治之”,就是把一个复杂的问题分解成两个或多个相同或相似的子问题,再把子问题分成更小的子问题,……,直到最后子问题可以直接求解,原问题的解就是所有子问题解的合并。
在计算机科学中,分治法就是运用分治思想的一种很重要的算法。分治法是很多高效算法的基础,如排序算法(快速排序,归并排序),傅立叶变换(快速傅立叶变换)等等。
分治法适用场景和应用分治法解决的问题一般步骤:
(1)问题可分解:问题可以分解为一组规模更小的相同或类似的子问题,子问题再分解为更小的子问题。例如,汉诺塔(Tower of Hanoi)问题。
(2)子问题求解:子问题容易求解,对子问题求解。
(3)问题解的合并(收集):对子问题解进行合并和收集,最后得到问题的解。
分治法的关键是问题解的合并。解的合并,没有统一的模式,具体问题要具体分析,以获得较好的合并算法。
比特位计数基于分治原理的右移位算法版本
基于分治原理的右移位算法的比特位计数版本,其中用到的位运算技巧非常精妙,令人叹为观止。
比特位计数基于分治法的右移位法版本的源代码,如下所示:
/***比特位计数基于分治法的右移位法版本***/
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 * (0x01010101)) >> 24; //解的合并和收集
return i; //返回语句
}
基于分治原理的右移位算法版本剖析:
该算法是基于分治法的右移位算法版本。要用到右移位算法的知识点,我们先来复习一下,上篇博客介绍的右移位算法版本,
【位操作】比特位计数(bit counting)之一【位运算】【汉明重量】【汉明距离】
其代码如下所示:
/***比特位计数的右移位法版本***/
public static int bitCountShiftR (int n)
{
int count=0;
while (n!=0)
{
count += n & 0B1 ; //关键代码,其中0B1=1
n >>= 1 ;
}
return count ;
}
比特位计数其原始的右移位版本的关键代码是count += n & 0B1 ;
关键代码中的0B1(二进制写法),可以有多种写法:0X1(十六进制)、01(八进制)或1(自然数)。但用0B1最本质,也最直观。
这行关键代码又可分解为:
int tmp = n & 0B1 ; //这里“n & 0B1”是关键中的关键
count += tmp;
分治法的右移位算法版本的算法实际上只有二部分,问题分治和解的合并。我们下面逐一进行分解剖析:
问题分治
第一行代码:i = (i & 0x55555555) + ((i >> 1) & 0x55555555);
算法中就用了这一行代码,实现了问题的分治和求解。
这里的分治法是把一个整型数32bit位长的二进制编码分解为16组2bit位长的二进制编码进行处理,一行代码完成了分治和求解。
这里的0x55555555 用二进制编码表示为0101 0101 0101 0101 0101 0101 0101 0101,一共是16组01。每组01实际上是一种特殊的分组掩码。
(i&0x55555555)处理了每组2bit右边(奇数位)的比特位,实现“n & 0B1”的功能。
((i>>1) & 0x55555555) 通过(i>>1)向右移位处理了每组2bit左边(偶数位)的比特位,实现“n & 0B1”的功能。
而且 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组。
解的合并
解的合并共有3行代码,也就是分三个步骤:
i = (i & 0x33333333) + ((i >> 2) & 0x33333333); //每4bit合并为2bit
i = (i & 0x0F0F0F0F) + ((i >> 4) & 0x0F0F0F0F); //每8bit合并为4bit
i = (i * (0x01010101)) >> 24; //解的合并和收集
前2行代码,其处理方法类似,只是粒度不同。第3行处理方式不同。
步骤一: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。
0x0F0F0F0F可看作4组0x0F(0000 1111)掩码。每组掩码处理2组4bit数据,处理的数据粒度为4bit。
请读者自行参照“步骤一”进行分析(算法完全相同,只是处理的数据粒度从2bit增加到4bit)。
最终把8组4bit粒度的解两两合并成4组,并保存在各自的8bit(实际有效值在低4bit里)的位段空间里。
步骤二完成时,i的值为:0000 0101 0000 0100 0000 0010 0000 0101
步骤三:i = (i * (0x01010101)) >> 24;
我们来分析这一行代码,这个算法与前面的完全不同,这是4组8bit解的合并。
这里i的初值为:0000 0101 0000 0100 0000 0010 0000 0101
因为0x01010101 = 0x01000000 + 0x00010000 + 0x00000100 + 0x00000001,所以
i * (0x01010101) = i * (0x01000000 + 0x00010000 + 0x00000100 + 0x00000001)
知识点:乘法与移位的关系
所以,i * (0x01010101) = i <<24 + i <<16 + i <<8 +i;
根据Java的向左移位规则,向左移n位时,移出的高位丢弃,右边补n位0。
下图演示了 i * (0x01010101) 运算:
图中圆角矩形中的0,是移位时,右边补n位0得到的。左边移出的高位都丢弃了。为了让读者看得更明白,我们先不把这4个值加起来。因为i = (i * (0x01010101)) >> 24; 等价于 i = ((i<<24) + (i<<16) + (i<<8) +i)>>24;
最后的右移“>>24”操作,获得的最终结果,是问题的解。下图是算法演示,:
最后得到结果,正整数i=2052399602的比特位计数值为16(0001 0000)。
说明:上图中,圆角矩形中的0,都是正整数“x>>24”右移时左边补24位0得到的。对于正整数,按位右移“>>”与按位无符号右移“>>>”等价。右边移出的低位都被丢弃。
算法最后一条返回语句:return i;
有的读者可能弄不明白,为什么i就是问题的答案?其实更严谨的写法是:return i&0x3f;
但其结果与“return i;”完全一致。从图解中可看到,实际上i的高位都是0,实际上只有最后6个比特位才是它的有效值,本例中为“010000”。返回语句:“return i;”是优化写法,可以省算力提高效率。
扩展说明:
步骤三的合并代码:i = (i * (0x01010101)) >> 24;
这行代码可以有多种写法,效果完全相同,但是效率有所不同:
(1)写法一:(算法原理与步骤一相同,只是数据粒度倍增。将在下篇【2bit】分组版本博客中剖析))
i = (i + (i >>> 8)) & 0x00FF00FF; //把每16bit合并为8bit
i = (i + (i >>> 16)) & 0x0000FFFF; //把每32bit合并为16bit(实际用到6bit)
(2)写法二,这是写法一的优化算法(其原理将在下篇【2bit】分组版本博客中剖析):
i = i + (i >>> 8); //对于正整数用“>>”和“>>>”是等价的。
i = i + (i >>> 16);
i = i & 0x3f; //0x3f的二进制表示0B0011 1111
(3)写法三,这也是一种优化算法(其原理将在后文【3bit】分组版本博客中剖析):
i = i % 255 ; //255是0xFF其二进制表示0B1111 1111
这种算法是基于“运算符%(取模)”奇妙的折叠合并功能。
因为有前文提到的前提条件:最终把8组4bit粒度的解两两合并成4组,并保存在各自的8bit(实际有效值在低4bit里)的位段空间里。
算式中255,其实也是一个特殊的掩码,其二进制表示为0B1111 1111。
i % 255 实际作用竟然可把4组8bit的解,折叠合并起来,得到问题的最终解。
其效果如右式: ((i&0xFF) + ((i>>>8)&0XFF) + ((i>>>16)&0XFF) + ((i>>>24)&0XFF));
其图解从略,也可参见“后文【3bit】分组版本博客中剖析”。
下面是完整的测试演示程序:
/***比特位计数(bit counting)算法分治法系列***/
/***
* @author QiuGen
* @description 比特位计数算法——分治法的右移位法【2bit】分组版本bitCountShiftRB (int i)
* 实现功能:演示分治法的右移位法【2bit】分组版本的比特位计数算法
* @date 2024/5/15
* ***/
public class BitsCountB {
/***比特位计数的右移位法算法版本***/
public static int bitCountShiftR (int n)
{
int count=0;
while (n!=0)
{ //这里的0X1,也可用01,0B1或1。
count += n & 0B1 ; //0X1
n >>= 1 ;
}
return count ;
}
/***分治法的右移位法【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 * (0x01010101)) >> 24; //版本一
//i = i % 255 ; //版本二
/*** 版本三 ***
i = (i + (i >>> 8)) & 0x00FF00FF; //把每16bit合并为8bit
i = (i + (i >>> 16)) & 0x0000FFFF; //把每32bit合并为16bit(实际用到6bit)
/****/
/*** 版本四 ***
i = (i + (i >>> 8));
i = (i + (i >>> 16));
i = i & 0x3f;
/****/
return i;
}
public static void printXB(int x) {
System.out.println("x="+x + " bitCountShiftR比特位计算值:"+bitCountShiftR(x));
System.out.println("x="+x + " bitCountShiftRB比特位计算值:"+bitCountShiftRB(x));
}
public static void main(String[] args) {
int x = 0B01111010010101010010000111110010;
printXB(x);
x = 13;
printXB(x);
x = 39;
printXB(x);
x = 377;
printXB(x);
}
}
测试结果图: