神奇的位运算操作

237 阅读5分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 3 天,点击查看活动详情

Bits Hacks

本文将介绍一些位运算相关的黑科技。在阅读本文前,您应当知道:

  • 二进制、十六进制等相关概念
  • 二进制与、或、非、异或、左移和右移相关概念及运算规则

基础操作

设置位

设置某二进制数x的第k位为1。我们需要一个在第k位为1的数做或运算,所以我们可以通过左移运算来实现:

y=x  (1<<k)y = x \ | \ (1 << k)

执行过程如下图所示:

image-20230202134611550

清零位

清零某二进制数x的第k位。我们可以通过与运算来实现清零,为了构造一个只有第k位为0的数,我们需要用到左移运算加非运算:

y=x &  (1<<k)y = x \ \& \ ~(1<<k)

执行过程如下图所示:

image-20230202134949523

翻转位

翻转某二进制数x的第k位。我们可以通过异或运算来实现翻转,只需要在第k位和1异或就可以,自然而然的,我们需要构造第k位为1的数:

y=x(1<<k)y = x \oplus (1 << k)

执行过程如下图所示:

image-20230202140045299

掩码操作

掩码操作是指,提取某二进制数x中的某一些位数,俗称mask,可以联想一下子网掩码:

y=x & masky = x\ \& \ mask

执行过程如下图所示:

image-20230202140527507

提取一串数位

从二进制数x中提取某一些数位,我们可以通过掩码操作来提取出对应的数据,并通过移位操作移动shift位数放到指定的位置:

y=(x & mask) >>shifty = (x \ \& \ mask) \ >> shift

执行过程如下图所示:

image-20230202140819144

设置一串数位

设置二进制数x中的一些数位,和提取一串数位类似,我们通过掩码和移位操作。先通过与操作把这一些数位设置成0,然后通过移位操作和或操作进行设置:

x=(x &  mask)  (y << shift)x = (x \ \& \ ~mask) \ | \ (y \ << \ shift)

执行过程如下图所示:

image-20230202141052529

为了安全性考虑,我们可以将y也进行一次掩码操作,这样保证只会设置我们需要的那些位数:

