传送门: 136. 只出现一次的数字 - 力扣(LeetCode)
137. 只出现一次的数字 II - 力扣(LeetCode)
260. 只出现一次的数字 III - 力扣(LeetCode)
先来看第一个问题:
在一个数组中,除了某个数字只出现一次,其余数字均出现两次,要求返回出现一次的数字
如果只看这个问题,那应该十分简单:我们只需要记录各个元素出现的次数即可。但本题还给出了额外的限制:空间复杂度为O(1),时间复杂度为O(n)。
这就有点麻烦了:我们不能记录各个元素出现的次数了:因为这必定需要额外空间。
那应该怎么办呢?
实际上,这就是个脑筋急转弯,我们也可以将其看做一种特定的技巧。我们考虑异或的性质:a ^ a = 0。相等的元素异或会为0,会对消掉!那这道题就迎刃而解,我们只需要把所有元素全部异或一遍,出现两次的数字两两对消,最后的结果就是答案。
代码很简单:
int singleNumber(vector<int>& nums) {
int ret = 0;
for (auto e: nums) ret ^= e;
return ret;
}
虽然很简单,但是没有见过类似的思路,恐怕不能轻易解决。力扣给了个简单分级,可能也是考虑到代码太短了吧(笑
第二个问题在第一个问题的基础上做了小小的修改:出现两次的数字变成了出现三次。
由于我们上一道题的思路是建立在一个脑筋急转弯上的,所以它并不能自然的迁移到第二个问题上:因为异或三个数可对消不了。
但常规的思路依然解决不了这个问题。第一道题真正给我们的经验是:我们应该尝试进行位运算。但这一次我们不能通过异或运算那么方便的解决问题,我们应该以位运算为切入点,展开更加深入的分析。
一个(或许)自然的想法是:既然是位运算,我们应该拆分到数字的各个位数上去观察问题。接下来是一个重要的事实:我们如果把数字的各个位数分别累加,最后得到一个数组 sum[32],考虑到若干的数出现三次的事实,那么sum中的元素对三取余,那些出现三次的数会被消除,只剩下我们的目标数字的各个位数!
其实到这里,我们已经可以写出代码了:
int singleNumber(vector<int>& nums) {
vector<int> sum(32);
for(int num : nums) {
unsigned newNum = num;
for(int j = 0; j < 32; j++) {
sum[j] += newNum & 1;
newNum >>= 1;
}
}
int ret = 0, m = 3;
for(int i = 0; i < 32; i++) {
ret <<= 1;
ret |= sum[31 - i] % m;
}
return ret;
}
但各位一定是精益求精的,对简洁而优美的代码有着极高的追求!
总之,我们不满于现状,还想要进一步改进我们的解法。
我们怎么改进呢?我们想要写出像第一问那样的,甚至不需要额外储存空间的,只需要遍历一遍数组就能够解决的方法。想要提出这种方法,我们需要进一步的观察:
一个关键(但也许并不显然)的点是:模3取余,其实是这样一个过程:
0 -> 1 -> 2 -> 0 _> ...... 也就是 00 ->01 ->10 -> 00 -> .......(2之大,一位装不下!)
我们记第一位为one,第二位为two。
当num的当前位为1,我们需要更新。if (one == 1 && two == 0) || (two == 1) one可以更新为0 否则更新为1;当num的当前位为0,我们不需要更新,保持原样即可。
我们尝试更加简练的写法,为此,我们用ones, twos记录各位上的情况。
考察真值表,我们写出更加简练的形式:
ones = ones ^ num & ~twos
这可以很容易被验证。
同样的,twos = (twos ^ num) & (ones | twos) 注意:此处的ones是更新前的ones
我们需要一个额外变量tmp来保存ones更新前的值。
但其实我们也可以省去这个额外变量,仍然考察真值表,不过这一次ones是更新后的:
twos = twos ^ num & ~ones
巧了,正好一样!
注意到最后的结果中一定没有2,因为最后的结果只出现了一次,我们最后返回ones即可。
现在,我们可以写出非常漂亮的代码了:
int singleNumber(vector<int>& nums) {
int ones = 0, twos = 0;
for (int num: nums) {
ones = ones ^ num & ~twos;
twos = twos ^ num & ~ones;
}
return ones;
}
如果你能看到这里,我深感荣幸和感激,希望你能继续看完第三题!
第三题相对第一题,发生了一个变化,现在,只出现一次的数,变成了两个!
比如 [2, 2, 3, 4, 4, 5]。
天啊,这又破坏了第二题里提供的方法:只出现一次的这两个数很可能在某个位上重合,这样对2取模就取没了,不行!
直接异或呢?这只会得到两个数的异或结果,怎么能将结果还原回两个数呢?
3 ^ 5 ^ 3= 5
3 ^ 5 ^ 5 = 3
得到3,你需要知道5;得到5,你需要知道3。
这有点棘手。但如果你自认有几分聪明,那你应该还能找到灵感:为什么我们不能把所有数都分成两类,一类包含5,一类包含3呢?
要寻求这两类数的划分,我们可以观察异或的结果:我们从低到高看,第一个1,就代表着两者二进制表示中的一个不一样的数,用这个数作为标志,我们就可以把全部的数划分成两类。
为什么要从低到高看呢?因为有一个为人熟知的技巧,可以取出最低位的1:
x & (-x)
至于为什么,这是因为 -x = ~x + 1,接下来的事情是显然的。
我们将每一类的元素全部异或起来,就分别给出两个代求结果了!
代码如下:
vector<int> singleNumber(vector<int>& nums) {
int x = 0;
for (int num: nums) {
x ^= num;
}
int l = (x == Int_MIN? x: x & (-x));
int a = 0, b = 0;
for (int num: nums) {
if ((num & l) != 0) {
a ^= num;
} else {
b ^= num;
}
}
return {a, b};
}
浅浅总结一下。这三道题都是在用位运算处理数组问题,第一题利用了异或的性质;第二题建立在对位运算的深入分析上,需要对位运算相当的了解;第三题是分类的思想,需要我们根据位的区别,恰当地将所有数分成两类。
可以说,这三道题不仅有趣,而且都是很有挑战性的。