位运算在算法中的应用小结

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/qq_16137569/article/details/82790378

  最近在刷LeetCode,接触到不少靠位运算提升算法效率的题目,这里刚好看到一篇关于位运算的总结,个人觉得挺完善的,这里翻译一下分享给大家,同时感谢一下LHearen大佬对位运算进行了详尽的总结。博客中统一用Python语言解释。

原文地址:https://leetcode.com/problems/sum-of-two-integers/discuss/84278/A-summary:-how-to-use-bit-manipulation-to-solve-problems-easily-and-efficiently

1. 位运算

  首先看下Wiki百科对位操作的定义:
  位操作是一种通过算法来处理bit或比单词短的数据片段的操作。需要位操作的计算机程序任务包括低级设备控制,错误检测和校正算法,数据压缩,加密算法和优化。对于大多数其他任务,现代程序语言允许编程者直接在抽象层面上工作,而不是去编程那些代表这些抽象的bit位(即我们现在编程都是用代码写程序,而不是0和1)。在源码中可以用的位操作包括:AND、OR、XOR、NOT和位移。
  在某些情况中,位操作可以避免或者减少在一个数据结构上需要进行循环的次数,并且可以成倍的效率提升,因为位操作是并行处理的。但是位操作的代码比较难以编写和维护。
  这里我们先用一个简单的问题来感受一下位操作的威力。比如现在问题是要求判断一个数x是不是2的整数幂,普通的思路是用循环不断去判断 x x 看是否能够被2整除,如果是,则除以2,否则这个数不是2的整数幂。代码如下:

def isPowerOfTwo(x):
	"""
	:type x: int
	:rtype: bool
	"""
	if x <= 0:
		return False
		
	while x % 2 == 0:
		x //= 2
		
	return x == 1

x & (x - 1)上面的普通解法,需要进行的循环次数随着x的增大而增大。现在再来看看基于位运算的解法:

def isPowerOfTwo(x):
	"""
	:type x: int
	:rtype: bool
	"""
	return not (x & (x - 1))

  不论x多大,基于位运算的解法统统只需要一步就能搞定,是不是非常简洁?而且位运算比普通计算要快得多。代码中x & (x - 1)的作用是将 x x 的二进制表示中右边第一个1置0。比如原来3的二进制表示是11,最右边的1置0后就变成了10,所以有3 & (3 - 1) = 2。而2的整数幂的二进制表示肯定是只有1个1的,如果将这个1置0,那么结果肯定是0。所以x & (x - 1)结果不为0的x都不是2的整数幂。至于为什么x & (x - 1)的作用是将 x x 的二进制表示中右边第一个1置0在下面统一解释。

2. 位运算的应用实例

  位运算的几个核心操作符是&(按位与)、|(按位或)、~(按位取反)、^(按位异或)、<<(左移)、>>(右移)。下面根据一些具体的问题,来看看位运算怎么应用。

Example 1: 计算给定整数的二进制表达中有多少个1
  最简单的思路就是每次判断数字的二进制表达的最后一位是否为1,如果是1就计数器加一,然后将数字右移一位,但是这样会有很多无效操作,比如100个0连在一起,需要右移100次才能遇到下一个1。这里可以用到上面提到的x & (x - 1),每次去掉最右边的一个1,x中有多少个1,就只要循环这么多次。代码如下:

def count1Bit(x):
	"""
	:type x: int
	:rtype: int
	"""
	count = 0
	while x:
		count += 1
		x &= x - 1
	return count

  上面也提到过,x & (x - 1)的作用是将x的二进制表示中右边第一个1置0,我们来分步看一下为什么会这样子。首先x-1的作用是将x最右边的1置0,并且这个1后面的所有0置1(如果这个1已经是最后一位则忽略不计)。如果将减一后的结果和原数按位相与,首先最右边一个1由于变成0了,肯定与的结果是0,最右边1后面的数字,原数都是0,现在都是1,那么与的结果肯定也还都是0。比如现在一个二进制数是 x :   0010 , 0101 , 0000 x 1 :   0010 , 0100 , 1111 x &amp; ( x 1 ) :   0010 , 0100 , 0000 \begin{aligned}x: &amp;0010,0101,0000\\ x-1: &amp;0010,0100,1111 \\x\&amp;(x-1): &amp;0010,0100,0000\end{aligned} 这里就没有考虑负数的情况了,由于计算机中负数是补码形式存储,而Python中整数的位数又没有限制,感觉没啥意义,这里就pass了。

