位运算 的探究

作者: | 更新日期:

给学弟出了一道题, 告诉你n个数, 其中只有一个数出现一次, 其他的数都出现三次.求出现一次的那个数.

本文首发于公众号:天空的代码世界,微信号:tiankonguse

前言

PS:更新时间 2014-10-22.

由于有人没看明白起初的O(n)操作, 所以这里就先简单的介绍一下 O(n log(n))的做法.
我这里添加上基本的做法, 然后把O(n)做法的分析写的更详细了一个, 明白的人可能看起来比较啰嗦, 抱歉了.
另外有人问假设是大部分都是4个相同, 只有一个是2个相同怎么做, 这个问题我们可以称为4-2问题.
这样的话这篇文章主要讲解的就是3-1问题了.

为了不偏离主题, 我重新写一篇文章专门讨论 n-m 问题.
如果你不想看基本做法, 可以直接跳到 问题的来源 的位置, 向下滚动的时候, 应该可以看到有个目录, 点击 问题的来源 即可.

基本做法

最暴力的方法

最暴力的方法就是排序了.
可以选择的排序有 快速排序, 基数排序等吧, 都是 O(n log(n)) 的算法.
声明基数排序看起来是 O(32n) 的复杂度, 其实就是 O(n log(n)) 的复杂度.
32位整数最大为 2\^32 , log(2\^32) = 32 log(2), 因此 32 就可以近似理解为 log 级别的了.
排序后我们就顺序统计了, 遇到不是三个连续的了, 就找到答案了.

聪明的位运算做法

由于只有32位, 我们可以开一个32位的数组, 然后遍历所有的数字时, 数字每一位都加到数组对应的位置中, 然后模3.
这样最后数组中肯定都是01,, 这些01组成的数字就是答案了.
这个做法看起来很巧妙的样子, 而且可以解决那种 n-m 问题(n>m),只需要模n即可.
但是复杂度实际上还是没有什么改进, 依旧是 O(n log(n)) 的复杂度.

问题的来源

学弟发给我一个代码, 第一眼竟然没看明白.

int run(int n, int* A) {
    int ones = 0;// 出现一次的标志位
    int twos = 0;// 出现第二次标志位
    for(int i = 0; i < n; i++) {
        ones = (ones ^ A[i]) & ~twos;// 第二次出现的去掉, 第一次出现的加上, 第三次出现的不变
        twos = (twos ^ A[i]) & ~ones;// 第一次出现的不变, 第二次出现的加上, 第三次出现的去掉
    }
    return ones;
}

然后想到, 可能有位运算的一些规律, 比如分配率, 结合律, 交换律等.

异或的一些共公式

a op b

a | b = b | a
a & b = b & a
a ^ b = b ^ a
~~a = a
~(a & b) = (~a) | (~b)
~(a | b) = (~a) & (~b)
~(a ^ b) = (~a) ^ b = a ^ (~b) = ~((~a) ^ (~b))

a op ( b op c)

a & (b | c) = (a & b) | (a & c)
a & (b ^ c) = (a & b) ^ (a & c)
a | (b & c) = (a | b) & (a | c)
a | (b ^ c) = (a | b) ^ (~a & c)
a ^ (b | c) = ?
a ^ (b & c) = ?
(a | (b & c )) & ~(b ^ c) = ?

a op ( a op c)

a b a ^ (a | b) a ^ (a&b) a | (a ^ b) a | (a&b) a&(a | b) a&(a ^ b)
0 0 0 0 0 0 0 0
1 0 0 1 1 1 1 1
0 1 1 0 1 0 0 0
1 1 0 0 1 1 1 0
a ^ (a | b) = ~a & b
a ^ (a & b) = a & ~b
a | (a ^ b) = a | b
a | (a & b) = a
a & (a ^ b) = a ^ (a & b)  = a & ~b
a & (a | b) = a

高级推导公式

a b (a ^ b)&(a&b) ~(a ^ b)&(a&b) ~(a ^ b)|(a&b)
0 0 0 0 1
1 0 0 0 0
0 1 0 0 0
1 1 0 1 1
a & ~a = 0
(a ^ b) & (a & b) = (a ^ b) & a & b = 0
~(a ^ b) & (a & b ) = a & b
~(a ^ b) | (a & b ) = ~(a ^ b)

代码的研究

我对异或的那些公式推导了一番, 也没推导出来什么.
后来学弟告诉我原理是:

原理

  • 默认one,two都是0, 即任何数字都不存在
  • 数字a第一次来的时候, one标记a存在, two不变
  • 数字a第二次来的时候, one标记a不存在, two标记a存在
  • 数字a第三次来的时候, one不变, two标记a不存在

分析

由于一直是异或, 没有左移或右移, 所以我们可以看成n个数字每一位每一位做了某些位操作而得到答案的.

只看一位后发现问题突然简单了.

因为只看一位的话, 就只有0和1了.

我们先不看只出现一次的那个数, 其他数字合起来就是每一位都出现了 3n 次0 和 3m 次1.

加上只出现一次的那个数, 就是告诉你若干个0,1. 其中有一个数字是 3n+1 个, 另外一个是3m个.

