我们一线大厂算法题,你会做吗?

作者 | 黄永灿

后端开发攻城狮,关注服务端技术与性能优化。

前言

一个数组中有 n 个数,除了这个数只出现一次之外所有的数都出现了三次,只循环一次找到这个数,例如数组 {1, 3, 4, 5, 2, 1, 5, 1, 2, 4, 2, 5, 4} 里面找出 3。

小试牛刀

咋一看,这道题目貌似出奇的简单,我们利用一个集合维护每个数字出现的次数,然后找到次数为 1 的就行了。但是题目里面还有一个条件:只循环一次,这个条件锁死了我们脑海中的很多思路,但它并非是不可以做到的。我们利用 HashMap 的 get、put 和 remove 方法时间复杂度都是 O(1) 的特性,用 HashMap 记录每个数字出现的次数,可以设计出如下满足要求的算法。

public int findNumber(int[] nums) {
    // 利用HashMap维护每个数字出现的次数
    Map<Integer, Integer> map = new HashMap();
    for (int num : nums) {
        int count = map.getOrDefault(num, 0);
        if (count == 2) {
            // 到达阈值移除这个数字
            map.remove(num);
        } else {
            map.put(num, count + 1);
        }
    }
    // 最后只会留下出现次数不等于3的数字
    return map.keySet().stream().findAny().orElse(-1);
}

当然,做算法题从来都不只是为了最低限度的达成目的,上面这种算法尽管做到了只循坏一次,但用到的空间复杂度却是 O(n),那么有没有更好的解决方案呢?答案是肯定的。

另辟蹊径

在讲更优的算法前,我们先放宽一下题目条件,把只循环一次的要求改为时间复杂度 O(n) 就行,然后我们用另外一种思路来解一下这道题目。

我们利用一个长度为 32 的 int 数组 counts,记录数组 nums 中每个数字的各个二进制位出现 1 的次数,例如数字 3 的二进制是 0011,那么我们就把数组 counts 的第 0 位和第 1 位的值分别加 1,数组循环完加完后,再把数组 counts 里的每个元素对 3 取余数,剩下的便是唯一数的二进制格式,然后把二级制转换成十进制便可以得到最终数字。

