寫在前面
如果覺得寫得好有所幫助,記得點個關注和點個贊,不勝感激!
這篇文章用來總結一個系列的算法題,也就是找到元素數組中重複出現的元素或者只出現一次的元素。其實在講解之前,如果之前有接觸過這類的類的同學,應該知道解決這類題的方式不外乎用位運算來搞定。使用位運算來解決這一系列問題很有趣,這也是爲什麼我想着專門寫一篇博文來記錄的原因,廢話不多說,咱們進入正文。
只出現一次的數字Ⅰ
如果沒有時間複雜度和空間複雜度的限制,這道題有很多種解法,可能的解法有如下幾種。
- 使用集合存儲數字。遍歷數組中的每個數字,如果集合中沒有該數字,則將該數字加入集合,如果集合中已經有該數字,則將該數字從集合中刪除,最後剩下的數字就是隻出現一次的數字。
- 使用哈希表存儲每個數字和該數字出現的次數。遍歷數組即可得到每個數字出現的次數,並更新哈希表,最後遍歷哈希表,得到只出現一次的數字。
- 使用集合存儲數組中出現的所有數字,並計算數組中的元素之和。由於集合保證元素無重複,因此計算集合中的所有元素之和的兩倍,即爲每個元素出現兩次的情況下的元素之和。由於數組中只有一個元素出現一次,其餘元素都出現兩次,因此用集合中的元素之和的兩倍減去數組中的元素之和,剩下的數就是數組中只出現一次的數字。
上述三種解法都需要額外使用 的空間,其中 nn 是數組長度。如果要求使用線性時間複雜度和常數空間複雜度,上述三種解法顯然都不滿足要求。那麼,如何才能做到線性時間複雜度和常數空間複雜度呢?答案是使用位運算。對於這道題,可使用異或運算 。異或運算有以下三個性質。
任何數和 做異或運算,結果仍然是原來的數,即 a 0=a。
任何數和其自身做異或運算,結果是 0,即 a a=0。
異或運算滿足交換律和結合律,即 a b a=b a a=b (a a)=b 0=b。有了概念之後,我們完成這道題就非常容易了,代碼如下:
public int singleNumber(int[] nums) {
int single = 0;
for (int num : nums) {
single ^= num;
}
return single;
}
只出現一次的數字Ⅱ
上面的那種情況是所有數字都是成對出現的,只有一個數字是落單的,找出這個落單的數字,而這道題有兩個落單的數字。(這裏引用LeetCode官方的圖,我就不畫了)
- 使用異或運算可以幫助我們消除出現兩次的數字;我們計算
bitmask ^= x
,則 bitmask 留下的就是出現奇數次的位。
x & (-x)
是保留位中最右邊 1 ,且將其餘的 1 設位 0 的方法。
- 首先計算
bitmask ^= x
,則 bitmask 不會保留出現兩次數字的值,因爲相同數字的異或值爲 0。但是 bitmask 會保留只出現一次的兩個數字(x 和 y)之間的差異。
- 我們可以直接從 bitmask 中提取 x 和 y 嗎?不能,但是我們可以用 bitmask 作爲標記來分離 x 和 y。我們通過
bitmask & (-bitmask)
保留 bitmask 最右邊的 1,這個 1 要麼來自 x,要麼來自 y。
public int[] singleNumber(int[] nums) {
int bitmask = 0;
for (int num : nums) bitmask ^= num;
int diff = bitmask & (-bitmask);
int x = 0;
for (int num : nums) if ((num & diff) != 0) x ^= num;
return new int[]{x, bitmask^x};
}
只出現一次的數Ⅲ
各二進制位的 位運算規則相同 ,因此只需考慮一位即可。如下圖所示,對於所有數字中的某二進制位 1 的個數,存在 3 種狀態,即對 3 餘數爲 0, 1, 2。
- 若輸入二進制位 1 ,則狀態按照以下順序轉換;
- 若輸入二進制位 0 ,則狀態不變。
如下圖所示,由於二進制只能表示 0, 1 ,因此需要使用兩個二進制位來表示 3 個狀態。設此兩位分別爲 two , one ,則狀態轉換變爲:
接下來,需要通過 狀態轉換表 導出 狀態轉換的計算公式 。
計算 one
設當前狀態爲 two one ,此時輸入二進制位 n 。如下圖所示,通過對狀態表的情況拆分,可推出 one 的計算方法爲:
if two == 0:
if n == 0:
one = one
if n == 1:
one = ~one
if two == 1:
one = 0
引入 異或運算 ,可將以上拆分簡化爲:
if two == 0:
one = one ^ n
if two == 1:
one = 0
引入 與運算 ,可繼續簡化爲:
one = one ^ n & ~two
計算 two
由於是先計算 one ,因此應在新 one 的基礎上計算 two 。如下圖所示,修改爲新 one 後,得到了新的狀態圖。觀察發現,可以使用同樣的方法計算 two ,即:
two = two ^ n & ~one
以上是對數字的二進制中 “一位” 的分析,而 int 類型的其他 31 位具有相同的運算規則,因此可將以上公式直接套用在 32 位數上。遍歷完所有數字後,各二進制位都處於狀態 00 和狀態 01 (取決於 “只出現一次的數字” 的各二進制位是 1 還是 0 ),而此兩狀態是由 one 來記錄的(此兩狀態下 twos 恆爲 0 ),因此返回ones 即可。
public int singleNumber(int[] nums) {
int one = 0;
int two = 0;
for(int num : nums) {
one = ~two & (one ^ num);
two = ~one & (two ^ num);
}
return one;
}
通用計算方法
給一個數組,每個元素都出現 k ( k > 1)
次,除了一個數字只出現 p 次(p >= 1, p % k != 0)
,找到出現 p 次的那個數。
爲了計數 次,我們必須要 個比特,其中 , 也就是 。假設我們 個比特依次是 。開始全部初始化爲 。。然後掃描所有數字的當前 位,用 表示當前的 。這裏舉個例子。
假如例子是 1 2 6 1 1 2 2 3 3 3, 3 個 1, 3 個 2, 3 個 3,1 個 6
1 0 0 1
2 0 1 0
6 1 1 0
1 0 0 1
1 0 0 1
2 0 1 0
2 0 1 0
3 0 1 1
3 0 1 1
3 0 1 1
初始 狀態 。
- 第一次遇到 , 個比特依次是 。
- 第二次遇到 , 個比特依次是 。
- 第三次遇到 , 個比特依次是 。
- 第四次遇到 , 個比特依次是 。
的變化規律就是遇到 變成 ,再遇到 1 變回 。遇到 的話就不變。所以 = ^ ,可以用異或來求出 。那麼 怎麼辦呢?
的話,當遇到 的時候,如果之前 是 , 就不變。如果之前 是 ,對應於上邊的第二次遇到 和第四次遇到 。 從 變成 和 從 變成 。所以 的變化規律就是遇到 同時 是 就變成 ,再遇到 同時 是 就變回 。遇到 的話就不變。和 的變化規律很像,所以同樣可以使用異或。 = ^ ( & ),多判斷了 是不是 。
, … 就是同理了, = ^ (- & … & & ) 。再說直接點,上邊其實就是模擬了每次加 的時候,各個比特位的變化。所以高位 只有當低位全部爲 的時候纔會得到進位 。
上邊有個問題,假設我們的 ,那麼我們應該在 之後就變成 ,而不是到 。所以我們需要一個 ,當沒有到達 的時候和 進行與操作是它本身,當到達 的時候和 相與就回到 。根據上邊的要求構造 ,假設 寫成二進制以後是 。
= ~( & & … & ),
-
如果,那麼
-
如果 , = ~ 。
舉兩個例子。
-
: 寫成二進制,,$ k_2 = 1$, = ~( & );
-
: 寫成二進制,, , , = ~( & ~ & );
很容易想明白,當 達到 的時候因爲我們要把 歸零。我們只需要用 和每一位進行與操作就回到了 。所以我們只需要把等於 的比特位取反,然後再和其他所有位相與就得到 ,然後再取反就是 了。如果 沒有達到 ,那麼求出來的結果一定是 ,這樣和原來的 位進行與操作的話就保持了原來的數。總之,最後我們的代碼就是下邊的框架。
for (int i : nums) {
xm ^= (xm-1 & ... & x1 & i);
xm-1 ^= (xm-2 & ... & x1 & i);
.....
x1 ^= i;
mask = ~(y1 & y2 & ... & ym) where yj = xj if kj = 1, and yj = ~xj if kj = 0 (j = 1 to m).
xm &= mask;
......
x1 &= mask;
}
考慮全部 bit
假如例子是 1 2 6 1 1 2 2 3 3 3, 3 個 1, 3 個 2, 3 個 3,1 個 6
1 0 0 1
2 0 1 0
6 1 1 0
1 0 0 1
1 0 0 1
2 0 1 0
2 0 1 0
3 0 1 1
3 0 1 1
3 0 1 1
之前是完成了一個 位,也就是每一列的操作。因爲我們給的數是 類型,所以有 位。所以我們需要對每一位都進行計數。有了上邊的分析,我們不需要再向解法三那樣依次考慮每一位,我們可以同時對 位進行計數。對於 等於 ,也就是這道題。我們可以用兩個 , 和 。 表示對於 位每一位計數的低位, 表示對於 位每一位計數的高位。通過之前的公式,我們利用位操作就可以同時完成計數了。
int x1 = 0, x2 = 0, mask = 0;
for (int i : nums) {
x2 ^= x1 & i;
x1 ^= i;
mask = ~(x1 & x2);
x2 &= mask;
x1 &= mask;
}
返回結果
因爲所有的數字都出現了 次,只有一個數字出現了 次。因爲 組合起來就是對於每一列 的計數。舉個例子
假如例子是 1 2 6 1 1 2 2 3 3 3, 3 個 1, 3 個 2, 3 個 3,1 個 6
1 0 0 1
2 0 1 0
6 1 1 0
1 0 0 1
1 0 0 1
2 0 1 0
2 0 1 0
3 0 1 1
3 0 1 1
3 0 1 1
看最右邊的一列 1001100111 有 6 個 1, 也就是 110
再往前看一列 0110011111 有 7 個 1, 也就是 111
再往前看一列 0010000 有 1 個 1, 也就是 001
再對應到 x1, x2, x3 就是
x1 1 1 0
x2 0 1 1
x3 0 1 1
-
如果 ,那麼如果出現一次的數字的某一位是 ,一定會使得 ,也就是計數的最低位置的對應位爲 ,所以我們把 返回即可。對於上邊的例子,就是 ,所以返回 。
-
如果 ,二進制就是 ,那麼如果出現 次的數字的某一位是 ,一定會使得 的對應位變爲 ,所以我們把 返回即可。
-
如果 ,二進制就是 ,那麼如果出現 次的數字的某一位是 ,一定會使得 和的對應位都變爲,所以我們把 或者 返回即可。
如下,如果
public int singleNumber(int[] nums) {
int x1 = 0, x2 = 0, mask = 0;
for (int i : nums) {
x2 ^= x1 & i;
x1 ^= i;
mask = ~(x1 & x2);
x2 &= mask;
x1 &= mask;
}
return x1;
}
至於爲什麼先對 異或再對 異或,就是因爲 的變化依賴於 之前的狀態。顛倒過來明顯就不對了。再擴展一下題目,對於 怎麼做,也就是每個數字出現了 次,只有一個數字出現了 次。首先根據 ,所以我們至少需要 個比特位。因爲 個比特位最多計數四次。然後根據 的二進制形式是 ,所以 = ~( & ~ & )。根據 的二進制是 ,所以我們最後可以把 返回。
public int singleNumber(int[] nums) {
int x1 = 0, x2 = 0, x3 = 0, mask = 0;
for (int i : nums) {
x3 ^= x2 & x1 & i;
x2 ^= x1 & i;
x1 ^= i;
mask = ~(x1 & ~x2 & x3);
x3 &= mask;
x2 &= mask;
x1 &= mask;
}
return x1;
}