「转载」Single Number Ⅱ题解:位运算+有限状态自动机@Krahets

本篇文章完全转载自Krahets的题解
作者:jyd
链接:https://leetcode-cn.com/problems/shu-zu-zhong-shu-zi-chu-xian-de-ci-shu-ii-lcof/solution/mian-shi-ti-56-ii-shu-zu-zhong-shu-zi-chu-xian-d-4/
来源:力扣(LeetCode)
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

解题思路:

如下图所示,考虑数字的二进制形式,对于出现三次的数字,各 二进制位 出现的次数都是 33 的倍数。
因此,统计所有数字的各二进制位中 11 的出现次数,并对 33 求余,结果则为只出现一次的数字。

Picture1.png

方法一:有限状态自动机

各二进制位的 位运算规则相同 ,因此只需考虑一位即可。如下图所示,对于所有数字中的某二进制位 11 的个数,存在 3 种状态,即对 3 余数为 0,1,20, 1, 2

  • 若输入二进制位 11 ,则状态按照以下顺序转换;
  • 若输入二进制位 00 ,则状态不变。

01200 \rightarrow 1 \rightarrow 2 \rightarrow 0 \rightarrow \cdots

Picture2.png

如下图所示,由于二进制只能表示 0,10, 1 ,因此需要使用两个二进制位来表示 33 个状态。设此两位分别为 twotwo , oneone ,则状态转换变为:

0001100000 \rightarrow 01 \rightarrow 10 \rightarrow 00 \rightarrow \cdots

Picture3.png

接下来,需要通过 状态转换表 导出 状态转换的计算公式 。首先回忆一下位运算特点,对于任意二进制位 xx ,有:

  • 异或运算:x ^ 0 = xx ^ 1 = ~x
  • 与运算:x & 0 = 0x & 1 = x

计算 oneone 方法:

设当前状态为 twotwo oneone ,此时输入二进制位 nn 。如下图所示,通过对状态表的情况拆分,可推出 oneone 的计算方法为:

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

Picture4.png

计算 twotwo 方法:

由于是先计算 oneone ,因此应在新 oneone 的基础上计算 twotwo
如下图所示,修改为新 oneone 后,得到了新的状态图。观察发现,可以使用同样的方法计算 twotwo ,即:

two = two ^ n & ~one

Picture5.png

返回值:

以上是对数字的二进制中 “一位” 的分析,而 int 类型的其他 31 位具有相同的运算规则,因此可将以上公式直接套用在 32 位数上。

遍历完所有数字后,各二进制位都处于状态 0000 和状态 0101 (取决于 “只出现一次的数字” 的各二进制位是 11 还是 00 ),而此两状态是由 oneone 来记录的(此两状态下 twostwos 恒为 00 ),因此返回 onesones 即可。

复杂度分析:
  • 时间复杂度 O(N)O(N) 其中 NN 位数组 numsnums 的长度;遍历数组占用 O(N)O(N) ,每轮中的常数个位运算操作占用 O(32×3×2)=O(1)O(32 \times3 \times 2) = O(1)
  • 空间复杂度 O(1)O(1) 变量 onesones , twostwos 使用常数大小的额外空间。

方法二:遍历统计

此方法相对容易理解,但效率较低,总体推荐方法一。

使用 与运算 ,可获取二进制数字 numnum 的最右一位 n1n_1

n1=num&in_1 = num \& i

配合 无符号右移操作 ,可获取 numnum 所有位的值(即 n1n_1 ~ n32n_{32} ):

num=num>>>1num = num >>> 1

建立一个长度为 32 的数组 countscounts ,通过以上方法可记录所有数字的各二进制位的 11 的出现次数。

int[] counts = new int[32];
for(int i = 0; i < nums.length; i++) {
    for(int j = 0; j < 32; j++) {
        counts[j] += nums[i] & 1; // 更新第 j 位
        nums[i] >>>= 1; // 第 j 位 --> 第 j + 1 位
    }
}

countscounts 各元素对 33 求余,则结果为 “只出现一次的数字” 的各二进制位。

for(int i = 0; i < 32; i++) {
    counts[i] %= 3; // 得到 只出现一次的数字 的第 (31 - i) 位 
}

利用 左移操作或运算 ,可将 countscounts 数组中各二进位的值恢复到数字 resres 上(循环区间是 i[0,31]i \in [0, 31] )。

for(int i = 0; i < counts.length; i++) {
    res <<= 1; // 左移 1 位
    res |= counts[31 - i]; // 恢复第 i 位的值到 res
}

最终返回 resres 即可。

由于 Python 的存储负数的特殊性,需要先将 00 - 3232 位取反(即 res ^ 0xffffffff ),再将所有位取反(即 ~ )。
两个组合操作实质上是将数字 3232 以上位取反, 00 - 3232 位不变。

复杂度分析:
  • 时间复杂度 O(N)O(N) 其中 NN 位数组 numsnums 的长度;遍历数组占用 O(N)O(N) ,每轮中的常数个位运算操作占用 O(1)O(1)
  • 空间复杂度 O(1)O(1) 数组 countscounts 长度恒为 3232 ,占用常数大小的额外空间。
代码:

实际上,只需要修改求余数值 mm ,即可实现解决 除了一个数字以外,其余数字都出现 mm 的通用问题。

class Solution:
    def singleNumber(self, nums: List[int]) -> int:
        counts = [0] * 32
        for num in nums:
            for j in range(32):
                counts[j] += num & 1
                num >>= 1
        res, m = 0, 3
        for i in range(32):
            res <<= 1
            res |= counts[31 - i] % m
        return res if counts[31] % m == 0 else ~(res ^ 0xffffffff)
class Solution {
    public int singleNumber(int[] nums) {
        int[] counts = new int[32];
        for(int num : nums) {
            for(int j = 0; j < 32; j++) {
                counts[j] += num & 1;
                num >>>= 1;
            }
        }
        int res = 0, m = 3;
        for(int i = 0; i < 32; i++) {
            res <<= 1;
            res |= counts[31 - i] % m;
        }
        return res;
    }
}