只出現一次的數算法題彙總

寫在前面
如果覺得寫得好有所幫助,記得點個關注和點個贊,不勝感激!
這篇文章用來總結一個系列的算法題,也就是找到元素數組中重複出現的元素或者只出現一次的元素。其實在講解之前,如果之前有接觸過這類的類的同學,應該知道解決這類題的方式不外乎用位運算來搞定。使用位運算來解決這一系列問題很有趣,這也是爲什麼我想着專門寫一篇博文來記錄的原因,廢話不多說,咱們進入正文。

只出現一次的數字Ⅰ

在這裏插入圖片描述
如果沒有時間複雜度和空間複雜度的限制,這道題有很多種解法,可能的解法有如下幾種。

  • 使用集合存儲數字。遍歷數組中的每個數字,如果集合中沒有該數字,則將該數字加入集合,如果集合中已經有該數字,則將該數字從集合中刪除,最後剩下的數字就是隻出現一次的數字。
  • 使用哈希表存儲每個數字和該數字出現的次數。遍歷數組即可得到每個數字出現的次數,並更新哈希表,最後遍歷哈希表,得到只出現一次的數字。
  • 使用集合存儲數組中出現的所有數字,並計算數組中的元素之和。由於集合保證元素無重複,因此計算集合中的所有元素之和的兩倍,即爲每個元素出現兩次的情況下的元素之和。由於數組中只有一個元素出現一次,其餘元素都出現兩次,因此用集合中的元素之和的兩倍減去數組中的元素之和,剩下的數就是數組中只出現一次的數字。

上述三種解法都需要額外使用 O(n)O(n) 的空間,其中 nn 是數組長度。如果要求使用線性時間複雜度和常數空間複雜度,上述三種解法顯然都不滿足要求。那麼,如何才能做到線性時間複雜度和常數空間複雜度呢?答案是使用位運算。對於這道題,可使用異或運算 \oplus。異或運算有以下三個性質。

任何數和 00 做異或運算,結果仍然是原來的數,即 a \oplus 0=a。
任何數和其自身做異或運算,結果是 0,即 a \oplus a=0。
異或運算滿足交換律和結合律,即 a \oplus b \oplus a=b \oplus a \oplus a=b \oplus (a \oplus a)=b \oplus 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 次的那個數。

爲了計數 kk 次,我們必須要 mm 個比特,其中 2m>=k2^m >=k, 也就是 m>=logkm >= logk。假設我們 mm 個比特依次是 xmxm1...x2x1x_mx_{m-1}...x_2x_1。開始全部初始化爲 0000...0000...00。然後掃描所有數字的當前 bitbit 位,用 ii 表示當前的 bitbit。這裏舉個例子。

假如例子是 1 2 6 1 1 2 2 3 3 3, 31, 32, 33,16
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  

初始 狀態 00...0000...00

  • 第一次遇到 11 , mm 個比特依次是 00...0100...01
  • 第二次遇到 11 , mm 個比特依次是 00...1000...10
  • 第三次遇到 11 , mm 個比特依次是 00...1100...11
  • 第四次遇到 11 , mm 個比特依次是 00..10000..100

x1x_1 的變化規律就是遇到 11 變成 11 ,再遇到 1 變回 00。遇到 00 的話就不變。所以 x1x_1 = x1x_1 ^ ii,可以用異或來求出 x1x_1 。那麼 x2...xmx_2...x_m 怎麼辦呢?

x2x_2 的話,當遇到 11 的時候,如果之前 x1x_100x2x_2 就不變。如果之前 x1x_111,對應於上邊的第二次遇到 11 和第四次遇到 11x2x_200 變成 11 和 從 11 變成 00。所以 x2x_2 的變化規律就是遇到 11 同時 x1x_111 就變成 11,再遇到 11 同時 x1x_111 就變回 00。遇到 00 的話就不變。和 x1x_1 的變化規律很像,所以同樣可以使用異或。x2x_2 = x2x_2 ^ (ii & x1x_1),多判斷了 x1x_1 是不是 11

x3x_3x4x_4xmx_m 就是同理了,xmx_m = xmx_m ^ (xmx_m-11 & … & x1x_1 & ii) 。再說直接點,上邊其實就是模擬了每次加 11 的時候,各個比特位的變化。所以高位 xmx_m 只有當低位全部爲 11 的時候纔會得到進位 11

00>01>10>11>0000 -> 01 -> 10 -> 11 -> 00

