SingleNumber类问题总结归纳

一、认识SingleNumber问题

下面依次贴出 LeetCode 中文网上关于 SingleNumber 的三个问题,并给出问题的求解代码。

问题一、136. 只出现一次的数字(136是该题在 leetcode 中的题号,下同)

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

说明:

你的算法应该具有线性时间复杂度。 你可以不使用额外空间来实现吗?(之前在leetcode的评论中看到有人对这句话有疑惑,这里解释一下。他不是要求你连变量都不能声明,而是说你的算法的空间复杂度应为O(1)而不是O(n))

示例 1:

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

示例 2:

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

这个问题的流传度应该是最广的,我最早是在《编程之美》上看到这个问题的,当时还没听过LeetCode呢(逃)。第一次看到这个问题的时候,想这个必然需要穷举啊,每次盯住一个元素,遍历数组看这个元素有没有再次出现,时间复杂度为 O ( n 2 ) O(n^2) O(n2)。要不然弄一个hash表,遍历一遍也可以解决问题,但这又需要 O ( n ) O(n) O(n)的空间复杂度。
结果一看别人的解法,一个异或运算就可以解决问题,时间复杂度 O ( n ) O(n) O(n),空间复杂度 O ( 1 ) O(1) O(1)。当时那感觉(jio),那叫一个不可思议。
下面给出这个问题的Java代码(可以直接在leetcode上提交,下同):
解法一:

class Solution {
    public int singleNumber(int[] nums) {
        int res = 0;
        for(int x : nums){
            res ^= x;
        }
        return res;
    }
}

解法二:

class Solution {
    public int singleNumber(int[] nums) {
		int[] s = new int[32];//位计数器
		for(int i = 0; i < nums.length; ++i){
			for(int j = 0; j < 32; ++j){
				if((nums[i] & 1<<j) != 0){//判断 nums[i]的第 j位是否为 1
					s[j] = (s[j]+1) % 2;
				}
			}
		}
		int res = 0;
		for(int j = 0; j < 32; ++j){
			if(s[j] > 0){
				res |= 1<<j;
			}
		}
		return res;
    }
}

本质上这两种解法是一样的,后面会进行讲解。

问题二、137. 只出现一次的数字 II

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

说明:

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

示例 1:

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

示例 2:

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

下面给出这个问题的Java代码:
解法一:

class Solution {
    public int singleNumber(int[] nums) {
        int p = 0, q = 0;
        for(int x : nums){
            p = ~q & (p ^ x);
            q = ~p & (q ^ x);
        }
        return p;
    }
}

解法二:

class Solution {
    public int singleNumber(int[] nums) {
        int[] s = new int[32];
		for(int i = 0; i < nums.length; ++i){
			for(int j = 0; j < 32; ++j){
				if((nums[i] & 1<<j) != 0){
					s[j] = (s[j]+1) % 3;
				}
			}
		}
		int res = 0;
		for(int j = 0; j < 32; ++j){
			if(s[j] > 0){
				res |= 1<<j;
			}
		}
		return res;
    }
}
问题三、260. 只出现一次的数字 III

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

示例 :

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

注意:

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

下面给出这个问题的Java代码:

class Solution {
    public int[] singleNumber(int[] nums) {
        int t = 0;
        for(int e : nums) t ^= e;
        t &= -t;//保留 t从右起的第一个二进制 1,其余位全部置零
        int a = 0, b = 0;
        for(int e : nums){
            if((e & t) == 0) a ^= e;
            else b ^= e;
        }
        return new int[]{a, b};
    }
}

二、SingleNumber问题归纳

今天的重点是问题一和问题二,并且我们要把这类问题推广到一般形式。问题三只是问题一的扩展问题,如果彻底理解了问题一和问题二,那么问题三也将不在话下。
我在前面问题一提到过,我第一次是在《编程之美》看到问题一的。那本书对于使用异或运算解题的解释是首先异或运算的运算规则是X ^ X = 0, X ^ 0 = X,其次异或运算又满足交换律和结合律,所以所有的数异或完后就是结果。这个解释完全没问题,逻辑上也理解的通,但是当时依然在很长的一段时间里,我总感觉没有彻底理解这个问题,或者说没有理解到点子上。后来发现也确实如此。
问题一和问题二都给了两种解法,我在问题一的最后提到过,这两种解法其实是等价的。聪明的读者应该已经发现了解法二其实更直观更容易理解,理解了解法二,其实也就理解了解法一。后面我们先就解法二进行讲解,然后再讲解看起来不那么直观的解法一。

一方面,在看完问题一和问题二这两道类似的问题之后,大家有没有这样一个疑问,就是这个重复次数是任意的吗,还是其中存在什么规律。例如问题一,那个 SingleNumber 只出现 1 次,可以改为出现 4 次吗?它的出现次数依旧特殊,别的元素都出现两次,就它出现四次。答案是当然不可以,其实这个重复次数是有一定的限制的,不能是任意的。或者说如果是任意的话,那就是另一类更难解决的问题了,可能就不存在 O ( n ) O(n) O(n) 时间复杂度, O ( 1 ) O(1) O(1) 空间复杂度的解法,大家有兴趣的话,可以自己去思考一下。
下面给出SingleNumber问题的一般形式,或者说是通用问法:

给定一个非空数组,除了一个数字 A A A 的重复次数为 y y y ,其余数字的重复次数均为 x ( x > 1 ) x( x > 1) x(x>1) 的整数倍,显然 y y y 不是 x x x 的整除倍,我们的目标就是找出这个数字 A A A

例如对于问题一,我们可以把题目改为,除了某个元素只出现一次以外,其余元素的出现次数均为 2 的整数倍,比如出现2,4,6,8,10…次都可以,算法不变。同样的,问题二也可以改为其余元素的出现次数均为 3 的整数倍,算法不变。

另一方面,既然SingleNumber问题都与数字的重复次数有关,那么本质上SingleNumber问题就可以看作是一个计数问题,普通的求解办法是将每个数看成一个整体进行计数,此时想要在线性时间内解决问题,就必需有 O ( n ) O(n) O(n)的辅助空间。而存在一个很巧妙的办法是,考虑数在计算机里的二进制表示,将数分解到位,按位计数,可以将空间复杂度优化到 O ( 1 ) O(1) O(1)

三、按位计数算法详述

现在假设有 n n n 个数存放在 32 位的 int 型数组 a [ 0 ⋯ n − 1 ] a[0\cdots n-1] a[0n1] 中,然后现在盯住 a [ i ] a[i] a[i] 这个整数的第 j ( j ∈ [ 0 , 31 ] ) j(j\in [0,31]) j(j[0,31])位,记作 a i j a_{ij} aij。我们将目标值 A A A 的第 j j j 位记作 A j A_{j} Aj,将 n n n 个数上第 j j j 位的累加和记作 S j S_{j} Sj,即 S j = ∑ i = 0 n − 1 a i j S_{j}=\sum_{i=0}^{n-1}a_{ij} Sj=i=0n1aij
首先有如下事实:

  • A j = 0 A_{j}=0 Aj=0,一定有 S j = k 1 x , k 1 ∈ N + S_{j}=k_{1}x,k_{1}\in N^{+} Sj=k1x,k1N+
  • A j = 1 A_{j}=1 Aj=1,一定有 S j = k 1 x + y = k 2 x + b , k 2 ∈ N + S_{j}=k_{1}x+y=k_{2}x+b,k_{2}\in N^{+} Sj=k1x+y=k2x+b,k2N+

