位運算 的探究

由於有人沒看明白起初的O(n)操作, 所以這裏就先簡單的介紹一下 O(n log(n))的做法.
我這裏添加上基本的做法, 然後把O(n)做法的分析寫的更詳細了一個, 明白的人可能看起來比較囉嗦, 抱歉了.
另外有人問假設是大部分都是4個相同, 只有一個是2個相同怎麼做, 這個問題我們可以稱爲4-2問題.
這樣的話這篇文章主要講解的就是3-1問題了.

爲了不偏離主題, 我重新寫一篇文章專門討論 n-m 問題.
如果你不想看基本做法, 可以直接跳到 問題的來源 的位置, 向下滾動的時候, 應該可以看到有個目錄, 點擊 問題的來源 即可.

最暴力的方法就是排序了.
可以選擇的排序有 快速排序, 基數排序等吧, 都是 O(n log(n)) 的算法.
聲明基數排序看起來是 O(32n) 的複雜度, 其實就是 O(n log(n)) 的複雜度.
32位整數最大爲 2^32 , log(2^32) = 32 log(2), 因此 32 就可以近似理解爲 log 級別的了.
排序後我們就順序統計了, 遇到不是三個連續的了, 就找到答案了.

由於只有32位, 我們可以開一個32位的數組, 然後遍歷所有的數字時, 數字每一位都加到數組對應的位置中, 然後模3.
這樣最後數組中肯定都是01,, 這些01組成的數字就是答案了.
這個做法看起來很巧妙的樣子, 而且可以解決那種 n-m 問題(n>m),只需要模n即可.
但是複雜度實際上還是沒有什麼改進, 依舊是 O(n log(n)) 的複雜度.

學弟發給我一個代碼, 第一眼竟然沒看明白.

  1. int run(int n, int* A) {
  2. int ones = 0;// 出現一次的標誌位
  3. int twos = 0;// 出現第二次標誌位
  4. for(int i = 0; i < n; i++) {
  5. ones = (ones ^ A[i]) & ~twos;// 第二次出現的去掉, 第一次出現的加上, 第三次出現的不變
  6. twos = (twos ^ A[i]) & ~ones;// 第一次出現的不變, 第二次出現的加上, 第三次出現的去掉
  7. }
  8. return ones;
  9. }

然後想到, 可能有位運算的一些規律, 比如分配率, 結合律, 交換律等.

  1. a | b = b | a
  2. a & b = b & a
  3. a ^ b = b ^ a
  4. ~~a = a
  5. ~(a & b) = (~a) | (~b)
  6. ~(a | b) = (~a) & (~b)
  7. ~(a ^ b) = (~a) ^ b = a ^ (~b) = ~((~a) ^ (~b))
  1. a & (b | c) = (a & b) | (a & c)
  2. a & (b ^ c) = (a & b) ^ (a & c)
  3. a | (b & c) = (a | b) & (a | c)
  4. a | (b ^ c) = (a | b) ^ (~a & c)
  5. a ^ (b | c) = ?
  6. a ^ (b & c) = ?
  7. (a | (b & c )) & ~(b ^ c) = ?
a b a ^ (a | b) a ^ (a&b) a | (a ^ b) a | (a&b) a&(a | b) a&(a ^ b)
0 0 0 0 0 0 0 0
1 0 0 1 1 1 1 1
0 1 1 0 1 0 0 0
1 1 0 0 1 1 1 0
  1. a ^ (a | b) = ~a & b
  2. a ^ (a & b) = a & ~b
  3. a | (a ^ b) = a | b
  4. a | (a & b) = a
  5. a & (a ^ b) = a ^ (a & b) = a & ~b
  6. a & (a | b) = a
