对0和1的操做是计算机最底层的操做,全部的程序无论用什么语言写的,都要转化成机器可以读懂的语言也就是二进制进行基本的运算,而这些基本的运算就是咱们今天要讲到的位运算。由于硬件的支持,计算机在进行二进制计算的时候要比普通的十进制计算快的多,把普通的运算用位运算的方法实现可以极大提升程序性能,是一个重要的技能。html
在计算机中有超过七种位运算操做,在这里就以Java为例来说解Java中须要用到的七种位运算符。java
符号 | 描述 | 规则 | 举例 |
---|---|---|---|
& | 按位与 | 左右两侧同时为1获得1,不然为0 | 1&1=1 1&0=0 |
| | 按位或 | 左右两侧只要一个是1就返回1,都是0才返回0 | 1|0=1 0|0=0 |
^ | 按位异或 | 左右两侧相同时为0,不一样为1 | 1^1=0 0^0=0 1^0=1 |
~ | 按位取反 | 1变成0,0变成1 | ~1=0 ~0=1 |
<< | 有符号左移 | 保持最高位的符号,其余的位向左移动,右侧用0补齐 | 1<<2=4 |
>> | 有符号右移 | 保持最高位符号,其余位向右移动,除最高位其余用0补齐 | 5>>1=2 -10>>1=-5 |
>>> | 无符号右移 | 全部位都向左移动,高位用0补齐 | 5>>>1=2 -10>>>1=2147483643 |
既然说到了位运算就不得不提一下在Java中int
类型数字的表示,Java的int一共是32位,其中第32位符号标志位,若是是0就表明是正数,1表明是负数,好比1的表示方法是0000 0000 0000 0000 0000 0000 0000 0001
,最大只能是前31位都置1的结果,也就是$2^{31}-1$;对于负数的表示须要注意,并非直接把对应的正数最高位0变成1,而是所有位取反而后加一,这样作的目的是为了让两个二进制数字直接相加的和为0,好比-1
的表示方法是1111 1111 1111 1111 1111 1111 1111 1111
,这样两个数字二进制相加获得的最终结果是0(最高位溢出舍弃掉)。负数的最小值是$2^{31}$,绝对值比正数最大值大一位,由于这个负数比较特殊,二进制表示为1000 0000 0000 0000 0000 0000 0000 0000
,在取反加一以后仍是它自己。算法
这里顺带介绍一下位操做的小技巧,其中的原理能够本身揣摩数组
序号 | 公式 | 解释(n 从0 开始) |
---|---|---|
1 | num |= 1<<n | 将num 第n 位设置为1 |
2 | num &= ~(1<<n) | 将num 第n 位设置为0 |
3 | num ^= 1<<n | 将num 第n 位取反 |
4 | num & (1<<n) == (1<<n) | 检查num 第n 位是否为1 |
5 | num = num & (num-1) | 将num 最低位的1 置0 |
6 | num & -num | 得到num 最低位的1 |
7 | num &= ~((1<<n+1)-1) | 将num 最n 位右侧置0 |
8 | num &= (1<<n)-1 | 将num 第n 位左侧置0 |
更多位操做相关技巧能够看这里这里。ide
这里捎带着说一下浮点类型float
在Java中的表示,若是已经比较清楚的同窗能够直接跳过这个部分。浮点数总共4个字节,表示能够用下面的公式:函数
$$ (-1)^s \times m \times 2^{e-127} $$性能
其中学习
s
,第31位,若是是1表明是负数,0表明正数e
,第30~23位,是指数位,表示一个无符号的整数,最大是255m
,第22~0位,尾数,包含了隐藏的1
,表示小数位,须要去掉后面的无效的0好比在浮点数0.15625
在二进制中的表示方法是0011 1110 0010 0000 0000 0000 0000 0000
(使用Java代码Integer.toBinaryString(Float.floatToIntBits(0.15625F))
能够获得浮点数的二进制表示,可是省去了最高位的0
),其中测试
0
,表示正数011 1110 0
指数位,十进制是124
,减去127以后获得-3
010 0000 0000 0000 0000 0000
尾数数,舍去小数后面的0
以后获得01
,加上默认1后获得1.01
(二进制表示的小数)0.15625
的二进制公式为$(-1)^0\times(1.01)\times2^{-3}=0.00101$,而后把二进制转成十进制就变成了$2^{-3}+2^{-5}=0.125+0.03125=0.15625$double类型和float的计算方法同样,只不过double的指数位有11位,尾数位52位,计算指数的时候须要减去1023。相比于float,double的精度要高不少,若是不在乎空间消耗的状况下,最好用double。网站
如今基本的计算机的操做咱们知道了,数字的二进制表示咱们也清楚了,那么如何经过二进制的位运算来完成十进制数字的计算呢?下面来一一揭晓。
Leetcode第371题两整数之和
不使用运算符+
和-
,计算两整数a
、b
之和。
示例1:
输入: a = 1, b = 2
输出: 3
示例2:
输入: a = -2, b = 3
输出: 1
这道题在leetcode的美国网站上面有1900多个dislike,看来你们对这个题目意见很大,不过就趁这个机会回顾一下计算机是怎样进行加法操做的。
回顾一下咱们小学就开始学习的十进制的加法,好比15+7
,最低位5+7
获得12
,对10
取模获得2
,进位为1
,再高位相加1+0
再加上进位1
就获得高位结果2
,组合起来就是22
。这里面涉及到了两个数字,一个是相加获得的低位,也就是5+7
获得的结果2
,第二个是进位1
。在二进制的计算中就是要经过位操做来获得结果的低位和进位,对于不一样的状况,用表格来表示一下,两个数字分别为a
和b
a | b | 低位 | 进位 |
---|---|---|---|
0 | 0 | 0 | 0 |
1 | 0 | 1 | 0 |
0 | 1 | 1 | 0 |
1 | 1 | 0 | 1 |
从上面的表格就能够发现,低位 = a^b
,进位 = a & b
。这样的计算可能要持续屡次,回想一下在十进制的计算中,若是进位一直大于0,就得日后面进行计算,在这里也是同样,只要进位不是0,咱们就得一直重复计算低位和进位的操做(须要在下一次计算以前要把进位向左移动一位,这样进位才能和更高位进行运算)。这个时候的a
和b
就是刚才计算的低位和进位,用简单的加法迭代的代码表示:
public int getSum(int a, int b) { if (a==0) return b; if (b==0) return a; int lower; int carrier; while (true) { lower = a^b; // 计算低位 carrier = a&b; // 计算进位 if (carrier==0) break; a = lower; b = carrier<<1; } return lower; }
这是leetcode第19题两数相除
给定两个整数,被除数dividend
和除数divisor
。将两数相除,要求不使用乘法、除法和mod
运算符。
返回被除数dividend
除以除数divisor
获得的商。
示例1:
输入: dividend = 10, divisor = 3
输出: 3
示例2:
输入: dividend = 7, divisor = -3
输出: -2
说明:
这道题其实能够很容易联想到咱们常常用到的十进制的除法,这里有一个例子来讲明如何把十进制除法的思想套用在二进制上面。
图片是用33
除以6
,对应二进制100001
除以110
,有三步:
110
开始向左移位右侧补0
,直到找到最大的比100001
小的数字11000
(图中右侧的0
已经被省略掉了),这个时候向左移动了两位也就是乘以100
(二进制),余数是1000
110
向左移动到最大的比1000
小数字,这个时候就是自己,至关于乘以1
(向左移动了0
位),余数是11
110
要小了,这个时候能够直接中止运算,将上面两个步骤计算获得的乘数(100
和1
)加起来就是咱们最后的结果了(101
)。这里咱们就用java代码来实现一下这个逻辑:
public int divide(int dividendInt, int divisorInt) { int shiftedDivisor; // 移位后的除数 int quotient = 0; // 记录除法获得的商 int remainder = dividendInt; while (remainder>=divisorInt) { int tempQuotient = 1; // 临时的商 dividendInt = remainder; // 处理上一轮的余数 shiftedDivisor = divisorInt; // 重置被除数 while (dividendInt>=shiftedDivisor) { shiftedDivisor <<=1; tempQuotient <<= 1; } quotient += tempQuotient >> 1; // 累加计算获得的商 remainder = dividendInt - (shiftedDivisor >> 1); // 位移优先级比减号低,要用括号 } return quotient; }
经过循环的方法获得了咱们要的除法的实现逻辑,可是这个方法只是对除数和被除数都是正数才起做用,好比除数是-100
被除数是10
或者-10
的时候,返回的结果是0
!对于-100
除以-10
这个例子,在比较余数和移位的除数的大小的时候,若是都是正数,余数比除数小的时候中止循环是正常的,可是在都是负数的时候,这样作就有问题了,余数比除数大,商才是0
,因此上面两个循环的终止条件的不等号方向换一下就能够了。
public int divide(int dividendInt, int divisorInt) { int shiftedDivisor; int quotient = 0; int remainder = dividendInt; while (remainder<=divisorInt) { // 注意 变成了小于等于 int tempQuotient = 1; dividendInt = remainder; shiftedDivisor = divisorInt; while (dividendInt<=shiftedDivisor) { // 注意 变成了小于等于 shiftedDivisor <<=1; tempQuotient <<= 1; } quotient += tempQuotient >> 1; remainder = dividendInt - (shiftedDivisor >> 1); } return quotient; }
如今咱们都是正数和都是负数的状况都有了,一个正数一个负数咋办呢?那就把符号变一下,变成同样的,最后在返回结果的时候变回来。这里题目善意的提醒了一下要考虑边界问题,在考虑转换符号的时候须要考虑到-2147483648
这个值的特殊性,也就是负数变正数要当心,可是正数变成负数能够随意,那就不如直接负数运算得了(参考这篇文章)。在累加每一次除法获得的商的时候,也是使用负数-1
的移位来避免溢出问题(好比-2147483648
除以1
带来的溢出问题)。
在对除数进行移位的时候,还须要注意一下移位以后除数不能溢出,可以使用的最好方法就是在移位以前判断一下除数是不是小于最小数字的一半(若是是用正数移位的话,就应该是判断是不是大于最大数字的一半),若是小于,下一次移位必定就会溢出,这个时候就只能直接终止循环。
在判断结果的符号的时候用一下位运算的小技巧,在上面讲到异或操做的时候,若是是相同的就返回0
,不然是1
,因此对两个数字最高位进行异或操做,若是是0
就表明结果是正号,反之就是负号。除此以外还有一个极端状况,当被除数取最小值-2147483648
而且除数取-1
的时候,获得的结果是2147483648
会溢出,由于只有这样的一个特例,能够直接排除,对于剩下的其余的数字无论怎样都不会溢出,整理以后的代码以下:
public int divide(int dividendInt, int divisorInt) { if (dividendInt == Integer.MIN_VALUE && divisorInt == -1) { return Integer.MAX_VALUE; // 对于极端状况,直接排除,返回int最大值 } boolean negSig = ((dividendInt ^ divisorInt) & Integer.MIN_VALUE) == Integer.MIN_VALUE; // 判断结果是不是负数 dividendInt = dividendInt > 0 ? -dividendInt : dividendInt; // 被除数取负值 divisorInt = divisorInt > 0 ? -divisorInt : divisorInt; // 除数取负值 int shiftedDivisor; // 移位后的除数 int quotient = 0; // 记录除法获得的商 int remainder = dividendInt; int minShiftDivisor = Integer.MIN_VALUE >> 1; // 防止溢出,大于这个值以后不能再向左移位了 while (remainder<=divisorInt) { int tempQuotient = -1; // 临时的商,所有用负数处理 dividendInt = remainder; // 处理上一轮的余数 shiftedDivisor = divisorInt; // 重置被除数 while (dividendInt<=(shiftedDivisor<<1) // 比被除数小才进行接下来的计算 && shiftedDivisor >= minShiftDivisor) { // 判断是否移位后会溢出 shiftedDivisor <<=1; tempQuotient <<= 1; } quotient += tempQuotient; // 累加计算获得的商 remainder = dividendInt - shiftedDivisor; // 获得余数,下一轮做为新的被除数 } return negSig?quotient:-quotient; // 若是是负数就直接返回结果,若是不是就变换成正数 }
leetcode第191题位1的个数
编写一个函数,输入是一个无符号整数,返回其二进制表达式中数字位数为1
的个数(也被称为汉明重量)。
示例1:
输入:00000000000000000000000000001011
输出:3
解释:输入的二进制串00000000000000000000000000001011
中,共有三位为'1'。
示例2:
输入:00000000000000000000000010000000
输出:1
解释:输入的二进制串00000000000000000000000010000000
中,共有一位为 '1'。
这道题任何人最简单暴力的作法就是直接移位,在Java里面直接用无符号的右移,每次判断最低位是否是1
,把判断为1
的全部的次数记录起来就能够了。
public int hammingWeight(int n) { int count = 0; for (int i = 0; i < 32; i++) { count += n&1; // 累加1 n>>>=1; // 循环右移1位 } return count; }
可是这道题有一个更加有意思的解法,首先对于一个二进制数字好比10100
,在减一操做以后获得10011
,比较这两个数字,发现原数字最低位的1
的右边的数字没有变,而右边的0
都变成了1
,而这个1
变成了0
,若是对原来的数字和减一的数字进行按位与,就会把最低位的1
置零。
因此就有了一个神奇的想法,若是咱们想把数字a
最低位的1
变成0
而不改变其余位,直接经过a&(a-1)
就能够获得了,而把全部的1
都变成了0
的次数不就是位1
的个数了吗?
好比对于数字101
,在通过a&(a-1) = 101&100 = 100
,再操做一次就是100&011 = 0
,一共两次操做,最后的结果就变成了0
。
简单代码以下:
public int hammingWeight(int n) { int count = 0; // 统计置0的次数 while (n!=0) { count ++; n = n&(n-1); // 最低位的1置0 } return count; }
看起来上面两个算法均可以达到咱们想要的目的,可是不知道效率怎么样,那就来测试比较一下(添加的java的内置计算1
个数的方法)
private void test() { int t = 10000000; // 比较一千万次 long s = System.currentTimeMillis(); for (int i = 0; i < t; i++) { hammingWeight1(-3); } System.out.println("Java builtin:\t" + (System.currentTimeMillis()-s)); s = System.currentTimeMillis(); for (int i = 0; i < t; i++) { hammingWeight2(-3); } System.out.println("最低位1置0: \t" + (System.currentTimeMillis()-s)); s = System.currentTimeMillis(); for (int i = 0; i < t; i++) { hammingWeight3(-3); } System.out.println("向右移位比较:\t" + (System.currentTimeMillis()-s)); } // java内置方法 public int hammingWeight1(int n) { return Integer.bitCount(n); } // 最低位1置0 public int hammingWeight2(int n) { int count = 0; while (n!=0) { count ++; n = n&(n-1); } return count; } // 向右移位比较 public int hammingWeight3(int n) { int count = 0; for (int i = 0; i < 32; i++) { count += n&1; // 累加1 n>>>=1; // 循环右移1位 } return count; }
输出的结果以下:
Java builtin: 10 最低位1置0: 150 向右移位比较: 10
虽然上面只是一次测试的结果(每次运行测试的时候结果都不同),可是能够看出最低位置0
这个方法效率要比普通的移位操做或者内置的方法慢一个数量级,猜想缘由应该是n=n&(n-1)
这个操做耗时比较高(n=n&(n-1)
实际上是两步,第一步int a = n-1
,第二步n=n&a
),而n&1
或者n>>>=1
这两个操做都要快不少。
回到测试,多测几回发现java内置的方法有的时候要比向右移位快不少,这就有意思了,得看看它究竟是怎么实现的:
public static int bitCount(int i) { // HD, Figure 5-2 i = i - ((i >>> 1) & 0x55555555); i = (i & 0x33333333) + ((i >>> 2) & 0x33333333); i = (i + (i >>> 4)) & 0x0f0f0f0f; i = i + (i >>> 8); i = i + (i >>> 16); return i & 0x3f; }
看完以后一句卧槽涌上心头,这写的是什么玩意儿?!这是什么神仙算法!那就来仔细看看这个算法,对每一步进行分解,假设咱们的输入是-1
public static int bitCount(int i) { // i = 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 -1的二进制 // HD, Figure 5-2 // 01 01 01 01 01 01 01 01 01 01 01 01 01 01 01 01 i = i - ((i >>> 1) & 0x55555555); // i = 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 (1)每2位的计算 // 00 11 00 11 00 11 00 11 00 11 00 11 00 11 00 11 i = (i & 0x33333333) + ((i >>> 2) & 0x33333333); // i = 0100 0100 0100 0100 0100 0100 0100 0100 (2)每4位求和 // 0000 1111 0000 1111 0000 1111 0000 1111 i = (i + (i >>> 4)) & 0x0f0f0f0f; // i = 00001000 00001000 00001000 00001000 (3)每8位求和 i = i + (i >>> 8); // i = 0000100000010000 0001000000010000 (4)每16位求和 i = i + (i >>> 16); // i = 00001000000100000001100000100000 (5)每32位求和 return i & 0x3f; // i = 00000000000000000000000000 100000 (6)取最低的6位 }
这个过程一共有6步,主要思想就是分治法,计算每二、四、八、1六、32位中1
的个数:
1
的个数,好比二进制11
有两个1
应该变成统计结果10
(11-((11>>>1)&01) = 10
),10
或者01
应该变成01
(10-((10>>>1)&01) = 01
,01-((01>>>1)&01) = 01
)1
的个数,也就是把上面计算获得的两位1
的个数和相加,好比01 00
,经过移位而且和掩码0011
按位与,变成0000
和0001
而后求和获得0001
1
的个数,这一步跟上面又不同了,不是先用掩码再相加,而是先相加再用掩码,主要不一样地方是在上一步两个二位的二进制数字相加可能会溢出,使用掩码以后就会获得错误的结果,好比10 10
,相加获得01 00
而后用00 11
按位与获得00 00
不是咱们想要的结果。当计算8位的个数的时候就不会溢出,由于4位里面1
的个数最可能是4个,也就是0100
,两个相加最大是1000
,不会进位到第二个4位,使用掩码可以获得正确的结果,而且能够清除多余的1
1
的个数,这个时候直接移位相加并无用掩码,缘由很简单,在上一步的每8位里面的的结果的是保存在最后4位里面,而且必定是准确的,直接相加确定不会溢出8位,结果也必定保存在最右边的8位里面,至于16位里面的左边的8位是什么值咱们根本就不关心,因此没有必要用掩码,也不会影响后面的计算1
,只须要6位来保存这个值。这样就经过一个精细的二进制的运算利用分治法获得了最后1
的个数,不得不感叹这样的作法实在是太妙了!
Java中还有不少精妙的整型数字的位操做方法好比下面的几种,虽然很想介绍一下,不过由于篇幅缘由就跳过,由于后面还有更加剧要的内容
highestOneBit
lowestOneBit
numberOfLeadingZeros
numberOfTrailingZeros
reverse
rotateLeft
rotateRight
leetcode第268题缺失数字
给定一个包含0, 1, 2, ..., n
中n
个数的序列,找出0 .. n
中没有出如今序列中的那个数。
示例1:
输入: [3,0,1]
输出: 2
这道题能够用哈希表或者求和的方法,都是不错的方法,不过既然这篇文章是关于位运算的,就用经典的位运算吧。
对于数字的位运算,按位异或有一个很重要的性质,对于数字a
和b
有:
a^a=0
a^0=a
a^b^a=b
以此类推能够发现若是对于一个数组里面的每个元素都进行按位异或,里面的出现频率是偶数的元素都会变成0
,若是数组里面每一个元素的出现频率都是偶数次,那么整个数组的按位异或的结果就是0
,若是只有一个元素出现了奇数次,按位异或的结果就是这个元素。
回到这道题,数组中全部的数字出现的次数是1
,没有出现的数字次数是0
,明显不能直接按位异或,那咱们就再这个数组后面添加一个0..n
的数组,包含全部的元素,这样两个数组合起来原来出现的数字的出现次数是2
,而原来没有出现的数字的出现次数是1
,就能够用按位异或了。
不过这里并不须要显性的添加数组,而是在循环内部再进行异或操做,代码以下:
public int missingNumber(int[] nums) { int xor = 0; for (int i = 0; i < nums.length; i++) { xor ^= i^nums[i]; // 循环内部尽可能按位异或虚拟的新增数组 } return xor^nums.length; // 补齐循环中漏掉的最后一个数字n }
leetcode第136题只出现一次的数字
给定一个非空整数数组,除了某个元素只出现一次之外,其他每一个元素均出现两次。找出那个只出现了一次的元素。
说明:
你的算法应该具备线性时间复杂度。 你能够不使用额外空间来实现吗?
示例 1:
输入: [2,2,1]
输出: 1
根据上面一题的按位异或的思路,这道题能够闭着眼睛写出结果:
public int singleNumber(int[] nums) { int num = 0; for (int i : nums) { num ^= i; } return num; }
这是leetcode第137题只出现一次的数字 II
给定一个非空整数数组,除了某个元素只出现一次之外,其他每一个元素均出现了三次。找出那个只出现了一次的元素。
说明:
你的算法应该具备线性时间复杂度。 你能够不使用额外空间来实现吗?
示例 1:
输入: [2,2,3,2]
输出: 3
这道题和上面的那一道题看起来彷佛是很类似的,不一样之处仅仅是在于原来是出现2次,如今是变成了3次,原来有异或的运算操做,可让a^a=0
,若是有一个操做符号使得a?a?a=0
,那这个问题就迎刃而解了。好像并不存在这样的运算符,那就想一想其余的方法。
首先看看异或运算符的特色,1^1=0
、0^1=1
、1^0=1
、0^0=0
,联想到加法运算,01+01=10
取低位为0
,因此其实就是运算符左右相加去掉进位(至关于对2取模)。若是想让三个1
相加获得0
,那就用十进制里面的相加而后对3
取模就能够了的。
这样对这个数组里面的每一个数字二进制位的每一位相加而且最后对3
取模,获得的就是只出现一次的数字的每个二进制位的结果。初步想法的代码以下:
public int singleNumber(int[] nums) { int bit; int res = 0; for (int i = 0; i < 32; i++) { bit = 0; for (int num : nums) { // 计算第i位的1的个数 bit += (num>>i)&1; } res |= (bit%3)<<i; // 根据1的个数取模获得结果 } return res; }
这其实也是一个比较通用的解法,这里出现的次数是3次,若是换成了4次、5次也能用这个方法解决。看了不少其余人的解法,还有一个更加精妙的解法,参考这里。
在上面的解法里面,咱们用一个bit
来保存全部的数字的某一位的和,每一个元素都是int
类型的,可是这道题的最多的重复次数是3,并且咱们最后须要的结果并非这个和,而是和对3取模,因此若是可以在计算的过程当中不停的取模,就能控制每一位的和小于3,最多2位的二进制数字就能够了,并不须要32位的int
来保存。虽然在java里面没有一个只有2位bit的二进制数字,可是能够用两个1位的二进制数字来表示,这样整个数组用两个int
类型的数字表示就能够了。上面解法的另一个问题是整个数组被遍历了32遍,由于每计算一位都要遍历一遍,可是若是用两个int
来表明的话,用适当的位操做,能够把遍历次数下降到一次!
先用lower
和higher
表明这个二进制数字的低位和高位,每次遇到一个二进制数字的数字的时候的变化能够用下图表示(由于遇到3取模获得0,因此不可能有lower=1 higher=1
这种状态,只能是中间存在的一种过渡状态)。
num | higher(old) | lower(old) | higher(过渡) | lower(过渡) | mask(掩码) | higher(new) | lower(new) |
---|---|---|---|---|---|---|---|
0 | 0 | 0 | 0 | 0 | 1 | 0 | 0 |
0 | 0 | 1 | 0 | 1 | 1 | 0 | 1 |
0 | 1 | 0 | 1 | 0 | 1 | 1 | 0 |
1 | 0 | 0 | 0 | 1 | 1 | 0 | 1 |
1 | 0 | 1 | 1 | 0 | 1 | 1 | 0 |
1 | 1 | 0 | 1 | 1 | 0 | 0 | 0 |
是否是感受有点懵逼,获得了这样的一个表格又要怎么把它变成公式呢?
首先看一下从老的状态到过渡状态,彻底就是一个二进制的加法,低位的数值根据lower
和num
能够获得的过渡状态是lower=lower^num
;而高位的数值须要获得低位的进位,也就是lower&num
,而后加上原来的高位就是higher^(lower&num)
,经过这两部就能够轻松计算出过渡状态的高位和低位。
过渡状态能够出现higher=1 lower=1
的状态,若是是出现是4次的话,这道题就直接解决了,不用任何额外的操做就能够获得最后的新状态,可是咱们这里要求的是3次,也就是当higher=1 lower=1
的时候须要把两位同时置零,为其余的状态时就保持不变。这个时候就要用到掩码,先计算出掩码,再经过掩码把过渡状态修正成最终的状态。掩码时根据过渡状态的高低位决定的,若是过渡状态的高低位组成的数字达到了咱们想要的阈值(这道题里面是3),掩码变成0
,高低位同时进行&
操做置零;没有达到的时候就是1
,使用&
操做至关于维持原来的值。能够看到这道题当higher=1 lower=1
时掩码mask=0
,其余时候mask=1
,很熟悉的操做,这不就是mask=~(higher&lower)
吗!这道题已经水落石出了!
最后的新状态直接用过渡状态和掩码按位与,higher=higher&mask
和lower=lower&mask
就到了新的值。
遍历完了整个数组以后,出现了三次的数字计算获得的higher
和lower
必定是0,出现了一次的数字的higher
必定也是0,而lower
低位就是表示的出现一次的数字的二进制位的值,因此最后获得的lower
就是须要的返回结果。
public int singleNumber(int[] nums) { int higher = 0, lower = 0, mask = 0; for (int num : nums) { higher = higher^(lower&num); lower = num^lower; mask = ~(higher&lower); // 计算掩码 higher &=mask; lower &= mask; } return lower; }
上面的求解过程简单点来将就是先用加法获得过渡状态,再用掩码计算出最终的新状态,对于任何出现了k
次的数组中找到只出现了一次的数字的题目都能用,这里只有两位(一个高位,一个低位)。若是k=5
,那么2位就不够了,须要3位数字来表示s1
、s2
和s3
,而掩码的计算就变成了mask = ~(s3&~s2&s1)
(当过渡状态为101
的时候掩码为0
)。
若是把上面的掩码的计算变成了mask=~(higher&~lower)
,这段代码就能够直接放到只出现一次的数字这道题里面。
除了使用过渡状态,还可使用卡诺图来直接解决新老状态转换的问题,具体的解题方法能够看这个帖子。
这是leetcode第260题只出现一次的数字 III
给定一个整数数组nums
,其中刚好有两个元素只出现一次,其他全部元素均出现两次。 找出只出现一次的那两个元素。
示例 :
输入: [1,2,1,3,2,5]
输出: [3,5]
注意:
[5, 3]
也是正确答案。这道题和上面的不一样之处在于上面须要求解的元素的出现次数只是一次,获得最后一个元素就能够了,然而这道题须要求解两个出现一次的元素。好比这两个元素分别是a
和b
,一次遍历通过按位异或以后获得的结果是a^b
,从这个信息里面咱们貌似什么结论都得不到。
既然要获得两个数字,须要遍历两次,若是两次都所有遍历,咱们想要的信息就会混在一块儿,惟一的方法就是把这个数组分红两个子数组,一个数组包含a
,另外一个包含b
,这样分别遍历就可以获得想要的a
和b
了。
要想拆分这个数组,须要区分a
和b
,因为a
和b
必定是不一样的,二进制表示的32位里面必定有1位是不一样的,找到了这一位,而后把整个数组里面这一位为1
的和为0
数字分别列为两个子数组(同一个数字确定会被划分到同一个子数组里面),分别异或就可以获得结果了。为了找到a
和b
不一样的二进制位,上面获得的a^b
就能派上用场了,异或结果为1
的确定是两个数字不一样的那一位,随便找一个就能够区分,这里咱们直接为1
的最低位。在文章的开头就有获取最低位的1
的操做——num&(-num)
,能够直接使用,简化的代码以下:
public int[] singleNumber(int[] nums) { if (nums == null || nums.length < 1) return null; int[] res = new int[2]; int xor = 0; for (int num : nums) { // 计算a^b xor = xor^num; } int bits = xor & (-xor); // 获取最低位1 for (int num : nums) { // 获取其中一个出现次数位1的数字a res[0] ^= (bits & num) == bits ? num : 0; } res[1] = res[0]^xor; // 根据根据前面的数字获得另外一个数字b return res; }
这是leetcode第338题比特位计数
给定一个非负整数num
。对于0 ≤ i ≤ num
范围中的每一个数字i
,计算其二进制数中的1
的数目并将它们做为数组返回。
示例 1:
输入: 2
输出: [0,1,1]
示例 2:
输入: 5
输出: [0,1,1,2,1,2]
进阶:
看完这道题,看到最后的进阶部分,是否是感受到很眼熟——要求线性时间内解决问题,空间复杂度位O(n)——这不就是动态规划吗!想一想这道题的问题的特色,找找最优子结构。
对于一个二进制数字n
,若是想获得他的从左到右第n
位中的1
的个数,能够先获得它的从左到右第n-1
位中的1
的个数,而后根据第n
位是不是1
来决定是否要加1
。要获得整个数字中的1
的个数,就须要知道从左到右第31
位(从1
开始计数)中的1
的个数,以及最低位是不是1
。获得前者并不难,由于若是把整个数字向右移一位就是一个前面已经计算过的数字(动态规划从小到大开始计算),这就变成了最优子结构了,递推公式变成了res[i] = res[i>>1] + (i&1);
,有了这个公式问题就解决了。
好比若是想获得10
也就是二进制1010
的1
的个数,能够先找到左边三个二进制数字101
的1
的个数,加上最右侧的位0
就能够了。
public int[] countBits(int num) { int[] res = new int[num+1]; // 不须要初始化,默认为0 for (int i = 0; i < res.length; i++) { res[i] = res[i>>1] + (i&1); } return res; }
这是leetcode第187题重复的DNA序列
全部 DNA 都由一系列缩写为 A,C,G 和 T 的核苷酸组成,例如:“ACGAATTCCG”。在研究 DNA 时,识别 DNA 中的重复序列有时会对研究很是有帮助。
编写一个函数来查找 DNA 分子中全部出现超过一次的 10 个字母长的序列(子串)。
示例:
输入:s = "AAAAACCCCCAAAAACCCCCCAAAAAGGGTTT"
输出:["AAAAACCCCC", "CCCCCAAAAA"]
看到了前面的那么多的明摆着用位操做的题目,再跳到这个题目感受是否是有点蒙蔽,彷佛跟位操做一点关系都没有。可是注意读题,里面有两个关键的限定条件——10个字母长的序列和只有“ACGT”四个字符,看到这两个条件就联想到一个int
数字有32位,因此一个int
数字就可以正好表明这样的一个10个字符长的序列。
在实现中咱们用
00
表明A
01
表明C
10
表明G
11
表明T
好比序列AAAAACCCCC
就能够用00 00 00 00 00 01 01 01 01 01
来表示,这只是从右到左的20位,更高位都是0
省略掉了。
一串DNA正好须要20位,高位直接用掩码去掉。每个惟一的DNA串都能用一个惟一的int
数字表示,用一个数组来表示每一串出现的次数,数组最大长度是1<<20
。
public List<String> findRepeatedDnaSequences(String s) { if (s == null || s.length() <= 10) return new ArrayList<>(); char[] chars = s.toCharArray(); // 转化成数组提升效率 int[] freq = new int[1<<20]; // 出现频率数组 int num = 0; // 计算过程当中的int值 int mask = (1<<20)-1; // 掩码,只保留最低的20位 for (int i = 0; i < 10; i++) { // 初始化第一个DNA串 num <<= 2; if (chars[i] == 'C') { num |= 1; } else if (chars[i] == 'G') { num |= 2; } else if (chars[i] == 'T') { num |= 3; } } freq[num]++; List<Integer> repeated = new ArrayList<>(); for (int i = 10; i < chars.length; i++) { // 遍历全部的长度为10的DNA串 num <<= 2; // 清楚最高的两位,也就是移除滑出的字符串 if (chars[i] == 'C') { num |= 1; } else if (chars[i] == 'G') { num |= 2; } else if (chars[i] == 'T') { num |= 3; } num &= mask; // 掩码 保留最低的20位 freq[num]++; // 统计出现频率 if (freq[num] == 2) repeated.add(num); // 只有出现次数是2的时候才计入,避免重复 } List<String> res = new ArrayList<>(repeated.size()); for (Integer integer : repeated) { // 将int数字转化成DNA串 char[] seq = new char[10]; for (int i = 9; i >= 0; i--) { switch (integer&3) { case 0:seq[i]='A';break; case 1:seq[i]='C';break; case 2:seq[i]='G';break; case 3:seq[i]='T';break; } integer >>=2; } res.add(new String(seq)); } return res; }
这篇文章主要主要讲解了二进制位操做的几个基本的使用方法和小技巧,顺带提了一下浮点型数字的表示方法,但愿可以增长对计算机底层的数据的理解。
后面讲到了几个leetcode上面的比较经典的题目好比两数相除和只出现一次的数字,都是使用位运算解决实际的问题,而重复的DNA序列是更高层次的经过位运算解决问题的案例。
洋洋洒洒写了几千字,但愿帮助你们加深对位运算的理解,并可以熟练运用在工做学习中。
A summary: how to use bit manipulation to solve problems easily and efficiently
Bit Manipulation 4% of LeetCode Problems
Bit Twiddling Hacks
Bitwise operators in Java
9.1 Floating Point
java浮点数的二进制格式分析
How is a floating point number represented in Java?
执行时间1ms,击败100%
详细通俗的思路分析,多解法
Detailed explanation and generalization of the bitwise operation method for single numbers
Bitwise Hacks for Competitive Programming
Bit Tricks for Competitive Programming
更多内容请看个人我的博客