文章目录
1. 简介
在实际运算过程中,位运算可以大大减少计算时间,从而可以减小算法整体的时间复杂度,但涉及位运算的题目技巧性也比较强。本文将介绍
C
+
+
{\rm C++}
C++中常用到的几种位运算,包括与&
、或|
、非~
和异或^
。
2. 常用的位运算
2.1 与运算 &
与运算的基本性质是:当两个二进制位进行与运算时,如果至少其中一位为
0
0
0,则最终结果为
0
0
0。如11010010 & 01001101 = 01000000
。涉及到具体的数字时,与运算具有如下性质:
x & 1 == 0/1 // 如果结果为0则x为偶数,否则x为奇数
x & (x - 1) // 可将最右边的1变成0
2.2. 或运算 |
或运算的基本性质是:当两个二进制位进行或运算时,如果至少其中一位为
1
1
1,则最终结果为
1
1
1。如11010010 | 01001101 = 11011111
。涉及到具体的数字时,或运算具有如下性质:
x | 1 // 将x的最后一位强制变为1
x | 2 // 将x的倒数第二位强制变为1,其他情况类似
2.3 非运算 ~
非运算的基本性质是:当二进制位进行非运算时,所有位取反即
0
0
0变成
1
1
1,
1
1
1变成
0
0
0。如~11010010 = 00101101
。涉及到具体的数字时,非运算具有如下性质:
x + (~x) = -1 // 一个数与它非后的结果相加得到的结果为-1
2.4 异或运算 ^
异或运算的基本性质是:当两个二进制位进行异或运算时,如果两个二进制相同则最终结果为
1
1
1;否则为
0
0
0。如11010010 ^ 01001101 = 10011111
。涉及到具体的数字时,异或运算具有如下性质:
x = x ^ y, y = x ^ y, x = x ^ y // 交换x和y
x ^ 0 = x // 任何数与0异或的结果为其本身
x ^ x = 0 // 相同数之间的异或结果为0
2.5 移位运算 << 和 >>
异或运算的基本性质是:左移运算符<< n
相当于原操作数乘以2 ^ n
,右移运算符>> n
相当于原操作数除以2 ^ n
。如2 << 1 = 4, 2 >> 1 = 1
。
在执行右移运算时,移出去的位直接舍弃,而高位补零。而在执行左移运算时,负数的左移运算较为特殊。我们知道,负数在内存的存在形式其实是其补码的形式。正数的原码、反码和补码都是一样的,而负数的反码是最高位符号位不变,其余位取反,补码是原码的反码再加一。在计算时,符号位也会参与运算,如(以 8 8 8位为例):
25 + (-25) = 0
0001 1010 = 25
+ 1110 0110 = -25 // 原码1001 1010,反码1110 0101,补码1110 0110
-----------------
0000 0000 = 0
同样,在执行移位运算时,最高的符号位也会参与运算。所以,正数左移后可能变为负数,也可能仍为正数;负数左移后可能变为正数,也可能仍为负数。而右移运算不会改变数的正负性,即如果是正数右移,则最高位补零;如果是负数右移,则最高位补一。如:
-35 = 1010 0011 => 反码(1101 1100) 补码(1101 1101)
现在执行左移运算(一位) => 1101 1101 << 1 = 1011 1010(补码)
然后将上述补码还原成原码为(1100 0110) = -70
-66 = 1100 0010 => 反码(1011 1101) 补码(1011 1110)
现在执行左移运算(一位) => 1011 1110 << 1 = 0111 1100(补码)
然后将上述补码还原成原码为(0111 1100) = 124
24 = 0001 1000 => 反码和补码均为(0001 1000)
现在执行左移运算(一位) => 0001 1000 << 1 = 0011 0000(补码)
然后将上述补码还原成原码为(0011 0000) = 48
70 = 0100 0110 => 反码和补码均为(0100 0110)
现在执行左移运算(一位) => 0100 0110 << 1 = 1000 1100(补码)
然后将上述补码还原成原码为(1111 0010) = -114
-1 = 1000 0001 => 反码(1111 1110) 补码(1111 1111)
由于右移运算不改变数的正负性,所以-1无论右移多少位,其结果均不改变
3. 位运算经典例题
3.1 丢失的数字
题目来源 268.丢失的数字
题目描述 给定一个包含[0, n]
中n
个数字的数组nums
,找出该数组中没有出现的那个数。并且假定数组中没有重复的数字。
如输入数组为nums[3, 0, 1]
,这里n = 3
,则没有出现的那个数字为2
。
这里,我们可以根据异或的性质直接在线性时间复杂度内找到该丢失的数字,x ^ x = 0
。由于在正常情况下,数组中的元素与其索引是一一对应的,如0 ~ nums[i] = 0, 1 ~ nums[i] = 1
等,其中i
表示数组中的某一位。现在由于丢失了0~n
之间的某个数,所以会出现某个索引没有与之对应的元素的情况,而要保持数组长度题目引入了nums.size()
这个值。所以,我们可以初始化变量为nums.size()
,然后不同将索引与之对应的元素做异或运算,最后保留下来的数字即为丢失的数字。程序如下:
int missingNumber(vector<int>& nums) {
int missing = nums.size();
// 遍历数组并不断做异或运算
for (int i = 0; i < size; ++i) {
missing ^= i ^ nums[i];
}
return missing;
}
其他题解 官方题解
3.2 两整数之和
题目来源 371.两整数之和
不使用+
运算符和-
运算符,计算两整数a
和b
之和。
由于不能直接使用加和减等运算符,我们将相加的数字展开成二进制位,如:
0 1 0 1 // 5
0 1 0 0 // 4
———————
1 0 0 1 // 9
通过上面的竖式我们可以轻易得到如果两个二进制位相加没有进位时,直接使用异或操作就可以了,即0 | 0 = 0, 0 | 1 = 1, 1 | 0 = 1
。当有进位时我们发现如果仍使用异或运算,则同为1
的两个二进制位会抵消为0
,这里我们可以转而使用与运算判断是否有进位。当两个二进位均为1
相加时会产生进位,我们可以使用与运算保存有进位的位置,然后将其左边一位加一就实现了进位操作。程序如下:
int getSum(int a, int b) {
// 两数异或得到不仅为的相加结果
// 两数与操作得到进位的位置
// 当存在进位时则一直循环
while (b) {
// 获得进位
int carry = ((unsigned int) a & b) << 1;
a = a ^ b; // 与运算
b = carry; // 保存余数,通过下一次的与运算即可将进位添加到结果中
}
return a;
}
在 L e e t C o d e {\rm LeetCode} LeetCode上还有许多关于位运算的题目,其思路大都是比较清奇的,需要多多练习,这里只给出了较为经典的两道题目。
4. 总结
本文总结了算法与数据结构中位运算的基本用法,通常位运算比乘法和除法的执行时间更短。而使用位运算解题的关键是记住常用位运算操作的基本性质,以及将它们组合所能得到的结果。
参考
- https://leetcode-cn.com/tag/bit-manipulation/.