状态机+位运算巧解算法,妙!太妙了!

217 阅读6分钟

剑指 offter2 中有这样一道算法题

  • 在一个数组 nums 中除一个数字只出现一次之外,其他数字都出现了三次。请找出那个只出现一次的数字。 这道题怎么解,最笨的方法遍历 + 哈希表
function singNumber(nums) {
    const numMap = new Map()
    for (const num of nums) {
        const count = numMap.get(num)
        if (count === undefined) {
            numMap.set(num, 1)
        } else {
            numMap.set(num, ++count)
        }
    }
    for (const [key, value] of numMap) {
        if (value === 1) {
            return key
        }
    }
}

在现在这么卷的大环境下,你要是在面试中还写这种代码,怕是面试官会让你直接回家等消息了[doge]

相信刷过 leetcode 上剑指 offter2 的小伙伴应该对 k神 很熟悉了,下面就来看看大佬是如何用状态机 + 位运算来解这道题的

首先来科普一下状态机

有限状态机(Finite-State Machine,FSM),简称状态机,是表示有限个状态以及在这些状态之间的转移和动作等行为的数学模型,具体点来说,它是一个有向图形,由一组节点和一组相应的转移函数组成。状态机通过响应一系列事件而“运行”。每个事件都在属于“当前” 节点的转移函数的控制范围内,其中函数的范围是节点的一个子集。函数返回“下一个”(也许是同一个)节点。这些节点中至少有一个必须是终态。当到达终态, 状态机停止。

可能上面的定义不太好懂,来举一个简单的例子看一下,就大概明白了。

灯泡只有两个状态,开和关,当它处于关的状态是,你给它一个开的指令,它就会变成开的状态,再给它一个关的的指令,它就会变成关的状态。

这就是一个状态机很简单的应用。当我们要设计一个状态机的时候,往往要先画出这个状态机的状态转移图,大概就是下面这个样子

image.png OK,说完状态机是什么,再来回到这个算法题。 来换个思路,重新考虑这道题,如果我们把所有数字都转成二进制来看,就会发现对于出现过三次的数字,把每个二进制位上的值都加起来,和一定会是 3 的倍数,那么我们把所有数字的二进制位上的值都加起来,再分别对 3 求余,那么结果就是只出现了一次的那个数字

 // [3, 3, 3, 5]
 //   0 0 1 1
 //   0 0 1 1
 //   0 0 1 1
 //   0 1 0 1
 // 每个位上都加起来
 //   0 1 3 4
 // 再分别 % 3
 //   0 1 0 1 = 5

接下来我们就来考虑如何用状态机的思路来解这道题

按照上面的计算过程,在计算某个二进制位的和的过程中,结果只有三个状态,即对 3 求余,结果为 0、1、2。这样我们就确定了了状态机的这一组状态了,然后我们再来看这三个状态是如何流转的。

这里的输入只有两个值 0 和 1,那么

  • 当输入 1 的时候,状态的变化过程就是 0 -> 1 -> 2 然后往复循环
  • 当输入 0 的时候,状态不变

因为二进制只有 0 和 1,所以这三种状态我们写成 00 01 10,现在就可以得到这个状态机的状态转移图了

image.png 这个状态转移过程可以用表格表示如下,n 为输入的值,也就是求和过程中的下一个值

ntwoone->twoone
00000
00101
01010
10001
10110
11000

根据这个状态转移图以及这个表格,可以写出来计算 one 的伪代码

 if two = 0
   if n = 0
      one = one
   if n = 1
      one = ~one
 if two = 1
   one = 0

然后我们来看如何用位运算来简化上面这个计算过程

先来回忆下位运算的性质,设某二进制位上的值为 x

// 异或运算
x ^ 0 = x  
// 0 ^ 0 = 0  1 ^ 0 = 1 所以还是 x
x ^ 1 = ~x 
// 1 ^ 1 = 0  0 ^ 1 = 1 所以结果是 ~x
// 与运算
x & 0 = 0  
// 不管是 0 还是 1,与 0 都是 0
x & 1 = x 
// 0 & 1 = 1  1 & 1 = 1, 所以还是 x

首先用异或运算来简化上述计算过程

 if two = 0
   one = one ^ n
 if two = 1
   one = 0

然后再用与运算在上面这个基础上接着简化

one = one ^ n & ~two

以上就是 one 的计算过程,然后再看 two 的计算过程, two 的计算要在 one 的计算结果的基础上 先来看一下计算 one 时状态的变化前后对比,注意这里是只有 one 变了 two 还没变

 // change before
   00 01 10
 // chang  after
   01 00 10
 // 现在把 two 和 one 的位置交换
   10 00 01
 // 调换一下顺序
   00 01 10
 // 可以看到还是最开始的样子

现在每个状态中的 one 其实就是原来的 two,那我们现在来计算现在的 one 其实就是在计算原来的 two ,所以 two 的计算就可以复刻 one 的计算过程

two = two ^ n & ~one

以上所有是单独一个二进制位的计算过程,其他每一个二进制位的计算也都是一样的,所以我们可以直接遍历源数组,对每一个元素都应用以上这个计算步骤,遍历完之后,各二进制位的状态就都处于 00 或者 01 两者之间了(为什么只有这两个状态了,因为 10 那个状态是表示余数是 2 的,它只会在计算过程中出现),最后具体是哪个取决于那个只出现一次数字的各个二进制位上是 0 还是 1,可以看到现在这两个状态的 two 都是 0,所以我们就可以不看 two,只看 one,one 才是实际记录状态的,因此我们只返回 one 就可以了。

如果你对这个结论有疑惑,那我们可以来看一下最后 one 和 two 的结果值,还是看 [3,3,3,5] 这个 case, 最后 one 的值是 0101,two 的值是 0000,按照原先说的用 one 和 two 把最终每个二进制位上的状态拼起来就是00 01 00 01,可以看到就是只有 0001 两个状态,这两个状态原先表示的值就是 00 -> 0, 01 -> 1,所以最后的结果值就是 0101,你看,这不就是 one 最后的值嘛。

OK,最后给出代码

function singNumber(nums) {
    let one = 0, two = 0
    for (const num of nums) {
        one = one ^ num & ~two
        two = two ^ num & ~one
    }
    return one
}

原题链接:leetcode.cn/problems/sh…