有趣的位运算
有趣的位运算
计算机的终极程序其实只有0和1,转化成集成电路的低电压和高电压来进行存储和运算。如果你是计算机相关专业出身或者是一名软件开发人员即使不对计算机体系结构如数家珍,至少也要达到能够熟练使用位运算的水平,要不然还是称为代码搬运工比较好:),位运算非常简单,非常容易理解而且很有趣,在平时的开发中应用也非常广泛,特别是需要优化的大数据量场景。你所使用的编程语言的+-*/实际上底层也都是用位运算实现的。在面试中如果你能用位运算优化程序、进行集合操作是不是也能加分呀。花费很少的时间就能带来很大的收获何乐而不为。本文总结了位运算的基本操作、常用技巧和场景实践,希望能给你带来收获。
原码、反码和补码
在讨论位运算之前有必要补充一下计算机底层使用的编码表示,计算机内部存储、计算的任何信息都是由二进制(0和1)表示,而二进制有三种不同的表示形式:原码、反码和补码。计算机内部使用补码来表示。
原码,就是其二进制表示(注意,有一位符号位)
反码,正数的反码就是原码,负数的反码是符号位不变,其余位取反
补码,正数的补码就是原码,负数的补码是反码+1
符号位,最高位为符号位,0表示正数,1表示负数。在位运算中符号位也参与运算。
位运算的基本操作
这里只涉及编程语言中拥有运算符号的位运算,其他运算不在讨论范围内。常用的位运算主要有6种:按位与、按位或、左移、右移、按位取反、按位异或。最后补充一种逻辑右移。
按位与操作 &
按位与&操作是指对两操作数进行按位与运算,其中两位都为1结果为1,其他情况为0。按位与是二目运算符。
1 & 1 = 1 1 & 0 = 0 0 & 1 = 0 0 & 0 = 0
例如:3 & 17 = 1
3=00000011
17=00010001
&=00000001
注意,这里表示二进制不足的位用0补足。
按位或操作 |
按位或 | 操作是指对两个操作数进行按位或运算,其中有至少有1位为1结果就为1,两位都为0结果为0。按位或运算是二目运算符。
1 | 1 = 1 1 | 0 = 1 0 | 1 = 1 0 | 0 = 0
例如:3 | 17 = 19
3=00000011
17=00010001
| =00010011
按位非操作 ~
按位非操作 ~ 就是对操作数进行按位取反,原来为1结果为0,原来为0结果为1。按为非操作是单目运算符。
例如:~33=-34
33= 00000000000000000000000000100001 (整数为32位)
~33=11111111111111111111111111011110=-34 (补码表示,符号位也参与运算)
左移操作 <<
左移操作 << 是把操作数整体向左移动,左移操作是二目运算符。
例如:33 << 2 = 100001 << 2 = 10000100 = 132
-2147483647 << 1 = 10000000000000000000000000000001 << 1 = 10 = 2 (符号位也参与运算)
技巧:a << n = a * 2^n (a为正数)
右移操作 >>
右移操作 >> 是把操作数整体向右移动,右移操作是二目运算符。
例如:33 >> 2 = 100001 >> 2 = 001000 = 8
-2147483647 >> 1 = 10000000000000000000000000000001 << 1 = 11000000000000000000000000000000 = -1073741824 (符号位也参与运算,补足符号位)
技巧:a >> n = a / 2^n (a为正数)
补充:逻辑右移 >>>
逻辑右移和右移的区别是,右移将A的二进制表示的每一位向右移B位,右边超出的位截掉,左边不足的位补符号位的数(比如负数符号位是1则补充1,正数符号位是0则补充0),所以对于算术右移,原来是负数的,结果还是负数,原来是正数的结果还是正数。逻辑右移将A的二进制表示的每一位向右移B位,右边超出的位截掉,左边不足的位补0。所以对于逻辑右移,结果将会是一个正数。
例如上面的-2147483647 >>> 1 = 01000000000000000000000000000000 = 1073741824 (补足0)。
按位异或操作 ^
按位异或 ^ 操作是把两个操作数做按位异或操作,其中两位相同则为0,不同则为1,按位异或是二目运算符,又称为不进位加法。
1 ^ 1 = 0 1 ^ 0 = 1 0 ^ 1 = 1 0 ^ 0 = 0
例如:33 ^ 12 = 45
33=00100001
12=00001100
^ = 00101101
技巧:异或是不进位加法,两个数做加法,把进位舍去。
位运算的技巧应用
不进位加法-异或 ^
异或是按位相同为0不同为1,其实就是做加法的过程把进位舍去了。这样一来我们就可以利用这个性质解决问题。想一想加法是怎么实现的呢?
如果两数相加没有进位是不是直接可以使用异或了?那如果有进位呢?那就把进位加上。
a + b = (a ^ b) + 进位
考虑一下进位如何实现,有进位的地方就是两个位都为1的地方,这就可以利用按位与运算了,与运算两个都为1结果为1其他情况为0,把两数相与的结果左移一位就是进位的结果。
a + b = (a ^ b) + ((a & b) << 1)
这样就完了吗?没有啊,这个还是使用了+号啊。不使用+号那就递归直到进位为0,或者使用循环一直对进位做不进位加法直到进位为0。
加法有了,-*/还会远吗
public int plus(int a, int b) { if (b == 0) return a; int _a = (a ^ b); int _b = ((a & b) << 1); return plus(_a, _b); } public int plus(int a, int b) { while (b != 0) { int _a = (a ^ b); int _b = ((a & b) << 1); a = _a; b = _b; } return a; }
异或两次等于没有异或 a ^ b ^ b = a
基于两个相同的数异或结果为0,一个数和0异或结果不变那么就是异或两次等于没有异或。a ^ b ^ b = a。
技巧应用:给一个数组,数组中的数字只有一个出现了一次,其他的都出现了两次,找出这个只出现一次的数字。
这个问题就可以巧妙的使用异或运算,把数组的数字全部异或一遍,得到的结果就是只出现一次的数字。
public int singleNumber(int[] nums) { int result = 0, n = nums.length; for (int i = 0; i < n; i++) { result ^= nums[i]; } return result; }
类似的问题还有很多,统一来说就是数组中只有一个数字出现了m次,其他的都出现了k次,找出出现m次的数字。这一类问题基本上都可以考虑使用异或来解决。有兴趣可以参考:http://www.lintcode.com/en/problem/single-number,链接后可以再加-ii,-iii,-iv。
取a最后一位1的位置a & (-a)
在机器中都是采用补码形式存在,负数的补码是反码+1。因此a & (-a)是取最后一位1。
例如:33 & (-33) = 1
33 = 00000000000000000000000000100001
-33=11111111111111111111111111011111
& = 00000000000000000000000000000001
技巧应用:给一个数组,只有两个数出现了一次,剩下的都出现了两次,找出出现一次的两个数字。
这个问题可以拆解成两个问题,把数组分成两部分,没一部分都满足只有一个数出现了一次剩下的都出现了两次,找出只出现一次的数字。这个问题就可以利用异或来解决了。关键就是怎么把数组分成这样的两部分。那就先把所有数字异或起来最后结果就是相当于只出现一次的两个数字异或的结果,对这个结果取最后一个1,那么在这一位这两个数肯定是不同的,接下来就可以根据这一位是不是1来把所有数字分到两个数组中。
自己体会。
public int[] singleNumber(int[] nums) { //用于记录,区分“两个”数组 int diff = 0; for(int i = 0; i < nums.length; i ++) { diff ^= nums[i]; } //取最后一位1 diff &= -diff; int[] rets = {0, 0}; for(int i = 0; i < nums.length; i ++) { //分属两个“不同”的数组 if ((nums[i] & diff) == 0) { rets[0] ^= nums[i]; } else { rets[1] ^= nums[i]; } } return rets; }
去掉a的最后一位1 a & (a - 1)
两个相同的数相与结果不变,那么a & (a - 1)就得到了a去掉最后一位1的数,这非常好理解。
例如:33 & (33 - 1) = 33 & 32 = 100001 & 100000 = 100000 = 32
技巧应用I:判断一个数是否是2的次幂。从二进制的角度思考,一个数如果是2的次幂,那么需要满足这个数大于0,这个数的二进制表示有且只有一个1.
直接把这个唯一的1消去看是否为0就可以了。
public boolean isPowerOf2(int n) { return n > 0 && (n & (n - 1)) == 0; }
技巧应用II:求一个整数的二进制表示的1的个数。有了这个技巧这个问题就非常简单了,把1全部消去,看消了几次就可以了。
public int countOnes(int num) { int count = 0; while (num != 0) { num = num & (num - 1); count++; } return count; }
技巧应用III:求一个整数转化为另一个整数需要改变多少位。这个问题也就是求两个整数有多少位不同就行了,改变不同的位置就能变成另一个数。使用异或非常简单的求出有多少位不同,然后问题就变成了上一个问题,求异或结果的1的个数。
public int countOnes(int num) { int count = 0; while (num != 0) { num = num & (num - 1); count++; } return count; } public int bitSwapRequired(int a, int b) { return countOnes(a ^ b); }
使用bit表示状态
在解决一个问题的时候,通常需要记录一些数据的状态和枚举,可以使用整数、布尔类型或者数组来表示,但是当状态多了之后就会占用大量的存储空间。这时候就可以把状态压缩成bit来表示。
例如:求一个集合的所有子集。这是一个NP问题,通常情况下使用回溯递归来解决。
public List<List<Integer>> subsets(int[] nums) { List<List<Integer>> ret = new ArrayList<>(); if(nums == null || nums.length == 0) return ret; List<Integer> list = new ArrayList<>(); dfs(ret, list, nums, 0); return ret; } private void dfs(List<List<Integer>> ret, List<Integer> list, int[] nums, int start) { if (start > nums.length) return; ret.add(new ArrayList<Integer>(list)); for (int i=start; i<nums.length; i++) { list.add(nums[i]); dfs(ret, list, nums, i+1); list.remove(list.size()-1); } }
换一个角度,使用一个正整数二进制表示的第i位是1还是0来代表集合的第i个数取或者不取。所以从0到2^n-1总共2^n个整数,正好对应集合的2^n个子集。如果集合为{1,2,3}则
0 000 {} 1 001 {1} 2 010 {2} 3 011 {1,2} 4 100 {3} 5 101 {1,3} 6 110 {2,3} 7 111 {1,2,3}
public List<List<Integer>> subsets(int[] nums) { List<List<Integer>> ret = new ArrayList<>(); int n = nums.length; for (int i=0; i<(1 << n); i++) { List<Integer> subset = new ArrayList<Integer>(); for (int j=0; j<n; j++) { if ((i & (1<<j)) != 0) //检查是否是1 subset.add(nums[j]); } ret.add(subset); } return ret; }
虽然在时间复杂度上没有优化但是这个位运算的解法还是比递归快了整整1ms(在leetcode上)。
位运算的工程实践
接下来看一个工程实践的例子,给两个8G的文件,文件每行存储了一个正整数,求这两个文件的交集,存储在另一个文件中,要求最多只能使用4G内存。你可能会想到把大文件分割成小文件,分批对比,这样比较麻烦,如果想到使用bit来压缩状态表示的话这个问题就变得简单了。
使用一个bit来表示这个整数存在或不存在,存在置1,不存在置0。先遍历一个文件,把所有整数的状态置位,然后遍历另一个文件,读取整数的bit如果为1则存储在结果文件中,如果为0则继续。这样就求出了交集。如果用一个bit表示一个整数的状态的话,4G内存可以表示34359738368个整数。如果一个整数存储在文件中平均占用6个字符的话4G内存所表示的整数能够存满192GB的空间。这样看起来,这种解法在时间和空间上都是满足要求的,并且思路清晰简单。
那么问题来了,怎么把二进制的某一位置1或者置0呢?比如,将a的第n位置1,首先通过1 << n得到只有第n位为1的数,然后进行按位或运算a | (1 << n)。同理如果把第n位置0,得到只有第n位为1的数后取反,然后做按位与运算a & ~(1 << n)。
那么如果相判断某一位是否为1呢?同样的先通过1 << n得到只有第n位为1的数然后做按为与运算,如果结果为0则原来位上为0否则为1, a & (1 << n)。
例如:
33 | (1 << 3) = 100001 | 001000 = 101001
33 & ~(1 << 5) = 100001 & 000001 = 000001
//伪代码 bit=0 while (num1 = readLine(file1)) { bit |= (1 << num1) } while (num2 = readLine(file2)) { if ((bit & (1 << num2)) == 0) continue else writeLine(file3, num2) }
其实这个就是BitSet的简单实现,可以看一下Java中BitSet源码的几个关键方法:
public void set(int bitIndex) { if (bitIndex < 0) throw new IndexOutOfBoundsException("bitIndex < 0: " + bitIndex); int wordIndex = wordIndex(bitIndex); expandTo(wordIndex); words[wordIndex] |= (1L << bitIndex); // Restores invariants checkInvariants(); } public void clear(int bitIndex) { if (bitIndex < 0) throw new IndexOutOfBoundsException("bitIndex < 0: " + bitIndex); int wordIndex = wordIndex(bitIndex); if (wordIndex >= wordsInUse) return; words[wordIndex] &= ~(1L << bitIndex); recalculateWordsInUse(); checkInvariants(); } public boolean get(int bitIndex) { if (bitIndex < 0) throw new IndexOutOfBoundsException("bitIndex < 0: " + bitIndex); checkInvariants(); int wordIndex = wordIndex(bitIndex); return (wordIndex < wordsInUse) && ((words[wordIndex] & (1L << bitIndex)) != 0); }
这个问题还有许多拓展问题,例如求差集、并集、对海量的URL去重、网页去重、垃圾邮件过滤等等,这些问题都可以用类似的思路去解决,只不过在真实的工程实践中很多代码可以不用写的这么底层。可以使用已经实现好的BitSet、Redis Bit和Bloomfilter。关于Bloomfilter可以参考下面的开源项目,集成了Java的BitSet和Redis,有很好的扩展性。可以直接使用Maven引入依赖,使用也很简单。
项目地址:https://github.com/wxisme/bloomfilter
总结
熟悉和善于使用位运算会将一些问题简单化并且能够提升效率和空间利用率,在某些特定场景下也是必须的,还可以帮助你去阅读包含位运算的源代码。同样也不能滥用位运算,有时候会增加问题复杂度而且会让你的代码变得阅读性很差,例如上面的工程问题,如果整数的数量很少,那你大可不必用bitset解决,使用最简单的方法还可以避免一些未知的问题(坑)。
补充:浮点数的二进制表示
浮点数不在本文讨论范围,浮点数的表示要比整数复杂一些,计算机中的浮点数本身就是有误差的,并且需要比较多的CPU运算,因此尽量使用整数类型,关于浮点数的表示可以参考:程序员必知之浮点数运算原理详解
推荐阅读书籍:《深入理解计算机系统(原书第3版)》
如果文章对你有帮助,请点击推荐鼓励作者 :)