對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
更多內容請看個人我的博客