关闭

连环位运算技巧让程序更高效

标签: 算法语言优化测试cx86
1137人阅读 评论(0) 收藏 举报
分类:

    位运算,也就是逻辑运算。C语言中实用的位运算无非6种:与(&)、或(|)、非(~)、异或(^)、左移(<<)和右移(>>)。这些位运算对一般的程序员来说只是有所耳闻,真正实用的机会并不如算术运算、循环之类的操作多。当然,很多人也并没有意识到位运算的强大性能和威力。

    单从性能上来说,以32位无符号整数为例,一次位运算的CPU指令的执行是加减运算的速度的一倍。在80x86下,一个逻辑运算需要2个CPU时钟周期,加减运算需要4个,而乘除则需要16个。这就很明显地体现出了其性能上的优越性。当然,原因也简单,因为CPU本身就是由与或非门这样的门电路组成的,加减乘除等其它运算的实现也是进过这样的逻辑运算来构建的,自然是直接的逻辑位运算的执行来的更直接,效率自然也就更高了。

   简单来说,在C++中,我们可以用a<<=1; 来替代a*=2; 因为它们的效果是一样的——乘2,在二进制数中的表现就是在最末位多了个0,其它的位向前移了一位。当然在C++编译的时候,一般也是会把a*=2; 优化成 sal a, 1,而不是mov ax, a; mul 2;,这也能对位算的效率优越性略见一斑。自然,a<<=2就等效于a*=4,a<<=3就等效于a*=8等等。再比如对整数进行奇偶判断时也是如此,a & 1 就会比 a % 2要高效的多,两者都是在a为奇数时非零。

   先几个基本的位操作:

a|=1<<n;          //置第n位为1
a&=1<<n;         //只保存第n位,其余清零
a&=~(1<<n);      //第n位清零
a^=1<<n;        //反转第n位
a &= a-1;      //对a的最低位的1清零
a |= ~(~0<<n); //置位a的最低n位

  还有在一些公司的笔试题中有用位运算实现交换变量的题目:

void swap(int &a, int &b)
{
	a^=b;
	b^=a;
	a^=b;
}

  上面这些还无非是一些小技巧,很浅显,谈不上高级应用,但以些为基础可以组合出一些有那么一点技术含量的。

   给定一个无符号整数a,如何判断a是否为2的整数次幂。这个问题不像那种复杂算法之类的问题让人摸不着头脑,很直观地可以想象到一种方法:(log(a)/log(2.0)==long(log(a)/log(2.0)))。这真是一个朴素的想法啊!明明是整数运算,偏偏捎带上log这样的浮点数运算函数,实在是没有必要啊。因为类型转换本来就是一种不高效的做法,在加上log这样的高精度函数来拉后腿,效率肯定just so so了。刚刚说到的位运算就可以解决这一问题。2的整数次幂在二进制表示里一定会只有一个1,其余其它位全部是0。那这样就可以这样写呗


template<typename T>
bool IsPowerOfTwo(T a)
{
	int count = 0;
	for(T k = 1; k <= (1<<sizeof(a)*8); k<<=1)
		if(k & a) count++;
	return count ==1;
}
   毫无疑问,这个函数对无符号数是可行的。可是这个函数“太像个函数了”,有个循环,这意味着它不能写一个inline函数。而且如果a=1,那个后面的几十次循环都是在浪费我们宝贵的时间。那可以对函数在改进一下吧?这就不必了,因为这个是徒劳的,只要有循环在,函数的效率就不可能是O(1)。看下面这个函数是不是小巧得多,也且优雅地解决了这一问题,当然还是O(1)的时间复杂度。

template<typename T>
inline bool IsPowerOfTwo(T n)	
{ return ((n&(n-1))==0);}
    如果觉得不好理解,那就解释一下,如果n=0...010...0(二进制,下同),那么n-1=0...0011...1,即原来n中1以后的0全变成了1,而n&(n-1)自然就全变为0了。当然,如果n的最后一个1前会还有1那肯定就把前面的1保存了下来,结果非零。其实质就相当于抹去n最末位的一个1。

   相信这个还是比较好理解的,有了这个,我们可以再提一个问题:计算一个32位无符号整数中1的个数。利用上一个小技巧是不是马上就有了思路,并大胆的写道:

int CountBits(UDWORD bits)
{
	int count = 0;
	while(bits)
	{
		bits &= bits-1;
		count++;
	}
	return count;
}
    恭喜你,你已经入道了,这已经远远比单纯的用与操作对32位遍历一遍要好得多了。可是,这还是有个循环,时间复杂度仍然是O(n)。我们来看一下面这个函数。

