只出现一次的数算法题汇总

写在前面
如果觉得写得好有所帮助,记得点个关注和点个赞,不胜感激!
这篇文章用来总结一个系列的算法题,也就是找到元素数组中重复出现的元素或者只出现一次的元素。其实在讲解之前,如果之前有接触过这类的类的同学,应该知道解决这类题的方式不外乎用位运算来搞定。使用位运算来解决这一系列问题很有趣,这也是为什么我想着专门写一篇博文来记录的原因,废话不多说,咱们进入正文。

只出现一次的数字Ⅰ

在这里插入图片描述
如果没有时间复杂度和空间复杂度的限制,这道题有很多种解法,可能的解法有如下几种。

  • 使用集合存储数字。遍历数组中的每个数字,如果集合中没有该数字,则将该数字加入集合,如果集合中已经有该数字,则将该数字从集合中删除,最后剩下的数字就是只出现一次的数字。
  • 使用哈希表存储每个数字和该数字出现的次数。遍历数组即可得到每个数字出现的次数,并更新哈希表,最后遍历哈希表,得到只出现一次的数字。
  • 使用集合存储数组中出现的所有数字,并计算数组中的元素之和。由于集合保证元素无重复,因此计算集合中的所有元素之和的两倍,即为每个元素出现两次的情况下的元素之和。由于数组中只有一个元素出现一次,其余元素都出现两次,因此用集合中的元素之和的两倍减去数组中的元素之和,剩下的数就是数组中只出现一次的数字。

上述三种解法都需要额外使用 O ( n ) O(n) O(n) 的空间,其中 nn 是数组长度。如果要求使用线性时间复杂度和常数空间复杂度,上述三种解法显然都不满足要求。那么,如何才能做到线性时间复杂度和常数空间复杂度呢?答案是使用位运算。对于这道题,可使用异或运算 ⊕ \oplus 。异或运算有以下三个性质。

任何数和 0 0 0 做异或运算,结果仍然是原来的数,即 a ⊕ \oplus 0=a。
任何数和其自身做异或运算,结果是 0,即 a ⊕ \oplus a=0。
异或运算满足交换律和结合律,即 a ⊕ \oplus b ⊕ \oplus a=b ⊕ \oplus a ⊕ \oplus a=b ⊕ \oplus (a ⊕ \oplus a)=b ⊕ \oplus 0=b。有了概念之后,我们完成这道题就非常容易了,代码如下:

public int singleNumber(int[] nums) {
    int single = 0;
    for (int num : nums) {
        single ^= num;
    }
    return single;
}

只出现一次的数字Ⅱ

在这里插入图片描述
上面的那种情况是所有数字都是成对出现的,只有一个数字是落单的,找出这个落单的数字,而这道题有两个落单的数字。(这里引用LeetCode官方的图,我就不画了)

  • 使用异或运算可以帮助我们消除出现两次的数字;我们计算 bitmask ^= x,则 bitmask 留下的就是出现奇数次的位。
    在这里插入图片描述
  • x & (-x) 是保留位中最右边 1 ,且将其余的 1 设位 0 的方法。
    在这里插入图片描述
  • 首先计算 bitmask ^= x,则 bitmask 不会保留出现两次数字的值,因为相同数字的异或值为 0。但是 bitmask 会保留只出现一次的两个数字(x 和 y)之间的差异。
    在这里插入图片描述
  • 我们可以直接从 bitmask 中提取 x 和 y 吗?不能,但是我们可以用 bitmask 作为标记来分离 x 和 y。我们通过 bitmask & (-bitmask) 保留 bitmask 最右边的 1,这个 1 要么来自 x,要么来自 y。
    在这里插入图片描述
public int[] singleNumber(int[] nums) {
    int bitmask = 0;
    for (int num : nums) bitmask ^= num;

    int diff = bitmask & (-bitmask);

    int x = 0;
    for (int num : nums) if ((num & diff) != 0) x ^= num;

    return new int[]{x, bitmask^x};
}

只出现一次的数Ⅲ

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

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

在这里插入图片描述
在这里插入图片描述
如下图所示,由于二进制只能表示 0, 1 ,因此需要使用两个二进制位来表示 3 个状态。设此两位分别为 two , one ,则状态转换变为:
在这里插入图片描述
在这里插入图片描述
接下来,需要通过 状态转换表 导出 状态转换的计算公式 。

计算 one

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

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

在这里插入图片描述

计算 two

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

two = two ^ n & ~one

在这里插入图片描述

以上是对数字的二进制中 “一位” 的分析,而 int 类型的其他 31 位具有相同的运算规则,因此可将以上公式直接套用在 32 位数上。遍历完所有数字后,各二进制位都处于状态 00 和状态 01 (取决于 “只出现一次的数字” 的各二进制位是 1 还是 0 ),而此两状态是由 one 来记录的(此两状态下 twos 恒为 0 ),因此返回ones 即可。