a b (a ^ b)&(a&b) ~(a ^ b)&(a&b) ~(a ^ b)|(a&b)
0 0 0 0 1
1 0 0 0 0
0 1 0 0 0
1 1 0 1 1
  1. a & ~a = 0
  2. (a ^ b) & (a & b) = (a ^ b) & a & b = 0
  3. ~(a ^ b) & (a & b ) = a & b
  4. ~(a ^ b) | (a & b ) = ~(a ^ b)

我對異或的那些公式推導了一番, 也沒推導出來什麼.
後來學弟告訴我原理是:

  • 默認one,two都是0, 即任何數字都不存在
  • 數字a第一次來的時候, one標記a存在, two不變
  • 數字a第二次來的時候, one標記a不存在, two標記a存在
  • 數字a第三次來的時候, one不變, two標記a不存在

由於一直是異或, 沒有左移或右移, 所以我們可以看成n個數字每一位每一位做了某些位操作而得到答案的.

只看一位後發現問題突然簡單了.

因爲只看一位的話, 就只有0和1了.

我們先不看只出現一次的那個數, 其他數字合起來就是每一位都出現了 3n 次0 和 3m 次1.

加上只出現一次的那個數, 就是告訴你若干個0,1. 其中有一個數字是 3n+1 個, 另外一個是3m個.

由於默認值是0, 所以我們只需要對1操作, 即操作所有數後, 剩下的是1答案就是1, 剩下的是0答案就是0.

注:假設1是3m個, 0是3n+1, 則操作完1後, 1剛好消去, 答案剛好是0了.
假設0是3m個, 1是3n+1, 則操作完1後, 1還剩一個, 答案也應該是1.

下面我看先看一下原理對應的圖表.

實際上就是一個狀態機,從(0,0)出發, 相同狀態作用與0和1.

a one1 two1 one2 two2
0 0 0 0 0
1 0 0 1 0
0 1 0 1 0
1 1 0 0 1
0 0 1 0 1
1 0 1 0 0

實際上可以看出, 處於0, 不影響one和two的值.因爲假設1有3個, 操作完後就是0, 答案就是0, 所以對於0我們只需要什麼都不做.

下面我們先看 one 的值是怎麼轉移的

a one1 a ^ one1 one2
0 0 0 0
1 0 1 1
0 1 1 1
1 1 0 0
0 0 0 0
1 0 1 0

我們可以看到, a ^ one1 後, 只有最後一個和 one2 不一樣.本來爲 0 的值卻爲 1 了.

所以我們需和一個數字進行 一種操作, 把最後那個 1 變爲 0, 而且其他行的值應該保持不變.

那我們現在有哪些已知的值呢?

比較簡潔的值有下面幾個.

a ~a one1 ~one1 two1 ~two1 a ^ one1 one2
0 1 0 1 0 1 0 0
1 0 0 1 0 1 1 1
0 1 1 0 0 1 1 1
1 0 1 0 0 1 0 0
0 1 0 1 1 0 0 0
1 0 0 1 1 0 1 0

可以看到, 比較簡潔的候選人有 a, ~a, one1, ~one1, two1, ~two1.

我們要選擇一個, 和 a ^ one1 進行一種操作, 來得到 one2.

比較簡潔的操作有 | , & , ^ 這三種.

進過大量的嘗試, 我們可以發現 ~two0候選人 和 &操作獲得勝利, 成功的得到 one2.

於是我們得到第一個 one 轉移的公式

  1. one2 = (a ^ one1) & ~two1;

接下來我們再看看 two 的值是怎麼轉移的.

a two1 a ^ two1 two2
0 0 0 0
1 0 1 0
0 0 0 0
1 0 1 1
0 1 1 1
1 1 0 0

我們驚奇的發現, a ^ two 後, 也是隻有一位和 two2 不同, 而且也是本來該是 0 的卻變爲 1 了.

我們改進找出候選人(a, ~a, one1, ~one1, two1, ~two1, one2, ~one2)和候選操作(|, &, ^).