x=(x &  mask)  ((y << shift) & maskx = (x \ \& \ ~mask) \ | \ ((y \ << \ shift)\ \& \ mask

高级操作

交换数字

交换变量x与y的值。我们可以通过temp作为中转,我们也可以通过位运算来实现。

其原理是来自于异或运算的特性。对于异或运算,连续两次异或同样的数,由于同样的数异或始终为0,而1和0异或上0都是他们自身,所以异或两次同样的数并不会修改当前数:

(x  y) y=x(x \ \oplus \ y) \ \oplus y = x

如图所示:

image-20230202142116562

因此,我们可以利用该特性来进行函数交换:

x=x  yy=x  y=(x  y)  y=x  (y  y)=xx=x  y=(x  y)  x=y  (x  x)=yx = x \ \oplus \ y \\ y = x \ \oplus \ y = (x \ \oplus \ y) \ \oplus \ y = x \ \oplus \ (y \ \oplus \ y) = x \\ x = x \ \oplus \ y = (x \ \oplus \ y) \ \oplus \ x = y \ \oplus \ (x \ \oplus \ x) = y

执行过程如下图所示:

image-20230202142143354

min操作

min操作是一个经常用到的操作,取两个数中小的那个。一般来说,我们可以这样实现:

// if
if (x < y) {
   r = x; 
} else {
   r = y;
}

// 三元表达式
r = ( x < y ) ? x : y;

在这样的代码中,如果不考虑编译器优化,就会出现一次跳转操作,这个可能会影响程序性能。

同样的,我们利用两次异或不改变的特性:

r=y ((x  y) &(x<y))r = y \ \oplus ((x \ \oplus \ y)\ \& \sim (x < y))

x<yx<y的时候,会这样迭代:

r=y  ((x  y) &1)=y  ((x  y) &(11111111))=y  (x  y)=xr = y \ \oplus \ ((x \ \oplus \ y) \ \& -1) = y \ \oplus \ ((x \ \oplus \ y) \ \& (11111111)) = y \ \oplus \ (x \ \oplus \ y) =x

相反的,如果x>=yx>=y的时候:

r=y  ((x  y) & 0)=y  0=yr = y \ \oplus \ ((x \ \oplus \ y) \ \& \ \sim 0) = y \ \oplus\ 0 = y

抽象来看,我们是将如下的if语句进行了变换:

if (test) {
    A
} else {
    B
}

变换成了:

B ((A  B) & (test))B \ \oplus ((A \ \oplus \ B)\ \& \ \sim (test))

考虑式子A&(test)A \& - (test),如果test为真,则返回A,为假则返回0。我们也可以利用这个小技巧来进行优化。

例如,当我们面对如下的问题,计算r的值:

r=(x+y) mod n,0x<n,0y<nr = (x + y)\ mod \ n, 0\leq x < n, 0\leq y<n

我们可以通过直接的取模运算来实现:

r = (x + y) % n;

但是除法的开销是比较大的,考虑到数据的范围,我们可以转换成如下的式子:

z = x + y;
r = (z < n) ? z : z - n;

到这里,我们就可以用我们如上的结果进行转换了:

z = x + y;
r = z - (n & - (z >= n));
计算2的次方

假设我们要计算2lgn2^{\lceil lgn\rceil}的值,我们可以用如下的方式实现:

uint64_t n;
--n;
n |= n >> 1;
n |= n >> 2;
n |= n >> 4;
n |= n >> 8;
n |= n >> 16;
n |= n >> 32;
++n

这样做的原因是,对于任何一个二进制数x,其取对数在做运算的话,肯定是大于等于该数,并且相当于把该数最左边一位1右边的数全部置成了0。考虑到等于的情况,我们先计算出2lg(n1)2^{\lceil lg(n-1)\rceil} 的值,再进行加一。通过这样的方式,我们可以加速取对数的操作。

其执行过程如下图,从上往下进行变化:

image-20230202144921053
计算最右边的1的位置

通过如下的方式,我们可以快速的计算出最右边的1的位置:

r=x &xr = x \ \& \sim x

因为补码需要+1的原因,所以最后一位1的位置是一致的,如下图所示:

image-20230202145653410

快速计算2的次方数

对于2的k次方x,快速计算lgxlgx也即k的值,可以通过都柏林序列的方式快速实现:

image-20230202145842046

都柏林序列是一个长度为2k2^k的序列,其中k的值以子串的形式出现:

image-20230202150111909

通过将该次方值x乘以序列值(也即左移k位),再取高位对比,就可以得到对应的k:

0b0001110124=0b110100000b11010000>>5=6convert[6]=40b00011101 * 2^4 = 0b11010000 \\0b11010000 >> 5 = 6 \\ convert[6] = 4
计算1的数量

我们可以通过如下的方式快速求得二进制数x中1的数量:

for(r = 0; x != 0; ++r){
    x &= x - 1;
}

这是因为对于x而言,每次减1就以为着最右边的1会变减去,从而在与操作中变成0,如此重复即可获得1的数量。

该操作在1数量少的时候适用,当数量过大则不太适用。

我们也可以通过对照表的方式来实现,每次取出x的一部分进行查表运算,例如取出3位二进制,那么0-7对应的1的位数是已知的,加上该部分,继续取出下一个3位即可:

static const int count[8] = {0,1,1,2,1,2,2,3};
for(int r = 0; x != 0; x >>=3){
    r += count[x & 0x08]
}

这个方式的快慢取决于内存操作的速度。

基于以上两种方式,我们可以用分治的方式来计算一个长串中1的数量。

写在最后

  • 很多编译器已经做了位运算的相关优化,我们无需再依照上面的方式进行优化。但是由于编译器并不是完全智能,有时候需要程序员自己来实现;
  • 位运算天然的具有向量运算属性,因而其并不只是单纯的在运算中,也被应用在很多其他的地方。