0x00 问题描述
给定一个整数数组,除了一个元素出现 次( , ),其余每个元素出现 ( )次,找到那个特殊的元素。
0x01 含有1bit数字的特殊情况
假设我们有一个只包含1bit
数字的数组(只能是
或
),我们想计算数组中
的数量,我们需要一个计数器count
。这样每当数字
的数量count
达到某个值时,比如说
, count
返回
并重新开始。假设计数器具有二进制形式的
位:
(从最高有效位到最低有效位)。我们可以得出以下四个性质:
- 计数器的初始状态为零
- 对于数组中的每个输入,如果我们碰到 ,则计数器应保持不变
- 对于数组中的每个输入,如果我们碰到 ,则计数器应该增加
- 为了覆盖 个计数,我们需要 ,这意味着 。
关键问题就在于:计数器中的每个位( )在扫描数组时如何变化。为了满足第二个性质,如果另一个操作数为 的话,我们使用哪些位运算会不改变操作数呢? 和 。
好的,我们现在有两个可用的表达式: 或 ,其中 是输入数组中的元素。哪一个更好?我们还不知道,所以我们要实际操作一下。
开始时,计数器的所有位都初始化为零,即 ,保证计数器的所有位保持不变。如果我们碰到 ,计数器将为 ,直到我们碰到输入数组中的第一个 。在我们碰到第一个 之后,我们得到: 。让我们继续,直到我们碰到第二个 ,之后我们得到: ,注意 从 变为 。如果使用 的话,在第二次计数之后, 仍然是 ,所以很明显我们应该使用 。 呢?以 为例,如果我们此时碰到 并需要更改 的值,那么在我们进行更改之前, 的值必须是多少?答案是: 必须为 ,否则我们不应该更改 ,因为将 从 更改为 即可。因此,只有当 和 都是 时, 才会改变值,或者用数学公式表示为 。类似地,只有当 和 都是 : 时, 才会改变值。
但是,你可能注意到上面的位运算结果范围是 ,而不是 。如果 ,我们需要一些“分割”机制,当计数达到 时,将计数器重新初始化为 。为此,我们使用称为掩码的一些变量对 进行按位与,即 。如果我们可以确保只有当计数达到 时掩码才为 并且对于所有其他计数情况都是 ,那么我们就完成了我们的目标。我们如何实现这一目标?对于每个计数,我们对计数器的每个位都有唯一的值,可以将其视为其状态。如果我们用二进制形式写 ,我们可以按如下方式构造掩码:
,如果 , ,如果 , 。
我们举一些例子:
;
;
总之,我们的算法将是这样的(nums
是输入数组):
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
xm &= mask;
......
x1 &= mask;
}
0x02 具有32bit数字的一般情况
现在是时候将我们的结果从 位数的情况推广到 位整数。一种直接的方法是为整数中的每个位创建 个计数器。但是,如果我们利用位运算,我们可以“整体”管理所有 个计数器。通常说的“整体”,是指使用 个 位整数而不是 个 位计数器,其中 是满足 的最小整数,原因是位运算仅适用于每个位,因此不同位上的操作彼此独立(明显,对吧?)。这允许我们将 个计数器的相应位分组为一个 位整数。 下面的示意图展示如何完成操作
顶行是
位整数,对于每个位,我们有一个相应的
位计数器(由向上箭头下方的列显示)。由于
位中的每一位的逐位运算彼此独立,因此我们可以将所有计数器的第
位分组为一个
位数字(由橙色框显示)。此
位数字中的所有位(表示为
)将遵循相同的按位运算。由于每个计数器都有
位,我们最终得到
个
位数,它们对应于0x01
中定义的
,但现在它们是
位整数而不是
位数。因此,在上面的算法中,我们只需要将
到
视为
位整数而不是
位数。其他一切都是一样的,我们就完成了。
0x03 返回什么
最后一件事是我们应该返回什么值,或者等价于
到
中的哪一个将等于Single Number
。为了得到正确的答案,我们需要了解
个
位整数
到
的含义。以
为例,
有
位,我们将它们标记为
。在我们完成扫描输入数组之后,
的第
位的值将由数组中所有元素的第
位确定(更具体地说,假设数组中所有元素的第
位为
的总计数是
,
及其二进制形式:
,那么根据定义,
的第
位将等于
)。现在你可以问自己这个问题:如果
的第
位是
,它意味着什么?
答案是找到可以为此做出贡献的
。出现
次的元素会有贡献吗?为什么没有?因为对于要贡献的元素,它必须同时满足至少两个条件:该元素的第
位是
并且该
的出现次数不是
的整数倍。第一个条件是微不足道的。第二个来自这样的事实:每当
的数量为
时,计数器将返回到零,这意味着
中的相应位将被重置为0。对于出现
次的元素,不可能同时满足这两个条件,所以它不会有所贡献。最后,只有出现
次的Single Number
会有所贡献。如果
,那么第
Single Number
不会有贡献。所以我们总是可以设置
,并说Single Number
出现
次。
让我们以二进制形式写
(注意
,所以它将适合
位)。这里我声称
等于Single Number
的条件是
,下面给出一个简短的证明。
如果
的第
位为1,我们可以有把握地说Single Number
的第
位也是
(否则没有任何东西可以使
的第
位为
)。我们要证明,如果
的第
位为
,那么Single Number
的第
位只能为
。假设在这种情况下Single Number
的第
位是
,让我们看看会发生什么。在扫描结束时,此
将被计为
次。根据定义,
的第
位将等于
,即1。这与
的第
位为0的假设相矛盾。因此,我们得出结论
的第
位将始终为与
时的Single Number
的第
位相同。因为对于
中的所有位都是如此(即对于
为真),所以我们得出结论
将等于Single Number
只要
。
所以现在很清楚我们应该返回什么。只需以二进制形式表示 并且当 时返回相应的 即可。总的来说,算法是 时间和 空间复杂度。
附注:有一个将
到
的每个位和Single Number
的每个位相关联的通用公式,
,其中
和
分别表示
的第
位和
。从该公式可以很容易地看出,如果
,则
,即
。此外,如果
,我们有
,即
。所以我们得到这样的结论:如果
,
,如果
,则
。这意味着表达式
也将被计算为
,因为上述表达式中只包含
和一些
的or
运算。
0x04 一些例子
以下是一些示例,用于说明算法的工作原理:
-
是 ,然后 ,我们只需要一个 位整数( )作为计数器。并且 所以我们甚至不需要 !一个完整的java程序将如下所示:public int singleNumber(int [] nums){ int x1 = 0; for (int i : nums){ x1 ^= i; } return x1; }
-
是 ,然后 ,我们需要两个 位整数( )作为计数器。而 所以我们需要一个面具。以二进制形式写 ,然后 ,所以我们有 。一个完整的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; } return x1; }
-
k是5,然后 ,我们需要三个 位整数( )作为计数器。而 所以我们需要一个面具。以二进制形式写 ,然后 ,所以我们有 。一个完整的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; } return x1; }
reference: