剑指 Offer 56 - I. 数组中数字出现的次数
题目:一个整型数组 nums 里除两个数字之外,其他数字都出现了两次。请写程序找出这两个只出现一次的数字。要求时间复杂度是O(n),空间复杂度是O(1)。
示例 1:
输入:nums = [4,1,4,6]
输出:[1,6] 或 [6,1]
示例 2:
输入:nums = [1,2,10,4,1,4,3,3]
输出:[2,10] 或 [10,2]
限制:
2 <= nums.length <= 10000
方法一:分组异或
思路:
让我们先来考虑一个比较简单的问题:
如果除了一个数字以外,其他数字都出现了两次,那么如何找到出现一次的数字?
答案很简单:全员进行异或操作即可。考虑异或操作的性质:对于两个操作数的每一位,相同结果为 0,不同结果为 1。那么在计算过程中,成对出现的数字的所有位会两两抵消为 0,最终得到的结果就是那个出现了一次的数字。
那么这一方法如何扩展到找出两个出现一次的数字呢?
如果我们可以把所有数字分成两组,使得:
1、两个只出现一次的数字在不同的组中;
2、相同的数字会被分到相同的组中。
那么对两个组分别进行异或操作,即可得到答案的两个数字。这是解决这个问题的关键。
那么如何实现这样的分组呢?
记这两个只出现了一次的数字为a和b,那么所有数字异或的结果就等于a和b异或的结果,我们记为x。如果我们把x写成二进制的形式xkxk−1⋯x2x1x0,其中xi∈{0,1},我们考虑一下xi=0 和xi=1的含义是什么?它意味着如果我们把a和b写成二进制的形式,ai和bi的关系—xi=1表示ai和bi不等,xi=0 表示ai和bi相等。假如我们任选一个不为0的xi,按照第i位给原来的序列分组,如果该位为0就分到第一组,否则就分到第二组,这样就能满足以上两个条件,为什么呢?
首先,两个相同的数字的对应位都是相同的,所以一个被分到了某一组,另一个必然被分到这一组,所以满足了条件 2。
这个方法在xi=1 的时候a和b不被分在同一组,因为xi=1 表示ai和bi不等,根据这个方法的定义[如果该位为0 就分到第一组,否则就分到第二组]可以知道它们被分进了两组,所以满足了条件1。
在实际操作的过程中,我们拿到序列的异或和x之后,对于这个[位]是可以任取的,只要它满足xi=1。但是为了方便,这里的代码选取的是[不为0的最低位],当然你也可以选择其他不为0的位置。
至此,答案已经呼之欲出了。
算法:
先对所有数字进行一次异或,得到两个出现一次的数字的异或值。
在异或结果中找到任意为 1 的位。
根据这一位对所有的数字进行分组。
在每个组内进行异或操作,得到两个数字。
class Method{
public int[] singleNumbers(int[] nums) {
int ret = 0;
for (int n : nums) {
ret ^= n;
}
int div = 1;
while ((div & ret) == 0) {
div <<= 1;
}
int a = 0, b = 0;
for (int n : nums) {
if ((div & n) != 0) {
a ^= n;
} else {
b ^= n;
}
}
return new int[]{a, b};
}
}
复杂度分析:
- 时间复杂度:O(n),我们只需要遍历数组两次。
- 空间复杂度:O(1),只需要常数的空间存放若干变量。
剑指 Offer 56 - II. 数组中数字出现的次数 II
题目:在一个数组 nums 中除一个数字只出现一次之外,其他数字都出现了三次。请找出那个只出现一次的数字。
示例 1:
输入:nums = [3,4,3,3]
输出:4
示例 2:
输入:nums = [9,1,7,9,7,9,7]
输出:1
限制:
1 <= nums.length <= 10000
1 <= nums[i] < 2^31
解题思路:
如下图所示,考虑数字的二进制形式,对于出现三次的数字,各 二进制位 出现的次数都是 3 的倍数。
因此,统计所有数字的各二进制位中 1 的出现次数,并对 3 求余,结果则为只出现一次的数字。
方法一:有限状态自动机
各二进制位的 位运算规则相同 ,因此只需考虑一位即可。如下图所示,对于所有数字中的某二进制位1的个数,存在 3 种状态,即对 3 余数为 0, 1, 2。
-
若输入二进制位 1 ,则状态按照以下顺序转换;
-
若输入二进制位 0 ,则状态不变。
0→1→2→0→⋯
如下图所示,由于二进制只能表示0, 1,因此需要使用两个二进制位来表示3个状态。设此两位分别为 two, one,则状态转换变为:
00→01→10→00→⋯
接下来,需要通过 状态转换表 导出 状态转换的计算公式 。首先回忆一下位运算特点,对于任意二进制位 x,有:
- 异或运算:
x ^ 0 = x
,x ^ 1 = ~x
- 与运算:
x & 0 = 0
,x & 1 = x
计算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即可。
复杂度分析:
- 时间复杂度 O(N): 其中 N 位数组 nums 的长度;遍历数组占用 O(N) ,每轮中的常数个位运算操作占用 O(32×3×2)=O(1) 。
- 空间复杂度 O(1): 变量 ones, twos使用常数大小的额外空间。
class Method1{
public int singleNumber(int[] nums) {
int ones = 0, twos = 0;
for(int num : nums){
ones = ones ^ num & ~twos;
twos = twos ^ num & ~ones;
}
return ones;
}
}
方法二:遍历统计
此方法相对容易理解,但效率较低,总体推荐方法一。
使用 与运算 ,可获取二进制数字 num 的最右一位 n1:n1 = num & i
配合 无符号右移操作 ,可获取 num 所有位的值(即n1~ n32):num = num >>> 1
建立一个长度为 32 的数组 counts,通过以上方法可记录所有数字的各二进制位的 1 的出现次数。
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 位
}
}
将 counts 各元素对 3 求余,则结果为 “只出现一次的数字” 的各二进制位。
for(int i = 0; i < 32; i++) {
counts[i] %= 3; // 得到 只出现一次的数字 的第 (31 - i) 位
}
利用 左移操作 和 或运算 ,可将 counts 数组中各二进位的值恢复到数字 res 上(循环区间是i∈[0,31] )。
for(int i = 0; i < counts.length; i++) {
res <<= 1; // 左移 1 位
res |= counts[31 - i]; // 恢复第 i 位的值到 res
}
最终返回 res 即可。
复杂度分析:
- 时间复杂度 O(N): 其中N位数组nums的长度;遍历数组占用O(N) ,每轮中的常数个位运算操作占用 O(1) 。
- 空间复杂度 O(1): 数组 counts 长度恒为 32 ,占用常数大小的额外空间。
代码:
实际上,只需要修改求余数值 m ,即可实现解决 除了一个数字以外,其余数字都出现 m 次 的通用问题。
class Method2{
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;
}
}
总结:
时光如流水,逝者如斯夫!平时在做事的时候都感叹时间为什么过的如此之慢!可当闲下来的时候却发现时间却怎么也不够用!时间又过的太快!此生苦短,对酒当歌,人生几何?
想了很久未来的路!都说三十而立!三十岁之前都应该以事业为主!如果没有立业何来立家之说?成家立业应该倒过来,先立业再成家!钱钟书先生曾在《围城》一书中说到:“围城外的人想进去,围城内的人想出来”!他用简短的话解释了“婚姻”二字!的确,说句不好听的!谁不想结婚?可现实却迫使我们必须得很优秀了之后才有资格去谈婚论嫁!不是我们不想,而是真的被现实打压怕了!于是便出现了各种各样的骚操作!
比如:先上车,后补票的说法!当然,希望是真爱哈~~~!如果不是那就真的太悲催了!还不如单身一人自由自在多好!它不香吗?重点是一个人没有羁绊!无忧无虑,无拘无束!
“婚姻”说简单也简单!就是柴米油盐酱醋茶,真心相爱不负他!“婚姻”说难也很难!就是五味杂陈辛酸泪,海王绿茶真爱废!
先立业吧!立不了业什么也不是!只能活在底层世界当中!
最后,愿我们都能在各行各业中能够取得不同的成就,不负亲人、朋友、老师、长辈和国家的期望!能够用自身的所学知识为国家贡献出自己的一份力量!一起加油!
2021年5月17日夜