仓库源文站点原文


key: 38 title: 只出现一次的数字

tag: [algorithms, leetcode]

这里分享三道寻找数组中只出现一次的数字的问题. 这些题使用哈希表都很好做, 但这里我们使用位运算, 可以很巧妙地在常数空间复杂度内解决问题.

第一题

题目源自 Leetcode 136 题

给定一个非空整数数组,除了某个元素只出现一次以外,其余每个元素均出现两次。找出那个只出现了一次的元素。

说明:

你的算法应该具有线性时间复杂度。 你可以不使用额外空间来实现吗?

示例 1:

输入: [2,2,1]
输出: 1

示例 2:

输入: [4,1,2,1,2]
输出: 4

异或运算的特性是, 异为真同为假. 即 1 ^ 1 = 0, 0 ^ 0 = 0, 1 ^ 0 = 1, 0 ^ 1 = 1. 因此, 两个相同的数异或的结果为 0, 任何数与 0 异或的结果都为它自身. 此外, 异或运算还满足交换律. 如果我们将数组里的元素全部异或会得到什么结果呢? 以数组 [4,1,2,1,2] 为例:

  4 ^ 1 ^ 2 ^ 1 ^ 2
= 4 ^ 1 ^ 1 ^ 2 ^ 2
= 4 ^ 0 ^ 0
= 4

因为异或运算满足交换律, 因此我们可以将出现两次的数字移动到一起, 它们异或的结果为 0; 结果就成了只出现一次的数字与 0 异或, 最后结果等于只出现一次的数字. 也就是说, 解这道题我们只需将数组里的元素全部异或就可以了. 代码非常简洁:

def singleNumber(nums):
    return reduce(lambda a, b: a ^ b, nums)

第二题

题目源自 Leetcode 260 题

给定一个整数数组 nums,其中恰好有两个元素只出现一次,其余所有元素均出现两次。 找出只出现一次的那两个元素。

示例 :

输入: [1,2,1,3,2,5]
输出: [3,5]

注意:

  1. 结果输出的顺序并不重要,对于上面的例子, [5, 3] 也是正确答案。
  2. 你的算法应该具有线性时间复杂度。你能否仅使用常数空间复杂度来实现?

这道题变成了有两个数字只出现一次. 怎么解呢? 如果我们将数组里的元素全部异或, 得到的结果应该是这两个只出现一次的数字的异或. 可是怎么从异或的结果还原出这两个数字呢?

别忘了异或运算的特性: 异为真同为假. 首先, 这两个只出现一次的数字必然是不相等的, 因此异或的结果至少有一个二进制位为 1. 反过来, 如果已知异或结果的第 i 位为 1, 那么这两个数中必有一个第 i 位也为 1. 如果我们遍历数组, 将所有第 i 位为 1 的数字全部异或会发生什么呢? 注意第 i 位为 1 的数要么是这两个只出现一次的数中的一个, 要么就是其它出现了两次的数. 出现两次的数异或结果必为 0. 因此, 将所有第 i 位为 1 的数字全部异或就能得到这两个只出现一次的数中的一个.

异或的逆运算还是异或, 即如果 a ^ b = ca ^ c = b. 因此只需再作一次异或就能得到另一个数了.

至于取异或结果某一个为 1 的二进制位, 我们可以这样做. 一个数与自己的相反数作按位与运算, 即 x & -x, 会保留这个数最右边的 1, 其余位都置为 0. 因为 -xx 的补码, 等于按位取反再加一, 这样最右边的 1 先取反变为 0; 而它右边所有的 0 (如果有的话) 会先取反变为 1, 然后加一全部进位变为 0, 最右边的 1 的位置 (刚刚取反变成 0 了) 进位又变回了 1. 这样 -x 再与 x 按位与, 就只剩下最右边的 1 了. 举个例子:

x   = 101000
-x  = ~x + 1
    = 010111 + 1    # 取反, 最右边的 1 变为 0, 且它右边的 0 全部变为 1
    = 011000        # 加一, 右边 0 取反得到的 1 全部进位

x & -x = 101000 & 011000
       = 001000     # 保留最右边的 1, 其余位都置为 0

最终的代码如下:

def singleNumber(nums):
    xor = reduce(lambda a, b: a ^ b, nums)
    mask = xor & -xor
    k = 0
    for n in nums:
        if n & mask:
            k ^= n

    return [k, k ^ xor]

第三题

题目源自 Leetcode 137 题

给定一个非空整数数组,除了某个元素只出现一次以外,其余每个元素均出现了三次。找出那个只出现了一次的元素。

说明:

你的算法应该具有线性时间复杂度。 你可以不使用额外空间来实现吗?

示例 1:

输入: [2,2,3,2]
输出: 3

示例 2:

输入: [0,1,0,1,0,1,99]
输出: 99

这道题中, 数组中的数字可能出现一次或三次, 这就没法像前两道题一样使用异或了. 因为一个数与自身异或三次得到的还是它本身, 这就无法与只出现一次的数区分开了.

既然无法使用异或, 我们不妨想想什么样的运算可以满足我们的需求. 假设有一种逻辑运算 @, 有

至于 1 @ 1 的结果, 我们不关心, 它可以是任意值. 这样的运算就能帮助我们解这个问题了. 但遗憾的是这个运算是不存在的 -- -- 1 @ 1 的值该为多少呢? 如果为 1, 那么 1 @ 1 @ 1 = 1 @ 1 = 1, 不满足需求; 如果为 0, 那么 1 @ 1 @ 1 = 0 @ 1 = 1 也不满足需求. 实际上, 要想 1 @ 1 @ 1 = 0, 这个运算就必须设法对 1 出现的次数计数, 只有 0 和 1 是不够用的.

既然只有 0 和 1 不够用, 那我们就用两个变量 a 和 b, 来对 1 出现的次数计数. 我们可以规定:

1 出现的次数 a b
0 0 0
1 0 1
2 1 0

我们可以定义 @ 运算接受元组 (a, b) 和位元 n. 它的结果是一个新的元组 (a', b'). 那么就有:

我们可以列出 @ 运算的真值表:

a b n a' b'
0 0 0 0 0
0 1 0 0 1
1 0 0 1 0
0 0 1 0 1
0 1 1 1 0
1 0 1 0 0

类似于 "按位与", "按位异或", 我们把整数的每一位都执行 @ 逻辑运算, 就是 "按位 @". 根据真值表我们很快就能写出按位 @ 运算的代码了:

a_ = a & ~b & ~n | ~a & b & n
b_ = ~a & b & ~n | ~a & ~b & n

接下来的做法就跟第一题一样了. 我们只需将数组里的元素全部执行按位 @. 因为当且仅当 b = 1 时 1 出现的次数为 1. 因此我们最后返回 b 即可.

def singleNumber(nums):
    a = b = 0
    for n in nums:
        a_ = a & ~b & ~n | ~a & b & n
        b_ = ~a & b & ~n | ~a & ~b & n
        a, b = a_, b_
    return b

参考资料: