在面試的準備過程當中,刷算法題算是必修課,固然我也不例外。某天,我刷到了一道神奇的題目:java
# 136. 只出現一次的數字 給定一個非空整數數組,除了某個元素只出現一次之外,其他每一個元素均出現兩次。找出那個只出現了一次的元素。 說明: 你的算法應該具備線性時間複雜度。 你能夠不使用額外空間來實現嗎? 示例 1: 輸入: [2,2,1] 輸出: 1 示例 2: 輸入: [4,1,2,1,2] 輸出: 4 複製代碼
我不由眉頭一皺,心說,這還不簡單,三下五除二寫下以下代碼:git
/** * HashMap * * @param nums 數組 * @return 結果 */ public int solution(int[] nums) { Map<Integer, Integer> map = new HashMap<>(); for (int num : nums) { if (map.containsKey(num)) { map.remove(num); } else { map.put(num, 1); } } return map.entrySet().iterator().next().getKey(); } 複製代碼
接着,我看到了另一道題目:github
# 137. 只出現一次的數字 II 給定一個非空整數數組,除了某個元素只出現一次之外,其他每一個元素均出現了三次。找出那個只出現了一次的元素。 說明: 你的算法應該具備線性時間複雜度。 你能夠不使用額外空間來實現嗎? 示例 1: 輸入: [2,2,3,2] 輸出: 3 示例 2: 輸入: [0,1,0,1,0,1,99] 輸出: 99 複製代碼
我不由眉頭又一皺,心說,好像是一樣的套路,便寫下了以下代碼:面試
/** * 使用Map,存儲key以及出現次數 * * @param nums 數組 * @return 出現一次的數字 */ public int singleNumber(int[] nums) { Map<Integer, Integer> map = new HashMap<>(); for (int num : nums) { if (map.containsKey(num)) { map.put(num, map.get(num) + 1); } else { map.put(num, 1); } } for (Integer key : map.keySet()) { if (map.get(key) == 1) { return key; } } return 0; } 複製代碼
而後,就出現了終極題目:算法
# 260. 只出現一次的數字 III 給定一個整數數組 nums,其中剛好有兩個元素只出現一次,其他全部元素均出現兩次。 找出只出現一次的那兩個元素。 示例 : 輸入: [1,2,1,3,2,5] 輸出: [3,5] 注意: 1. 結果輸出的順序並不重要,對於上面的例子, [5, 3] 也是正確答案。 2. 你的算法應該具備線性時間複雜度。你可否僅使用常數空間複雜度來實現? 複製代碼
我不由又皺了一下眉頭,心說,嗯……接着便寫下以下代碼:數組
/** * 使用Map,存儲key以及出現次數 * * @param nums 數組 * @return 出現一次的數字的數組 */ public int[] singleNumber(int[] nums) { int[] result = new int[2]; Map<Integer, Integer> map = new HashMap<>(); for (int num : nums) { if (map.containsKey(num)) { map.put(num, map.get(num) + 1); } else { map.put(num, 1); } } int i = 0; for (Integer key : map.keySet()) { if (map.get(key) == 1) { result[i] = key; i++; } } return result; } 複製代碼
用幾乎同一種思路作了三道題,不得不誇一下本身:bash
作完這三道題目,提交了答案以後,執行用時和內存消耗都只超過了 10% 的解題者。不禁得眉頭緊鎖(終於知道本身爲啥擡頭紋這麼深了),發現事情並無這麼簡單……markdown
以後我又找了一下其餘解法,以下:數據結構
/** * #136 根據題目描述,因爲加上了時間複雜度必須是 O(n) ,而且空間複雜度爲 O(1) 的條件,所以不能用排序方法,也不能使用 map 數據結構。答案是使用 位操做Bit Operation 來解此題。 * 將全部元素作異或運算,即a[1] ⊕ a[2] ⊕ a[3] ⊕ …⊕ a[n],所得的結果就是那個只出現一次的數字,時間複雜度爲O(n)。 * 根據異或的性質 任何一個數字異或它本身都等於 0 * * @param nums 數組 * @return 結果 */ private int solution(int[] nums) { int res = 0; for (int num : nums) { res ^= num; } return res; } 複製代碼
/** * #137 嗯……這個咱們下面再作詳解 * 這裏使用了異或、與、取反這些運算 * * @param nums 數組 * @return 出現一次的數字 */ public int singleNumber2(int[] nums) { int a = 0, b = 0; int mask; for (int num : nums) { b ^= a & num; a ^= num; mask = ~(a & b); a &= mask; b &= mask; } return a; } 複製代碼
/** * #260 在這裏把全部元素都異或,那麼獲得的結果就是那兩個只出現一次的元素異或的結果。 * 而後,由於這兩個只出現一次的元素必定是不相同的,因此這兩個元素的二進制形式確定至少有某一位是不一樣的,即一個爲 0 ,另外一個爲 1 ,如今須要找到這一位。 * 根據異或的性質 任何一個數字異或它本身都等於 0 ,獲得這個數字二進制形式中任意一個爲 1 的位都是咱們要找的那一位。 * 再而後,以這一位是 1 仍是 0 爲標準,將數組的 n 個元素分紅兩部分。 * 1. 將這一位爲 0 的全部元素作異或,得出的數就是隻出現一次的數中的一個 * 2. 將這一位爲 1 的全部元素作異或,得出的數就是隻出現一次的數中的另外一個。 * 這樣就解出題目。忽略尋找不一樣位的過程,總共遍歷數組兩次,時間複雜度爲O(n)。 * * 使用位運算 * * @param nums 數組 * @return 只出現一次數字的數組 */ public int[] singleNumber2(int[] nums) { int diff = 0; for (int num : nums) { diff ^= num; } // 獲得最低的有效位,即兩個數不一樣的那一位 diff &= -diff; int[] result = new int[2]; for (int num : nums) { if ((num & diff) == 0) { result[0] ^= num; } else { result[1] ^= num; } } return result; } 複製代碼
看完上面的解法,我腦海中只有問號的存在,啥意思啊?!oop
下面就讓咱們簡單瞭解一下位運算並解析一下這三道題目。
異或邏輯的關係是:當AB不一樣時,輸出P=1;當AB相同時,輸出P=0。「⊕」是異或數學運算符號,異或邏輯也是與或非邏輯的組合,其邏輯表達式爲:P=A⊕B。在計算機語言中,異或的符號爲「 ^ 」。
異或運算 A ⊕ B 的真值表以下:
A | B | ⊕ |
---|---|---|
F | F | F |
F | T | T |
T | F | T |
T | T | F |
因此咱們從 #136
題解中瞭解,經過異或運算,兩個相同的元素結果爲 0,而 任何數 與 0 進行異或操做,結果都爲其自己。
「與」運算是計算機中一種基本的邏輯運算方式,符號表示爲 「&」,參加運算的兩個數據,按二進制位進行「與」運算。運算規則:0&0=0;0&1=0;1&0=0;1&1=1;即:兩位同時爲「1」,結果才爲「1」,不然爲0。另,負數按補碼形式參加按位與運算。
與運算 A & B 的真值表以下:
A | B | & |
---|---|---|
F | F | F |
F | T | F |
T | F | F |
T | T | T |
「與運算」的特殊用途:
清零。若是想將一個單元清零,即便其所有二進制位爲0,只要與一個各位都爲零的數值相與,結果爲零。
取一個數的指定位
方法:找一個數,對應X要取的位,該數的對應位爲1,其他位爲零,此數與X進行「與運算」能夠獲得X中的指定位。例:設 X=10101110,取X的低4位,用 X & 0000 1111 = 0000 1110
便可獲得;還可用來取 X 的二、四、6位。
參加運算的兩個對象,按二進制位進行「或」運算。運算規則:0|0=0; 0|1=1; 1|0=1; 1|1=1;即 :參加運算的兩個對象只要有一個爲1,其值爲1。另,負數按補碼形式參加按位或運算。
或運算 A | B 的真值表以下:
A | B | | |
---|---|---|
F | F | F |
F | T | T |
T | F | T |
T | T | T |
或運算」特殊做用:
經常使用來對一個數據的某些位置1。
方法:找到一個數,對應X要置1的位,該數的對應位爲1,其他位爲零。此數與X相或可以使X中的某些位置1。
例:將 X=10100000 的低4位 置爲1 ,用 X | 0000 1111 = 1010 1111
便可獲得。
參加運算的一個數據,按二進制位進行「取反」運算。運算規則:~1=0; ~0=1;即:對一個二進制數按位取反,即將0變1,1變0。
使一個數的最低位爲零,能夠表示爲:a&~1
。~1 的值爲 1111111111111110,再按「與」運算,最低位必定爲0。由於「~」運算符的優先級比算術運算符、關係運算符、邏輯運算符和其餘運算符都高。
OK,截止到這兒,三道題目中使用的位運算介紹完畢,那麼這裏咱們插入一下 #137
的詳細題解。
public int singleNumber2(int[] nums) { // 這裏咱們改一下變量名 // 用 one 記錄到當前處理的元素爲止,二進制1出現「1次」(mod 3 以後的 1)的有哪些二進制位; // 用 two 記錄到當前計算的變量爲止,二進制1出現「2次」(mod 3 以後的 2)的有哪些二進制位。 int one = 0, two = 0; int mask; for (int num : nums) { // 因爲 two 要考慮,one 的已有狀態,和當前是否繼續出現。因此要先算 two ^= one & num; // one 就是一個0,1的二值位,在兩個狀態間轉換 one ^= num; // 當 one 和 two 中的某一位同時爲1時表示該二進制位上1出現了3次,此時須要清零。 mask = ~(one & two); // 清零操做 one &= mask; two &= mask; } // 即用 二進制 模擬 三進制 運算。最終 one 記錄的是最終結果。 return one; } 複製代碼
首先考慮一個相對簡單的問題,加入輸入數組裏面只有 0 和 1,咱們要統計 1 出現的次數,當遇到 1 就次數加 1,遇到 0 就不變,當次數達到 k 時,統計次數又迴歸到 0。咱們能夠用 m 位來作這個計數工做,即 xm, xm−1, …, x1,只須要確保 2m > k 便可,接下來咱們要考慮的問題就是,在每一次check元素的時候,作什麼操做能夠知足上述的條件。在開始計數以前,每個計數位都初始化位0,而後遍歷nums
,直到遇到第一個1,此時 x1 會變成1,繼續遍歷,直到遇到第二個1,此時 x1=0, x2=1,直到這裏應該能夠看出規律了。每遇到一個1,對於 xm, xm−1, …, x1,只有以前的全部位都爲1的時候才須要改變本身的值,若是原本是1,就變成0,原本是0,就變成1 ,若是遇到的是0,就保持不變。搞清楚了這個邏輯,寫出表達式就不難了。這裏以 m = 3 爲例給出 java
代碼:
for(int num: nums) { x3 ^= x2 & x1 & num; x2 ^= x1 & num; x1 ^= num; // other operations } 複製代碼
可是到這裏尚未解決當 1 的次數到 k 時,計數值要從新返回到 0,也就是全部計數位都變成 0 這個問題。解決辦法也是比較巧妙。
假設咱們有一個標誌變量,只有當計數值到 k 的時候這個標誌變量才爲 0,其他狀況下都是 1,而後每一次check元素的時候都對每一個計數位和標誌變量作與操做,那麼若是標誌變量爲 0,也就是計數值爲 k 的時候,全部位都會變成 0, 反之,全部位都會保持不變,那麼咱們的目的也就達到了。
好,最後一個問題是怎麼計算標誌變量的值。將 k 轉變爲二進制,只有計數值達到 k,全部計數位纔會和 k 的二進制同樣,因此只須要將 k 的二進制位作 與操做 ,若是某個位爲 0,就與該位 取反 以後的值作與操做。
以 k=3, m=2 爲例,簡要的 java
代碼以下:
// where yj = xj if kj = 1, // and yj = ~xj if kj = 0, // k1, k2是 k 的二進制表示(j = 1 to 2). mask = ~(y1 & y2); x2 &= mask; x1 &= mask; 複製代碼
將這兩部分合起來就是解決這個問題的完整算法了。
將一個運算對象的各二進制位所有左移若干位(左邊的二進制位丟棄,右邊補0)。
例:a = a<< 2將a的二進制位左移2位,右補0,左移1位後a = a *2;
若左移時捨棄的高位不包含1,則每左移一位,至關於該數乘以2。
代碼示例,本代碼中的整數爲32位整數,因此爲負數的話,二進制表示其長度爲32:
/** * << 表示左移,若是該數爲正,則低位補0,若爲負數,則低位補1。如:5<<2的意思爲5的二進制位往左挪兩位,右邊補0,5的二進制位是0000 0101 , 就是把有效值101往左挪兩位就是0001 0100 */ @Test public void leftShiftTest() { int number1 = 5; System.out.println("左移前的十進制數爲:" + number1); System.out.println("左移前的二進制數爲:" + Integer.toBinaryString(number1)); int number2 = number1 << 2; System.out.println("左移後的十進制數爲:" + number2); System.out.println("左移後的二進制數爲:" + Integer.toBinaryString(number2)); System.out.println(); int number3 = -5; System.out.println("左移前的十進制數爲:" + number3); System.out.println("左移前的二進制數爲:" + Integer.toBinaryString(number3)); int number4 = number3 << 2; System.out.println("左移後的十進制數爲:" + number4); System.out.println("左移後的二進制數爲:" + Integer.toBinaryString(number4)); } 複製代碼
結果以下:
左移前的十進制數爲:5
左移前的二進制數爲:101
左移後的十進制數爲:20
左移後的二進制數爲:10100
左移前的十進制數爲:-5
左移前的二進制數爲:11111111111111111111111111111011
左移後的十進制數爲:-20
左移後的二進制數爲:11111111111111111111111111101100
複製代碼
>>
表示右移,表示將一個數的各二進制位所有右移若干位,正數左補0,負數左補1,右邊丟棄。操做數每右移一位,至關於該數除以2。
>>>
表示無符號右移,也叫邏輯右移,即若該數爲正,則高位補0,而若該數爲負數,則右移後高位一樣補0。
例如:a = a >> 2
將a的二進制位右移2位,左補0 or 補1得看被移數是正仍是負。
代碼示例,本代碼中的整數爲32位整數,因此爲負數的話,二進制表示其長度爲32:
@Test public void rightShift() { int number1 = 10; System.out.println("右移前的十進制數爲:" + number1); System.out.println("右移前的二進制數爲:" + Integer.toBinaryString(number1)); int number2 = number1 >> 2; System.out.println("右移後的十進制數爲:" + number2); System.out.println("右移後的二進制數爲:" + Integer.toBinaryString(number2)); System.out.println(); int number3 = -10; System.out.println("右移前的十進制數爲:" + number3); System.out.println("右移前的二進制數爲:" + Integer.toBinaryString(number3)); int number4 = number3 >> 2; System.out.println("右移後的十進制數爲:" + number4); System.out.println("右移後的二進制數爲:" + Integer.toBinaryString(number4)); System.out.println("***********************邏輯右移**********************"); int a = 15; System.out.println("邏輯右移前的十進制數爲:" + a); System.out.println("邏輯右移前的二進制數爲:" + Integer.toBinaryString(a)); int b = a >>> 2; System.out.println("邏輯右移後的十進制數爲:" + b); System.out.println("邏輯右移後的二進制數爲:" + Integer.toBinaryString(b)); System.out.println(); int c = -15; System.out.println("邏輯右移前的十進制數爲:" + c); System.out.println("邏輯右移前的二進制數爲:" + Integer.toBinaryString(c)); int d = c >>> 2; System.out.println("邏輯右移後的十進制數爲:" + d); System.out.println("邏輯右移後的二進制數爲:" + Integer.toBinaryString(d)); } 複製代碼
結果以下:
右移前的十進制數爲:10
右移前的二進制數爲:1010
右移後的十進制數爲:2
右移後的二進制數爲:10
右移前的十進制數爲:-10
右移前的二進制數爲:11111111111111111111111111110110
右移後的十進制數爲:-3
右移後的二進制數爲:11111111111111111111111111111101
***********************邏輯右移**********************
邏輯右移前的十進制數爲:15
邏輯右移前的二進制數爲:1111
邏輯右移後的十進制數爲:3
邏輯右移後的二進制數爲:11
邏輯右移前的十進制數爲:-15
邏輯右移前的二進制數爲:11111111111111111111111111110001
邏輯右移後的十進制數爲:1073741820
邏輯右移後的二進制數爲:111111111111111111111111111100
複製代碼
以上就是咱們常見的幾種位運算了,其中左移、右移等操做,在 HashMap
的源碼中也會常常看到,理解了這些位操做,對於理解源碼也是有必定幫助的,固然也會幫助咱們寫出執行效率更高的代碼。
從上面的部分示例中能夠看出,位運算一般用來下降包含排列,計數等複雜度比較高的操做,固然也能夠用來代替乘 2 除 2,判斷素數,偶數,倍數等基本操做,可是我認爲其意義在於前者,即用計數器來下降設計到排列或者計數的問題的複雜度。
最後一點,三道算法題中,#136
、#260
理解起來倒還好,#137 Single Number II
的題解可能須要費一點功夫,至少我尚未徹底理解,但不能輕易放棄對不對,繼續啃啊!
以上即是我我的的簡單總結,若是有紕漏或者錯誤,歡迎進行指出及糾正。