Example 2: 不使用加减法实现两个整数相加
  不能使用加减法,那就只能靠位运算了。我们可以将加法分解一下,每次先计算不考虑进位的加法,然后计算进位,最后把两个结果加在一起。感觉光说也不太好解释,先看程序:

def getSum(a, b):
	"""
	:type a: int
	:type b: int
	:rtype: int
	"""
	while b:
		a, b = a ^ b, (a & b) << 1
		a &= 0xFFFFFFFF		# 32位整型
		b &= 0xFFFFFFFF
		if a > 0x7FFFFFFF:		# 考虑负数的情况
			a = ~(a ^ 0xFFFFFFFF)
	return a

  上面程序中,a ^ b的结果是不考虑进位的和,因为1+1和0+0的结果都是0,只有0+1或1+0才是1,这刚好符合异或的性质。而(a & b) << 1表示进位的结果,因为只有1+1才会发生进位,并且需要将这个进位左移1位(因为是进位往前一位加)。然后一直循环,直到进位为0,此时的a就是输入两个数的和了。以5+7为例: (Round1) a :   0000 , 0101 b :   0000 , 0111 a b :   0000 , 0010 a &amp; b :   0000 , 0101 ( a &amp; b ) &lt; &lt; 1 :   0000 , 1010 \begin{aligned}a: &amp;0000,0101\\b: &amp;0000,0111\\a^{\wedge}b: &amp;0000,0010 \\ a\&amp;b: &amp;0000,0101\\(a\&amp;b)&lt;&lt;1: &amp;0000,1010\end{aligned}\tag{Round1} (Round2) a :   0000 , 0010 b :   0000 , 1010 a b :   0000 , 1000 a &amp; b :   0000 , 0010 ( a &amp; b ) &lt; &lt; 1 :   0000 , 0100 \begin{aligned}a: &amp;0000,0010\\b: &amp;0000,1010\\a^{\wedge}b: &amp;0000,1000 \\ a\&amp;b: &amp;0000,0010\\(a\&amp;b)&lt;&lt;1: &amp;0000,0100\end{aligned}\tag{Round2} (Round3) a :   0000 , 1000 b :   0000 , 0100 a b :   0000 , 1100 a &amp; b :   0000 , 0000 ( a &amp; b ) &lt; &lt; 1 :   0000 , 0000 \begin{aligned}a: &amp;0000,1000\\b: &amp;0000,0100\\a^{\wedge}b: &amp;0000,1100 \\ a\&amp;b: &amp;0000,0000\\(a\&amp;b)&lt;&lt;1: &amp;0000,0000\end{aligned}\tag{Round3} b=0时停止循环,此时a=0b1100=12,结果正确。
  不过因为在Python中,整型的长度没有限制,当一个整数超过32位时,Python会自动扩展,理论上可以表示无穷大的数(实际最大能够表示多大我也没有测试过)。这个就直接导致进位右移操作在Python算法中起不到效果,永远不可能出现b=0的情况。所以Python代码中需要判断整数溢出的情况。现在假设整数由32bit表示,a &= 0xFFFFFFFF就是获取a的32位二进制表示,如果a为负数,那么得到的是a的无符号补码,这里需要注意一下。由于在计算机中,二进制第一位是符号位,0表示正数,1表示负数,所以32位能表示的最大的正数为0x7FFFFFF,即 0111 , 1111 , 1111 , 1111 , 1111 , 1111 , 1111 , 1111 0111,1111,1111,1111,1111,1111,1111,1111 如果a &= 0xFFFFFFFF的结果大于0x7FFFFFFF,就说明a是个负数,需要进行转原码的处理。
  我们如何根据无符号的负数补码得其有符号的原码呢?先回顾一下补码是如何计算的:正数的补码就是其本身;负数的补码是保持符号位不变,其他位按位取反,然后加1得到补码。现在最大的问题就是如果一个数和0xFFFFFFFF与运算之后,得到的数是无符号类型的,所以也不存在符号位这一说了。这里我们可以先将a ^= 0xFFFFFFFF,这其实也就是按位取反,不过这种形式的按位取反得到的还是无符号类型,如果先用~进行按位取反,Python会将得到一个有符号类型的数,这改变了数的大小,显然不行。所以第二次再用~进行按位取反,得到的有符号的a的补码了。
  简而言之,如果a是负数,a &= 0xFFFFFFFF得到的是a无符号的补码形式,如果先用a ^= 0xFFFFFFFF按位取反,再用a = ~a按位取反,得到的还是原来的数(两次取反相互抵消,负负得正),只不过变成a有符号的补码了。这时Python会将其当做负数的补码处理,会再对其进行一次求补码的操作,得到a的原码。可能我解释的不是很清楚,下面用两个例子看看。
  对9按位取反: 9 :   0000 , 0000 , 0000 , 0000 , 0000 , 0000 , 0000 , 1001 9 :   0000 , 0000 , 0000 , 0000 , 0000 , 0000 , 0000 , 1001 9 :   1111 , 1111 , 1111 , 1111 , 1111 , 1111 , 1111 , 0110 :   1000 , 0000 , 0000 , 0000 , 0000 , 0000 , 0000 , 1001 + 1 :   1000 , 0000 , 0000 , 0000 , 0000 , 0000 , 0000 , 1010 \begin{aligned}9的原码: &amp; 0000,0000,0000,0000,0000,0000,0000,1001 \\ 9的补码: &amp; 0000,0000,0000,0000,0000,0000,0000,1001 \\\sim 9: &amp;1111,1111,1111,1111,1111,1111,1111,0110 \\ 反码: &amp;1000,0000,0000,0000,0000,0000,0000,1001 \\反码+1: &amp;1000,0000,0000,0000,0000,0000,0000,1010\end{aligned} Python编译器会将 9 \sim 9 的结果视为一个负数的补码(因为符号位为1),所以 9 \sim 9 实际代表的数应该是 9 \sim 9 的补码所代表的数。根据上面结果, 9 = 10 \sim 9 = -10
  对-5按位取反: 5 :   1000 , 0000 , 0000 , 0000 , 0000 , 0000 , 0000 , 0101 5 :   1111 , 1111 , 1111 , 1111 , 1111 , 1111 , 1111 , 1010 5 :   1111 , 1111 , 1111 , 1111 , 1111 , 1111 , 1111 , 1011 5 :   0000 , 0000 , 0000 , 0000 , 0000 , 0000 , 0000 , 0100 \begin{aligned}-5的原码: &amp; 1000,0000,0000,0000,0000,0000,0000,0101 \\ -5的反码: &amp;1111,1111,1111,1111,1111,1111,1111,1010\\ -5的补码: &amp;1111,1111,1111,1111,1111,1111,1111,1011 \\\sim -5: &amp;0000,0000,0000,0000,0000,0000,0000,0100\end{aligned} 5 \sim -5 符号位为0,所以Python编译器会将其识别为正数,正数的补码还是其本身,所以 5 = 4 \sim -5=4 最后强调一下,~按位取反是针对数据在计算机中存储的形式,即对补码进行的!!! 符号位也会取反,时刻记着。