a ~a one1 ~one1 one2 ~one2 two1 ~two1 a ^ two1 two2
0 1 0 1 0 1 0 1 0 0
1 0 0 1 1 0 0 1 1 0
0 1 1 0 1 0 0 1 0 0
1 0 1 0 0 1 0 1 1 1
0 1 0 1 0 1 1 0 1 1
1 0 0 1 0 1 1 0 0 0

由於第一個是 & 操作找到的, 我們這次當然先看 & 操作了.

a ^ two1 是 0 的那些行不用看了, 因爲 0 任何數都是0.

我們只需要看 a ^ two1 是 1 的那些行.

對於第二行, 我們需要找出是0的列, 對於其他的行, 我們需要找到是1的列, 這樣才能得到 two2.

第二行, 是 0 的列有 ~a, one1, ~one2, two1, 這些成員臨時入選.
第四行, 臨時候選列中 有 1 的列有 one1, ~one2, 這些成員再次臨時入選, 競爭很激烈, 每次減半, 相信下次就沒有了.
第五行, 臨時候選中由 1 的列只有 ~one2 了, 進入了臨時候選.
最後, 由於所有的都遍歷完了, ~one2 獲勝, 於是公式找到了.

  1. two2 = (a ^ two1) & ~one2;

上面的兩個式子已經很簡潔了.

  1. one2 = (a ^ one1) & ~two1;
  2. two2 = (a ^ two1) & ~one2;
  3. one1 = one2
  4. two1 = two2

發現我們不需要 one2 和 two 這兩個變量了, 於是公式簡化爲

  1. one = (a ^ one) & ~two;
  2. two = (a ^ two) & ~one;

此時, 我們就得到了和最開始寫的那個公式一樣的結論了.

其實, 最開始學弟給我的程序不是這個簡潔的cpp寫的程序, 而是一個java寫的程序.

我之所以推導異或的式子也是因爲下面的程序.

看下面的最開始的程序.

  1. public int singleNumber(int[] A) {
  2. if(A.length == 1) return A[0];
  3. // A[0] is correct to start
  4. // Take care of processing A[1]
  5. A[0] ^= A[1];
  6. // Set A[1] to either 0 or itself
  7. A[1] = (A[0]^A[1])&A[1];// => A[1] = A[0] & A[1];
  8. // Continue with algorithm as normal
  9. for(int i = 2; i < A.length; i++){
  10. A[1] |= A[0]&A[i];
  11. A[0] ^= A[i];
  12. A[2] = ~(A[0]&A[1]);
  13. A[0] &= A[2];
  14. A[1] &= A[2];
  15. }
  16. return A[0];
  17. }

由於程序對前兩個數特判了, 我們可以用兩個變量替換, 這樣就可以從0開始循環了.

  1. public int singleNumber(int[] A) {
  2. int one = 0, two=0, tmp;
  3. for(int i = 0; i < A.length; i++){
  4. two = two | (one & A[i]);
  5. one = one ^A[i];
  6. tmp = ~(one & two);
  7. one = one &tmp;
  8. two = two &tmp;
  9. }
  10. return A[0];
  11. }

看起來好複雜的樣子, 我給他再合併一下.

  1. public int singleNumber(int[] A) {
  2. int one = 0, two=0, tmp;
  3. for(int i = 0; i < A.length; i++){
  4. tmp = ~((one ^A[i]) & (two | (one & A[i])));
  5. two = (two | (one & A[i])) & tmp;
  6. one = (one ^A[i]) & tmp;
  7. }
  8. return A[0];
  9. }

看着還是好複雜的樣子, 這時我上面的那些異或公式就派上用場了.

可以先對 tmp 展開.

  1. tmp = ~((one ^ a) & (two | (one & a)))
  2. => ~( ((one ^ a) & two) | ((one ^ a) & (one & a)) ) // 按 a & (b | c) = (a & b) | (a & c) 展開
  3. => ~ ((one ^ a) & two) //(one ^ a) & (one & a) 恆等於 0
  4. => ~(one ^ a) | ~two

