最近遇到這樣一道演算法題:
Given an array of integers, every element appears three times except for one. Find that single one.
一組整數,除了一個只出現一次以外,其他每個整數都恰好出現三次,要尋找那個特殊的整數。
似曾相識
首先,它讓我想起了另外一道類似的題目,如果把上面的“恰好三次”,改成“恰好兩次”,尋找那個特殊的整數,又該怎麼解?
那樣的話,我希望找到一個方法,讓兩個相同的數進行運算以後,能夠泯滅掉,這樣所有的數進行運算,剩下的值就是那個特殊的數。恰好有這樣的方法,這個方法就是“異或”:
1 2 3 4 5 6 |
public int singleNumber(int[] A) { int total = 0; for (int a : A) total ^= a; return total; } |
通用演算法
“恰好兩次”恰好有“異或”來解,現在“恰好兩次”變成了“恰好三次”,推廣一點說,如果是“恰好N次”,該怎麼解?
通用的演算法中,用一個HashMap可以得到複雜度近似為n的解法,key為數字本身,value計數,到三次的時候delete掉這個entry,迴圈完成以後整個HashMap中剩下的就是那個特殊的整數了。這個解法普普通通,沒有敘述的必要。這個方法可以保證“恰好N次”一樣解決。這個演算法很簡單,就不寫出來了。
另外一個思路,藉由位操作,對於整數32位,對於每一位,整個數列的數加起來去取3的餘數,就是那個特殊的數在該位上的值。這個方法也可以保證“恰好N次”一樣能夠被解決:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
public int singleNumber(int[] A) { int ret = 0; for (int i = 0; i < 32; i++) { int c = 0, mask = 1 << i; // ① mask, 第i位為1,其他位都為0 for (int j = 0; j < A.length; j++) { int val = (A[j] & mask); if ( val>0 || val<0 ) { // ② 如果該數在這一位上為1,計數器就加一 c++; } } if (c%3 > 0) // ③ 這一位的計數除以3取餘數,在這裡只可能為0或1 ret |= mask; } return ret; } |
關於補碼
但是,我在一開始實現這個演算法的時候,在上面程式碼中②的位置,我漏掉了val<0的情況,因為第一印象告訴我,一個正整數去與上一個掩碼數,會得到一個正整數。但是這是錯誤的印象。比如在引數A等於{ -1, -1, -2, -1 }的時候,漏掉val<0的結果等於一個荒唐的2147483646。
這是為什麼呢?
因為負數在記憶體中是以補碼方式存放的,第一位最高位是符號位,0表示正數,1表示負數,僅當表示負數的時候,餘下的31位等於那個數的數值每一位都取反,然後加1。例如-1,這32位數是:
1 2 3 4 |
// 取反加一前: 1(符號位)000 0000 0000 0000 0000 0000 0000 0001 // 取反加一後: 1(符號位)111 1111 1111 1111 1111 1111 1111 1111 |
32位整數的範圍是從-2147483648到2147483647,為何負值比正值能表示的數多一個,就在於這個“加一”(表示0的時候符號位是0,相當於表示0的時候佔用了正數的表示法)。
所以,如果漏掉了上面程式碼中val<0的情況,在執行到i=31的迴圈的時候,掩碼mask即1<<i是-2147483648,因為它把符號位給變成了1,後面都是0:
1 2 3 4 |
// 即 1(符號位)000 0000 0000 0000 0000 0000 0000 0000 // 如果按照“取反加一”的規則,它是由它自己取反加一而來的,發生了溢位 1(符號位)000 0000 0000 0000 0000 0000 0000 0000 |
所以這個數也是補碼錶示的負數中,最特殊的一個。
那為什麼上面說漏掉val<0之後算錯的結果是2147483646呢?
這個實際要求解的數-2在記憶體中的表示是這樣的:
1 2 3 4 5 6 |
// 取反加一前: 1(符號位)000 0000 0000 0000 0000 0000 0000 0010 // 取反加一後: 1(符號位)111 1111 1111 1111 1111 1111 1111 1110 // 符號位錯誤: 0(符號位)111 1111 1111 1111 1111 1111 1111 1110 |
由於前面說到的,符號位錯誤了,變成了正數,而正數的表示法可不是補碼錶示,所以得出了2147483646這個數。
藉助兩個數的每一位儲存資訊
下面這個方法稍微有點難理解,而且很容易寫錯。需要兩個數(one和accumulation),因為一個數在每一位上面無法存放超過兩次同樣的數出現的資訊。每次迴圈中,需要先標記出現,然後再清零出現過三次的標誌位。最終one留下的每一位都是無法清零的,即出現次數不是3的整數倍的。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
public int singleNumber4(int A[]) { int one = 0; // 出現一次的標誌位 int accumulation = 0; // 積累標誌位 for (int i = 0; i < A.length; i++) { accumulation |= A[i] & one; // 只要第二次或者以上出現,就為1 one ^= A[i]; // 出現奇數次保留,偶數次拋棄 int t = one & accumulation; // 第三次的時候one和accumulation都保留了該位的值 one &= ~t; // 清零出現三次的該位的值 accumulation &= ~t; } return one; } |
其實,這道題還有許多其他做法,既包括利用位運算的其他做法,也有那種“先排序,然後再尋找特殊數”這樣突破常規想法的解法(其實我覺得先排序這樣的做法很好啊,雖然複雜度稍微高一些(取決於排序的時間複雜度了),但是清晰,而且通用性更好。方法雖然簡單,但是我大概受到的教條式思維太嚴重了,這樣的方法根本沒有想到……)。