位运算 | (二)位运算常见技巧及讲解

前言

上一节里我们介绍了位运算的几个基本运算符以及运算规则,在本节将会结合具体案例来讲解位运算的一些常见使用技巧及应用场景。为了让介绍更加有条理,本文将按照与(&)、或(|)、异或(^)、取反(~)以及位移运算操作的顺序,来分别介绍对应运算的常见使用技巧。对于某些技巧,如果需要使用多个运算符结合,则会靠后讲解,此外本文中针对某个数的位数均从0开始。

技巧总结

  1. &的常见技巧

    我们知道,&运算只有在运算的两个数位都为1时,才为1。所以这个特点也正是&的一些技巧核心(多通过一个魔法数用于将数字的某些位置0)。例如将一个数x的第2位置0,只需要将x和0xffff fffb进行&运算即可(b对应1011)。下面就来具体介绍几个与&有关的技巧:

    • 一般来说,我们判断一个数的奇偶,都是通过x % 2的结果进行判断,其实我们也可以通过x & 1的结果来判断,由上文,我们知道x & 1会将除了第0位,其它位全部置0。而奇数的二进制表示末位为1,偶数为0。故可以通过这种方式用于判断奇偶。

      特别注意,由于运算符优先级的原因,对于x & 1 == 1,会先运算1 == 1,所以对于x & 1 == 1,必须改为对于(x & 1) == 1。由于Java中不像C等语言可以使用if (x & 1) {}这样的语句。因此,其实在Java中还是直接使用x % 2 == 0来的方便。

    • 在第一点里我们讲了一个看起来不怎么实用的技巧,这次我们将一个稍微有用点的。我们都知道在ASCII表里,A - Z对应编码为65(0100 0001) - 90(0101 1010),而a - z对应编码为97(0110 0001) - 122(0111 1010)。仔细比较,我们可以发现,对于小写字母和大写字母的二进制表示,比如A和a,一个为0100 0001(A),一个为0110 1010(a),只有第5位有差别,因此我们只需要将第5位利用&置0,其它位保留,便可以很快将小写字母转为大写。而这个魔法数便是-33(0xffff ffdf),因此在Java中我们可以通过(char) (c & -33)这个语句,快速将一个字母c转为其对应的大写字母。既然有小写转大写,那么有大写转小写的方法吗?当然有啦,不过并不是使用&,而是使用|运算,下面很快就会讲到。

      上面我们讲了两个比较基础的技巧,下面就讲一个稍微复杂一些的技巧,同时也准备了相应的例题,以加深理解。

    • 首先,先说一个结论n & (n - 1)用于将n对应二进制位最右边的1去掉(对于二进制数位中不包含1的0,后面将不做讨论)。然后我们再来解释具体原因(为了方便,接下来的数以二进制形式表示):对于任意一个数,由于我们考虑的是将一个数的二进制数位的最后一个1置为0,因此我们我可将一个数n表示为xxx100这种形式(x代表任意0或者1,代表最有右边的1前面的数位,由于是数位中的最后的一个1,那边1后面也就一定跟着若干个0了(对于1而言,即1后面跟着0个0,但最终依然满足这个运算,这里我们就以2个0代表若干个0))。

      那么对于n(xxx100),n-1(xxx011),其运算结果,不用我说,大家也都知道是xxx 000,实现了将n(xxx 100)的最后一个1置为0的目标。这里只是做了简单证明,大家可以自己进行验证运算试试。下面我们就来根据这个技巧来讲一道例题(并不一定说是这道题的最好结果,但可以用于加深对这个技巧的理解):

      统计一个数中二进制位中1的个数,试着不使用Java等语言自带的bitCount方法试试看!

      当看到这题,我们很容易想到的方法就是类似下面这种代码:

      count = 0
      while n != 0:
          '''
          对二进制位的每一位进行判断
          为1则count加1,否则加0,
          总运算次数取决于n个最高位1所在的位置
          '''
          count += n & 1
          n >>= 1
      print(count)
      

      不过,试着想一想,怎么能够利用我们刚才所讲的方法呢?仔细考虑可以想到,刚才的技巧可以用于将一个数的最后一个数位置为0,那么每去掉最后一个1,接下来便可以去掉倒数第二个1…仔细考虑,这种方法的运算次数只需要数位中1的个数的次数,下面是代码实现:

      count = 0
      while n != 0:
          # 每次去掉n的二进制表示中的一个1
          n &= n - 1
          count += 1
      print(count)
      

      这个技巧的本质依然是将指定为置为0(即将一个数二进制数位中最后一个1置为0),所以和&有关的技巧的核心都是为了将某些数位置为0。下面还会讲到一个和~组合来实现置0操作的技巧,我们放到讲解~的时候再说。

  2. |的常见技巧

    由上文讨论&的技巧核心(将数位置0),相信聪明的各位一定知道,|的核心技巧便是利用|运算的特点,将某些数位置为1。还是举个例子,比如将一个数x的第3位置1,只需要将x和0x0000 0008进行|运算即可(8对应1000)。下面就介绍几个与这一核心有关的技巧:

    • 在第一点我们就先说上文提到的将大写字母转为小写字母的方法。由上面讨论大小写字母的二进制时提到的,每个小写字母与其大写字母只有第5位不同,小写字母第5位均为1,而大写字母均为0。因此,我们便可以通过|运算,将第5位置为1,即可将一个大写字母转为小写字母,这个魔法数便是32(0x0000 0040),因此在Java中我们可以通过(char) (c | 32)这个语句,快速将一个字母c转为其对应的大写字母。

      |运算当然不止这一个简单的常用技巧,不过为了平(qiang)衡(po)性(zheng),加上那个技巧也使用到了位移运算,虽然其核心是|,我还是将其放到位运算部分就行讲解了。

  3. ^的常见技巧

    ^运算符不像&|可以有一些常用的运算式,使用^的时候,大多都是利用其(两数相同则结果为0,否则非0)这个特性,下面就来讲两个小技巧,第一个还是有一定运用场景的:

    • 在讲第一个技巧之前,我们先看一道题:

      给定两个整数,判断两个数是否同号(即同正或者同负)。

      当我们第一眼看到题,很容易就想到下面两种代码:

      def f(a:int, b:int):
          return (a >= 0 and b >= 0) or (a < 0 and b < 0)
      
      def f(a:int, b:int):
       # 可能会溢出,并不正确
          return a * b >= 0
      

      而这道题,如果我们使用^,则不仅会让代码看起来特别简洁,还不需要担心乘法的溢出问题,对应的代码如下:

      def f(a:int, b:int):
          return (a ^ b) >= 0
      

      而原理也很简单,由于两个如果同号,那么最高符号位一定相同,经过异或后会变为0,那么异或结果就为非负数,故可以根据两数异或结果的正负来判断两数是否同号。

    • 第二个技巧则并不是那么实用,更多的是让人感觉有意思,相信很多人也都见到过,代码和解释如下:

      a ^= b // 原始的a变为了a ^ b
      b ^= a // 此时的b = (a ^ b) ^ b = a
      a ^= b // 当前a的值为a ^ b,b的值变为了a,故a = (a ^ b) ^ a = b
      

      第一次看上述代码和解释可能会觉得有些迷惑,但稍加思考就会发现,这个操作只是为交换两个数的值而已。这中方法在实际编码中并不建议使用,更多的拓展自己的思维方式。

  4. ~的常见技巧

    其实~的常用技巧可以说没有,唯一一个还不是作为主角,为了平衡一下,就放到这里讲了,相信看了上文已经知道,主角是谁了,对,就是&!先来回顾一下~的用处:用于将二进制数位中1转化为0,0转化为1,一个整数得到其对应的负数可以使用(~x + 1)。由于这个技巧其实也不是那么常用吧,我只是因为强迫症哈哈,所以先说结论:n & (~n + 1)可以用于将一个数的二进制位中除了最后一个1之外,其它数位均置为0,听起来是不是觉得有点懵,但是又感觉有点熟悉?这就对了,这个其实我们在&里最后一个例子,反过来的效果,那里是把一个数的最后一个1置为0,而这里这是保护这最后一个1,其它位都变为0!下面来解释原因(又使用到了之前的表示方法,对于0和1的情况,大家可以自己验证哟,为了效果名言,我们就把1后面的若干个0表述为2个0了):考虑n(xxx100),为了计算~n + 1,我们先计算~n,结果为zzz011这里的z代表和x相反),然后进行加1可以得到zzz100,因此n和~n + 1的计算结果就如下:

      x x x 1 0 0
    
      &
    
      z z z 1 0 0
    
      0 0 0 1 0 0 (由于x和z相反故为0)
    

    这里有一道例题,可以用到这个技巧,当然这道题的核心是使用^的特点,不过大家也可以试试。

  5. 位移运算(>><<>>>)的常见技巧

    通过上一节的学习我们了解到了位移运算的规则以及一些运算细节,一般来说位移操作都可以代替乘法进行乘2或者除2之类的操作,就像在Java中的ArrayList源码中,在进行容量扩充时,通过类似x + (x >> 1)实现了将原始容量扩充为1.5倍,下面我就来讲一些与位移运算的技巧:

    • 第一点先讲一个基础的,也是我们经常容易忽略的,相信很多人也有所了解,就是在二分查找中,(left + right) / 2这个操作会因为left和right都很大时导致加法溢出而无法得到想要的结果,一般的解决方法,都是通过left + (right - left) / 2来实现,不过在Java中,由于我们有>>>这个无符号右移,所以我只需要使用(left + right) >>> 1即可避免溢出,当然由于不是所有语言都有支持,所以>>>这个技巧也显得有些局限。

    • 最后我们来讲一个比较实用的操作(虽然只是在刷题中实用,在LeetCode上有好几道题可以利用这一思路)。由于比较复杂,我们直接来讲结论,然后再仔细分析和证明:

      1. a |= a >> 16;	// 整个操作用于使得a最高
      2. a |= a >> 8;		// 位1及以下均变为1,例如
      3. a |= a >> 4;		// 1010 0000,经过这几步操作
      4. a |= a >> 2;		// 会变为1111 1111.
      5. a |= a >> 1;
      

      下面就开始进行具体证明,整个解释基于8位整数而言(为了讲解方便,因此我们只需要3-5步的操作),数位从0开始,由于数字0不包含1,所以经过操作得到结果依然是0,即0 - 7,假设有一个数其最高位的1在第x位(0 <= x <= 7),那么经过第一次操作,第x位和第x - 4位均会变为1(利用了|运算置1的特点),然后再执行第4步操作,由于我们现在第x位和第x - 4位均为1,故经过第5步,我们现在就可以让第x - 2位和第x - 6位也变为1,同理经过第5步,我们就可以让第x-7 ~ x位均为1(假如x为3,那么对应-4 ~ 3,负数位抛弃,即0 ~ 3位均会变为1),最终实现了x位及x以下数位均置为1的操作。最后再留一道题,正是利用了上面的思路,大家可以试一下,数字的补数

小结

以上便是关于几个常见位运算的使用技巧和具体案例说明,当然还有很多常用的技巧,由于通用性等原因,可能会放到后面再讲,不过兴趣是最好的老师,还是希望大家能自己多发掘一些,然后一起交流,此外后续还会总结与位运算有关的数据结构,可能的话还会有实际应用场景的总结,这次就讲到这里,如有错误之处,也请各位帮忙指出。

猜你喜欢

转载自blog.csdn.net/qq_41698074/article/details/107713326
今日推荐