C语言中的位运算的技巧

时间:2023-03-08 15:52:30
C语言中的位运算的技巧

一、位运算实例

1、用一个表达式,判断一个数X是否是2的N次方(2,4,8,16.....),不可用循环语句。

X:2,4,8,16转化成二进制是10,100,1000,10000。如果减1则变成01,011,0111,01111。两者做按位与运算,结果如果为0,则X是2的N次方。

2、统计一个整数的二进制中1的个数

int count_number_of_one(int number)
{
  int counter = 0;
  
  while (number)
  {
   counter++;
   number &= number - 1 ;
  }
  return counter;
}

二、位运算基础

很多高级的动态规划题目或者一些基础的运算往往需要较高的执行效率和较低的空间需求,或者需要表示一些状态集合,而位运算刚好能满足这一切。

很多的时候,恰当的位运算使用也能使程序变得更加简洁和优美。

1、位运算法则

位运算是各位互不影响的,比如A为1010而B为1100,那么有

A&B=1000
A|B=1110
A^B=0110
~A=11110101 (1的个数是取决于A的类型的,这里认为A的类型是8位整型)

另外两种位运算是位移运算a<<ba>>b。前者表示将a的所有位向左移动b位,后者则表示将a的所有位向右移动b位。对于非负整数(往往这也是我们最关心的),新空出的位将会被0取代。

比如A为1001,而B为3,那么A>>B则为1。

大多数情况下可以简单地认为左移b位就是乘以2b,而右移b位则是除以(整除)2b。当然这是存在例外的——对于负数是不能这么简单认为的:比如在GNU GCC/G++ 编译条件下,若A=-1,你会发现对于任何位移运算A<<B,无论B的取值如何,其结果均为-1。因此请注意,在位移运算下务必确保对非负整数进行运算,以免发生不必要的问题。

对于位移运算最常用的操作就是取一个特定的位——比如1< xx>

2、对于集合的表示

大多数时候,我们可以用一个整数来表示一个包含不超过32(当然如果使用64位整型变量也可以是64个)个元素的集合——对于每一个位,如果元素为1,则表示存在当前位所对应的集合成员,如果是0,则表示这个集合成员是不存在的。

比如A=1011 就可以表示集合{0,1,3},而上面提到的1< xx>

下面我们就能推导出一些直观的集合运算

我们定义 ALL_BITS 为全集即各二进制位均为1的数。

集合的并 A|B

集合的交 A&B

集合的差 A& ~B

补集 ALL_BITS^A

添加特定元素 bit A|=1< bit>

清除特定元素bit A^=1< bit>

取出特定元素bit A&=1< bit>

