前言
此为leetcode中第137个问题Single Number II讨论中的一篇译文,这里是原文连接。
1、问题描述
给定一个整数数组,一个元素出现p
次,其余元素出现k
(k > 1
)次(p >= 1, p % k != 0
)。找到出现p
次的元素。
2、数组元素只有1位的特殊情况
正如其他人指出的,可以使用位操作,我们回顾一下整数在计算机中是如何表示的——按二进制位表示。首先,我们先来考虑只有一位的情况。假设我们有一个整数数组,它的元素只有1位二进制(0
或者1
),我们需要统计数组中1
的数量,当1
的数量达到某一个值,比如k
时,计数重置为0
,重新开始统计(这个k
就是问题描述中的k
)。为了记录到目前为止我们统计了多少个1
,我们需要一个计数器。假设计数器有m
位二进制:xm, ..., x1
(从高位到低位)。我们至少可以总结出计数器的四个特性:
- 计数器有一个初始值,为了简单起见,置为
0
; - 对于数组中的每个输入,如果为
0
,计数器保持不变; - 对于数组中的每个输入,如果为
1
,计数器加1
; - 为了保证计数器表示的值能大于等于
k
,所以2^m >= k
,等式两边取对数:m >= logk
。
这是关键部分:当我们扫描数组时,计数器的每一位(x1
到xm
)是如何变化的?我们可以使用位操作,为了保证第2个特征,回想一下,那些位操作与0
进行运算时,不会改变自身呢?没错,你说对了:x = x | 0
和x = x ^ 0
。
我们现在有表达式:x = x | i
或者x = x ^ i
,i
为数组元素。哪一个表达式更好呢?我们现在还不知道,所以我们来实际统计一下。
开始,计数器的所有位都初始化为0
,即xm = 0, ... , x1 = 0
。我们选择的位运算,要保证当扫描到数组元素为0
时,计数器的每一位都不变。计数器的值为0
,直到扫描到数组第一个为1
的元素。当我们扫描到第一个为1
的元素,计数器的每一位为:xm = 0, ..., x2 = 0, x1 = 1
。让我们继续扫描,直到找到第二个为1
的元素,这时计数器的每一位为:xm = 0, ..., x2 = 1, x1 = 0
。注意,x1
由1
变为了0
。对于x1 = x1 | i
,经过2次计数(也就是2次x1 = x1 | 1
)后,x1
的值仍然为1
。所有很明显,我们应该使用x1 = x1 ^ i
。关于x2, ..., xm
应该怎么计算呢?我们要找出x2, ..., xm
改变值的条件。以x2
做为示例。如果我们再扫描到一个1
,想要改变x2
的值,x1
的值必须为多少呢?答案是:x1
必须为1
,因为x1
为0
的话,把x1
由0
变为1
就可以了,而不必改变x2
的值(其实这就是进位的原理,比如十进制要进位,必须要后一位为9
)。所以,x2
要改变值的条件是,x1
和i
都为1
,对应的算术表达式为x2 = x2 ^ (x1 & i)
。同理,xm
要改变值的条件是,xm-1, ..., x1
和i
都为1
,对应的算术表达式为:xm = xm ^ (xm-1 & ... & x1 & i)
。没错,这就是我们找到的位操作方法。
然而,我们应该注意到上面的位操作中,计数是从0
到2 ^ m - 1
,而不是k
。如果k
满足k < 2 ^ m - 1
,当计数器等于k
时,我们需要一种重置机制把计数器重置为0
。为了达到这个目的,我们申请一个变量mask
,分别对计数器的每一位(xm, ..., x1)
和mask
进行按位与
操作,即xm = xm & mask, ..., x1 = x1 & mask
。如果我们能保证,只有当计数器的值等于k
,并且统计的都是数组元素为1
情况下,mask
会为0
,我们就达到了目的。我们是如何做到这一点的呢?试着想想用k
计数与其他计数的区别。没错,它是统计元素为1
的数量!对于每一次计数,计数器的每一位都有唯一的值,可以视为计数器的状态。如果我们把k
写成二进制的形式:km, ..., k1
,我们可以按照如下的方法来构造mask
:
// 如果kj = 1,yj = xj;kj = 0,yj = ~xj。j的取值范围是1到m。
mask = ~(y1 & y2 & ... & ym)
让我们来做一些例子:
k = 3: k1 = 1, k2 = 1, mask = ~(x1 & x2);
k = 5: k1 = 1, k2 = 0, k3 = 1, mask = ~(x1 & ~x2 & x3);
综上所述,我们的算法如下(nums
为输入的数组):
for (int i : nums) {
xm ^= (xm-1 & ... & x1 & i);
xm-1 ^= (xm-2 & ... & x1 & i);
.....
x1 ^= i;
// 如果kj = 1,yj = xj;kj = 0,yj = ~xj。j的取值范围是1到m。
mask = ~(y1 & y2 & ... & ym);
xm &= mask;
......
x1 &= mask;
}
3、数组元素为32位整数的一般情况
现在我们把数组元素从1
位推广到32
位。一种最直接的方法是为32
位整数中的每一位创建一个计数器(共32
个计数器)。你可能在其他的文章中看到过这种解决方案。因为我们使用位操作,也许我们能“共同”管理这32
个计数器。说是“共同管理”,是因为m
的最小值满足m >= logk
时,我们可以用m
个32位的整数代替32
个m位的计数器。能替代的原因是,位操作只作用于每个位,不同位上的操作是相互独立的(这是很明显的事,是吧?)。这允许我们将32
个计数器的对应位放到一个32位整数中。下面的示意图展示了我们是如何做到这一点的。
第一行是一个32位的整数,对于每一位,我们都有一个对应的m位计数器(由向上箭头下方的列所示)。由于32
位中的每一位操作都是相互独立的,所以我们可以将所有计数器的第m位
放到一个32位整数中(如橙色框所示)。这个32位整数(表示为xm
)的所有位都将遵循同样的位操作。由于每个计数器都有m
位,所以我们得到了m
个32位整数,对应第2部分中定义的x1, ..., xm
,只不过是用32位的整数替换了1位的数字。因此,在上面的算法中,我们把x1, ..., xm
看做是32位的整数,而不是1位的数字即可。其他的内容,都是一样的,到这里我们就扩展到了32位整数的一般情况了。
4、返回值
最后一个问题是,我们应该返回什么值,或者说x1
到xm
中那个值与我们要返回的值相等。为了得到正确答案,我们需要理解x1, ..., xm
这m
个32位整数分别代表什么。以x1
为例,x1
有32
位,我们把它标记为r
(r
的取值为1
到32
)。当我们扫描完数组后,x1
第r
位的值由数组中所有元素第r
位共同决定(更具体的说,数组中所有元素第r
位为1
的总数为q
,定义q' = q % k
,q'
的二进制形式为:q'm, ..., q'1
,然后根据定义,x1
第r
位的值等于q'1
)。现在你可以问自己这样一个问题:x1
的第r
位为1
意味着什么?
答案是要找到这个1
是由哪些数组元素贡献的。一个出现k
次的元素会起贡献吗?不会,为什么?因为一个元素要有贡献,它至少必须同时满足两个条件:这个元素的第r
位为1
,这个元素出现的次数不是k
的整数倍。第一个条件很简单。第二个条件来自于这样一个事实:当1
的数量等于k
时,计数器将会重置为0
,这意味着x1
中相对应的位会被置为0
。对于一个出现k
次的元素,同时满足这两个条件是不可能的,所有它不可能贡献。最后,只有出现p
(p % k != 0
)次的元素才会贡献。如果p > k
,那么这个元素前k * [p / k]
([p/k]
表示p/k
向下取整)次也不会有贡献。因此我们设置p' = p % k
,并说元素有效地出现了p'
次。
我们把p'
用二进制形式表示:p'm, ..., p'1
(注意p' < k
, 所以m
个比特位足够用来表示它)。这里我声明xj
等于所求元素的条件是p'j = 1
(j
的取值为1
到m
),下面给出快速证明。
如果xj
的第r
位为1
,我们可以肯定的说,所求元素的第r
位也为1
(否则,没有任何元素能把xj
的第r
为置为1
)。我们只需要证明,xj
的第r
位为0
,所求元素的第r
位只能为0
。假设xj
第r
位为0
,而所求元素第r
位为1
,我们来看看会发生什么。扫描结束后,所求元素的这个1
将会被统计p'
次。根据定义,xj
的第r
位等于p'j
,等于1
。这和假设xj
的第r
位为0
冲突。因此,我们得出结论,只要p'j = 1
,xj
的第r
位就总是和所求元素的第r
位一样。这对于xj
所有位都是成立的(即:r = 1
到32
都成立),所以我们可以得出结论,p'j = 1
时,xj
就等于所求元素。
要返回什么值,现在已经很明显了,用二进制形式表示p' = p % k
,当p'j = 1
时,返回xj
即可。总结起来,算法的时间复杂度为O(n * logk)
,空间复杂度为O(logk)
。
附注: 这里有一个把xj
的每一位、p'j
、和所求元素s
的每一位联系起来的通用公式:(xj)_r = s_r & p'j
,(xj)_r
和s_r
分别表示xj
和所求元素s
的第r
位。从公式中,当p'j = 1
时,很容易得到(xj)_r = s_r
,也就是说p'j = 1
时,xj = s
,和我们上面说的一样。此外当p'j = 0
时,不管所求元素的值为多少,(xj)_r = 0
,所以当p'j = 0
时,xj
都为0
。所以我们得到结论:如果p'j = 1
,xj = s
;如果p'j = 0
,xj = 0
。这意味着表达式(x1 | x2 | ... | xm
)的结果也等于所求元素s
,因为这个表达式本质上是所求元素与自己和一些0
进行或
运算,最终也会等于所求元素。
5、示例
这里有一些简单的例子来说明算法是如何运行的(你可以很容易的举出其他例子):
k = 2, p = 1
k
为2
,所以m = 1
,我们只需要一个32位的整数(x1
)做为计数器即可。并且2 ^ m = k
,所以我们也可以不用mask
变量。完整的Java示例如下:
public int singleNumber(int[] nums) {
int x1 = 0;
for (int i : nums) {
x1 ^= i;
}
return x1;
}
k = 3, p = 1
k
为3
,所以m = 2
,我们需要两个32位整数(x2
,x1
)做为计数器。并且2 ^ m > k
,所以我们需要mask
变量。把k
写成二进制形式:k = '11'
,k1 = 1
,k2 = 1
,所以mask = ~(x1 & x2)
。完整的Java示例如下:
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;
}
// 因为p = 1,二进制形式为 p = '01',所以p1 = 1,所以应该返回x1。
// 如果p = 2,二进制形式为 p = '10',所以p2 = 1,所以应该返回x2。
// 或者直接返回(x1 | x2)。
return x1;
}
k = 5, p = 3
k
为5
,所以m = 3
,我们需要三个32位整数(x3
,x2
,x1
)做为计数器。并且2 ^ m > k
,所以我们需要mask
变量。把k
写成二进制形式:k = '101'
,k1 = 1
,k2 = 0
,k3 = 1
,所以mask = ~(x1 & ~x2 & x3)
。完整的Java示例如下:
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;
}
// 因为p = 3,二进制形式为 p = '11',所以p1 = p2 = 1,所以返回x1,或者x2都可以。
// 如果p = 4,二进制形式为 p = '100',只有p3 = 1,所以x1、x2、x3中,只能返回x3。
// 或者直接返回(x1 | x2 | x3)。
return x1;
}
最后,感谢那些为这篇文字提供反馈使它变得更好的人们。希望这篇文章对你有帮助,祝大家编码快乐。