由于默认值是0, 所以我们只需要对1操作, 即操作所有数后, 剩下的是1答案就是1, 剩下的是0答案就是0.

注:假设1是3m个, 0是3n+1, 则操作完1后, 1刚好消去, 答案刚好是0了.
假设0是3m个, 1是3n+1, 则操作完1后, 1还剩一个, 答案也应该是1.

下面我看先看一下原理对应的图表.

实际上就是一个状态机,从(0,0)出发, 相同状态作用与0和1.

a one1 two1 one2 two2
0 0 0 0 0
1 0 0 1 0
0 1 0 1 0
1 1 0 0 1
0 0 1 0 1
1 0 1 0 0

实际上可以看出, 处于0, 不影响one和two的值.因为假设1有3个, 操作完后就是0, 答案就是0, 所以对于0我们只需要什么都不做.

one 的转移推导

下面我们先看 one 的值是怎么转移的

a one1 a ^ one1 one2
0 0 0 0
1 0 1 1
0 1 1 1
1 1 0 0
0 0 0 0
1 0 1 0

我们可以看到, a ^ one1 后, 只有最后一个和 one2 不一样.本来为 0 的值却为 1 了.

所以我们需和一个数字进行 一种操作, 把最后那个 1 变为 0, 而且其他行的值应该保持不变.

那我们现在有哪些已知的值呢?

比较简洁的值有下面几个.

a ~a one1 ~one1 two1 ~two1 a \^ one1 one2
0 1 0 1 0 1 0 0
1 0 0 1 0 1 1 1
0 1 1 0 0 1 1 1
1 0 1 0 0 1 0 0
0 1 0 1 1 0 0 0
1 0 0 1 1 0 1 0

可以看到, 比较简洁的候选人有 a, ~a, one1, ~one1, two1, ~two1.

我们要选择一个, 和 a ^ one1 进行一种操作, 来得到 one2.

比较简洁的操作有 , & , ^ 这三种.

进过大量的尝试, 我们可以发现 ~two0候选人 和 &操作获得胜利, 成功的得到 one2.

于是我们得到第一个 one 转移的公式

one2 = (a ^ one1) & ~two1;

two 的转移推导

接下来我们再看看 two 的值是怎么转移的.

a two1 a ^ two1 two2
0 0 0 0
1 0 1 0
0 0 0 0
1 0 1 1
0 1 1 1
1 1 0 0

我们惊奇的发现, a ^ two 后, 也是只有一位和 two2 不同, 而且也是本来该是 0 的却变为 1 了.

我们改进找出候选人(a, ~a, one1, ~one1, two1, ~two1, one2, ~one2)和候选操作( , &, ^).
a ~a one1 ~one1 one2 ~one2 two1 ~two1 a ^ two1 two2
0 1 0 1 0 1 0 1 0 0
1 0 0 1 1 0 0 1 1 0
0 1 1 0 1 0 0 1 0 0
1 0 1 0 0 1 0 1 1 1
0 1 0 1 0 1 1 0 1 1
1 0 0 1 0 1 1 0 0 0

由于第一个是 & 操作找到的, 我们这次当然先看 & 操作了.

a ^ two1 是 0 的那些行不用看了, 因为 0 任何数都是0.

我们只需要看 a ^ two1 是 1 的那些行.

对于第二行, 我们需要找出是0的列, 对于其他的行, 我们需要找到是1的列, 这样才能得到 two2.

第二行, 是 0 的列有 ~a, one1, ~one2, two1, 这些成员临时入选.
第四行, 临时候选列中 有 1 的列有 one1, ~one2, 这些成员再次临时入选, 竞争很激烈, 每次减半, 相信下次就没有了.
第五行, 临时候选中由 1 的列只有 ~one2 了, 进入了临时候选.
最后, 由于所有的都遍历完了, ~one2 获胜, 于是公式找到了.

two2 = (a ^ two1) & ~one2;

综合分析

上面的两个式子已经很简洁了.

one2 = (a ^ one1) & ~two1;
two2 = (a ^ two1) & ~one2;
one1 = one2;
two1 = two2;

发现我们不需要 one2 和 two 这两个变量了, 于是公式简化为

one = (a ^ one) & ~two;
two = (a ^ two) & ~one;

此时, 我们就得到了和最开始写的那个公式一样的结论了.

java代码的答案

其实, 最开始学弟给我的程序不是这个简洁的cpp写的程序, 而是一个java写的程序.

我之所以推导异或的式子也是因为下面的程序.

看下面的最开始的程序.

public int singleNumber(int[] A) {
    if(A.length == 1) return A[0];
    // A[0] is correct to start
    // Take care of processing A[1]
    A[0] ^= A[1];
    // Set A[1] to either 0 or itself
    A[1] = (A[0]^A[1])&A[1];// => A[1] = A[0] & A[1];

    // Continue with algorithm as normal
    for(int i = 2; i < A.length; i++){
        A[1] |= A[0]&A[i];
        A[0] ^= A[i];
        A[2] = ~(A[0]&A[1]);
        A[0] &= A[2];
        A[1] &= A[2];
    }
    return A[0];
}