然後對 two 展開

  1. tmp = ~(one ^ a) | ~two
  2. one2 = (one ^ a) & tmp
  3. = (one ^ a) & (~(one ^ a) | ~two)
  4. = ((one ^ a) & ~(one ^ a)) | ((one ^ a) & ~two)
  5. = (one ^ a) & ~two
  6. two2 = (two | (one & a)) & tmp
  7. = (two & tmp) | ((one & a) & tmp )
  8. = (two & (~(one ^ a) | ~two)) | ((one & a) & tmp )
  9. = (two & ~(one ^ a)) | (two & ~two) | ((one & a) & tmp )
  10. = (two & ~(one ^ a)) | ((one & a) & tmp ) // (two & ~two) 恆等於 0
  11. = (two & ~(one ^ a)) | ((one & a )& (~(one ^ a) | ~two) )
  12. = (two & ~(one ^ a)) | (((one & a ) & ~(one ^ a)) | ((one & a ) & ~two))
  13. = (two & ~(one ^ a)) | ((one & a ) | ((one & a ) & ~two) )
  14. = (two & ~(one ^ a)) | (one & a )
  15. = (two | (one & a )) & (~(one ^ a) | (one & a ))
  16. = (two | (one & a )) & ~(one ^ a)

化簡到哪裏就化簡不動了, 但是經過分析, 發現對於 one, a 和 two.

  • one 和 a 同時爲0的時候, two2=two, one2=0
  • one 和 a 同時爲1的時候, two2=1, one2=0
  • one 和 a 不同時, two2 = 0, one2 = ~two

此時再想想那原理

  • one 和 a 同時爲0, one和two的值不變, 因爲 a=0.
  • one 和 a 同時爲1, 說明是第二個1. two應該標記爲1, one標記爲0
  • 如果a爲0, 則one爲1, two肯定爲0, ~two還是1, 即保持不變
  • 如果a爲1, 則one爲0, 此時two可能是0或1.
    • 如果two爲0, 這時來一個1, 應該標記one=1, 即這是第一個1.
    • 如果 two 爲1, 這時來一個1, 說明是第三個1, 則one=two=0.

前面我們說原理了, 也就是那張表.

有了表, 我們只要通過某些公式得到那種表就行了.

但是我們怎麼樣才能找到那些公式就是一個問題了, 那個cpp代碼找到了一種簡潔的公式, 但是這個java代碼就沒有那麼幸運, 找到這麼複雜的公式.

經過我的化簡, one 的求值已經簡化到和cpp的one一樣的求值公式了.

但是java的第二個就沒有那麼幸運了, 化簡到相對最簡時還是有些複雜.

當然java的第二個公式可能還可以化簡, 但是我時間有限, 沒有時間去化簡, 所以讀者可以自己嘗試化簡一下.

有了那個原理的思路, 我們可以用另一種思路來試試.

  • 來第一個1時 one 標記爲1
  • 來第二個1時 two 標誌爲1
  • 來第三個1時, one和two重置爲0

此時狀態轉移表是

a one1 two1 one2 two2
0 0 0 0 0
1 0 0 1 0
0 1 0 1 0
1 1 0 1 1
0 1 1 1 1
1 1 1 0 0

這時使用異或後有什麼發現呢?

a one1 ~one1 two1 ~two1 a ^ one1 a ^ two1 one2 two2
0 0 1 0 1 0 0 0 0
1 0 1 0 1 1 1 1 0
0 1 0 0 1 1 0 1 0
1 1 0 0 1 0 1 1 1
0 1 0 1 0 1 1 1 1
1 1 0 1 0 0 0 0 0

這個公式用上面的方法很容易推導的, 大家可以推導一下.

大家自己可以想想, 方法多多, 關鍵在於你怎麼去想.

發佈了6 篇原創文章 · 獲贊 31 · 訪問量 45萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章