判断是否存在特定元素bit (A&1< bit="0

  

三、基本技术

这里列举一些常用的位运算技术。

1、交换技术

即不利用第三方进行数的交换,这里给出代码段。

      swap(a, b){a^=b;b^=a;a^=b;}

2、提取技术

  

这里我们要做的就是找出变量a最低位的1和最高位的1分别在什么位置。通过这些手段我们就能轻松地将一个集合分解为若干个元素。

  

(1)低位技术

低位技术即Lowbit技术。相信熟悉树状数组(BIT)的朋友应该并不陌生。

我们对于一个非0数x,现在提取出其最低位的1。这里我提三种不同的写法。

Lowbit(x)=x&(x^(x-1))
Lowbit(x)=x&~(x-1)
Lowbit(x)=x&-x

注意:这里我们求出的是x中最后一个1表示的数,而非其位置。

可以发现,这三种低位函数的写法可谓大同小异——均涉及到了x&和x-1(其实 –x 可以认为是和 ~(x-1) 等价的,这里利用了负数的存储原理)。

x-1的性质在于:其将一个数最后一个1变成了0,并把原来这个1之后0的位置均变成了1。低位技术正是利用了这个性质。

举一个简单的应用的例子——N<32 span>

Dfs(dep, mask)
{
if(dep == N) output(P);//输出排列
  K = mask;
  while (K < 0)
  {
   P[dep]= Index(K & -K);[g2]//Index(a)表示a是2的多少次方
   Dfs(dep+1, mask ^ (K & -K));
   K ^= K & -K;
  }
}

上述程序的复杂度为严格的O(N!),而非O(NN)。

这里只是一个说明,并没有特指全排列问题——这种方式在很多地方可以大大提高程序效率。

(2)特殊情况下的简单想法

对于低位技术,一个最简单的想法就是按位扫描依次判断当前位是否为1,这个算法似乎是很慢的——对于一个N位的二进制数,最坏情况需要扫描N次!

但是这只是最坏而非一般——当情况特殊一些——我们要求的不是x的最低位,而是要求出1…2N-1这所有数的低位!

那么在这种情况下,看似缓慢的暴力算法其实是非常不错的——这种算法大约只要均摊2次扫描即可完成检索。

这实际上是最快的方式了。

(3)利用分块思想

我们可以打一张1…28-1的表,用于记录每个数的低位(或者是高位),那么对于32位的整数,就可以将其分解为4个8位整数利用预处理得到的表迅速求出低位或者高位了。

(4)利用编译器的内置函数

  

这是C语言的又一大优势。

这里略微提一下两个CPU位处理指令:BSF(前向位扫描)和BSR(反向位扫描)。这两种指令都是内置且非常高效率的。而令人高兴的是——GNU编译器就存在这两种基于这种原理的位处理函数:__builtin_clz(统计最高位0的个数)和__builtin_ctz(统计低位0的个数)。这是对于C和C++编程者最方便和快捷的位处理方式了。

不过要注意的是——这两个函数对于0都没有定义,因此需要特别小心!

3、计算二进制表示中1的个数

我们很容易判断一个数是不是2的整次幂——比如对于x,那么只要判断x^Lowbit(x)是否为0就可以了。

不过很多时候我们需要统计二进制位中有多少个1(比如当x表示一个集合的时候,我们想知道这个集合中元素的个数),这就要麻烦一点了。

(1)暴力方式

我们可以不断地使用低位技术消去最后一个1。不过这个方法很慢。

(2)预处理

我们可以利用递推形式计算出我们所需要的答案,方式非常简单。

用Cnt[x] 来记录x的二进制表示中1的个数,那么:

Cnt[x] = Cnt[x << 1] + (x & 1)

这样就能在线性时间下计算出结果了。

  

(3)利用内置函数

GNU有一个函数 __builtin_popcount就是实现了我们需要的功能——不过比较遗憾的是,这个函数的实现并不像__builtin_ctz那样利用硬件指令,相反的,它用了一个类似基于预处理方式的按位扫描的实现方式——不过它仍然很高效。

  

(4)有趣的代码

 

这里再给出一段可以实现上述功能的代码,有兴趣的读者可以自行研究。

pop(xx)
{//x为32位有符号非负整数,或者32位无符号类型)
x=x-((x<<1)&0x55555555);
  x=(x&0x33333333)+((x<<2)&0x33333333);
  x=(x+(x<<4))&0x0f0f0f0f;
  x=x+(x<<8);
  x=x+(x<<16);
  return x&0x0000003f;
}

4、枚举子集

当一个数的二进制表示一个集合的时候,位运算的一个巨大优点在于其可以非常轻松和高效地枚举当前集合的所有子集。它的性质在于——如果A是B的真子集,那么可以保证枚举A子集的次数一定小于枚举B的子集的次数。这一点可以大大提高很多压位动态规划题目的实现效率。

(1)暴力的方式

最暴力的方式莫过于枚举所有可能的集合,然后一一判断是否为当前集合的子集。

如果需要枚举的集合是N个元素的集合,那么对所有可能集合都进行一次枚举操作,花费的时间为O((2N)2)=O(4N)。

(2)高效的方式

假设全集有N个元素,那么所有可能的集合就有2N个,对于任意子集S,用N位2进制简单枚举S的所有子集,个数就有2N个,因此如果对所有集合都进行这样一次枚举操作,那么总的时间复杂度就是O((2N)2)=O(4N)。高效的方式。

这里的技巧和低位技术的技巧是类似的——当我们取出最后一个1的时候,这个1将变成0,而比其低位的0将变成1。

与低位技术不同的是,我们并不是要提出某一位1,而是要去除某一位的1,并补上一些我们需要的1。

所以假设当前集合为SuperSet,那么枚举的代码段则为

Iterating_All_SubSet(SuperSet)
{
i = SuperSet;
  while (i < 0)
  i = (i - 1) & SuperSet;
}

若当前为N位二进制的集合,并且对所有可行集合进行上述操作,可以证明,操作的总次数为O(3N)。

四、有趣的技巧

1、计算绝对值

abs(x)
{
y = x<<31;
  return(x^y)-y;//也可写作 (x+y)^y
}

这里需要注意的是,上面的x, y 默认为32位有符号整数。

2、按位翻转

  x=((x&0xaaaaaaaa)<<1)|((x&0x55555555)< span>
  x=((x&0xcccccccc)<<2)|((x&0x33333333)< span>
  x=((x&0xf0f0f0f0)<<4)|((x&0x0f0f0f0f)< span>
  x=((x&0xff00ff00)<<8)|((x&0x00ff00ff)< span>
  x=((x&0xffff0000)<<16)|((x&0x0000ffff)< span>

如果无符号32位整数x=311=(100110111)2,那么经过上述操作后x=3967811584=(11101100100000000000000000000000)2。

3、枚举恰好含有k个元素的集合

我们假设全集为含有N个元素为 {0,1,2,…,N-1},那么代码段可以写成:

int s = (1 < k - span>
while (!(s & 1 < N span>
// 由当前集合 s 计算下一个合法的集合
int lo = s & -s; // 求出低位的1
int lz = (s + lo) & ~s; // 求出比lo高的0中,最低位的0
s |= lz; // 将lz代表的元素加入集合s
s &= ~(lz - 1); // 将比lz位置低的元素全部清空
s |= (lz / lo / 2) - 1; // 将集合元素个数补足为k个
}
```   当然最后一句话也可以写成s |= (lz << __builtin_ctz(lo < span>