public int singleNumber(int[] nums) {
	int one = 0;
	int two = 0;
	for(int num : nums) {
		one = ~two & (one ^ num);
		two = ~one & (two ^ num);
	}
	return one;
}

通用计算方法

给一个数组,每个元素都出现 k ( k > 1) 次,除了一个数字只出现 p 次(p >= 1, p % k != 0),找到出现 p 次的那个数。

为了计数 k k k 次,我们必须要 m m m 个比特,其中 2 m > = k 2^m >=k 2m>=k, 也就是 m > = l o g k m >= logk m>=logk。假设我们 m m m 个比特依次是 x m x m − 1 . . . x 2 x 1 x_mx_{m-1}...x_2x_1 xmxm1...x2x1。开始全部初始化为 0 0 0 00...00 00...00 00...00。然后扫描所有数字的当前 b i t bit bit 位,用 i i i 表示当前的 b i t bit bit。这里举个例子。

假如例子是 1 2 6 1 1 2 2 3 3 3, 31, 32, 33,16
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 00...00 00...00

  • 第一次遇到 1 1 1 , m m m 个比特依次是 00...01 00...01 00...01
  • 第二次遇到 1 1 1 , m m m 个比特依次是 00...10 00...10 00...10
  • 第三次遇到 1 1 1 , m m m 个比特依次是 00...11 00...11 00...11
  • 第四次遇到 1 1 1 , m m m 个比特依次是 00..100 00..100 00..100

x 1 x_1 x1 的变化规律就是遇到 1 1 1 变成 1 1 1 ,再遇到 1 变回 0 0 0。遇到 0 0 0 的话就不变。所以 x 1 x_1 x1 = x 1 x_1 x1 ^ i i i,可以用异或来求出 x 1 x_1 x1 。那么 x 2 . . . x m x_2...x_m x2...xm 怎么办呢?

x 2 x_2 x2 的话,当遇到 1 1 1 的时候,如果之前 x 1 x_1 x1 0 0 0 x 2 x_2 x2 就不变。如果之前 x 1 x_1 x1 1 1 1,对应于上边的第二次遇到 1 1 1 和第四次遇到 1 1 1 x 2 x_2 x2 0 0 0 变成 1 1 1 和 从 1 1 1 变成 0 0 0。所以 x 2 x_2 x2 的变化规律就是遇到 1 1 1 同时 x 1 x_1 x1 1 1 1 就变成 1 1 1,再遇到 1 1 1 同时 x 1 x_1 x1 1 1 1 就变回 0 0 0。遇到 0 0 0 的话就不变。和 x 1 x_1 x1 的变化规律很像,所以同样可以使用异或。 x 2 x_2 x2 = x 2 x_2 x2 ^ ( i i i & x 1 x_1 x1),多判断了 x 1 x_1 x1 是不是 1 1 1

x 3 x_3 x3 x 4 x_4 x4 x m x_m xm 就是同理了, x m x_m xm = x m x_m xm ^ ( x m x_m xm- 1 1 1 & … & x 1 x_1 x1 & i i i) 。再说直接点,上边其实就是模拟了每次加 1 1 1 的时候,各个比特位的变化。所以高位 x m x_m xm 只有当低位全部为 1 1 1 的时候才会得到进位 1 1 1

00 − > 01 − > 10 − > 11 − > 00 00 -> 01 -> 10 -> 11 -> 00 00>01>10>11>00

上边有个问题,假设我们的 k = 3 k = 3 k=3,那么我们应该在 10 10 10 之后就变成 00 00 00,而不是到 11 11 11。所以我们需要一个 m a s k mask mask ,当没有到达 k k k 的时候和 m a s k mask mask进行与操作是它本身,当到达 k k k 的时候和 m a s k mask mask 相与就回到 00...000 00...000 00...000。根据上边的要求构造 m a s k mask mask,假设 k k k 写成二进制以后是 k m . . . k 2 k 1 k_m...k_2k_1 km...k2k1

m a s k mask mask = ~( y 1 y_1 y1 & y 2 y_2 y2 & … & y m y_m ym),

  • 如果 k j = 1 k_j = 1 kj=1,那么 y j = x j y_j = x_j yj=xj

  • 如果 k j = 0 k_j = 0 kj=0 y j y_j yj = ~ x j x_j xj