Example 3: 给一个从0~n的连续整数数组,找出数组缺少的一个数,比如数组为[0, 1, 3],缺少的数为2
  这个我们当然可以用等差数列求和公式算出理论上数组的和,再减去数组实际的和,就能得到缺少的这个数。如果用位运算应该怎么做呢?代码如下

def missingNumber(nums):
	"""
	:type nums: list
	:rtype: int
	"""
	ret = 0
	for i, x in enumerate(nums):
		ret ^= i
		ret ^= x
	return ret ^ len(nums)

  这里用到的其实是异或的一个特性:两个同样的数异或结果为0。就以[0, 1, 3]为例,程序中ret初始值为0,ret每次循环分别同ix进行了一次异或操作,这里i代表完整数组应该出现的值,x代表实际数组中的值。循环结束时,ret同[0, 0, 1, 1, 2, 3]进行了异或,因为完整数组是从0 ~ n的,循环结束时只访问了0 ~ n-1,所以ret需要再和len(nums)异或一次。ret就总共和[0, 0, 1, 1, 2, 3, 3]进行了异或,相同的数异或抵消为,2只出现了一次,所以2就是这个连续数组缺失的数字。
  异或很适合“找不同”的问题,下面再看一个例子。

Example 4: 一个无序整数数组中,除了两个数字只出现过一次,其他数字都出现了两次,找出这两个只出现一次的数字出来

