由三道 LeetCode 題目簡單瞭解一下位運算

你可作過這幾道題?

在面試的準備過程當中,刷算法題算是必修課,固然我也不例外。某天,我刷到了一道神奇的題目: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

下面就讓咱們簡單瞭解一下位運算並解析一下這三道題目。

簡單介紹一下位運算

1. 異或運算(^)

異或邏輯的關係是:當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 進行異或操做,結果都爲其自己。

2. 與操做(&)

「與」運算是計算機中一種基本的邏輯運算方式,符號表示爲 「&」,參加運算的兩個數據,按二進制位進行「與」運算。運算規則: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

「與運算」的特殊用途:

  1. 清零。若是想將一個單元清零,即便其所有二進制位爲0,只要與一個各位都爲零的數值相與,結果爲零。

  2. 取一個數的指定位

    方法:找一個數,對應X要取的位,該數的對應位爲1,其他位爲零,此數與X進行「與運算」能夠獲得X中的指定位。例:設 X=10101110,取X的低4位,用 X & 0000 1111 = 0000 1110 便可獲得;還可用來取 X 的二、四、6位。

3. 或操做(|)

參加運算的兩個對象,按二進制位進行「或」運算。運算規則: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. 經常使用來對一個數據的某些位置1。

    方法:找到一個數,對應X要置1的位,該數的對應位爲1,其他位爲零。此數與X相或可以使X中的某些位置1。

    例:將 X=10100000 的低4位 置爲1 ,用 X | 0000 1111 = 1010 1111 便可獲得。

4. 取反操做(~)

參加運算的一個數據,按二進制位進行「取反」運算。運算規則:~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,只須要確保 2 > 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;
複製代碼

將這兩部分合起來就是解決這個問題的完整算法了。


5. 左移運算符(<<)

將一個運算對象的各二進制位所有左移若干位(左邊的二進制位丟棄,右邊補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
複製代碼

6. 右移運算符(>>)

>> 表示右移,表示將一個數的各二進制位所有右移若干位,正數左補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 的題解可能須要費一點功夫,至少我尚未徹底理解,但不能輕易放棄對不對,繼續啃啊!

以上即是我我的的簡單總結,若是有紕漏或者錯誤,歡迎進行指出及糾正。

參考:

相關文章
相關標籤/搜索