位运算 的探究

由于有人没看明白起初的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)) 的复杂度.

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

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

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

 
 
  1. a | b = b | a
  2. a & b = b & a
  3. a ^ b = b ^ a
  4. ~~a = a
  5. ~(a & b) = (~a) | (~b)
  6. ~(a | b) = (~a) & (~b)
  7. ~(a ^ b) = (~a) ^ b = a ^ (~b) = ~((~a) ^ (~b))
 
 
  1. a & (b | c) = (a & b) | (a & c)
  2. a & (b ^ c) = (a & b) ^ (a & c)
  3. a | (b & c) = (a | b) & (a | c)
  4. a | (b ^ c) = (a | b) ^ (~a & c)
  5. a ^ (b | c) = ?
  6. a ^ (b & c) = ?
  7. (a | (b & c )) & ~(b ^ 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
 
 
  1. a ^ (a | b) = ~a & b
  2. a ^ (a & b) = a & ~b
  3. a | (a ^ b) = a | b
  4. a | (a & b) = a
  5. a & (a ^ b) = a ^ (a & b) = a & ~b
  6. 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
 
 
  1. a & ~a = 0
  2. (a ^ b) & (a & b) = (a ^ b) & a & b = 0
  3. ~(a ^ b) & (a & b ) = a & b
  4. ~(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 的值是怎么转移的

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 转移的公式

 
 
  1. one2 = (a ^ one1) & ~two1;

接下来我们再看看 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 获胜, 于是公式找到了.

 
 
  1. two2 = (a ^ two1) & ~one2;

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

 
 
  1. one2 = (a ^ one1) & ~two1;
  2. two2 = (a ^ two1) & ~one2;
  3. one1 = one2
  4. two1 = two2

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

 
 
  1. one = (a ^ one) & ~two;
  2. two = (a ^ two) & ~one;

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

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

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

看下面的最开始的程序.

 
 
  1. public int singleNumber(int[] A) {
  2. if(A.length == 1) return A[0];
  3. // A[0] is correct to start
  4. // Take care of processing A[1]
  5. A[0] ^= A[1];
  6. // Set A[1] to either 0 or itself
  7. A[1] = (A[0]^A[1])&A[1];// => A[1] = A[0] & A[1];
  8. // Continue with algorithm as normal
  9. for(int i = 2; i < A.length; i++){
  10. A[1] |= A[0]&A[i];
  11. A[0] ^= A[i];
  12. A[2] = ~(A[0]&A[1]);
  13. A[0] &= A[2];
  14. A[1] &= A[2];
  15. }
  16. return A[0];
  17. }

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

 
 
  1. public int singleNumber(int[] A) {
  2. int one = 0, two=0, tmp;
  3. for(int i = 0; i < A.length; i++){
  4. two = two | (one & A[i]);
  5. one = one ^A[i];
  6. tmp = ~(one & two);
  7. one = one &tmp;
  8. two = two &tmp;
  9. }
  10. return A[0];
  11. }

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

 
 
  1. public int singleNumber(int[] A) {
  2. int one = 0, two=0, tmp;
  3. for(int i = 0; i < A.length; i++){
  4. tmp = ~((one ^A[i]) & (two | (one & A[i])));
  5. two = (two | (one & A[i])) & tmp;
  6. one = (one ^A[i]) & tmp;
  7. }
  8. return A[0];
  9. }

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

可以先对 tmp 展开.

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

然后对 two 展开

 
 
  1. tmp = ~(one ^ a) | ~two
  2. one2 = (one ^ a) & tmp
  3. = (one ^ a) & (~(one ^ a) | ~two)
  4. = ((one ^ a) & ~(one ^ a)) | ((one ^ a) & ~two)
  5. = (one ^ a) & ~two
  6. two2 = (two | (one & a)) & tmp
  7. = (two & tmp) | ((one & a) & tmp )
  8. = (two & (~(one ^ a) | ~two)) | ((one & a) & tmp )
  9. = (two & ~(one ^ a)) | (two & ~two) | ((one & a) & tmp )
  10. = (two & ~(one ^ a)) | ((one & a) & tmp ) // (two & ~two) 恒等于 0
  11. = (two & ~(one ^ a)) | ((one & a )& (~(one ^ a) | ~two) )
  12. = (two & ~(one ^ a)) | (((one & a ) & ~(one ^ a)) | ((one & a ) & ~two))
  13. = (two & ~(one ^ a)) | ((one & a ) | ((one & a ) & ~two) )
  14. = (two & ~(one ^ a)) | (one & a )
  15. = (two | (one & a )) & (~(one ^ a) | (one & a ))
  16. = (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.

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

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

但是我们怎么样才能找到那些公式就是一个问题了, 那个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

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

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

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值