举两个例子。

  • k = 3 k = 3 k=3: 写成二进制, k 1 = 1 k_1 = 1 k1=1,$ k_2 = 1$, m a s k mask mask = ~( x 1 x_1 x1 & x 2 x_2 x2);

  • k = 5 k = 5 k=5: 写成二进制, k 1 = 1 k_1 = 1 k1=1, k 2 = 0 k_2 = 0 k2=0, k 3 = 1 k_3 = 1 k3=1, m a s k mask mask = ~( x 1 x_1 x1 & ~ x 2 x_2 x2 & x 3 x_3 x3);

很容易想明白,当 x 1 x 2 . . . x m x_1x_2...x_m x1x2...xm 达到 k 1 k 2 . . . k m k_1k_2...k_m k1k2...km 的时候因为我们要把 x 1 x 2 . . . x m x_1x_2...x_m x1x2...xm 归零。我们只需要用 0 0 0 和每一位进行与操作就回到了 0 0 0。所以我们只需要把等于 0 0 0 的比特位取反,然后再和其他所有位相与就得到 1 1 1 ,然后再取反就是 0 0 0 了。如果 x 1 x 2 . . . x m x_1x_2...x_m x1x2...xm 没有达到 k 1 k 2 . . . k m k_1k_2...k_m k1k2...km ,那么求出来的结果一定是 1 1 1,这样和原来的 b i t bit 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, 31, 32, 33,16
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  

之前是完成了一个 b i t bit bit 位,也就是每一列的操作。因为我们给的数是 i n t int int 类型,所以有 32 32 32 位。所以我们需要对每一位都进行计数。有了上边的分析,我们不需要再向解法三那样依次考虑每一位,我们可以同时对 32 32 32 位进行计数。对于 k k k 等于 3 3 3 ,也就是这道题。我们可以用两个 i n t int int x 1 x_1 x1 x 2 x_2 x2 x 1 x_1 x1 表示对于 32 32 32 位每一位计数的低位, x 2 x_2 x2 表示对于 32 32 32 位每一位计数的高位。通过之前的公式,我们利用位操作就可以同时完成计数了。

int x1 = 0, x2 = 0, mask = 0;

for (int i : nums) {
    x2 ^= x1 & i;
    x1 ^= i;
    mask = ~(x1 & x2);
    x2 &= mask;
    x1 &= mask;
}
返回结果

因为所有的数字都出现了 k k k 次,只有一个数字出现了 p p p 次。因为 x m . . . x 2 x 1 x_m...x_2x_1 xm...x2x1 组合起来就是对于每一列 1 1 1 的计数。举个例子

假如例子是 1 2 6 1 1 2 2 3 3 3, 31, 32, 33,16
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   
    
看最右边的一列 100110011161, 也就是 110
再往前看一列 011001111171, 也就是 111
再往前看一列 001000011, 也就是 001
再对应到 x1, x2, x3 就是
x1 1 1 0
x2 0 1 1
x3 0 1 1
  • 如果 p = 1 p = 1 p=1,那么如果出现一次的数字的某一位是 1 1 1 ,一定会使得 x 1 x_1 x1 ,也就是计数的最低位置的对应位为 1 1 1,所以我们把 x 1 x_1 x1 返回即可。对于上边的例子,就是 110 110 110 ,所以返回 6 6 6

  • 如果 p = 2 p = 2 p=2,二进制就是 10 10 10,那么如果出现 2 2 2次的数字的某一位是 1 1 1 ,一定会使得 x 2 x_2 x2 的对应位变为 1 1 1,所以我们把 x 2 x_2 x2 返回即可。

  • 如果 p = 3 p = 3 p=3,二进制就是 11 11 11,那么如果出现 3 3 3次的数字的某一位是 1 1 1 ,一定会使得 x 1 x_1 x1 x 2 x_2 x2的对应位都变为 1 1 1,所以我们把 x 1 x_1 x1 或者 x 2 x_2 x2 返回即可。

如下,如果 p = 1 p = 1 p=1

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; 
}

至于为什么先对 x 2 x_2 x2 异或再对 x 1 x_1 x1 异或,就是因为 x 2 x_2 x2 的变化依赖于 x 1 x_1 x1 之前的状态。颠倒过来明显就不对了。再扩展一下题目,对于 k = 5 , p = 3 k = 5, p = 3 k=5,p=3 怎么做,也就是每个数字出现了 5 5 5 次,只有一个数字出现了 3 3 3 次。首先根据 k = 5 k = 5 k=5,所以我们至少需要 3 3 3 个比特位。因为 2 2 2 个比特位最多计数四次。然后根据 k k k 的二进制形式是 101 101 101,所以 m a s k mask mask = ~( x 1 x_1 x1 & ~ x 2 x_2 x2 & x 3 x_3 x3)。根据 p p p 的二进制是 011 011 011,所以我们最后可以把 x 1 x_1 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;  
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值