def last1Bit(x):
	"""
	找到x二进制中最右边1的位置
	:type x: int
	:rtype: int
	"""
	index = 0
	while num & 1 == 0 and index < 32 * 8:
		num >>= 1
		index += 1
	return index

def is1Bit(x, index):
	"""
	判断指定位置上的二进制位是否为1
	:type x: int
	:type index: int
	:rtype: bool
	"""
	x >>= index
	return x & 1
	
def findNumsAppearOnce(nums):
	"""
	:type nums: list
	:rtype: list
	"""
	ret = 0
	for x in nums:
		ret ^= x
	index = last1Bit(ret)
	a = b = 0		# a,b是要返回的两个数
	for x in nums:
		if is1Bit(x, index):			# 将nums分为两部分,每部分中恰好存在一个只出现一次的数
			a ^= x
		else:
			b ^= x
	return [a, b]

  这个问题也很适合用异或来做,如果只有一个数字出现一次,那么对整个数组异或一次即可,因为其他出现两次的必定两两相互抵消。但现在是两个数字,最后异或的结果是这两个不同数字异或的结果。比如现在数组是[1, 1, 2, 3, 3, 5, 7, 8, 7, 8],全部异或一趟得到的结果是ret = 0111 1 :   0001 1 :   0001 2 :   0010 3 :   0011 3 :   0011 5 :   0101 7 :   0111 8 :   1000 7 :   0111 8 :   1000 r e t :   0111 \begin{aligned}1: &amp;0001\\ 1: &amp;0001\\ 2: &amp;0010\\ 3: &amp;0011\\ 3: &amp;0011\\ 5: &amp;0101\\ 7: &amp;0111\\ 8: &amp;1000\\ 7: &amp;0111\\ 8: &amp;1000\\\mathrm{ret}: &amp;0111 \end{aligned} 经过上面的分析可以知道,0111一定是这两个只出现一次数字异或的结果,所以如果ret中某一位为1,那么这两个数的对应二进制位必定是一个数为1,一个数为0,比如2的最后一位是0,5的最后一位是1。我们现在就可以根据这一点将原来的数组分为两部分:最后一位为1的,最后一位为0的。这两部分必定会分别包含一个只出现一次的数,然后剩下我们要做的就只是分别在这两个子数组中找出其中只出现一次的数而已了。

Example 5: 找出不大于N的最大的2的幂指数
  这个问题本质上就是找到N的二进制表达中最左边的1所在的位置,然后将后面的1全部置0的问题。笨办法是循环判断找到最高位的1,这个问题还可以转化为将最左边的1后面全部置1,然后加1再右移一位。比如现在N=19,其二进制表示为10011,我们只要将其转化为11111然后加1变为100000,最后再右移一位变为10000即得到不大于19的最大二次幂为16。将最高位右边全部置1可以用或逻辑|来实现:

def largestPower(N):
	"""
	:type N: int
	:rtype: int
	"""
	N |= N >> 1
	N |= N >> 2
	N |= N >> 4
	N |= N >> 8
	N |= N >> 16
	return (N + 1) >> 1

位运算符|的一个作用就是尽可能多的保留1,这里我们默认用32bit来表示整数,所以上述代码中只考虑了除去符号位的后31位。

3. 小结

  常用的位运算包括&(按位与)、|(按位或)、~(按位取反)、^(按位异或)、<<(左移)、>>(右移)。

  • &通常用于需要选择特定的数位的情况,因为任何一个数位和1相与得到的是其本身;
  • |通常用于要求尽可能保留1的情况,因为只要有1,或运算的结果一定是1;
  • ~按位取反会将符号位一同取反,得到的还是一个有符号整数;
  • ^常用于去除重复元素的情况,和“负负得正”这种情况比较类似;
  • <<>>通常和上面4种位运算符搭配使用,不过要注意在Python中左移是不会发生溢出的,需要人为判断溢出。

猜你喜欢

转载自blog.csdn.net/qq_16137569/article/details/82790378