上邊有個問題,假設我們的 k=3k = 3,那麼我們應該在 1010 之後就變成 0000,而不是到 1111。所以我們需要一個 maskmask ,當沒有到達 kk 的時候和 maskmask進行與操作是它本身,當到達 kk 的時候和 maskmask 相與就回到 00...00000...000。根據上邊的要求構造 maskmask,假設 kk 寫成二進制以後是 km...k2k1k_m...k_2k_1

maskmask = ~(y1y_1 & y2y_2 & … & ymy_m),

  • 如果kj=1k_j = 1,那麼yj=xjy_j = x_j

  • 如果 kj=0k_j = 0yjy_j = ~xjx_j

舉兩個例子。

  • k=3k = 3: 寫成二進制,k1=1k_1 = 1,$ k_2 = 1$, maskmask = ~(x1x_1 & x2x_2);

  • k=5k = 5: 寫成二進制,k1=1k_1 = 1, k2=0k_2 = 0, k3=1k_3 = 1, maskmask = ~(x1x_1 & ~x2x_2 & x3x_3);

很容易想明白,當 x1x2...xmx_1x_2...x_m 達到 k1k2...kmk_1k_2...k_m 的時候因爲我們要把 x1x2...xmx_1x_2...x_m 歸零。我們只需要用 00 和每一位進行與操作就回到了 00。所以我們只需要把等於 00 的比特位取反,然後再和其他所有位相與就得到 11 ,然後再取反就是 00 了。如果 x1x2...xmx_1x_2...x_m 沒有達到 k1k2...kmk_1k_2...k_m ,那麼求出來的結果一定是 11,這樣和原來的 bitbit 位進行與操作的話就保持了原來的數。總之,最後我們的代碼就是下邊的框架。

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, 31, 32, 33,16
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  

之前是完成了一個 bitbit 位,也就是每一列的操作。因爲我們給的數是 intint 類型,所以有 3232 位。所以我們需要對每一位都進行計數。有了上邊的分析,我們不需要再向解法三那樣依次考慮每一位,我們可以同時對 3232 位進行計數。對於 kk 等於 33 ,也就是這道題。我們可以用兩個 intintx1x_1x2x_2x1x_1 表示對於 3232 位每一位計數的低位,x2x_2 表示對於 3232 位每一位計數的高位。通過之前的公式,我們利用位操作就可以同時完成計數了。

int x1 = 0, x2 = 0, mask = 0;

for (int i : nums) {
    x2 ^= x1 & i;
    x1 ^= i;
    mask = ~(x1 & x2);
    x2 &= mask;
    x1 &= mask;
}

返回結果

因爲所有的數字都出現了 kk 次,只有一個數字出現了 pp 次。因爲 xm...x2x1x_m...x_2x_1 組合起來就是對於每一列 11 的計數。舉個例子

假如例子是 1 2 6 1 1 2 2 3 3 3, 31, 32, 33,16
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   
    
看最右邊的一列 100110011161, 也就是 110
再往前看一列 011001111171, 也就是 111
再往前看一列 001000011, 也就是 001
再對應到 x1, x2, x3 就是
x1 1 1 0
x2 0 1 1
x3 0 1 1
  • 如果 p=1p = 1,那麼如果出現一次的數字的某一位是 11 ,一定會使得 x1x_1 ,也就是計數的最低位置的對應位爲 11,所以我們把 x1x_1 返回即可。對於上邊的例子,就是 110110 ,所以返回 66

  • 如果 p=2p = 2,二進制就是 1010,那麼如果出現 22次的數字的某一位是 11 ,一定會使得 x2x_2 的對應位變爲 11,所以我們把 x2x_2 返回即可。

  • 如果 p=3p = 3,二進制就是 1111,那麼如果出現 33次的數字的某一位是 11 ,一定會使得 x1x_1x2x_2的對應位都變爲11,所以我們把 x1x_1 或者 x2x_2 返回即可。

如下,如果p=1p = 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; 
}

至於爲什麼先對 x2x_2 異或再對 x1x_1 異或,就是因爲 x2x_2 的變化依賴於 x1x_1 之前的狀態。顛倒過來明顯就不對了。再擴展一下題目,對於 k=5,p=3k = 5, p = 3 怎麼做,也就是每個數字出現了55 次,只有一個數字出現了 33 次。首先根據 k=5k = 5,所以我們至少需要 33 個比特位。因爲 22 個比特位最多計數四次。然後根據 kk 的二進制形式是 101101,所以 maskmask = ~(x1x_1 & ~x2x_2 & x3x_3)。根據 pp 的二進制是 011011,所以我們最後可以把 x1x_1 返回。

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;  
}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章