前言
由於有人沒看明白起初的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)) 的複雜度.
問題的來源
學弟發給我一個代碼, 第一眼竟然沒看明白.
int run(int n, int* A) {
int ones = 0;// 出現一次的標誌位
int twos = 0;// 出現第二次標誌位
for(int i = 0; i < n; i++) {
ones = (ones ^ A[i]) & ~twos;// 第二次出現的去掉, 第一次出現的加上, 第三次出現的不變
twos = (twos ^ A[i]) & ~ones;// 第一次出現的不變, 第二次出現的加上, 第三次出現的去掉
}
return ones;
}
然後想到, 可能有位運算的一些規律, 比如分配率, 結合律, 交換律等.
異或的一些共公式
a op b
a | b = b | a
a & b = b & a
a ^ b = b ^ a
~~a = a
~(a & b) = (~a) | (~b)
~(a | b) = (~a) & (~b)
~(a ^ b) = (~a) ^ b = a ^ (~b) = ~((~a) ^ (~b))
a op ( b op c)
a & (b | c) = (a & b) | (a & c)
a & (b ^ c) = (a & b) ^ (a & c)
a | (b & c) = (a | b) & (a | c)
a | (b ^ c) = (a | b) ^ (~a & c)
a ^ (b | c) = ?
a ^ (b & c) = ?
(a | (b & c )) & ~(b ^ c) = ?
a op ( a op 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 |
a ^ (a | b) = ~a & b
a ^ (a & b) = a & ~b
a | (a ^ b) = a | b
a | (a & b) = a
a & (a ^ b) = a ^ (a & b) = a & ~b
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 |
a & ~a = 0
(a ^ b) & (a & b) = (a ^ b) & a & b = 0
~(a ^ b) & (a & b ) = a & b
~(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 的轉移推導
下面我們先看 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 轉移的公式
one2 = (a ^ one1) & ~two1;
two 的轉移推導
接下來我們再看看 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 獲勝, 於是公式找到了.
two2 = (a ^ two1) & ~one2;
綜合分析
上面的兩個式子已經很簡潔了.
one2 = (a ^ one1) & ~two1;
two2 = (a ^ two1) & ~one2;
one1 = one2;
two1 = two2;
發現我們不需要 one2 和 two 這兩個變量了, 於是公式簡化爲
one = (a ^ one) & ~two;
two = (a ^ two) & ~one;
此時, 我們就得到了和最開始寫的那個公式一樣的結論了.
java代碼的答案
其實, 最開始學弟給我的程序不是這個簡潔的cpp寫的程序, 而是一個java寫的程序.
我之所以推導異或的式子也是因爲下面的程序.
看下面的最開始的程序.
public int singleNumber(int[] A) {
if(A.length == 1) return A[0];
// A[0] is correct to start
// Take care of processing A[1]
A[0] ^= A[1];
// Set A[1] to either 0 or itself
A[1] = (A[0]^A[1])&A[1];// => A[1] = A[0] & A[1];
// Continue with algorithm as normal
for(int i = 2; i < A.length; i++){
A[1] |= A[0]&A[i];
A[0] ^= A[i];
A[2] = ~(A[0]&A[1]);
A[0] &= A[2];
A[1] &= A[2];
}
return A[0];
}
由於程序對前兩個數特判了, 我們可以用兩個變量替換, 這樣就可以從0開始循環了.
public int singleNumber(int[] A) {
int one = 0, two=0, tmp;
for(int i = 0; i < A.length; i++){
two = two | (one & A[i]);
one = one ^A[i];
tmp = ~(one & two);
one = one &tmp;
two = two &tmp;
}
return A[0];
}
看起來好複雜的樣子, 我給他再合併一下.
public int singleNumber(int[] A) {
int one = 0, two=0, tmp;
for(int i = 0; i < A.length; i++){
tmp = ~((one ^A[i]) & (two | (one & A[i])));
two = (two | (one & A[i])) & tmp;
one = (one ^A[i]) & tmp;
}
return A[0];
}
看着還是好複雜的樣子, 這時我上面的那些異或公式就派上用場了.
可以先對 tmp 展開.
tmp = ~((one ^ a) & (two | (one & a)))
=> ~( ((one ^ a) & two) | ((one ^ a) & (one & a)) ) // 按 a & (b | c) = (a & b) | (a & c) 展開
=> ~ ((one ^ a) & two) //(one ^ a) & (one & a) 恆等於 0
=> ~(one ^ a) | ~two
然後對 two 展開
tmp = ~(one ^ a) | ~two
one2 = (one ^ a) & tmp
= (one ^ a) & (~(one ^ a) | ~two)
= ((one ^ a) & ~(one ^ a)) | ((one ^ a) & ~two)
= (one ^ a) & ~two
two2 = (two | (one & a)) & tmp
= (two & tmp) | ((one & a) & tmp )
= (two & (~(one ^ a) | ~two)) | ((one & a) & tmp )
= (two & ~(one ^ a)) | (two & ~two) | ((one & a) & tmp )
= (two & ~(one ^ a)) | ((one & a) & tmp ) // (two & ~two) 恆等於 0
= (two & ~(one ^ a)) | ((one & a )& (~(one ^ a) | ~two) )
= (two & ~(one ^ a)) | (((one & a ) & ~(one ^ a)) | ((one & a ) & ~two))
= (two & ~(one ^ a)) | ((one & a ) | ((one & a ) & ~two) )
= (two & ~(one ^ a)) | (one & a )
= (two | (one & a )) & (~(one ^ a) | (one & a ))
= (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.
java 代碼爲什麼這麼複雜
前面我們說原理了, 也就是那張表.
有了表, 我們只要通過某些公式得到那種表就行了.
但是我們怎麼樣才能找到那些公式就是一個問題了, 那個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 |
這個公式用上面的方法很容易推導的, 大家可以推導一下.
大家自己可以想想, 方法多多, 關鍵在於你怎麼去想.