很多初学程序设计的人不熟悉位运算,或者只是听说过名字,没有真正在程序中使用过。实际上,位运算不仅可以大大提升程序的执行速度,而且可以很巧妙地解决一些普通方法无法解决的问题。这篇文章就来盘点几个常见的位运算tricks。
一、交换两个整数
如何交换两个整数呢?不少人的第一反应是下面的代码:
1
2
3
int tmp = b ;
b = a ;
a = tmp ;
如果对问题做一些限制:不能引入临时变量。那么很多人会进一步想到下面的代码:
1
2
3
a = a + b ;
b = a - b ;
a = a - b ;
但是上面的代码有一个问题:当a和b太大的时候,有可能在执行a = a + b
一行的之后直接溢出。有没有可以避免溢出,并且不引入临时变量的方法呢?答案就是利用异或运算。
1
2
3
a = a ^ b ;
b = a ^ b ;
a = a ^ b ;
上面的代码为什么是正确的?我们要从异或谈起。异或运算满足三个性质:首先,一个数异或0等于它本身;其次,一个数异或它本身等于0;最后,异或满足交换律和结合律。
所以,第二句话中的b
其实等于(a ^ b) ^ b = a ^ (b ^ b) = a ^ 0 = a
。
同理,第三句话中的a
其实等于(a ^ b) ^ a = b ^ (a ^ a) = b ^ 0 = b
。
这就达到了交换两个整数的目的。
二、判断整数二进制表示中1的个数
首先我们来观察a & (a - 1)
这个表达式干了什么。
如果a
的二进制最后一位是1,那么将a
减一,即最后一位变为0,而前面其他位都不变,将a
和a - 1
按位与,也就相当于消掉了a
的最后一个1。
如果a
的二进制最后一位为0,且从右往左数第m位第一次出现1,那么将a
减一,则第m位由1变成0,m右面的所有位都变成1,m左边的所有位不变。将a
和a - 1
按位与,还是相当于消掉了a
的最后一个1。
总结:a & (a - 1)
用来消去a
的二进制表示的最右边的1。
所以,为了判断a
的二进制表示中1的个数,我们可以一直让a
与a - 1
进行按位与,一直消掉最右边的1,直到所有的1全部消掉为止。这个判断方法对于负数好像依然有效。
1
2
3
4
5
6
int count = 0 ;
while ( n ){
n &= ( n - 1 );
count ++ ;
}
return count ;
扩展: 如何判断一个整数是否是2的整数次方?
思路与答案
展开
一个正整数是2的整数次方的充要条件是其二进制表示中只有1个1。弄清楚这一点,写出对应的代码就很简单了。
1
return ( n > 0 ) && (( n & ( n - 1 )) == 0 );
扩展: 输入一个非负整数n
,我们要输出一个大小为n+1
的数组arr
,其中arr[i]
表示i
的二进制表示中所含有的1的个数。
思路与答案
展开
我们很容易想出一个时间复杂度为
O(n*sizeof(int))
的方法,但是我们有更好的方法,可以在
O(n)
的时间内仅遍历一次即可得出结果。那就是利用动态规划。
回想我们求一个数二进制表示方式中1的数量时采取的方法,很容易列出状态转移方程:
F(n) = 1 + F(n & (n-1)) (n > 0)
,以及
F(0) = 0
。对于正整数而言,
n & (n-1)
一定小于
n
,所以我们列出的状态转移方程是可行的。
对应的代码如下:
1
2
3
4
5
6
vector < int > countBits ( int num ) {
vector < int > res ( num + 1 , 0 );
for ( int i = 1 ; i <= num ; i ++ )
res [ i ] = 1 + res [ i & ( i - 1 )];
return res ;
}
三、数组中只出现一次的元素
有一个int
类型的数组arr
,其中除了一个元素只出现一次之外,其他的每个元素都出现正好两次。将这个单独的元素找出来。要求O(n)
的时间复杂度以及O(1)
的空间复杂度。
这个题仍然是利用异或运算的性质,我们只需要令res = 0
,然后令res
依次异或arr
中的每个元素。这样,arr
中成对出现的元素都会异或成0,最后剩余的就是那个单独的元素。
1
2
3
4
5
6
int singleNumber ( vector < int >& nums ) {
int res = 0 ;
for ( int i = 0 ; i < nums . size (); i ++ )
res ^= nums [ i ];
return res ;
}
扩展: 还是上面那个数组arr
,这次变成了有两个单独的元素。需要把这两个单独的元素全部找出来。
思路与答案
展开
解题的基本思路还是基于异或。假设两个单独的元素是
a
和
b
:
我们按照与上面相同的方法,将数组中所有的元素依次异或一遍,可以知道,得到的值是
a ^ b
。
由于
a
不可能等于
b
,所以我们知道得到的
a ^ b
的二进制表示中肯定至少有1位是1。我们找到这样的一位,可以知道,
a
和
b
的二进制表示在这一位是不同的。
然后我们遍历原数组,根据原数组的每个元素的这一位是不是1,我们可以将原数组分为两部分,并且
a
和
b
肯定处于不同的部分,且每个部分都满足“所有元素都出现两次,只有一个元素出现一次”的性质。
到此为止,这个问题就转化为了之前的问题。只需要对每个部分异或一遍就可以了。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
void FindNumsAppearOnce ( vector < int > data , int * num1 , int * num2 ) {
if ( data . size () <= 1 ){
num1 = NULL ;
num2 = NULL ;
return ;
}
int exclusiveRes = 0 ;
for ( int i = 0 ; i < data . size (); i ++ )
exclusiveRes ^= data [ i ];
int differentBit = 0 ;
while (( exclusiveRes & ( 1 << differentBit )) == 0 )
differentBit ++ ;
int res1 = 0 ;
int res2 = 0 ;
int compareFlag = ( 1 << differentBit );
for ( int i = 0 ; i < data . size (); i ++ ){
if ( data [ i ] & compareFlag )
res1 ^= data [ i ];
else
res2 ^= data [ i ];
}
( * num1 ) = res1 ;
( * num2 ) = res2 ;
return ;
}
扩展: 还是上面那个数组arr
,这次除去一个单独的元素之外,每个元素都出现正好三次。找出那个单独的元素。
思路与答案
展开
原则上来讲,只要元素出现的次数为偶数,都可以用异或的方法解决,但是如果是三次就不可以了,我们要寻求新的解决方法。
由于
int
类型有32位,我们可以统计每一位上1的出现次数。如果没有那个单独的元素,那么每一位上1的出现次数肯定是3的倍数。现在加入了那个单独的元素,一定有某些位上1的出现次数是3的倍数加1。
所以我们可以将所统计的每一位上1的出现次数对3取余。如果余数为0,说明单独的元素这一位并不是1;如果余数为1,说明单独的元素这一位是1。这样我们就找到了那个单独的元素所有是1的二进制位,进而还原出该元素。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int singleNumber ( vector < int >& nums ) {
if ( nums . size () == 0 )
return 0 ;
int mark [ 32 ] = { 0 };
for ( int i = 0 ; i < nums . size (); i ++ )
for ( int j = 0 ; j < 32 ; j ++ )
if (( nums [ i ] & ( 1 << j )) != 0 )
mark [ j ] ++ ;
int res = 0 ;
for ( int i = 0 ; i < 32 ; i ++ )
if ( mark [ i ] % 3 == 1 )
res |= ( 1 << i );
return res ;
}
四、求一个数组的所有子集
已知一个数组中没有相同的元素,且该数组长度不大于32,求它的所有子集,且每个子集中的元素按照从小到大的顺序排列。
通过集合论的知识,我们知道对于一个集合来说,若其元素个数为n
,则其子集的个数为2 ^ n
。对于任意一个子集,我们用一个长度为n
的二进制串来表示元素的状态。如果第i
位是1,说明原数组的第i
个元素位于当前子集中,否则说明没有位于当前子集中。
因此我们可以枚举0
到2 ^ n - 1
,然后对其中的每一个数依次还原相应的子集。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
vector < vector < int >> subsets ( vector < int >& nums ) {
vector < vector < int >> ret ;
int limit = ( 1 << nums . size ()) - 1 ;
sort ( nums . begin (), nums . end ());
for ( int i = 0 ; i <= limit ; ++ i ){
vector < int > tp ;
int p = i , now = 0 ;
while ( p ){
if ( p & 1 == 1 )
tp . push_back ( nums [ now ]);
p >>= 1 ;
++ now ;
}
ret . push_back ( tp );
}
return ret ;
}
扩展: 给定一个9×9大小的char
类型数组,代表一个尚未被填充的数独。其中.
代表该位置为空位,1-9
代表该位置有数字,判断这个数独是否是有效的。(需要注意的是,一个有效的数独不一定有解,只需要初始状态下被填充的格子满足数独的要求即可。)
思路与答案
展开
这个题的关键在于对每行、每列和每个对角线分别记录数字
1-9
是否存在,我们当然可以用一个长度为9的
bool
类型数组去记录,但是更好的方法是只用一个
int
类型的整数去记录,其二进制表示的第0到8位分别代表
1-9
是否存在。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
int getCell ( int i , int j ){
int a = i / 3 ;
int b = j / 3 ;
return a * 3 + b ;
}
bool isValidSudoku ( vector < vector < char >>& board ) {
if ( board . size () != 9 )
return false ;
if ( board [ 0 ]. size () != 9 )
return false ;
vector < int > col ( 9 , 0 );
vector < int > row ( 9 , 0 );
vector < int > cell ( 9 , 0 );
for ( int i = 0 ; i < 9 ; i ++ ){
for ( int j = 0 ; j < 9 ; j ++ ){
if ( board [ i ][ j ] == '.' )
continue ;
int num = board [ i ][ j ] - '0' ;
int mark = 1 << ( num - 1 );
int cellIndex = getCell ( i , j );
if (( col [ j ] & mark ) != 0 || ( row [ i ] & mark ) != 0 || ( cell [ cellIndex ] & mark ) != 0 )
return false ;
col [ j ] |= mark ;
row [ i ] |= mark ;
cell [ cellIndex ] |= mark ;
}
}
return true ;
}
上面的两个问题使我们意识到,我们可以利用二进制记录一个元素在或不在的状态,也就是建立一个简易的hashset。事实上,在海量数据的处理问题中,经常用到一种类似的方法:将一组有范围的数据中的每一个数据映射为内存中的一个bit,该方法叫做bitmap。
例题: 我们有40亿个不重复的无序unsigned int
数据,然后给定一个数,如何快速判断这个数是否在那40亿个数当中?
思路与答案
展开
unsigned int
类型整数的大小范围为0
到2 ^ 32 - 1
,也就是有2 ^ 32
即4G种情况。
我们可以在内存中申请一块空间,空间的每一个bit代表一个整数,这样只需要4G / 8 = 512M的空间即可,是可以接受的。
我们将这些bit都初始化为0,然后我们遍历这40亿个整数,每遍历到一个,则将其对应的bit置为1。最后我们检查给定的数对应的bit:如果是0,说明该数不在那40亿个数中;否则说明在那40亿个数中。
扩展: 如何在2.5亿个int中找出不重复的整数?
思路与答案
展开
int
类型的取值有2 ^ 32
种情况,我们为每个数分配两个bit的空间,这样所消耗的内存即为4G / 4 = 1G,可以接受。
我们可以令00表示该整数没有出现过,01表示出现过一次,10表示出现过多次。每读入一个数,就更改相应位置的数值。最后遍历一遍这1G大小的空间,将值为01的位置对应的整数输出即可。
五、反转一个整数的二进制表示
将一个unsigned int
类型的二进制表示中的32位反转,并输出得到的新整数。以4位二进制为例,如果输入为1(二进制为0001
),则输出为8(二进制为1000
)。
我们可以定义一个变量m = 0
存储结果,每次将m
左移一位,然后取原数字n
的最低位赋给m
的最低位,最后将n
右移一位。这样循环32次即为结果。
1
2
3
4
5
6
7
8
9
uint32_t reverseBits ( uint32_t n ) {
uint32_t m = 0 ;
for ( int i = 0 ; i < 32 ; i ++ ){
m <<= 1 ;
m = m | ( n & 1 );
n >>= 1 ;
}
return m ;
}
当然,关于这个问题,还有许许多多更加变态的方法。下面举一例不那么变态的:
反转一个数的二进制表示,其实可以用类似于归并排序的方法完成。第一步,将原数字的第1位和第2位、第3位和第4位……依次交换;第二步,将原数字的第1-2位与第3-4位、第5-6位与第7-8位……依次交换;第三步,将原数字的第1-4位和5-8位、第9-12位和13-16位……依次交换。依次类推,对于32位的unsigned int
类型,仅需进行log(32) = 5
次即可完成。这种思路写成代码更是十分工整:
1
2
3
4
5
6
7
8
9
uint32_t reverseBits ( uint32_t n ) {
uint32_t x = n ;
x = (( x & 0x55555555 ) << 1 ) | (( x & 0xAAAAAAAA ) >> 1 );
x = (( x & 0x33333333 ) << 2 ) | (( x & 0xCCCCCCCC ) >> 2 );
x = (( x & 0x0F0F0F0F ) << 4 ) | (( x & 0xF0F0F0F0 ) >> 4 );
x = (( x & 0x00FF00FF ) << 8 ) | (( x & 0xFF00FF00 ) >> 8 );
x = (( x & 0x0000FFFF ) << 16 ) | (( x & 0xFFFF0000 ) >> 16 );
return x ;
}
六、格雷码
在一组数的编码中,若任意两个相邻的代码只有一位二进制数不同,则称这种编码为格雷码。比如三位的典型格雷码序列为000, 001, 011, 010, 110, 111, 101, 100
。现在输入一个整数n
(不大于31),输出n
位的典型格雷码序列。
我们不妨称k
位的典型格雷码序列为ans{k}
,如何由ans{k-1}
得到ans{k}
呢?
观察格雷码的规律我们可以发现,ans{k}
的前半部分的最高位均为0,且除了最高位之外的部分与ans{k-1}
完全相同;后半部分的最高位均为1,而除了最高位之外的部分正好和ans{k-1}
成倒序关系。
通过这个规律,我们可以写出生成n
位典型格雷码序列的递归版本和非递归版本。这里仅写出非递归版本:
1
2
3
4
5
6
7
8
9
vector < int > grayCode ( int n ){
vector < int > ans ( 1 , 0 );
for ( int i = 0 ; i < n ; i ++ ){
for ( int j = ans . size () - 1 ; j >= 0 ; j -- ) {
ans . push_back (( 1 << i ) + ans [ j ]);
}
}
return ans ;
}
七、不使用四则运算符实现加法
考虑二进制下两个数相加的操作,其实分三步:
首先不考虑进位,完成两个数的加法运算,结果为sum
;
然后只考虑进位,计算两个数相加之后的进位情况,结果为carryOver
;
最后将sum
和carryOver
相加,注意这一步相当于递归调用,仍然是拆分成先不考虑进位计算加法,然后计算进位,最后二者相加这三步。递归的终止条件是carryOver
的值是0。
那么如何用位操作实现第一步和第二步呢?
首先不考虑进位的情况下,0和0以及1和1都会得到0,而0和1以及1和0会得到1。这其实是二进制的异或运算。然后只考虑进位的情况下,只有1和1的情况下会进位,所以相当于是二进制的与运算之后左移一位。
我们可以列出表达式:a + b = (a ^ b) + ((a & b) << 1)
。根据此表达式写出代码:
1
2
3
4
5
6
7
8
9
10
11
12
int Add ( int num1 , int num2 )
{
int sum ;
int carryOver ;
do {
sum = num1 ^ num2 ;
carryOver = ( num1 & num2 ) << 1 ;
num1 = sum ;
num2 = carryOver ;
} while ( num2 );
return num1 ;
}
扩展: (x & y) + ((x ^ y) >> 1)
是干什么用的?
思路与答案
展开
x & y
是取二者相同的位与,得到的结果是二者相同的位之和的一半。
x ^ y
是取二者不同的位之和,所以(x ^ y) >> 1
是二者不同的位之和的一半。
所以,上面两个表达式相加,其实就是取x
和y
的平均数。
总结
本篇文章总结了二进制和位运算的一些用途。其实,位运算还有很多其他的妙用,包括快速幂算法、树状数组 等。有时间我会继续在博客上介绍这些内容。