inline int CountBits(UDWORD bits){
	bits = bits - ((bits & 0xAAAAAAAA) >> 1);
	bits = ((bits & 0xCCCCCCCC) >> 2) + (bits & 0x33333333);
	bits = ((bits >> 4) + bits) & 0x0F0F0F0F;
	return (bits * 0x01010101) >> 24;
}
    可能你会感到它异常的凶残,不过首先肯定的是其CPU计算花销周期为8*2+3*4+1*16=44,而上一个函数为 (1*2+2*4)*n=10*n,期望为160,这个算法是上个算法的1/4。 而且没有循环,是O(1)时间复杂度的。那其是否可以实现,又是如何得以实现的呢?我们再来分析一下。

    首先,0xAAAAAAAAA=10101010....,8个1010的循环。bits与0xAAAAAAAAA与之后只会保留bits的偶数位的1,称为tmp。tmp后移一位bits的偶数位的1就成了其低一位了。这就好,bits减一个比自己低一位的偶数位为1奇数位全为1的数tmp,其结果会怎样呢?bits偶数位为1 的下一个奇数位为1,则该偶数位不变,奇数位变零;若其奇数位为0,则偶数位变0,下一奇数位变为1。这一操作的最明显的效果就是这时的bits每两位都代表原bits的这两位有几个1。

   好,再来看第二句,这句的变化最明显的就是移位变成了2。这也很好理解,既然这时的bits每两位都代表原bits的这两位有几个1,只要把它们加起来就行了,当然,操作单元也就变成了两位。0xCCCCCCCC=11001100...,8个1100的循环,0x33333333=00110011...,8个0011的循环。这就很明显了,加号前面的是为了把以2分组后为偶数组的数后移一组与奇数组对齐;加号后面是为了取出奇数组。然后,相加就可想而知了——每四位都记录着原来这四位上1的个数。

   第三句同理,只不过是变成了每八位都记录着原来这八位上1的个数。

   第四句很关键,目的不用说,就是为了把这四个8位上的数加在一起。如果你不是通过语意推出来的,你就不用看下面的解释了。我们来分析一下,bits乘以0x01010101,就可以有竖式表示为(设这时的bits=0x0q0w0e0r,Q、W、E、R均为四位数,之所以为四位数是因为其可表示16以内的值,而这8位中不可以超8个1,所以这时的bits只是每个字节的值都不会超过四位(3位))


0x0q0w0e0r

X        0x01010101

————————————

              0x0q0w0e0r

         0x0q0w0e0r

    0x0q0w0e0r

0x0q0w0e0r

————————————

0x------q+w+e+r--------

     只思虑最高字节,其值已赫然成为q+w+e+r,抹去低24位,即是。当然,之所以最后才用乘法,不仅是效率上的考虑,还是因为在过小的组上如4位一组时,会导致低位进位冲毁高位数值。第二句之所以没有像第三句先加后与也是为了防止组间进位的发生。还有就是,C语言中无符号数溢出不会报错,所以,这个函数不会因为最后的乘法溢出而崩溃。


    接下来,我们再讨论第三个问题:计算一个不比x大的最大2的整数次幂。

    这个问题的话MS也可以用第一个问题的方法解呢,不过那不是高效的,因为它会用到循环,以致O(n)的时间复杂度。下面给出一个以32位无符号整数为例的只需要26个指令周期的高效算法:

inline UDWORD LargePowerOf2(UDWORD x)
{
	x |= (x >> 1);
	x |= (x >> 2);
	x |= (x >> 4);
	x |= (x >> 8);
	x |= (x >> 16);
	return (x & ~(x >> 1));
}
    这个算法的思想是这样的,因为不比x大的最大2的整数次幂一定会是与x位数相同,且最高位是1,其余是0。因此,以x为1的最高位为基,按指数级向后拓,将其后的位全变以1,代码中前5句就是在做这个。之后后移一位取反与x本身相与,自然只剩下x的最高位了——也就是不比x大的最大2的整数次幂。当然,最后一句也可以写成 return (x>>1)+1; 这样的效果是相同的。

   这个方法还可以用来求x的MSB、粗略估计x对2的对数等问题,只是问题的不同表示而已。


   还有一个相对容易想到解法的问题:对一个32位无符号整数进行倒转。即使其最高位与最低位的值互换,次高位与次低位,依次类推。

   如果进行遍历测试后置位清零,不用说,又是线性的。采用二分法,可以将时间复杂度降为O(logn)。采用二分法的常用模型,先相邻的两个位对换,再相邻的四个位对换,依次类推即可了。代码如下:

inline UDWORD ReverseBits(UDWORD n)
{
	n = ((n >>  1) & 0x55555555) | ((n <<  1) & 0xaaaaaaaa);
	n = ((n >>  2) & 0x33333333) | ((n <<  2) & 0xcccccccc);
	n = ((n >>  4) & 0x0f0f0f0f) | ((n <<  4) & 0xf0f0f0f0);
	n = ((n >>  8) & 0x00ff00ff) | ((n <<  8) & 0xff00ff00);
	n = ((n >> 16) & 0x0000ffff) | ((n << 16) & 0xffff0000);
	return n;
}


  再比如,如何判断32位的无符号数所占的4个字节是否有0,这可能也好写。下面我们来对比一下两种方法的性能:

// 普通写法
BOOL HasNullByte0(udword x) 
{
	unsigned char *pByte =(unsigned char*)&x;
	return x[0] && x[1] && x[2] && x[3];
}
// 位运算写法
inline BOOL HasNullByte(udword x) 
{ return ((x + 0xfefefeff) & (~x) & 0x80808080);}

 当然,普通方法也是很高效的,但其编译后的程序有3个&&运算,即需要进行3次短路求值,而且普通方法需要4次访存操作,而位运算方法可以只进行一次内存访问。具体的算法原理就不再分析了。


  好,我们再来看一个综全性较强的题目:输入一个数n,输出是2的几次幂(即以2为底的对数,向下取整);若n是0输出-1。

  看类有点复杂,但其实这个问题都算不上问题,因为前面已经说过类似的题目了,无非是计算n的MSB是2的几次幂。


int Log2(UDWORD n)
{
	return n? CountBits(LargePowerOf2(n)-1): -1;
}

  是不是看了有种恍然大悟的感觉呢,当然在这里,LargePowerOf2展开优化后效率会更高,但这只是简单的策略而已。 其实位运算的技术并不仅限于此,这里只总结了几个不用循环的经典问题,还有很多高级的应用就不在这展示了。相信位运算能为大家的程序增光添彩、减压提速而大有裨益。

0
0

查看评论
* 以上用户言论只代表其个人观点,不代表CSDN网站的观点或立场
    个人资料
    • 访问:3813次
    • 积分:61
    • 等级:
    • 排名:千里之外
    • 原创:2篇
    • 转载:3篇
    • 译文:0篇
    • 评论:0条
    文章分类