五分钟带你领略: 位运算操作之美

3,239 阅读3分钟

今天在火车站候车,实在无聊,不如把之前领略过的算法复个盘。先亮出题目:

这一题本身并不难,难的是进一步的优化。直接AC过去简单,但能不能经得住更深入的提问,非常考验面试者的数学功底和科班素养。简而言之,看你到底懂不懂计算机的二进制操作。

思路1: 哈希表遍历一遍+找出值为1的键

/**
 * @param {number[]} nums
 * @return {number[]}
 */
var singleNumber = function(nums) {
    let hash = {};
    let res = [];
    for(let i = 0; i < nums.length; i++) {
      if(hash[nums[i]] !== undefined) {
        hash[nums[i]]++;
      }else {
        hash[nums[i]] = 1;
      }
    }
    Object.keys(hash).map(item => {
      if(hash[item] === 1) res.push(item);
    })
    return res;
};

事实上这个算法的时间复杂度是O(n),已经很优秀了。空间复杂度也是O(n),有没有优化的余地呢?

有,而且可以让空间复杂度降到O(1),也就是常数级别。

思路2: 所有数进行异或 + 分组

首先简单科普一下异或操作,相同数异或为0,相异则结果为1。

如 1 ^ 1 = 0, 0 ^ 1 = 1

那么由此可以推出一些结论:

  1. 任何数和0异或,结果一定为它本身。
  2. 两个相等的数异或,结果一定为0

因此,我们第一步是这样:

let sign = 0;
for(let i = 0; i < nums.length; i ++){
    sign ^= nums[i];
}

现在sign的结果是什么呢?如果只有一个不重复的数,那还好说,重复两次的数都抵消了,异或完的结果就是这个数。但是现在是两个数,应该如何来处理呢?

首先,有一点是非常确定的,那就是两个不重复数的二进制位一定有一位不同,也就是说异或完成的结果一定有一位是1。我们找出这一位,然后自己另外定义一个只有这一位是1、其它位都是0的数,用这个数和数组中每个数相与,如果相与结果为0,放到第一组,否则放到另外一组。每组的数字都全部进行异或,最后每组重复的数已经消掉,只剩下一个数,即不重复的数,这就把两个不重复的数分开了。

let n = 1;
let res = [0, 0];
while((n & sign) === 0){
    n <<= 1;//左移一位
}

for(let i = 0; i < nums.length; i++) {
    if((nums[i] & n) === 0) {
        res[0] ^= nums[i];
    }else {
        res[1] ^= nums[i];
    }
}
return res;

可以看到,这一次的空间复杂度彻底地降到了O(1)级别,已经非常优秀了。再次提交,性能也是有了质的飞跃。

不过,大家回过头反思我们寻找第一次遍历后寻找异或结果中1的位置这个过程,可能会有更直接、更本质的操作,那就是:可以让它和它的相反数进行与操作。

因为计算机里面对于负数的存储是采用符号位置1,后面的位取反加一,这样会有一个规律:

一个正整数和它的相反数进行相与操作,结果中一定只含一个一,其他位位0,而且这个1的位置正好是这个正整数最后一个1的位置。

即上面求n的过程可以直接写为:

let n = sign & (-sign);

再次提交,依然AC。