由于程序对前两个数特判了, 我们可以用两个变量替换, 这样就可以从0开始循环了.

public int singleNumber(int[] A) {
    int one = 0, two=0, tmp;
    for(int i = 0; i < A.length; i++){
        two = two | (one & A[i]);
        one = one ^A[i];
        tmp = ~(one & two);
        one = one &tmp;
        two = two &tmp;
    }
    return A[0];
}

看起来好复杂的样子, 我给他再合并一下.

public int singleNumber(int[] A) {
    int one = 0, two=0, tmp;
    for(int i = 0; i < A.length; i++){
        tmp = ~((one ^A[i]) & (two | (one & A[i])));
        two = (two | (one & A[i])) & tmp;
        one = (one ^A[i]) & tmp;
    }
    return A[0];
}

看着还是好复杂的样子, 这时我上面的那些异或公式就派上用场了.

可以先对 tmp 展开.

tmp =  ~((one ^ a) & (two | (one & a)))
    => ~( ((one ^ a) & two) | ((one ^ a) & (one & a)) ) // 按 a & (b | c) = (a & b) | (a & c) 展开
    => ~ ((one ^ a) & two) //(one ^ a) & (one & a) 恒等于 0
    => ~(one ^ a) | ~two

然后对 two 展开

tmp = ~(one ^ a) | ~two

one2 = (one ^ a) & tmp
    = (one ^ a) & (~(one ^ a) | ~two)
    = ((one ^ a) & ~(one ^ a)) | ((one ^ a) & ~two)
    = (one ^ a) & ~two
    

two2 = (two | (one & a)) & tmp
    = (two & tmp) | ((one & a) & tmp )
    = (two & (~(one ^ a) | ~two)) | ((one & a) & tmp )
    = (two & ~(one ^ a)) | (two & ~two) | ((one & a) & tmp )
    = (two & ~(one ^ a)) | ((one & a) & tmp ) // (two & ~two) 恒等于 0
    = (two & ~(one ^ a)) | ((one & a )& (~(one ^ a) | ~two) )
    = (two & ~(one ^ a)) | (((one & a ) & ~(one ^ a)) | ((one & a ) & ~two)) 
    = (two & ~(one ^ a)) | ((one & a ) | ((one & a ) & ~two) ) 
    = (two & ~(one ^ a)) | (one & a )
    = (two | (one & a )) & (~(one ^ a) | (one & a ))
    = (two | (one & a )) & ~(one ^ a)

化简到哪里就化简不动了, 但是经过分析, 发现对于 one, a 和 two.

  • one 和 a 同时为0的时候, two2=two, one2=0
  • one 和 a 同时为1的时候, two2=1, one2=0
  • one 和 a 不同时, two2 = 0, one2 = ~two

此时再想想那原理

  • one 和 a 同时为0, one和two的值不变, 因为 a=0.
  • one 和 a 同时为1, 说明是第二个1. two应该标记为1, one标记为0
  • 如果a为0, 则one为1, two肯定为0, ~two还是1, 即保持不变
  • 如果a为1, 则one为0, 此时two可能是0或1.
    • 如果two为0, 这时来一个1, 应该标记one=1, 即这是第一个1.
    • 如果 two 为1, 这时来一个1, 说明是第三个1, 则one=two=0.

java 代码为什么这么复杂

前面我们说原理了, 也就是那张表.

有了表, 我们只要通过某些公式得到那种表就行了.

但是我们怎么样才能找到那些公式就是一个问题了, 那个cpp代码找到了一种简洁的公式, 但是这个java代码就没有那么幸运, 找到这么复杂的公式.

经过我的化简, one 的求值已经简化到和cpp的one一样的求值公式了.

但是java的第二个就没有那么幸运了, 化简到相对最简时还是有些复杂.

当然java的第二个公式可能还可以化简, 但是我时间有限, 没有时间去化简, 所以读者可以自己尝试化简一下.

其他方法

有了那个原理的思路, 我们可以用另一种思路来试试.

  • 来第一个1时 one 标记为1
  • 来第二个1时 two 标志为1
  • 来第三个1时, one和two重置为0

此时状态转移表是

a one1 two1 one2 two2
0 0 0 0 0
1 0 0 1 0
0 1 0 1 0
1 1 0 1 1
0 1 1 1 1
1 1 1 0 0

这时使用异或后有什么发现呢?

a one1 ~one1 two1 ~two1 a \^ one1 a \^ two1 one2 two2
0 0 1 0 1 0 0 0 0
1 0 1 0 1 1 1 1 0
0 1 0 0 1 1 0 1 0
1 1 0 0 1 0 1 1 1
0 1 0 1 0 1 1 1 1
1 1 0 1 0 0 0 0 0

这个公式用上面的方法很容易推导的, 大家可以推导一下.

大家自己可以想想, 方法多多, 关键在于你怎么去想.

本文首发于公众号:天空的代码世界,微信号:tiankonguse
如果你想留言,可以在微信里面关注公众号进行留言。

关注公众号,接收最新消息

tiankonguse +
穿越