题目描述(中等难度)
136 题 的升级版,这个题的话意思是,每个数字都出现了 3 次,只有一个数字出现了 1 次,找出这个数字。同样要求时间复杂度为 O(n),空间复杂度为 O(1)。
大家可以先看一下 136 题 ,完全按 136 题 的每个解法去考虑一下。
解法一
先不考虑空间复杂度,用最常规的方法。
可以用一个 HashMap
对每个数字进行计数,然后返回数量为 1
的数字就可以了。
public int singleNumber(int[] nums) {
HashMap<Integer, Integer> map = new HashMap<>();
for (int i = 0; i < nums.length; i++) {
if (map.containsKey(nums[i])) {
map.put(nums[i], map.get(nums[i]) + 1);
} else {
map.put(nums[i], 1);
}
}
for (Integer key : map.keySet()) {
if (map.get(key) == 1) {
return key;
}
}
return -1; // 这句不会执行
}
时间复杂度:O(n)。
空间复杂度:O(n)。
解法二 数学推导
回想一下 136 题 中,每个数字都出现两次,只有一个数字出现 1
次是怎么做的。
假设我们的数字是a b a b c c d
怎么求出
d
呢?只需要把出现过的数字加起来乘以
2
,然后减去之前的数字和就可以了。什么意思呢?
上边的例子出现过的数字就是
a b c d
,加起来乘以二就是2 * ( a + b + c + d)
,之前的数字和就是a + b + a + b + c + c + d
。
2 * ( a + b + c + d) - (a + b + a + b + c + c + d)
,然后结果是不是就是d
了。。。。。。看完这个解法我只能说
tql
。。。找出现过什么数字,我们只需要一个
Set
去重就可以了。
这里的话每个数字出现了 3
次,所以我们可以加起来乘以 3
然后减去之前所有的数字和。这样得到的差就是只出现过一次的那个数字的 2
倍。
public int singleNumber(int[] nums) {
HashSet<Integer> set = new HashSet<>();
int sum = 0;
for (int i = 0; i < nums.length; i++) {
set.add(nums[i]);
sum += nums[i];
}
int sumMul = 0;
for (int n : set) {
sumMul += n;
}
sumMul = sumMul * 3;
return (sumMul - sum) / 2;
}
然而并没有通过
原因就是 int
是 32
位整数,计算机中是以补码表示的,详细的参考 趣谈补码 。
问题的根本就出现在,如果 2a = c
,那么对于 a
的取值有两种情况。在没有溢出的情况下,a = c/2
是没有问题的。但如果 a
是很大的数,加起来溢出了,此时 a = c >>> 1
。
举个具体的例子,
如果给定的数组是 [1 1 1 Integer.MaxValue]
。如果按上边的解法最后得到的就是
(1 + Ingeger.MaxValue) * 3 - (1 + 1 + 1 + Integer.MaxValue) = 2 * Integer.MaxValue
由于产生了溢出
2 * Integer.MaxValue = -2
,最后我们返回的结果就是 -2 / 2 = -1
。
所以这个思路行不通了,因为无法知道是不是会溢出。
解法三 位操作
136 题通过异或解决了问题,这道题明显不能用异或了,参考 这里-easy-to-understand-solution-easily-extended-to-any-times-of-occurance) 的一个解法。
我们把数字放眼到二进制形式
假如例子是 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
再往前看一列 0110011111 有 7 个 1
再往前看一列 0010000 有 1 个 1
我们只需要把是 3 的倍数的对应列写 0,不是 3 的倍数的对应列写 1
也就是 1 1 0,也就是 6。
原因的话,其实很容易想明白。如果所有数字都出现了 3
次,那么每一列的 1
的个数就一定是 3
的倍数。之所以有的列不是 3
的倍数,就是因为只出现了 1
次的数贡献出了 1
。所以所有不是 3
的倍数的列写 1
,其他列写 0
,就找到了这个出现 1
次的数。
public int singleNumber(int[] nums) {
int ans = 0;
//考虑每一位
for (int i = 0; i < 32; i++) {
int count = 0;
//考虑每一个数
for (int j = 0; j < nums.length; j++) {
//当前位是否是 1
if ((nums[j] >>> i & 1) == 1) {
count++;
}
}
//1 的个数是否是 3 的倍数
if (count % 3 != 0) {
ans = ans | 1 << i;
}
}
return ans;
}
时间复杂度:O(n)。
空间复杂度:O(1)。
解法四 通用方法
参考 这里。
解法三中,我们将数字转为二进制,统计了每一位的 1
的个数。我们使用了一个 32位
的 int
来统计。事实上,我们只需要看它是不是 3
的倍数,所以我们只需要两个 bit
位就够了。初始化为 00
,遇到第一个 1
变为 01
,遇到第二个 1
变为 10
,遇到第三个 1
变回 00
。接下来就需要考虑怎么做到。
本来想按自己理解的思路写一遍,但 这里 写的很好了,主要还是翻译下吧。
将问题一般化
给一个数组,每个元素都出现 k ( k > 1)
次,除了一个数字只出现 p
次(p >= 1, p % k !=0)
,找到出现 p
次的那个数。
考虑其中的一个 bit
为了计数 k
次,我们必须要 m
个比特,其中 $2^m >=k$ ,也就是 m >= logk
。
假设我们 m
个比特依次是 $x_mx_{m-1}...x_2x_1$ 。
开始全部初始化为 0
。00...00
。
然后扫描所有数字的当前 bit
位,用 i
表示当前的 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
。
第一次遇到 1
, m
个比特依次是 00...01
。
第二次遇到 1
, m
个比特依次是 00...10
。
第三次遇到 1
, m
个比特依次是 00...11
。
第四次遇到 1
, m
个比特依次是 00..100
。
x1
的变化规律就是遇到 1
变成 1
,再遇到 1
变回 0
。遇到 0
的话就不变。
所以 x1 = x1 ^ i
,可以用异或来求出 x1
。
那么 x2...xm
怎么办呢?
x2
的话,当遇到 1
的时候,如果之前 x1
是 0
,x2
就不变。如果之前 x1
是 1
,对应于上边的第二次遇到 1
和第四次遇到 1
。 x2
从 0
变成 1
和 从 1
变成 0
。
所以 x2
的变化规律就是遇到 1
同时 x1
是 1
就变成 1
,再遇到 1
同时 x1
是 1
就变回 0
。遇到 0
的话就不变。和 x1
的变化规律很像,所以同样可以使用异或。
x2 = x2 ^ (i & x1)
,多判断了 x1
是不是 1
。
x3,x4 ... xm
就是同理了,xm = xm ^ (xm-1 & ... & x1 & i)
。
再说直接点,上边其实就是模拟了每次加 1
的时候,各个比特位的变化。所以高位 xm
只有当低位全部为 1
的时候才会得到进位 1
。
00 -> 01 -> 10 -> 11 -> 00
上边有个问题,假设我们的 k = 3
,那么我们应该在 10
之后就变成 00
,而不是到 11
。
所以我们需要一个 mask
,当没有到达 k
的时候和 mask
进行与操作是它本身,当到达 k
的时候和 mask
相与就回到 00...000
。
根据上边的要求构造 mask
,假设 k
写成二进制以后是 km...k2k1
。
mask = ~(y1 & y2 & ... & ym)
,
如果kj = 1
,那么yj = xj
如果 kj = 0
,yj = ~xj
。
举两个例子。
k = 3: 写成二进制,k1 = 1, k2 = 1, mask = ~(x1 & x2)
;
k = 5: 写成二进制,k1 = 1, k2 = 0, k3 = 1, mask = ~(x1 & ~x2 & x3)
;
很容易想明白,当 x1x2...xm
达到 k1k2...km
的时候因为我们要把 x1x2...xm
归零。我们只需要用 0
和每一位进行与操作就回到了 0
。
所以我们只需要把等于 0
的比特位取反,然后再和其他所有位相与就得到 1
,然后再取反就是 0
了。
如果 x1x2...xm
没有达到 k1k2...km
,那么求出来的结果一定是 1
,这样和原来的 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
之前是完成了一个 bit
位,也就是每一列的操作。因为我们给的数是 int
类型,所以有 32
位。所以我们需要对每一位都进行计数。有了上边的分析,我们不需要再向解法三那样依次考虑每一位,我们可以同时对 32
位进行计数。
对于 k
等于 3
,也就是这道题。我们可以用两个 int
,x1
和 x2
。x1
表示对于 32
位每一位计数的低位,x2
表示对于 32
位每一位计数的高位。通过之前的公式,我们利用位操作就可以同时完成计数了。
int x1 = 0, x2 = 0, mask = 0;
for (int i : nums) {
x2 ^= x1 & i;
x1 ^= i;
mask = ~(x1 & x2);
x2 &= mask;
x1 &= mask;
}
返回什么
最后一个问题,我们需要返回什么?
解法三中,我们看 1
出现的个数是不是 3
的倍数,不是 3
的倍数就将对应位置 1
。
这里的话一样的道理,因为所有的数字都出现了 k
次,只有一个数字出现了 p
次。
因为 xm...x2x1
组合起来就是对于每一列 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
,那么如果出现一次的数字的某一位是 1
,一定会使得 x1
,也就是计数的最低位置的对应位为 1
,所以我们把 x1
返回即可。对于上边的例子,就是 110
,所以返回 6
。
如果 p = 2
,二进制就是 10
,那么如果出现 2
次的数字的某一位是 1
,一定会使得 x2
的对应位变为 1
,所以我们把 x2
返回即可。
如果 p = 3
,二进制就是 11
,那么如果出现 3
次的数字的某一位是 1
,一定会使得 x1
和x2
的对应位都变为1
,所以我们把 x1
或者 x2
返回即可。
所以这道题的代码就出来了
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;
}
至于为什么先对 x2
异或再对 x1
异或,就是因为 x2
的变化依赖于 x1
之前的状态。颠倒过来明显就不对了。
再扩展一下题目,对于 k = 5, p = 3
怎么做,也就是每个数字出现了5
次,只有一个数字出现了 3
次。
首先根据 k = 5
,所以我们至少需要 3
个比特位。因为 2
个比特位最多计数四次。
然后根据 k
的二进制形式是 101
,所以 mask = ~(x1 & ~x2 & x3)
。
根据 p
的二进制是 011
,所以我们最后可以把 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;
}
而 136 题 中,k = 2, p = 1
,其实也是这个类型。只不过因为 k = 2
,而我们用一个比特位计数的时候,等于 2
的时候就自动归零了,所以不需要 mask
,相对来说就更简单了。
public int singleNumber(int[] nums) {
int x1 = 0;
for (int i : nums) {
x1 ^= i;
}
return x1;
}
这个解法真是太强了,完全回到二进制的操作,五体投地了,推荐再看一下英文的 原文 分析,太强了。
总
解法一利用 HashMap
计数很常规,解法二通过数学公式虽然没有通过,但溢出的问题也就我们经常需要考虑的。解法三把数字放眼到二进制,统计 1
的个数已经很强了。解法四直接利用 bit
位来计数,真的是大开眼界了,神仙操作。
更多详细通俗题解详见 leetcode.wang 。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。