一、引言
这是我翻译自 A summary: how to use bit manipulation to solve problems ealily and efficiently 的译文。
这篇笔记整理是 LeetCode 第 371 题的讨论区最高票答案,其中整理了有关如何简单、高效地使用位操作来解决问题的方法。其中文章开篇,先将基础的常见的位操作使用方法梳理了一遍,然后晓之以具体事例来进行讲解,最后还扩展延伸,将 LeetCode 上面涉及到位操作的比较困难的题目进行了梳理讲解。
我自己看了之后,觉得作者整理的非常好,一度想要来把它翻译过来,终于在今天有了充足的时间。希望这篇文章在能让自己缅怀一番之余,还能尽自己的绵薄之力让更多的人看到。
其中,因为作者写的 Application 部分涉及到了后面比较难的 LeetCode 原题,这里不作太多的解读,以免让读者日后遇到了原题失去了做题的第一感觉(越是空白的时候做题才越能收获更多)。
看完这篇文章若能够给你一点点位操作技巧的印象,我就已经很满足了。而有句话说的不假:
古人学问无遗力,少壮工夫老始成。
纸上得来终觉浅,绝知此事要躬行。
因为鄙人能力有限,翻译难免有失偏颇,若有错误,尽可指出。
以下是译文内容。
二、整理笔记:如何简单、高效地使用位操作来解决问题
1. WIKI(来自维基百科)
位操作是一种在算法中对一个位(bit)或者小于一个字(word)的数据进行处理的一种操作。计算机程序设计(Computer Programming)过程中需要使用位操作来进行底层设备控制、错误定位、校正算法、数据压缩、加密算法和最优化处理。在其他大多数的情况下,现代的编程语言允许程序员直接使用抽象出来的位操作方法来操作位数据。在代码中,我们可以使用这些位操作运算符:与(AND)、或(OR)、异或(XOR)、非(NOT)和位移(bit shifts)运算。
位操作,在有些情况下可以减少甚至避免对于数据结构的循环,从而带来成倍的性能提升。虽然位操作提高了我们的代码性能,却让我们的代码变得更加的难于书写和维护。
2. 基础
位操作运算符有如下这些:
符号 | 含义 |
---|---|
& | 与 |
| | 或 |
~ | 非 |
^ | 异或 |
<< | 按位左移 |
>> | 按位右移 |
异或操作符并没有对应的相反的操作,这里简单解释下:
在输入两个操作数之后,如果其中有只有一个 1,则异或结果为1。也就是说,如果输入的两个操作数,两个都是 1 或者都是 0,则异或结果为 0。按位异或操作符使用
^
符号表示,一般使用XOR
缩写表示异或操作。
位运算基本的用法如下:
操作 | 英文 | 举例 |
---|---|---|
取并集 | Set Union | A | B |
取交集 | Set intersection | A & B |
取A中不属于B的部分 | Set subtraction | A & ~B |
按位取反 | Set negation | ALL_BITS ^ A or ~A |
将某位设置为1 | Set bit | A |= 1 << bit |
将某位设置为0 | Clear bit | A &= ~(1 << bit) |
测试某位的值 | Test bit | (A & 1 << bit) != 0 |
提取最后一个1 | Extract last bit | A & -A or A & ~(A - 1) or x ^ (x & (x - 1)) |
删除最后一个1 | Remove last bit | A & (A - 1) |
获取全部为1的字节 | Get all 1-bits | ~0 |
上表中出现的部分术语解释如下:
名称 | 含义 |
---|---|
ALL_BITS | 各个位上为1的数字 |
bit | 移动的位数 |
说明下上表中出现的部分操作含义:
名称 | 说明 |
---|---|
A |= 1 << bit | 将1左移bit位,与A对应位或操作,即赋A对应位为1 |
A &= ~(1 << bit) | 将1左移bit位,全部取反,此时应为 01111(类似格式的数),因为是与操作,仅将A对应位的位置为0,其他位未做改变 |
(A & 1 << bit) != 0 | 将A的对应位的值与1相与,如果结果为0,则A的测试位的值为0,否则为1 |
A & -A | 计算机表示正数与负数(补数)是同模的,因此可得A的最后一位(不懂可参考我之前的博客补码10000000为什么可以表示-128?) |
A & ~(A - 1) | 举例参考:A = 1, A - 1 = 0,取反为 255,正好得到最后一位为1;同理,A = 0, A - 1 = -1(也就是255),取反为0,正好得到最后一位为0 |
x ^ (x & (x - 1)) | 举例参考:x = 1, x - 1 = 0, x & (x - 1) = 0, x ^ (x & (x - 1)) = 1;同理,x = 0, x - 1 = -1(也就是255),x & (x - 1) = 0, x ^ (x & (x - 1)) = 0 |
A & (A - 1) | A 与 A - 1的最后一位必然不同,相与必然为0,结果使最右边一位的1设置为0(这里-1的作用是错位生1) |
3. 实例
计算给定数字的二进制表示中 1 的个数
int count_one(int n) {
int count = 0;
while (n) {
n = n & (n - 1);
count++;
}
return count;
}
是否是 4 的幂数(实际上也可以使用映射表、迭代或者递归方法做)
bool isPowerOfFour(int n) {
return !(n & (n - 1) && (n & 0x55555555))
// check the 1-bit location;
}
这里稍微解释下:0x55555555是一个什么样的数字呢?让我们看看它的二进制表示:
0101 0101 0101 0101 0101 0101 0101 0101
这里,因为 int 在 C++ 中一般为 4 个字节表示,所以这里使用了 4 个字节的模,而这个模数字有什么特点呢?只有当我们的参数都是 4 的倍数的值拼凑(和)起来的,才能与这个模数字相与为真(也就是说,后半句代码鉴定了当前是不是 4 的倍数)。
另外,前半句代码:
n & (n - 1)
限制了当前的数字必须只有是 1 个 4 的倍数的值拼凑起来的(比如 12 = 4 + 8)是不可以的。由上述两个条件得到结果。
4. ^
技巧
使用 ^
来删除二进制表示中数字完全相同的位从而保存不同的,或者保存不同的位删除相同的。
SUM OF TWO INTEGERS
使用 ^
和 &
将两个整数相加
int getSum(int a, int b) {
return b == 0 ? a : getSum(a ^ b, (a & b) << 1);
// be careful about the terminating condition
}
关于这道题,我已经有过博客了,想要了解的同学可以点击这里 LeetCode之路:371. Sum of Two Integers,在这里就不做过多的解释了,思路大体一样,只是作者写的更加简练。
MISSING NUMBER
给出一个数组,这个数组都是由不同的数字组成的,这些数字的取值从 0 到 n,请找出那个丢失的数字。当然,你也可以使用数学方法。
比如:
nums = [0, 1, 3]
return 2
int missingNumber(vector<int>& nums) {
int ret = 0
for (int i = 0; i < nums.size(); ++i {
ret ^= i;
ret ^= nums[i];
}
return ret ^= nums.size();
}
这道题很有意思,值得一说:
首先,我们要知道异或操作是用来干嘛的,是用来将相同的与不同的分开的。
那么这里如何使用这个特性呢?我们可以将异或操作集中加入 0 到 n 的顺序操作数,这里,已经有了一份 0 到 n 的操作数的一个拷贝了,此时,我们再来遍历这个参数数组,里面只要有与 0 到 n 的数组不一样的拷贝,那么就可以异或出来这个值。
尤其值得注意的一点是,这里的:
return ret ^= nums.size();
可能很多人不理解,这是因为在循环中,循环变量的取值只能由 0 到 n - 1,n 值需要在最后加上。
5. |
技巧
尽量包容下所有的值为 1 的位。
Largest Power of 2
给定一个数字 N,找出最大的 2 的幂数小于或者等于 N 的值。
long largest_power(long N) {
// changing all right side bits to 1.
N = N | (N >> 1);
N = N | (N >> 2);
N = N | (N >> 4);
N = N | (N >> 8);
N = N | (N >> 16);
return (N + 1) >> 1;
}
这道题需要解释下:
其实作者的注释非常中肯了,就是将当前数字的二进制表达式中的最高位的 1 右边的所有的数字全部置为 1。然后呢,我们再处理最高位的问题,怎么处理这个最高位呢?我们可以使之加 1,实现进位加 1,然后我们再右移 1 位,保证当前数字小于等于参数值。
这是一个非常非常秒的方法!读到这里,实在难掩心中的激动之情。
REVERSE BITS
反转给出的 32 位的无符号整型数。
uint32_t reverseBits(uint32_t n) {
unsigned int mask = 1 << 31, rest = 0;
for (int i = 0; i < 32; ++i) {
if (n & i) rest |= mask;
n >>= 1;
mask >>=1;
}
return rest;
}
这个解法值得一说,首先,将 mask 设置到参数字符的最左边,然后进入循环,循环中,使用:
n & i
来获取到右边第 i 位的值,然后复制给 mask 当前所在位位置,然后参数和 mask 都右移一位。这就相当于什么呢?相当于有个指针,指向了数字二进制位的最左边,我们从右边读,读到 1 个就赋值给最左边 1 个值,完成反转。
接下来看另一个方法:
uint32_t reverseBits(uint32_t n) {
uint32_t mask = 1, ret = 0;
for (int i = 0; i < 32; ++i) {
ret <<= 1;
if (mask & n) ret |= 1;
mask <<= 1;
}
return ret;
}
这个方法其实更加奇妙:
我们把数字的二进制表示首尾相连,我们每次读到一个数字,就从尾巴开始写入这个数字,就像我们弹出一个栈的数据,然后压入一个栈的数据,完成数据反转。
这里,我们不妨这么想象它:ret 是一个新的跟 n 同样长度的数字,然后呢,它的头部接着 n 的头部,它的尾部接着 n 的尾部,我们每次对 n 进行一位左移,就相当于牵着 ret 的尾巴又拉出来了一位。
如果读者可以像我上述描述的那样想象的话,那么这个问题也就很简单了,如此完成了数据的反转。
6. &
技巧
用来甄选一些需要的位。
反转一些指定的位:
x = ((x & 0xaaaaaaaa) >> 1) | ((x & 0x55555555) << 1);
x = ((x & 0xcccccccc) >> 2) | ((x & 0x33333333) << 2);
x = ((x & 0xf0f0f0f0) >> 4) | ((x & 0x0f0f0f0f) << 4);
x = ((x & 0xff00ff00) >> 8) | ((x & 0x00ff00ff) << 8);
x = ((x & 0xffff0000) >> 16) | ((x & 0x0000ffff) << 16);
这里需要解释下:
十六进制 | 二进制 | 功能 |
---|---|---|
0xaa | 1010 1010 | 选择指定 1 位 |
0x55 | 0101 0101 | 与0xaa的位置刚好相反 |
0xcc | 1100 1100 | 选择指定 1 位 |
0x33 | 0011 0011 | 与0xcc的位置刚好相反 |
0xf0 | 1111 0000 | 选择指定 1 位 |
0x0f | 0000 1111 | 与0x0f的位置刚好相反 |
我们可以通过使用特殊的数值,来实现屏蔽或者甄选我们需要的位的功能。
BITWISE AND OF NUMBERS RANGE
给定一个范围[m, n],其中 0 <= m <= n <= 2147483647(0x7FFFFFFF),返回指定范围内所有数字的相与结果。
比如:
[5, 7]
return 4
int rangeBitwiseAnd(int m, int n) {
int a = 0;
while (m != n) {
m >>= 1;
n >>= 1;
a++;
}
return m << a;
}
简要说明下:
要求指定范围内所有数字的相与结果,我们可以将上下限制的两个数字视作参考数,判断是否相等,不等,则每次右移一位,直到相等为止(这是在除去范围内不等的数字位数),然后返回 m 左移 a 位的值。
如果读者还不理解的话,可以这么想象,一定范围内的值,肯定只有一定范围的位数发生改变,我们只需要将这范围的位数全部置 0,则为我们需要返回的值。
NUMBER OF 1 BITS
写这么一个函数:它可以返回一个无符号整型数的二进制表达式中含有 1 的个数。
int hammingWeight(uint32_t n){
int count = 0;
while (n) {
n = n & (n - 1);
count++;
}
return count;
}
这里的:
n & (n - 1)
用来去掉最后一位有效位,不再作过多赘述,在基础模块中已经提到了。
int hammingWeight(uint32_t n) {
ulong mask = 1;
int count = 0;
for (int i = 0; i < 32; ++i) {
if (mask & n) count++;
mask <<= 1;
}
return count;
}
思路很简单,其中:
mask & n
是用来每次左移相与测试各个位的值的(还记得我们上面写的 Test bit 吗)。
7. 应用
这里涉及到更多更难更复杂的 LeetCode 题目,就不一一翻译了,因为毕竟后面的题目还要慢慢做,作者这里的意思就是将 LeetCode 上涉及到位操作的比较困难的题目都梳理了一遍。
如果有感兴趣的同学可以点击这里观看原文的应用部分:
A summary: how to use bit manipulation to solve problems easily and efficiently,在里面找到 Application 标题即可。
三、总结
这篇文章真的非常值得一读,甚至于如果你觉得读我的译文怎么都觉得不够痛快,可以找原文拜读拜读。
翻译的过程,其实也就是第二遍阅读学习的过程,在原文的基础上,我另外添加了一些表格,对于某些生涩的地方,尽我所能进行了阐述,难免会有不到之处,有疑惑的地方可以找原文查看,也可以给我留言。
另外,之所以费尽力气来翻译、阐述这么一篇文章,很大原因在于我很崇拜位操作的神奇之处:
位操作解题的方法,往往给人一种不可思议、难以想象的感觉,即使是理解了之后,依然也会觉得非常奇妙。
也许正是这种奇妙的感觉,促使了我去整理翻译了这么一份宝贵的文章。
依然是那句话:
To be Stronger!