写在前面
如果觉得写得好有所帮助,记得点个关注和点个赞,不胜感激!
这篇文章用来总结一个系列的算法题,也就是找到元素数组中重复出现的元素或者只出现一次的元素。其实在讲解之前,如果之前有接触过这类的类的同学,应该知道解决这类题的方式不外乎用位运算来搞定。使用位运算来解决这一系列问题很有趣,这也是为什么我想着专门写一篇博文来记录的原因,废话不多说,咱们进入正文。
只出现一次的数字Ⅰ
如果没有时间复杂度和空间复杂度的限制,这道题有很多种解法,可能的解法有如下几种。
- 使用集合存储数字。遍历数组中的每个数字,如果集合中没有该数字,则将该数字加入集合,如果集合中已经有该数字,则将该数字从集合中删除,最后剩下的数字就是只出现一次的数字。
- 使用哈希表存储每个数字和该数字出现的次数。遍历数组即可得到每个数字出现的次数,并更新哈希表,最后遍历哈希表,得到只出现一次的数字。
- 使用集合存储数组中出现的所有数字,并计算数组中的元素之和。由于集合保证元素无重复,因此计算集合中的所有元素之和的两倍,即为每个元素出现两次的情况下的元素之和。由于数组中只有一个元素出现一次,其余元素都出现两次,因此用集合中的元素之和的两倍减去数组中的元素之和,剩下的数就是数组中只出现一次的数字。
上述三种解法都需要额外使用 O ( n ) O(n) O(n) 的空间,其中 nn 是数组长度。如果要求使用线性时间复杂度和常数空间复杂度,上述三种解法显然都不满足要求。那么,如何才能做到线性时间复杂度和常数空间复杂度呢?答案是使用位运算。对于这道题,可使用异或运算 ⊕ \oplus ⊕。异或运算有以下三个性质。
任何数和
0
0
0 做异或运算,结果仍然是原来的数,即 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 次的那个数。
为了计数 k k k 次,我们必须要 m m m 个比特,其中 2 m > = k 2^m >=k 2m>=k, 也就是 m > = l o g k m >= logk m>=logk。假设我们 m m m 个比特依次是 x m x m − 1 . . . x 2 x 1 x_mx_{m-1}...x_2x_1 xmxm−1...x2x1。开始全部初始化为 0 0 0。 00...00 00...00 00...00。然后扫描所有数字的当前 b i t bit bit 位,用 i i i 表示当前的 b i t bit 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
初始 状态 00...00 00...00 00...00。
- 第一次遇到 1 1 1 , m m m 个比特依次是 00...01 00...01 00...01。
- 第二次遇到 1 1 1 , m m m 个比特依次是 00...10 00...10 00...10。
- 第三次遇到 1 1 1 , m m m 个比特依次是 00...11 00...11 00...11。
- 第四次遇到 1 1 1 , m m m 个比特依次是 00..100 00..100 00..100。
x 1 x_1 x1 的变化规律就是遇到 1 1 1 变成 1 1 1 ,再遇到 1 变回 0 0 0。遇到 0 0 0 的话就不变。所以 x 1 x_1 x1 = x 1 x_1 x1 ^ i i i,可以用异或来求出 x 1 x_1 x1 。那么 x 2 . . . x m x_2...x_m x2...xm 怎么办呢?
x 2 x_2 x2 的话,当遇到 1 1 1 的时候,如果之前 x 1 x_1 x1 是 0 0 0, x 2 x_2 x2 就不变。如果之前 x 1 x_1 x1 是 1 1 1,对应于上边的第二次遇到 1 1 1 和第四次遇到 1 1 1。 x 2 x_2 x2 从 0 0 0 变成 1 1 1 和 从 1 1 1 变成 0 0 0。所以 x 2 x_2 x2 的变化规律就是遇到 1 1 1 同时 x 1 x_1 x1 是 1 1 1 就变成 1 1 1,再遇到 1 1 1 同时 x 1 x_1 x1 是 1 1 1 就变回 0 0 0。遇到 0 0 0 的话就不变。和 x 1 x_1 x1 的变化规律很像,所以同样可以使用异或。 x 2 x_2 x2 = x 2 x_2 x2 ^ ( i i i & x 1 x_1 x1),多判断了 x 1 x_1 x1 是不是 1 1 1。
x 3 x_3 x3, x 4 x_4 x4 … x m x_m xm 就是同理了, x m x_m xm = x m x_m xm ^ ( x m x_m xm- 1 1 1 & … & x 1 x_1 x1 & i i i) 。再说直接点,上边其实就是模拟了每次加 1 1 1 的时候,各个比特位的变化。所以高位 x m x_m xm 只有当低位全部为 1 1 1 的时候才会得到进位 1 1 1 。
00 − > 01 − > 10 − > 11 − > 00 00 -> 01 -> 10 -> 11 -> 00 00−>01−>10−>11−>00
上边有个问题,假设我们的 k = 3 k = 3 k=3,那么我们应该在 10 10 10 之后就变成 00 00 00,而不是到 11 11 11。所以我们需要一个 m a s k mask mask ,当没有到达 k k k 的时候和 m a s k mask mask进行与操作是它本身,当到达 k k k 的时候和 m a s k mask mask 相与就回到 00...000 00...000 00...000。根据上边的要求构造 m a s k mask mask,假设 k k k 写成二进制以后是 k m . . . k 2 k 1 k_m...k_2k_1 km...k2k1。
m a s k mask mask = ~( y 1 y_1 y1 & y 2 y_2 y2 & … & y m y_m ym),
-
如果 k j = 1 k_j = 1 kj=1,那么 y j = x j y_j = x_j yj=xj
-
如果 k j = 0 k_j = 0 kj=0, y j y_j yj = ~ x j x_j xj 。
举两个例子。
-
k = 3 k = 3 k=3: 写成二进制, k 1 = 1 k_1 = 1 k1=1,$ k_2 = 1$, m a s k mask mask = ~( x 1 x_1 x1 & x 2 x_2 x2);
-
k = 5 k = 5 k=5: 写成二进制, k 1 = 1 k_1 = 1 k1=1, k 2 = 0 k_2 = 0 k2=0, k 3 = 1 k_3 = 1 k3=1, m a s k mask mask = ~( x 1 x_1 x1 & ~ x 2 x_2 x2 & x 3 x_3 x3);
很容易想明白,当 x 1 x 2 . . . x m x_1x_2...x_m x1x2...xm 达到 k 1 k 2 . . . k m k_1k_2...k_m k1k2...km 的时候因为我们要把 x 1 x 2 . . . x m x_1x_2...x_m x1x2...xm 归零。我们只需要用 0 0 0 和每一位进行与操作就回到了 0 0 0。所以我们只需要把等于 0 0 0 的比特位取反,然后再和其他所有位相与就得到 1 1 1 ,然后再取反就是 0 0 0 了。如果 x 1 x 2 . . . x m x_1x_2...x_m x1x2...xm 没有达到 k 1 k 2 . . . k m k_1k_2...k_m k1k2...km ,那么求出来的结果一定是 1 1 1,这样和原来的 b i t bit bit 位进行与操作的话就保持了原来的数。总之,最后我们的代码就是下边的框架。
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
之前是完成了一个 b i t bit bit 位,也就是每一列的操作。因为我们给的数是 i n t int int 类型,所以有 32 32 32 位。所以我们需要对每一位都进行计数。有了上边的分析,我们不需要再向解法三那样依次考虑每一位,我们可以同时对 32 32 32 位进行计数。对于 k k k 等于 3 3 3 ,也就是这道题。我们可以用两个 i n t int int, x 1 x_1 x1 和 x 2 x_2 x2。 x 1 x_1 x1 表示对于 32 32 32 位每一位计数的低位, x 2 x_2 x2 表示对于 32 32 32 位每一位计数的高位。通过之前的公式,我们利用位操作就可以同时完成计数了。
int x1 = 0, x2 = 0, mask = 0;
for (int i : nums) {
x2 ^= x1 & i;
x1 ^= i;
mask = ~(x1 & x2);
x2 &= mask;
x1 &= mask;
}
返回结果
因为所有的数字都出现了 k k k 次,只有一个数字出现了 p p p 次。因为 x m . . . x 2 x 1 x_m...x_2x_1 xm...x2x1 组合起来就是对于每一列 1 1 1 的计数。举个例子
假如例子是 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
-
如果 p = 1 p = 1 p=1,那么如果出现一次的数字的某一位是 1 1 1 ,一定会使得 x 1 x_1 x1 ,也就是计数的最低位置的对应位为 1 1 1,所以我们把 x 1 x_1 x1 返回即可。对于上边的例子,就是 110 110 110 ,所以返回 6 6 6。
-
如果 p = 2 p = 2 p=2,二进制就是 10 10 10,那么如果出现 2 2 2次的数字的某一位是 1 1 1 ,一定会使得 x 2 x_2 x2 的对应位变为 1 1 1,所以我们把 x 2 x_2 x2 返回即可。
-
如果 p = 3 p = 3 p=3,二进制就是 11 11 11,那么如果出现 3 3 3次的数字的某一位是 1 1 1 ,一定会使得 x 1 x_1 x1 和 x 2 x_2 x2的对应位都变为 1 1 1,所以我们把 x 1 x_1 x1 或者 x 2 x_2 x2 返回即可。
如下,如果 p = 1 p = 1 p=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;
}
至于为什么先对 x 2 x_2 x2 异或再对 x 1 x_1 x1 异或,就是因为 x 2 x_2 x2 的变化依赖于 x 1 x_1 x1 之前的状态。颠倒过来明显就不对了。再扩展一下题目,对于 k = 5 , p = 3 k = 5, p = 3 k=5,p=3 怎么做,也就是每个数字出现了 5 5 5 次,只有一个数字出现了 3 3 3 次。首先根据 k = 5 k = 5 k=5,所以我们至少需要 3 3 3 个比特位。因为 2 2 2 个比特位最多计数四次。然后根据 k k k 的二进制形式是 101 101 101,所以 m a s k mask mask = ~( x 1 x_1 x1 & ~ x 2 x_2 x2 & x 3 x_3 x3)。根据 p p p 的二进制是 011 011 011,所以我们最后可以把 x 1 x_1 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;
}