什么是位运算
位运算,就是利用计算机二进制的特性,对整数(int
long long int
等)进行在二进制位上的修改与读取。这种运算远快于四则运算和取模。
位运算的基本操作
位运算一共有6种常用运算,分别是与、或、异或、取反、左移、右移。接下来我就来分别介绍:
与(a & b
)
与运算,就是按位与,先把两个整数(同数据类型)对齐,再在每一位上进行与运算(就是如果两个bit都是1,那结果就是1,否则为0),与出来的结果再整合成整数后作为结果,可见,位运算的结果也是整数。
a & b | |||||
---|---|---|---|---|---|
a = 10 | 0 | 1 | 0 | 1 | 0 |
b = 7 | 0 | 0 | 1 | 1 | 1 |
ans = 2 | 0 | 0 | 0 | 1 | 0 |
或(a | b
)
或运算的原理和与运算差不多,也是先对齐,再按位或(如果两个bit都是0,那返回0,否则返回1)。最后整合成整数。
a | b | |||||
---|---|---|---|---|---|
a = 10 | 0 | 1 | 0 | 1 | 0 |
b = 7 | 0 | 0 | 1 | 1 | 1 |
ans = 15 | 0 | 1 | 1 | 1 | 1 |
异或(a ^ b
)
异或是一种很神奇的运算,它的运算规则是:如果相同位的两个bit不同,返回1,否则返回0。
a ^ b | |||||
---|---|---|---|---|---|
a = 10 | 0 | 1 | 0 | 1 | 0 |
b = 7 | 0 | 0 | 1 | 1 | 1 |
ans = 13 | 0 | 1 | 1 | 0 | 1 |
取反(~a
)
取反,顾名思义,就是把一个整数的每一个bit都反过来(1变0,0变1)。要注意的是,取反只有一个整数参与运算。
~a | |||||
---|---|---|---|---|---|
a = 10 | 0 | 1 | 0 | 1 | 0 |
ans = 21 | 1 | 0 | 1 | 0 | 1 |
左移右移(a << b
a >> b
)
上面两个表达式的作用是将a的二进制位左(右)移b个bit。舍掉被“挤出来”的bit,空位补0。注意,这两种操作与b的bit位无直接关系,并且,左右移的结果会直接影响到a的值。
a << b | |||||||
---|---|---|---|---|---|---|---|
a = 10 | 0 | 1 | 0 | 1 | 0 | ||
b = 2 | |||||||
a = ans = 8 | 0 | 1 | 0 | 0 | 0 |
a >> b | |||||||
---|---|---|---|---|---|---|---|
a = 10 | 0 | 1 | 0 | 1 | 0 | ||
b = 2 | |||||||
a = ans = 2 | 0 | 0 | 0 | 1 | 0 |
位运算的使用技巧
讲到位运算的使用,我们要很隆重的推出一个概念——掩码。就是说这个掩码数字和现有的数字进行位运算后可以达到特殊的目的。
判断奇偶
一提到判断奇偶,你一定会想到a % 2 == 0
吧。但是mod运算速度慢,再调用多次后会大大拖慢速度。所以我们就要寻找一种更高效的判断方法。我们发现,奇数的最右位一定是1,偶数一定是0。那我们只要把待判断的数和1作与运算(a & 1
)如果结果是1,a则为奇数,否则为偶数。(应为与运算已经把左边的所有bit置为0,如果末位为1,则1 & 1 == 1
,否则0 & 1 == 0
)
if (a & 1) {
//a为奇数
} else {
//a为偶数
}
判断某一位是1或0
这种操作和判断奇偶很相似,但是这里用到的掩码不是1,而是1 << n
。这里的n为要取位的下标,最右边下标为0。如果结果等于0,则第n位为0,否则为1。
n = 2 | |||||
---|---|---|---|---|---|
a = 10 | 0 | 1 | 0 | 1 | 0 |
1 << n | 0 | 0 | 1 | 0 | 0 |
a & 1 << n == 0 | 0 | 0 | 0 | 0 | 0 |
由上表可知,a的第三位为0。
if (a & 1 << n == 0) { //注意,`<<`的优先级比`&`高
//a的第n+1位为0
} else {
//a的第n+1位为1
}
把某一位置成1
既然我们知道了怎么判断某一位是否为1,那我们怎么把这一位置成1呢?我们发现,或运算有这样的特性:如果原码对应掩码的bit位为0,结果不会变化;如果原码对应掩码的bit位为1,则结果的那一位一定是1。再把结果赋给原数,不就达到目的了吗?掩码的构造其实很简单,和判断是一样的,就是1 << n
。
n = 2 | |||||
---|---|---|---|---|---|
a = 10 | 0 | 1 | 0 | 1 | 0 |
1 << n | 0 | 0 | 1 | 0 | 0 |
a = a | 1 << n | 0 | 1 | 1 | 1 | 0 |
a = a | 1 << n
把某一位置成0
能把某一位置成1,就能把这一位置成0。我们发现,与运算有这样的特性:如果原码对应掩码的bit位为1,结果不会变化;如果原码对应掩码的bit位为0,则结果的那一位一定是0。正好达到目的!但是这个掩码的构造有点复杂。我们会构造00100
这样的掩码,但是不会构造11011
这样的掩码。怎么办呢?这时候就要让取反出场了。把00100
取反,不就变成11011
了吗?目的再次达到!把原码和掩码一与,再赋给原数,达到最终目的!
n = 3 | |||||
---|---|---|---|---|---|
a = 10 | 0 | 1 | 0 | 1 | 0 |
~(1 << n) | 1 | 0 | 1 | 1 | 1 |
a = a & ~(1 << n) | 0 | 0 | 0 | 1 | 0 |
a = a & ~(1 << n) //注意,`~`的优先级比`&`、`<<`都要高,所以要加括号
交换
讲了那么多,异或好像没被用到。现在轮到它出场了。它的作用是交换,具体操作是这样的:
//把a与b交换
a = a ^ b;
b = a ^ b;
a = a ^ b;
这样做的好处就是不要定义中间变量了。具体是什么原理,等我update以后再补充吧^_^
。
位运算的使用领域
我们了解了位运算的各种操作和使用技巧,那么接下来就要来了解一下在那些地方可以使用到位运算了。
位运算最大也是最重要的用处就是表示状态(这里的状态只能有两种情况)。很常见的一种应用就是在暴力的时候遍历所有的状态。如果你开一个bool
的一维数组来维护这些状态的话,非常考验代码实现能力。如果你用for循环嵌套的话,像这样:
for (int i0 = 0; i0 <= 1; ++i0)
for (int i1 = 0; i1 <= 1; ++i1)
for (int i2 = 0; i2 <= 1; ++i2)
for (int i3 = 0; i3 <= 1; ++i3)
for (int i4 = 0; i4 <= 1; ++i4)
for (int i5 = 0; i5 <= 1; ++i5)
for (int i6 = 0; i6 <= 1; ++i6)
for (int i7 = 0; i7 <= 1; ++i7)
for (int i8 = 0; i8 <= 1; ++i8)
for (int i9 = 0; i9 <= 1; ++i9)
...
虽然只有1024次运算,但是打这个嵌套会让你开始怀疑人身,绝对的用考场时间换运行时间。但是如果我们改用位运算的话,就会非常的简洁明了:
for (int i = 0; i < 1 << 10; ++i) {
...
}
而且这样运行效率也高。
当然,如果位运算只是用来这样暴力的话就太大材小用了。它表示状态的真正精髓之处是它能把状态用整数,作为数组的下标来进行DP(可以参考我写的这篇文章:旅行商问题 状压DP)