S j = ∑ i = 0 n − 1 a i j = { k 1 x A j = 0 k 2 x + b A j = 1 S_j=\sum_{i=0}^{n-1}a_{ij}=\begin{cases} k_{1}x & A_{j}=0 \\ k_{2}x+b & A_{j}=1 \end{cases} Sj=i=0n1aij={k1xk2x+bAj=0Aj=1
由上式可得 A j = S j % x b A_j=\frac{S_j\%x}{b} Aj=bSj%x
随即,便可得到目标值 A A A A = ∑ j = 0 31 A j ⋅ 2 j A=\sum_{j=0}^{31}A_j\cdot 2^j A=j=031Aj2j

按位计数法伪码实现,即解法二

前提是需要知道数据类型的位数,下面以 32 位的 int 型为例,伪代码如下:

int singleNumber(int* a, int n){
	S[32] = {0}
	for(i = 0; i < n; ++i){
		for(j = 0; j < 32; ++j){
        	if(a[i] & 1<<j){
            	S[j] = (S[j]+1) % x;//对 a数组第 j位进行计数,x可以从题目中看出
        	}
    	}
	}
	A = 0;
	for(j = 0; j < 32; ++j){
    	if(S[j] > 0){
        	A |= 1<<j;//将 A的第 j位置 1
    	}
	}
	return A;
}

四、真值表法详述,即解法一

真值表在本题中的表现,着实让我惊艳了一下。用真值表我们可以实现我们自定义的运算规则。
在 SingleNumber 问题中,我们需要借助真值表实现一个计数器,这个计数器的功能是,每计数 x x x 次自动清零。
如果用二进制数来当计数器,那么计数 x x x 次,至少需要 m m m 位二进制数。
2 m − 1 < x ≤ 2 m 2^{m-1}<x\le 2^m 2m1<x2m所以我们需要 m m m 个变量(不像上一种方法,必须有 32 个变量),将 m m m 个变量对应的第 j j j 位逻辑上组成一个 m m m 位的计数器,记作 S j S_j Sj。计数器根据输入 a i j a_{ij} aij的计数规律为: S j = ( S j + a i j ) % x S_j=(S_j+a_{ij})\%x Sj=(Sj+aij)%x,即每计数 x x x 次自动清零。计数结束后, m m m 个变量的值要么是结果 A A A,要么是 0。 原理同上面描述的按位计数算法。
以重复 5 的倍数找 3 次为例,需要 3 个变量,3 个变量的对应第 j j j 位组成一个 3 位计数器。计数器的计数过程为: 000 ⟶ 1 001 ⟶ 1 010 ⟶ 1 ( 011 ) ⟶ 1 100 ⟶ 1 000 000\longrightarrow^1001\longrightarrow^1010\longrightarrow^1(011)\longrightarrow^1100\longrightarrow^1000 000100110101(011)11001000下面我们分别用 p , q , r p,q,r pqr 来表示计数器的 3 个位, i n p u t input input 相当于 a i j a_{ij} aij。最后计数结束后, p p p 位对应的变量,值为 0,而 q , r q,r qr 对应的变量,值为结果 A A A

用真值表法求解,首先需要列出如下变量变化过程的真值表:

pqrinputPQR
0000000
0010001
0100010
0110011
1000100
0001001
0011010
0101011
0111100
1001000

我们根据当前的 p , q , r p,q,r p,q,r 与输入 i n p u t input input 经过某种关系映射后得到 P , Q , R P,Q,R P,Q,R,所以理论上因变量 P , Q , R P,Q,R P,Q,R 的自变量为 p , q , r , i n p u t p,q,r,input p,q,r,input
即:
P = f ( p , q , r , i n p u t ) P=f(p,q,r,input) P=f(p,q,r,input)
Q = g ( p , q , r , i n p u t ) Q=g(p,q,r,input) Q=g(p,q,r,input)
R = φ ( p , q , r , i n p u t ) R=\varphi(p,q,r,input) R=φ(p,q,r,input)
然后根据真值表,写出下列变量更新的表达式:(可不化简)
P = p ⋅ q ‾ ⋅ r ‾ ⋅ i n p u t ‾ + p ‾ ⋅ q ⋅ r ⋅ i n p u t P = p\cdot\overline{q}\cdot\overline{r}\cdot\overline{input}+\overline{p}\cdot q\cdot r\cdot input P=pqrinput+pqrinput

Q = p ‾ ⋅ q ⋅ r ‾ ⋅ i n p u t ‾ + p ‾ ⋅ q ⋅ r ⋅ i n p u t ‾ + p ‾ ⋅ q ‾ ⋅ r ⋅ i n p u t + p ‾ ⋅ q ⋅ r ‾ ⋅ i n p u t Q=\overline{p}\cdot q\cdot\overline{r}\cdot\overline{input}+\overline{p}\cdot q\cdot r\cdot\overline{input}+\overline{p}\cdot\overline{q}\cdot r\cdot input+\overline{p}\cdot q\cdot\overline{r}\cdot input Q=pqrinput+pqrinput+pqrinput+pqrinput
⇒ = p ‾ ⋅ q ⋅ i n p u t ‾ + p ‾ ⋅ i n p u t ⋅ ( q ⨁ r ) \Rightarrow=\overline{p}\cdot q\cdot\overline{input}+\overline{p}\cdot input\cdot(q\bigoplus r) =pqinput+pinput(qr)

R = p ‾ ⋅ q ‾ ⋅ r ⋅ i n p u t ‾ + p ‾ ⋅ q ⋅ r ⋅ i n p u t ‾ + p ‾ ⋅ q ‾ ⋅ r ‾ ⋅ i n p u t + p ‾ ⋅ q ⋅ r ‾ ⋅ i n p u t R=\overline{p}\cdot\overline{q}\cdot r\cdot\overline{input}+\overline{p}\cdot q\cdot r\cdot\overline{input}+\overline{p}\cdot\overline{q}\cdot\overline{r}\cdot input+\overline{p}\cdot q\cdot\overline{r}\cdot input R=pqrinput+pqrinput+pqrinput+pqrinput
⇒ = p ‾ ⋅ r ⋅ i n p u t ‾ + p ‾ ⋅ r ‾ ⋅ i n p u t \Rightarrow=\overline{p}\cdot r\cdot\overline{input}+\overline{p}\cdot\overline{r}\cdot input =prinput+prinput
⇒ = p ‾ ⋅ ( r ⨁ i n p u t ) \Rightarrow=\overline{p}\cdot(r\bigoplus input) =p(rinput)

以上是一位的计数运算,而实际上对于位运算来说所有位的运算都是相同的。故而上述算法不用分解到位,单独运算,再进行整合。对于上面的式子,如果你忘记了怎么化简,不化简也可以,不影响大局。

真值表法实现自动计数,不仅可以进一步优化空间复杂度,而且不需要知道具体的数据类型位数。重复 5 的倍数找 3 次的 SingleNumber 问题的真值表法C语言代码如下:

#include<stdio.h>
int singleNumber(int* a, int len){
	int p = 0, q = 0, r = 0;
	int op;
	for(int i = 0; i < len; i++){
		op = p;//临时变量保存 p的值。因为下面 p要先更新,而后面 q,r的更新又需要用到 p的旧值
		p = (p & ~q & ~r & ~a[i])|(~p & q & r & a[i]);
		q = (~op & q & ~a[i])|(~op & a[i] & (q^r));
		r = ~op & (r^a[i]);//这里 r的更新没有用到 q,否则还需要一个临时变量保存 q的旧值
	}
	return r;
}
int main(){
	int a[13] = {5,5,5,2,2,7,7,7,7,7,2,5,5};
	printf("%d\n",singleNumber(a,13));
	return 0;
}

到了这里还没完哦,其实我们可以进一步优化空间复杂度,去掉在更新过程中用到的临时变量。例如问题二的解法一就没有临时变量。那我们应该怎么做呢?
答案是,换一下映射关系中的自变量即可,使用最新的值去更新后面的变量。
映射关系如下所示:
P = f ′ ( p , Q , R , i n p u t ) P=f^{'}(p,Q,R,input) P=f(p,Q,R,input)
Q = g ′ ( P , q , R , i n p u t ) Q=g^{'}(P,q,R,input) Q=g(P,q,R,input)
R = φ ′ ( P , Q , r , i n p u t ) R=\varphi^{'}(P,Q,r,input) R=φ(P,Q,r,input)
根据真值表,可以写出如下新的变量更新表达式:
P = p ⋅ Q ‾ ⋅ R ‾ ⋅ i n p u t ‾ + p ‾ ⋅ Q ‾ ⋅ R ‾ ⋅ i n p u t = Q ‾ ⋅ R ‾ ⋅ ( p ⨁ i n p u t ) P = p\cdot\overline{Q}\cdot\overline{R}\cdot\overline{input}+\overline{p}\cdot\overline{Q}\cdot\overline{R}\cdot input=\overline{Q}\cdot\overline{R}\cdot(p\bigoplus input) P=pQRinput+pQRinput=QR(pinput)

Q = P ‾ ⋅ q ⋅ R ‾ ⋅ i n p u t ‾ + P ‾ ⋅ q ⋅ R ⋅ i n p u t ‾ + P ‾ ⋅ q ‾ ⋅ R ‾ ⋅ i n p u t + P ‾ ⋅ q ⋅ R ⋅ i n p u t Q=\overline{P}\cdot q\cdot\overline{R}\cdot\overline{input}+\overline{P}\cdot q\cdot R\cdot\overline{input}+\overline{P}\cdot\overline{q}\cdot\overline{R}\cdot input+\overline{P}\cdot q\cdot R\cdot input Q=PqRinput+PqRinput+PqRinput+PqRinput
⇒ = P ‾ ⋅ i n p u t ⋅ ( q ‾ ⋅ R ‾ + q ⋅ R ) + P ‾ ⋅ q ⋅ i n p u t ‾ \Rightarrow=\overline{P}\cdot input\cdot(\overline{q}\cdot\overline{R}+q\cdot R)+\overline{P}\cdot q\cdot\overline{input} =Pinput(qR+qR)+Pqinput

R = P ‾ ⋅ Q ‾ ⋅ r ⋅ i n p u t ‾ + P ‾ ⋅ Q ⋅ r ⋅ i n p u t ‾ + P ‾ ⋅ Q ‾ ⋅ r ‾ ⋅ i n p u t + P ‾ ⋅ Q ⋅ r ‾ ⋅ i n p u t R=\overline{P}\cdot\overline{Q}\cdot r\cdot\overline{input}+\overline{P}\cdot Q\cdot r\cdot\overline{input}+\overline{P}\cdot\overline{Q}\cdot\overline{r}\cdot input+\overline{P}\cdot Q\cdot\overline{r}\cdot input R=PQrinput+PQrinput+PQrinput+PQrinput
⇒ = P ‾ ⋅ ( r ⨁ i n p u t ) \Rightarrow=\overline{P}\cdot(r\bigoplus input) =P(rinput)
C语言代码如下:

#include<stdio.h>
int singleNumber(int* a, int len){
	int p = 0, q = 0, r = 0;
	for(int i = 0; i < len; i++){
		p = ~q & ~r & (p^a[i]);
		q = ~p & a[i] & (~q & ~r|q & r)|~p & q & ~a[i];
		r = ~p & (r^a[i]);
	}
	return q;//只能返回 q。p和 r为 0,为什么?
}
int main(){
	int a[13] = {5,5,5,2,2,7,7,7,7,7,2,5,5};
	printf("%d\n",singleNumber(a,13));
	return 0;
}

留一个问题给大家自己思考,为什么优化后的代码只能返回 q q q ,而之前的代码却返回 q , r q,r qr 都可以呢。

五、最后提一下问题三

假设那两个只出现一次的元素分别为 A A A B B B,由题意易知 A ≠ B A\not= B A=B

  1. 将所有元素依次做一遍异或运算,则最终的结果就是 A ⨁ B A\bigoplus B AB。由于 A ≠ B A\not= B A=B,故 A ⨁ B ≠ 0 A\bigoplus B\not=0 AB=0
  2. 则必然存在这种情况, A A A B B B 在某一位上必然一个为 1,一个为 0。而这一位可以根据 A ⨁ B A\bigoplus B AB 上的非零位确定。
  3. 根据所有元素在这一位上是 0 还是 1,可以将元素分为两类。由于 A A A B B B 必然不可能出现在同一类中,所以这两类元素就等价于两个问题一。将这两类元素分别各自做异或运算,就可以分别得到 A A A B B B

最后的最后再给大家一个扩展问题自己思考并动手编码,其实如果上面的内容都懂了的话,这个题应该很简单。问题如下:

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

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值