开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 3 天,点击查看活动详情
Bits Hacks
本文将介绍一些位运算相关的黑科技。在阅读本文前,您应当知道:
- 二进制、十六进制等相关概念
- 二进制与、或、非、异或、左移和右移相关概念及运算规则
基础操作
设置位
设置某二进制数x的第k位为1。我们需要一个在第k位为1的数做或运算,所以我们可以通过左移运算来实现:
执行过程如下图所示:
清零位
清零某二进制数x的第k位。我们可以通过与运算来实现清零,为了构造一个只有第k位为0的数,我们需要用到左移运算加非运算:
执行过程如下图所示:
翻转位
翻转某二进制数x的第k位。我们可以通过异或运算来实现翻转,只需要在第k位和1异或就可以,自然而然的,我们需要构造第k位为1的数:
执行过程如下图所示:
掩码操作
掩码操作是指,提取某二进制数x中的某一些位数,俗称mask,可以联想一下子网掩码:
执行过程如下图所示:
提取一串数位
从二进制数x中提取某一些数位,我们可以通过掩码操作来提取出对应的数据,并通过移位操作移动shift位数放到指定的位置:
执行过程如下图所示:
设置一串数位
设置二进制数x中的一些数位,和提取一串数位类似,我们通过掩码和移位操作。先通过与操作把这一些数位设置成0,然后通过移位操作和或操作进行设置:
执行过程如下图所示:
为了安全性考虑,我们可以将y也进行一次掩码操作,这样保证只会设置我们需要的那些位数:
高级操作
交换数字
交换变量x与y的值。我们可以通过temp作为中转,我们也可以通过位运算来实现。
其原理是来自于异或运算的特性。对于异或运算,连续两次异或同样的数,由于同样的数异或始终为0,而1和0异或上0都是他们自身,所以异或两次同样的数并不会修改当前数:
如图所示:
因此,我们可以利用该特性来进行函数交换:
执行过程如下图所示:
min操作
min操作是一个经常用到的操作,取两个数中小的那个。一般来说,我们可以这样实现:
// if
if (x < y) {
r = x;
} else {
r = y;
}
// 三元表达式
r = ( x < y ) ? x : y;
在这样的代码中,如果不考虑编译器优化,就会出现一次跳转操作,这个可能会影响程序性能。
同样的,我们利用两次异或不改变的特性:
当的时候,会这样迭代:
相反的,如果的时候:
抽象来看,我们是将如下的if语句进行了变换:
if (test) {
A
} else {
B
}
变换成了:
考虑式子,如果test为真,则返回A,为假则返回0。我们也可以利用这个小技巧来进行优化。
例如,当我们面对如下的问题,计算r的值:
我们可以通过直接的取模运算来实现:
r = (x + y) % n;
但是除法的开销是比较大的,考虑到数据的范围,我们可以转换成如下的式子:
z = x + y;
r = (z < n) ? z : z - n;
到这里,我们就可以用我们如上的结果进行转换了:
z = x + y;
r = z - (n & - (z >= n));
计算2的次方
假设我们要计算的值,我们可以用如下的方式实现:
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。考虑到等于的情况,我们先计算出的值,再进行加一。通过这样的方式,我们可以加速取对数的操作。
其执行过程如下图,从上往下进行变化:
计算最右边的1的位置
通过如下的方式,我们可以快速的计算出最右边的1的位置:
因为补码需要+1的原因,所以最后一位1的位置是一致的,如下图所示:
快速计算2的次方数
对于2的k次方x,快速计算也即k的值,可以通过都柏林序列的方式快速实现:
都柏林序列是一个长度为的序列,其中k的值以子串的形式出现:
通过将该次方值x乘以序列值(也即左移k位),再取高位对比,就可以得到对应的k:
计算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的数量。
写在最后
- 很多编译器已经做了位运算的相关优化,我们无需再依照上面的方式进行优化。但是由于编译器并不是完全智能,有时候需要程序员自己来实现;
- 位运算天然的具有向量运算属性,因而其并不只是单纯的在运算中,也被应用在很多其他的地方。