public int findNumber(int[] nums) {
    // 利用数组记录每个数字的每个二进制位值之和
    int[] counts = new int[32];
    for(int num : nums) {
    	// 记录各个二级制位出现1的次数
        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;
}

其运转过程如下:

我们可以发现,这种算法的时间复杂度为 32 * n + 32 ,尽管依旧是 O(n),但其基数却非常庞大,同样空间复杂度尽管是 O(1),但长度为 32 的 int 数组显然不能算小,那么我们可不可以在这种思路上继续优化呢?

不破不立

上面这种解法的本质是利用一个数组 counts 记录 nums 里面的数字的每个 bit 位出现的次数,而我们可以看到,实际上 counts 数组里面存储 3 及 3 以上的数字其实是没有意义的,因为最终都是要对 3 取余数,那么我们可不可以设计一种算法,让里面的数字实现类似于三进制的运算,到 3 归零,而不是一直加下去。

很可惜,现有的计算机体系里面并没有能直接支持三进制运算的基本数据结构,但这并不妨碍我们去间接的实现三进制运算。二进制的基本单位是 bit,一个 bit 只能实现二进制运算,而用两个 bit 组合起来便能实现四进制的运算,我们只需要对两个 bit 的进位规则稍加改造,便能实现三进制运算。

我们用第一个 bit 记录出现 1 次的 bit 位,用第二个 bit 记录出现 2 次的 bit 位,当 bit 位出现第 2 次的时候,将第一个 bit 归零,第二个 bit 置为 1,当 bit 位出现第 3 次的时候,我们不去动第一个 bit 位,而是直接将第二个 bit 归零。也就是说不会出现 bit 1 和 bit 2 同时为 1 的情况,或者说当 bit 1 和 bit 2 同时为 1 时,则两个 bit 都需要归零。


初始值出现 1 次出现 2 次出现 3 次
bit 10100
bit 20010

同样,一个长度为 32 的 bit 数组不就是 int 么,既然每个 bit 位需要用两个 bit 来实现三进制运算,那么我们就用两个 int 数字 once 和 twice 来实现,它们分别记录各个 bit 位出现一次和两次的情况。

好了,到这里为止,我们大致的思路就讲完了,那么我们该如何用代码实现这样一个三进制的 bit 运算呢?我相信大家应该都听过这样一句话,做算法题理解其大概的思路就行了,不必深究细节。这句话我只赞同 50%,思路固然是算法题很重要的一部分,但是如果只有思路,却不知如何实现的话,也只能算是一知半解。

柳暗花明

首先,在二进制中如果我们想实现类似的 bit 归零,我们会用到异或操作 ^,它的基本定义是:

恒等律:

0 ^ X = X

归零律:

X ^ X = 0

PS:0 ^ 0 = 0 1 ^ 1 = 0

交换律:

A ^ B = B ^ A

结合律:

A ^ B ^ C = A ^ (B ^ C) = (A ^ B) ^ C

根据恒等律、归零律和结合律,我们可以得到

A ^ B ^ B = A ^ 0 = A

如果我们上面的问题改一下,改成除了这个数之外,其它数字都出现两次,我们可以利用上面的特性,很轻松的解决这个问题,只需要把所有数字异或 ^ 一下就能得到结果。

A ^ B ^ B ^ C ^ C ^ D ^ D ... = A ^ 0 ^ 0 ^ 0 ... = A

而在三进制中,我们同样可以用异或操作实现 once 的进位和归零操作,那么我们该如何实现 once 到 twice 的进位操作呢?答案就是拿 nums 里面的数字和 once 做与 & 操作,剩下的还是 1 的 bit 位即代表是要向 twice 进的 bit 位。由此便可以得到我们的初步实现,为了方便大家理解,我们先借助一个变量 threeTimes 用来记录每一轮循环中完成出现 3 次并归零的 bit 位。

public static int findNumber(int[] nums) {
    int once = 0; // 记录出现过1次的bits
    int twice = 0; // 记录出现过2次的bits
    int threeTimes; // 记录每轮循环出现3次并归零的bits
    for (int num : nums) {
        twice |= once & num; // 1.在更新once前面更新twice
        once ^= num; // 2.更新once
        threeTimes = once & twice; // 3.once和twice中都为1的bits即为出现了3次的bits
        once &= ~threeTimes; // 4.抹去once中出现了3次的bits
        twice &= ~threeTimes; // 5.抹去twice中出现3次的bits
    }
    return once;
}

第一步,之所以要在更新 once 之前更新 twice,是为了得到 once 需要向 twice 的进位 once & num 的值,如果先更新 once,那么我们就无法得到要进位的 bits。其次为什么更新 twice 值用或 | 而不是用异或 ^ 呢?用异或操作确实可以得到 twice 在这一轮循环的最终值,但是为了后面方便得到 threeTimes 的值,这里先用或操作保留需要进位归零的 bits。

第二步,没啥好说的,得到 twice 的值后直接用异或 ^ 操作更新 once,但需要注意,这里得到的 once 还不是这轮循环的最终值。

第三步,计算得出 threeTimes 的值,前面我们提到了,三进制运算中不应该出现相同位置的两个 bit 值都为 1,当 bit 1 和 bit 2 同时为 1 时,则代表这个 bit 位出现了 3 次,两个 bit 位都需要归零。我们把 once 和 twice 做与 & 操作,便可以得到出现 3 次的 bits。

第四步,抹去 once 中出现 3 次的 bit 位,也即是上一步所说的两个 bit 的归零操作,将 threeTimes 按位取反 ~ 操作得到未出现 3 次的 bits,再和 once 做与 & 运算,即得到最终的 once。

第五步,抹去 twice 中出现 3 次的 bit 位,原理同上。

其运转过程如下:

总结

至此,这道看似很简单的算法题才算是给出了令人满意的答案,这也是很多一线大厂近期非常喜欢出的一道算法题,要求做到仅循环一次,空间复杂度 O(1),但以上答案就是终点了么?很显然并不是,我们可以看到实际上上面的运算过程是有很大的优化空间的,threeTimes 的存在也是可以被化简掉的,感兴趣的同学可以去试着化简一下。

全文完


以下文章您可能也会感兴趣:

我们正在招聘 Java 工程师,欢迎有兴趣的同学投递简历到 rd-hr@xingren.com 。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值