【解题笔记】LeetCode 只出现过一次的数字

传送门: 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};
    }

        浅浅总结一下。这三道题都是在用位运算处理数组问题,第一题利用了异或的性质;第二题建立在对位运算的深入分析上,需要对位运算相当的了解;第三题是分类的思想,需要我们根据位的区别,恰当地将所有数分成两类。

        可以说,这三道题不仅有趣,而且都是很有挑战性的。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值