比特位(Bit)的妙用
关于比特位算法,在《编程珠玑》中有很多地方都提到了利用比特位存储数据的算法(这本书中称之为位向量),例如第1章中利用比特位实现一个内存占用小的磁盘查找程序,第13章利用比特位实现集合等待。在很多地方,都可以利用比特位来巧妙地实现一些算法,大大提高算法的执行效率,下面举两个例子说明(题目来源于LeetCode):
LeetCode 85:Maximal Rectangle
这道题目意思是:给定一个只有0和1的矩阵,求出含有1最多的子矩阵的面积(即求出该矩阵中含有的1的个数)。
初看这道题目,我立刻想到了在上篇文章中提到的二维数组中最大连续子数组之和的题目,将上一篇文章中提到的算法稍加改动,就可以应用到这道题中,在求一维数组中最大子数组之和时,我们利用下列三个关系式:
1)新的MaxEndHere=之前的MaxEndHere+Array[i]
2)新的MaxEndHere=新的MaxEndHere > Array[i]? 新的MaxEndHere:Array[i]
3)MaxLen=新的MaxEndHere>MaxLen ? 新的MaxEndHere:MaxLen
我们将上述关系做些改动,主要的改动在MaxEndHere上,此处的MaxEndHere表示的是连续1的长度,所以我们将MaxEndHere的关系式携程如下形式:
新的MaxEndHere=Array[i]==1? 之前MaxEndHere+1:0
由此得到这种情况下的最大连续长度过程:
int GetMax_XLen(char *Array,int X_end) { int MaxLen=0,MaxLenEndHere=0,CurSelfLen=0;; for(int X_i=X_end;X_i>=0;X_i--) { if(Array[X_i]==1) MaxLenEndHere+=1; else MaxLenEndHere=0; MaxLen=MaxLenEndHere > MaxLen ? MaxLenEndHere:MaxLen; } return MaxLen; }
题目的输入是字符’1’,这里为处理方便,将字符‘1’改为整型数1来处理。
知道一维情况下的解决方案后,二维的也就很方便了,所不同 的地方求“合并”一维数组的方法与求和时不同,求和需要将同一列的相加,而这里需要将同一列和元素作按位与运算。在求面积时,给定(Start,End)对,求得此时X轴方向(行向量方向)的最大连续1的长度,然后此时的面积=(Start-End+1) * Max_XLen。由此可以得到下列代码:
for(int Y_Start=0;Y_Start<=matrix.size()-1;Y_Start++) { for(X_i=0;X_i<=X_end;X_i++) MergeArray[X_i]=matrix[Y_Start][X_i]; for(int Y_End=Y_Start;Y_End<=matrix.size()-1;Y_End++) { for(X_i=0;X_i<=X_end;X_i++) MergeArray[X_i]&=matrix[Y_End][X_i]; //通过与运算得到合并的一维数组 int Cur_XLen=GetMax_XLen(MergeArray,X_end); CurArea=(Y_End-Y_Start+1) * Cur_XLen; //求得(Y_Start,Y_End)下的最大面积 if(CurArea > MaxArea) MaxArea=CurArea; } }
设矩阵的大小为N(行) * M(列),则这种算法的时间复杂度为O(M * N^2)。
将上述代码提交到LeetCode的评测系统后,虽然AC了,但发现运行时间不太理想。
可以说这个运行时间不太理想,也说明算法还是有优化空间的。仔细考虑了下这个题的特征,尽管可以使用最大子数组和的算法,但不同的是,最大子数组和中的元素是所有的整型数,而这道题仅仅是0 和 1,那既然如此,可以考虑将这些0 和 1用比特位的方法来表示,将这些0 和1存储在一个字节中,这样在GetMax_XLen的扫描中,我不是一个比特一个比特的扫描,而是一个字节一个字节的扫描,这样我的扫描时间在理论上就可以减少至原来的1/8,达到优化的目的。如下图所示:
将字符合并到一个字节并不难,关键在于合并到字节后,如何求解连续 1 的最大长度?
这里用一个数据结构BitDesc来描述一个字节的各个状态
struct BitDesc { int LeftMax; int RightMax; int Max; BitDesc():LeftMax(0),RightMax(0),Max(0) {} };
其中LeftMax表示从字节的最高位算起,连续1的最大长度,RightMax表示从字节的最低位算起,连续1的最大长度,Max表示字节中连续1的最大长度,这三个参数的关系如下图所示:
当然,这只是示意图,实际情况下,可能Max=RightMax 或者 Max=LeftMax。由于一个字节的范围是[0,255],所以这些参数完全可以实现计算并存储。由字符’1’和’0’合并得到字节必定在[0,255]的范围内,需要时直接获取即可。那么下面的关键就是如何通过这些参数得到连续最大长度的1。
为得到最大长度的1,首先分析连续1是如何形成的,有两种情况,一是字节本身的连续1,二是多字节拼合的连续1:
1)字节本身的连续1。
这种情况下的连续 1 是字节内部的,它不与其他字节的 1 相连接,如下图所示:
2)多字节拼接而成的1。
即前一字节与后一字节的1拼合二成,若有字节0xFF,则可能存在多字节拼合。
此时的长度=字节1.RightMax+字节2.LeftMax
多字节拼合
此时的长度=字节1.RightMax+8+字节3.LeftMax
若要完成多字节拼合,中间的字节必须是0xFF,我们可以将上面的关系式一般化,得到拼合的1的长度。
拼合1长度=字节1.RightMax + 8*0xFF的个数 + 字节n.LeftMax
知道如何得到连续1后,MaxLen的值就取决于字节本身的连续1和拼合1二者中的较大值,即:
MaxLen=max(字节本身连续1,拼合的1,MaxLen)
在扫描字节时,如果这个字节是0xFF,那么记录下连续的0xFF的个数,然后扫描下一个字节;如果不是0xFF,那么根据拼合1长度的关系式,求出此时拼合1的长度,将该值与之前求得MaxLen以及该字节本身的最大连续1进行比较,得到此时的MaxLen。根据这些描述,得到如下代码:
int GetMax_XLen(char *Array,int X_end) { int MaxCharCnt=0,CurLen=0,MaxLen=0; int LastLeftLen=0; LastLeftLen=bit_desc[(unsigned char)Array[X_end]].LeftMax;//LastLeftLen表示字节n的LeftMax MaxLen=bit_desc[(unsigned char)Array[X_end]].Max; for(int X_i=X_end-1;X_i>=0;X_i--) { unsigned char CurChar=(unsigned char)Array[X_i]; if(X_i!=0 && CurChar==0xFF) MaxCharCnt++;//记录连续0xFF的个数 else { CurLen=bit_desc[CurChar].RightMax+MaxCharCnt * 8+LastLeftLen;//拼合1长度的计算 // 字节1的RightMax 0xFF的个数 * 8 MaxLen=CurLen > MaxLen ? CurLen:MaxLen; MaxLen=bit_desc[CurChar].Max > MaxLen? bit_desc[CurChar].Max:MaxLen; //字节本身的最大连续1 //初始化相关参数,用于计算新的拼合1的长度 LastLeftLen=bit_desc[CurChar].LeftMax; //得到新的字节n的LeftMax MaxCharCnt=0; } } return MaxLen; }
将比特位改为在字节中存储后,主要变化的算法就是一维情况下最大长度的求解方法。其它的算法并没有很大的变化,采用按字节扫描后,扫描时间在理论上是原先算法的1/8,再次提交代码,发现运行时间有了很大的改观:
这是巧用比特位优化算法的第一个例子。后面我们再看另一个例子:
LeetCode 50:pow(x,n)
题目内容很简单,就是求x的n次方的值。这道题要是直接按数学上的定义,一个个地乘过去,当n很大时(例如n=2^32-1),必然超时。这里就得借助比特位来求解这道题了。
我们知道,任何一个整型数,都可以用二进制来表示,给定二进制的表示,我们也可以求出其十进制的值,给定正整数n,可表示为下式:
那么pow(x,n)就可以表示为下式:
式中,bit[i]的取值只有两种:0 和 1.当bit[i]=0时,,则
。若bit[i]=1,则该乘积项为
。
由此可见,我们只需要预先计算出所有可能的的值,然后利用位运算判断n的比特位分布情况,就可以利用上式求出x^n。
那么,接下来的工作分为两步,一是求出所有可能的,二是分析n的比特位分布情况,求出pow(x,n)。
题目限定n是int型,int型为32位的数。所以应当是x^(1)—x^(2^31)。要求得这些值,我们自然不可能去一个个地乘以x,否则我们的比特位算法就没有任何意义了。这里我们注意到
所以,计算时,只需要记录下前一次计算的结果,将其平方,就可以得到新的值,如此计算只需要计算32次即可。
2)求出pow(x,n)
要求出pow(x,n),关键在于分析n的比特位分布情况,这里只需要利用位运算(移位,按位与)就可以了,确定了n中比特“1”的分布情况后,利用步骤一预计算的结果,就可以得到pow(x,n)。
后记
利用比特位来优化算法还是挺实用的,这里我想到了一个这样的题目:有700桶酒,其中有一桶有毒,问至少要多少只小白鼠,才能知道是哪一桶有毒。
其实这也是一道利用比特位来求解的题目,老鼠的死与活可看成是1 和 0 两个状态,将每个老鼠作为一个比特位。我们将酒桶编号,从0-699,然后按其二进制的表示的比特位来给各个老鼠喂食,若该位是 1 ,则喂食,若该位是 0 ,则不喂食。将这700桶酒全部喂食后,观察老鼠的死亡状态,将死亡的老鼠代表的比特位置为 1 ,活的置为 0,就可以得到二进制的串,将其转换为十进制,就知道哪桶酒有毒。知道整个原理后,至少需要多少只老鼠,就意味着至少要多少个比特位来表示0-699这些数,那么这个问题很简单,就是log(699)+1=10。