常见的位运算技巧
简要介绍了计算机中整数二进制的存储方式,以及算法题中常见的位运算技巧
本页目录
什么是位运算?
位运算是整数之间的运算,将十进制整数转化为二进制后,按位进行AND(&),OR(|),XOR(^),NOT(~)等逻辑运算,或者整体进行<<,>>等左右移位运算.
注意到我说了整数,而不是非负整数.那计算机,尤其是python里,如何存储一个负数的二进制表示呢?
负数的二进制
以下内容均为在python中的情况.python中,整数其实自带符号这一属性.然而为了在位运算中自洽的模拟,思想上,我们假设python中存储的二进制没有固定长度,因此,除了表示数字之外,前面的无限个数字都用来表示符号,比如7的表示就是… 0000 0111
有一个名字叫补码,我很反感.我试图揣测当年设计师的思路,给出一个合理的表示负数的方法
- 首先,负数应该与正数一一对应,且做出区分.在十进制中,人类让数字部分相同,符号部分作+和-两种来区分,比如-2对应+2,等等
- 其次,不能与先前的位运算冲突,比如
<<表示乘以2,>>表示除以2再向下取整 - 最重要的,0,不应该和-0混淆.这两个数字就应该是一样的.人类能直观的把-0自动看作0,但是计算机不能
因此,在计算机中,光靠额外的一位数字存储正负号,会带来很大的麻烦,这不是一个很好的设计
于是,设计师一拍大腿:你不是要一一对应吗?那我在负数里面,索性把0和1的含义互换,用1表示空缺,用0表示占位,这样,正负数自然就区分开来
可是0依旧是问题.按照这种思路,…. 0000 0000表示+0, …1111 1111表示-0.怎么办?人为的把负数往后移动一位就好了!让… 1111 1111去表示-1, …. 1111 1110去表示-2, 等等.
这样,想要看懂一个负数的二进制,只需要:
- 把0和1反写
- 加1
得到的数字就是这个负数的数字部分,或者说相反数
这么做,三条规则都满足了吗?
- 正负数能区分,满足
<<依旧表示乘以2- 在末尾补上0后,相当于对他取反后的结果乘以2再加1
- 记原本取反的结果为a,那这个数字的数字部位就是a+1
- 移位后取反的结果就是2a+1, 这个数字的数字部位就是2a+1+1=2(a+1)
- 符号不变,数字部位恰好变为原来的2倍
>>依旧表示//2- 去掉最后一位数,相当于去掉取反后的结果的最后一位数
- 记原本取反后的数字是a, 那么原数的数字部位就是a+1
- 去掉最后一位数字后结果就是a//2, 原数的数字部位就是a//2 + 1
- 假设a为奇数,那么(-(a+1))//2 = (-2k-2)//2 = -k-1 = -(a//2 +1)
- 假设a为偶数,那么(-(a+1))//2 = (-2k-1)//2 = (-2k-2)//2 = -k-1 = -(a//2 +1)
- 依旧成立,注意对负的奇数//2操作,比如-5//2=-3而非(-5)//2
比如数字… 1111 1010, 按照这种方式变换:
- 取反:…. 0000 0101
- 加一:…. 0000 0110
…. 0000 0110 正是6,因此-6的二进制表示就是… 1111 1010
你可能会疑惑,电脑怎么知道… 1111 1010是-6还是一个很大的数字?
- 在python中,这个问题不存在,因为python自带符号属性
- 在其他语言,比如C中,数字是有长度限制的,因此最开头一个数"充当了"正负号的显示器.同时,你在声明变量的时候,就已经确定了这个数字带不带符号了.同一个1111 1010, 你赋值给没有符号的变量,它就是250;赋值给带符号的变量,它就是-6.
- 因此完全没必要担心,这种方法就是无比的出色
取反的本质
我先问你一个问题,15的二进制表示是…. 0000 1111, 对15取反,得到多少?
- 0,因为… 0000 1111取反后变成了….1111 0000, 原来的15只用了最右端四位数字,因此取反后的数字就是最右端的四位数字,即-0=0
- -16,因为取反后的结果….1111 0000是一个负数,因此0和1需要反过来看,此外还要加上1,因此是-(15+1)=-16
- -32,取反后….1111 0000的后四位是0,前面都是1,这表示-0,不合法,因此需要往前额外借一个1,得到的数字就是-32
选择1和3的读者,建议先去消化上一段:负数的二进制表示
在谈及负数的二进制表示时,我们已经知道:获取一个负二进制数的数字部分,第一步就是"取反".那时我们认为,取反就是把1变成0,把0变成1.然而,你有没有发现,取反后的结果加上原来的结果,必然是一个全1的二进制数字, 也就是-1:~n+n= … 1111 1111=-1. 这对于所有数字都是成立的,因为所有数字都是由0和1组成的,无出其右.这将是继-n+n=0之后,又一个值得记忆的公理
移项后得到~n=-1-n, 就是取反操作的数学本质,其余所有貌似合理的解释都是狗屁,不用管.
常见技巧
1. 利用取反实现加1与减1
我们知道了一个整数加上它取反的结果等于-1,根据这个-1,能创造出+1与-1的效果
- -~n=-(-1-n)=n+1, 就是加一操作
- ~-n=-1-(-n) =n-1, 就是-1操作
2. 异或的性质
- a ^ a = 0
- a ^ 0 = a
- a ^ -1 = ~a
3. 利用异或判断数字是否同号
正数的符号是0,负数的符号是1,正负号不同的两个数进行异或运算,得到的符号一定是1,也就是负数.因此,x^y≥0表明两者同号(当且仅当x=y取等),x^y < 0表明两者异号
4. 利用AND和移位提取二进制的第k位
AND运算有这样的性质
- 与1运算保持不变:0 & 1 = 0, 1 & 1 = 1
- 与0运算变为0: 0 & 0= 0, 1 & 0 = 0
我们可以人为的利用一个仅在第k位为1的数字,提取出原数的第k个数字:
>>> a = 15
>>> k = 3
>>> bin(a)
'0b1111'
>>> bin(a & (1 << (k-1)))
'0b100'5. 利用AND判断奇偶性
很简单,最后一位是1,就是奇数;是0,就是偶数,对负数也恰好是一样的
>>> a = 15
>>> a & 1
1
>>> (a+1) & 1
06. 利用OR将第k位修改为1
OR运算有这样的性质
- 与0运算,保持不变:1 | 0 = 1, 0 | 0 = 0
- 与1运算,永远是1: 1 | 1 = 1, 0 | 1 = 1
可以利用这点,将指定位置的任意数字变成1
>>> a = 9
>>> bin(a)
'0b1001'
>>> bin(a | (1 << (k-1)))
'0b1101'7. 利用异或反转第k位
OR运算有这样的性质
- 与0运算,保持不变:1 ^ 0 = 1, 0 ^ 0 = 0
- 与1运算,反转数字: 1 ^ 1 = 0, 0 ^ 1 = 1
可以利用这点,将指定位置的任意数字反转
>>> a = 13
>>> bin(a)
'0b1101'
>>> bin(a ^ (1 << (k-1)))
'0b1001'比如力扣2997题 这题就利用了异或的交换律,与异或反转的技巧.对每一个具体数字的操作,就相当于是对整体的操作.因此,只需要看从数组整体异或的结果到k,需要反转几位数就可以了
class Solution:
def minOperations(self, nums: List[int], k: int) -> int:
return reduce(xor, nums, k).bit_count()8. 利用AND和取反将第k位修改为0
换一个角度利用AND的效果
- 与1运算,保持不变:0 & 1 = 0, 1 & 1 = 1
- 与0运算,永远是0: 0 & 0= 0, 1 & 0 = 0
这回我们创建一个全是1,只有第k位是0的二进制数字
>>> a = 15
>>> k = 3
>>> bin(a)
'0b1111'
>>> bin(a & ~(1 << (k-1)))
'0b1011'9. 利用AND消除最后一个1
减1后会把最后一个1变成0,原本最后一个1后面所有的数字变成1.这导致从最后一个1及其后面的所有数字都与原数是反的.根据AND运算,不同的数字运算得到0,这达成了消除最后一个1的效果
>>> a = 14
>>> bin(a)
'0b1110'
>>> bin(a & (a-1))
'0b1100'