本文档主要是对算法刷题中使用到的位运算知识的整理。
在二进制的世界中,数字只有“0”和“1”,由“0”和“1”形成的指令也就是计算机可以直接执行的指令。在大学中学到的数电、模电中的与、或、非、异或等操作就是今天将要讲的二进制的基本位运算。
运算符号 |
描述 |
运算规则 |
---|---|---|
& |
与 |
全1为1,有0则0 |
| |
或 |
有1为1,全0则0 |
~ |
非 |
“0”变成“1”,“1”变成“0” |
^ |
异或 |
相同为0,相异为1 |
<< |
左移 |
各二进制位全部左移若干位,高位丢弃,低位补0 |
>> |
右移 |
各二进位全部右移若干位,对无符号数,高位补0,有符号数,各编译器处理方法不一样,有的补符号位(算术右移),有的补0(逻辑右移) |
与运算的表示符号是“&”,两个二进制数之间进行与运算(与操作)是指对应位之间进行与运算,比如“0010&1011=0010”,对应位之间的与操作分别为 “0&1=0”、“0&0=0”、“1&1=1”、“0&1=0”。
还记得在数电这门课程中关于二进制的与运算是这样讲的,二进制数的“0”和“1”就像是电路中开关的“开”与“闭”,与操作就像是一个电路中有两个串联的开关,只有开关都“闭合”即表示开关“闭合”的两个二进制数都为“1”时,电路才会导通,其余任何情况电路都不会导通。
因此,与运算有这样的计算规则:“全1为1,有0则0”。
或运算的表示符号是“|”,或操作就像是一个由两个开关并联组成的电路,只要有一个开关“闭合”,这个电路就会导通。因此,或运算有这样的计算规则:“有1为1,全0则0”。
0或上任意一个数仍是这个数本身。
非运算的表示符号是“~”,非操作非常简单,主要把“0”变成“1”,“1”变成“0”即可。若a表示一个一位二进制数(要么是0,要是1),则a的非运算结果为“1-a”,并且有 "a & (1-a) = 0"、"a | (1-a) = 1"。
异或运算的表示符号为 "^",计算规则是“相同为0,相异为1”。
两个相同的数异或值为0。
左移i位相当于乘以2的i次幂,右移i位相当于除以2的i次幂。
如果遇到字符串的题目,字符串中没有重复的字符,并且都是大写或者都是小写字母,就可以优先考虑状态压缩,然后采用位运算进行后续求解。
怎么将字母表示为二进制状态?
1、切入点:字符串的长度不超过26,任一字符串中,每个字母至多出现一次;
2、每个字符对应到二进制压缩的位上, 'a' 对应到 0 位, 'b' 对应到 1 位,以此类推 'z' 对应 25 位;
3、所有的字符串都可以用二进制数来唯一表示,比如’abd‘可以用二进制数 1011 唯一表示;
具体有如下的实现代码:
int mask = 0;
for (int i = 0; i < str.size(); ++i) {
mask |= 1 << (str[i] - 'a');
}
参考内容
集合可以使用二进制来进行表示:二进制从低到高的第i位为1表示i在集合中,为0表示i不在集合中。比如集合 {0,2,3}可以用二进制 “1101”,相应的二进制 “1101” 就可以表示集合{0,2,3}。正式的公式如下所示,其中S表示每个集合。
对于{0, 2, 3}这样的一个集合,有,其对应的二进制数为“1101”。
借助于二进制的位运算和集合表示法可以将集合与集合之间的关系,集合与元素之间的关系进行如下总结。
术语 |
集合表示 |
位运算表示 |
集合表示举例 |
位运算表示举例 |
---|---|---|---|---|
交集 |
A ∩ B |
a & b |
{1,2,3} ∩ {0,1,3} ={1,3} |
1101 & 1011 = 1001 |
并集 |
A ∪ B |
a | b |
{1,2,3} ∪ {0,1,3} ={0,1,2,3} |
1101 | 1011 = 1111 |
对称差 |
A Δ B |
a ⊕ b |
{1,2,3} Δ {0,2} ={0,1,3} |
1110 ^ 0101 = 1011 |
差 |
A - B |
a & ~ b |
{1,2,3} - {0,2} = {1,3} |
1110 & 1010 = 1010 |
差(一个是另一个集合的子集时) |
A - B(B ⊆ A) |
a ⊕ b 或a & ~ b |
{1,2,3} Δ {3} = {1,2} {1,2,3} - {3} = {1,2} |
1110 ^ 1000 = 0110 1110 & 0111 = 0110 |
包含于 |
A ⊆ B |
a & b=a a | b=b |
{0,2} ⊆ {0,2,3} |
0101 & 1101 = 0101 0101 | 1101 = 1101 |
注:
数学上,两个集合的对称差是只属于其中一个集合,而不属于另一个集合的元素组成的集合,去掉两个集合的交集,集合各自剩下的元素组成的新集合即为答案。
注意区分差和对称差,比如A-B表示在集合A中但是不在集合B中的元素组成的集合。
如果B⊆A即B是A的子集,则A-B等价于AΔB,即位运算a&~b等价于a⊕b。
术语 |
集合表示 |
位运算表示 |
集合表示举例 |
位运算表示举例 |
---|---|---|---|---|
空集 |
∅ |
0 |
||
单元素集合 |
{i} |
1 << i |
{3} |
1 << 3 |
全集 |
U={0,1,2,3,...,n-1} |
(1 << n) - 1 |
{0,1,2,3} |
(1 << 4) - 1 |
补集 |
CuS=U-S |
~s或者 ((1 << n) - 1) ⊕ s |
U={0,1,2,3} S={2} 补集为{0,1,3} |
~s=1011, ((1 << 4) - 1) ⊕ 0100 = 1011 |
属于 |
i ∈ S |
(s >> i) & 1 == 1 |
1 ∈ {1,3} |
(1010 >> 1) & 1 == 1 |
不属于 |
i ∉ S |
(s >> i) & 1 == 0 或 != 1 |
0 ∉ {1,3} |
(1010 >> 0) & 1 == 0 |
添加元素 |
S ∪ {i} |
s | (1 << i) |
{2,3} ∪ {1} |
1100 | (1 << 1) = 1110 |
删除元素 |
S - {i} |
s & ~ (1 << i) |
{0,2,3} - {2} |
1101 & ~(1 << 2) = 1001 |
删除元素 (一定在集合中) |
S - {i}(i ∈ S) |
s ⊕ (1 << i) |
{0,2,3} - {2} |
1101 ^ 0100 = 1001 |
删除集合中的最小元素 |
s & (s - 1) |
删除集合{1,2,3}中的最小元素 |
1110 & (1110 - 1) =1110 & 1101 = 1100 |
注:
补集,一般指绝对补集,指全集中不属于某一子集的所有元素组成的集合。
对于删除元素可以有两种方法,如果确定某一元素就在集合中,可以使用位运算进行删除;如果不确定某一元素是否在集合中,则使用 &~ 的方法。
还有一些问题可以借助相应程序语言提供的库函数来解决:
术语 |
C++ |
Python |
---|---|---|
求集合的大小 (即求集合中元素的个数) |
__builtin_popcount(s) |
s.bit_count() |
二进制长度 (即求集合中1的最高位) |
32-__builtin_clz(s) |
s.bit_length() |
集合中的最小元素 (即求集合中1的最低位) |
__builtin_ctz(s) |
(s & -s).bit_length() - 1 |
注:
二进制的长度指的是二进制数中最高位的1位于二进制数从低位往高位数的第几位+1(因为二进制数位是从0位开始数的),__builtin_clz() 计算的是32位二进制数从高位往低位数的连续0的个数。二进制的长度普通计算方法是这样的:
int getLength(uint32_t n) {
int res = 0;
while (n) {
++res;
n >>= 1;
}
return res;
}
集合 {0,2,3} 用二进制数表示为 “1101”,该集合中的最小元素是 0,__builtin_ctz() 表示的是32位二进制数中从低位往高位数连续0的个数。
只包含最小元素的子集可以用 s & -s 来表示,-s在计算机中是用s的补码来表示的,即反码+1 (~s + 1)。
例如一个集合用二进制数表示为 s = 1010,-s = ~s + 1 = 0101 + 1 = 0110, s & -s = 0010, 于是集合中最小元素表示的二进制数为0010。
因为集合可以用二进制数来表示了,所以求集合中的元素个数可以通过计算对应的二进制数中“1”的个数来完成。这样也就可以通过一些简单的方法得到:
int hammingWeight(uint32_t n){
int ans = 0;
while(n){
if(n & 1){
++ans;
}
n >>= 1;
}
return ans;
}
int hammingWeight(uint32_t n){
int ans = 0;
while(n){
n &= (n - 1);
++ans;
}
return ans;
}
代码中提到的汉明重量表示的是字符串相对于同样长度的0字符串的汉明距离(两个相同长度的字符串,对应位置字符不同的数目),因此汉明重量就是统计字符串中非0字符的个数,对于二进制串来说就是统计1的个数,如1011的汉明重量就是3。
设元素范围从 0 到 n-1,枚举判断每个元素是否在集合 s 中:
for (int i = 0; i < n; ++i) {
if ((s >> i) & 1) { // 如果元素 i 是否在集合 s 中
// 代码块
}
}
设集合内元素范围为 0 到 n-1,枚举该集合的所有子集(包括空集和全集)为:
for (int s = 0; s < (1 << n); ++s) {
// 每一个s表示的就是该集合的一个子集
}
设集合为s,从大到小枚举s的所有非空子集sub:
for (int sub = s; sub; sub = (sub - 1) & s) {
// 枚举的每一个sub都是符合题意的非空子集sub
}
需要注意的是这里的集合S已经不是从 0 到 n-1 的 n 个元素的集合了。普通的二进制减法会把最低位的1变成0,同时1右侧的0变为1。这里压缩版的二进制减法是类似的,最低位的1依然变为0对应sub-1操作,而1右侧的0只保留s中的1对应的是 &s 操作。
例如某一个集合的二进制数表示为 101010,普通的二进制减法的下一位是 101001,而压缩版的二进制减法也就是我们需要的下一位是 101000,通过 101010 & (101010-1) 计算得到。