位运算“奇技淫巧”大总结(算法进阶)

文章目录

    • 运算性质
      • 异或运算的一些性质
    • 秀秀伸手
      • 1、只用位运算来完成两个整数相加
      • 2、不用临时变量,交换a、b两个数的值
      • 3、判断一个数是奇数还是偶数
      • 3、快速计算2*n、2*n+1和n/2
      • 4、`N&(N-1)`是啥?`N&(~N+1)` 是什么?`N&(-N)`是啥?
      • 5、我们知道`n>>3`是计算n/8的值,那n%8可以用位运算代替么?可以!对于16,32,...,512等`2^x`的数同理
      • 6、`n>0 && (n& (n-1)) == 0` 等价于n为2的某次方。(其中n为正整数)
      • 7、遍历二进制形式的子集
      • 8、掩码运算
    • 几道小题目
      • 1、判断一个整数的二进制表示中,为1的位的个数。
      • 2、如果给你一堆int整数,告诉你其中仅有一个数出现了奇数次,其他数都出现了偶数次?问怎么找出这个数?
      • 3、位运算压轴出场
    • 总结

运算性质

以int类型为例,位运算只需单独考虑对应的每一位即可。

异或运算的一些性质

  1. 奇数个1异或在一起结果为1,偶数个1异或在一起结果为0;
  2. 从0开始,每连续4个整数一组,每一组异或结果为0,即4k^(4k+1)^(4k+2)^(4k+3) = 0
    衍生性质:
    x o r [ 0.. n ] = { n ,当 n   m o d   4 = = 0 1 ,当 n   m o d   4 = = 1 n + 1 ,当 n   m o d   4 = = 2 0 ,当 n   m o d   4 = = 3 xor[0..n] = \left\{\begin{matrix} & n ,当 n\bmod4==0 & \\ & 1 ,当 n\bmod4==1 & \\ & n+1 ,当 n\bmod4==2 & \\ & 0 ,当 n\bmod4==3 & \end{matrix}\right. xor[0..n]= n,当nmod4==01,当nmod4==1n+1,当nmod4==20,当nmod4==3
    x o r [ i . . j ] = x o r [ 0.. j ] ⊕ x o r [ 0.. i − 1 ] xor[i..j] = xor[0..j] \oplus xor[0..i-1] xor[i..j]=xor[0..j]xor[0..i1]
  3. 偶数个整数在一起,要么总体异或结果为0,要么其中必存在某个数 x x x,使得除去 x x x之外剩余的数异或在一起结果不为0。(反证法,力扣题目:810. 黑板异或游戏)

秀秀伸手

1、只用位运算来完成两个整数相加

力扣题目:371. 两整数之和

public static int add(int num1, int num2) {
    int sum, carry;
    do {
        //两个数异或,二进制的相加,但不进位
        //比如:01010 ^ 00101 = 01111
        //因为不涉及到进位,01010 + 00101结果就是01111
        sum = num1 ^ num2;

        //计算进位的部分,两个bit都是1才会产生进位,用按位与
        //是向高位进位,因此左移1位
        carry = (num1 & num2) << 1;

        num1 = sum;
        num2 = carry;
    } while (carry != 0); //只要计算中产生了进位,还得继续把进位部分累加进来

    return sum;
}
/**
* 稍微简化一下
*/
public int getSum(int a, int b) {
    //按位异或 其实就是不进位的加法
    int ans = a;
    while(b != 0) {
        int carry = (ans&b)<<1;//计算出该进位的部分,暂存
        ans = ans^b;//再通过异或,计算不进位的相加结果
        b = carry;
    }
    return ans;
}
/**
* 更简洁的递归写法
* 异或可以看成是不进位的相加
 */
public int add(int a, int b) {
    if(a==0)return b;
    if(b==0)return a;
    // 前一项是不进位的相加结果
    // 后一项是进位,进位最后始终会变成0
    return add(a^b, (a&b)<<1);
}

关于异或(^),有一种可能更直观的理解:两个数的二进制位,按最低位对齐(高位补0)之后,分别进行按位相加(但忽略进位)的结果

2、不用临时变量,交换a、b两个数的值

关于异或运算有2个性质:
a ^ a = 0
0 ^ a = a

```java
a = a ^ b;
b = a ^ b; // (a^b)^b = a ^ (b^b) = a ^ 0 = a
a = a ^ b; // (a^b)^a = a^a ^ b = 0^b = b;
```
(不过,个人倒是觉得没必要用这种方式来交换两个数)

《linux多线程服务端编程》501页分析了,用异或运算交换变量,是错误的行为。并且不能加快运算,也不能节省内存。
参考:用异或来交换两个变量是错误的https://blog.csdn.net/solstice/article/details/5166912

3、判断一个数是奇数还是偶数

//判断最低一个bit是1(奇数)还是0(偶数)
return (n&1)==1;//true: 奇数;false: 偶数

3、快速计算2n、2n+1和n/2

2*n  等价于 n<<1
2*n+1等价于 n<<1|1
n/2  等价于 n>>1

比如,在二分查找中一个惯用的技巧,即求[left, right]的中间位置:
int mid = (left+right)>>1;
不过,为了防止相加后溢出,超出int的表示范围,最好这样写:
int mid = left + ((right - left)>>1); // 这里必须加括号!

4、N&(N-1)是啥?N&(~N+1) 是什么?N&(-N)是啥?

这3者的关键是要先理解N&(N-1)N-1是将二级制的N,其最右边部分的10..0(其中0可以有0到多个),打散为01..1(其中1的个数和前边0的个数相同)。那么 N&(N-1) 的结果就是将N的二进制形式中,最低位的1抹掉后的数

`N&(~N+1)`和`N&(-N)`,这两者其实是等价的。他们的结果是,将`二级制的N,其最右边一个为1的位保留成1,其他位全变成0`的数。当然这是N不为0的情况。
如果N=0呢?
```java
N&(~N+1) = 0 & (-1+1) = 0  //事实上,0与任何数按位与结果是0
```

备注:

  1. java代码验证: -N == (~N+1)

    int val = Integer.MAX_VALUE;
    assert -val == (~val+1);
    val = Integer.MIN_VALUE;
    assert -val != (~val+1);
    for(int i=-1000;i<1000;i++) {
        assert -i != (~i+1);
    }
    
  2. -N == (~N+1)其实是有原因的,java在表示负数的时候,用的是补码,补码就是由相应的正数值按位取反(得到反码),再加1得出。

    因此,如果N是一个正数,从二进制形式看,~N+1(即N的补码)就是用来表示-N这个负数的。

    //以byte类型为例,来体会一下补码
    01111111 //127
    00000000 //0
    11111111 //-1   由1的二进制0000_0001按位取反1111_1110,再加1得出
    10000001 //-127 127的二级制0111_1111按位取反1000_0000,再加1得出
    10000000 //-128 128的二级制1000_0000按位取反0111_1111,再加1得出
    byte的表示范围为:[-128, 127]
    
    Integer.MIN_VALUE == 0x80000000 (1<<31)
    Integer.MAX_VALUE == 0x7fffffff
    

5、我们知道n>>3是计算n/8的值,那n%8可以用位运算代替么?可以!对于16,32,…,512等2^x的数同理

n%8 等于 n&7 //即取二进制n的低3位

6、n>0 && (n& (n-1)) == 0 等价于n为2的某次方。(其中n为正整数)

如何理解:
n为2的某次方,等价于n的二进制形式中有且仅有1个1,其余bit为0,那么n& (n-1)) 必然为0。
反过来,如果n不为2的某次方,n的二进制形式中至少会有2个1,那么n-1是将n的二进制中最后1个1打散,即xx..x100..0变成xx..x011..1。n& (n-1))必不为0(只是将最低位的1变成了0,至少还留有高位的1)。

小结一下:
** n = n & (n-1)的结果是抹掉n(二进制形式中)最低位的1**
** n = n &(~n+1)的结果是,只保留n二进制中最低位的1,其他位全变为0**
备注:这是力扣第231题:231. 二的幂

7、遍历二进制形式的子集

遍历一个整数的二进制形式中,所有为1的bit组成的集合的子集。
第一种,通过for循环

int mask = 0b10101;
// 第一种:因为有i>0条件,遍历结果中至少含有1个bit的1
for (int i = mask; i > 0; i = (i - 1) & mask) {
    System.out.println(Integer.toBinaryString(i));
}
/**输出结果为:
 * 101010
 * 101000
 * 100010
 * 100000
 * 1010
 * 1000
 * 10
 */

第二种,通过do{}while()循环:

int mask = 0b10101;
int subset = mask;
// 遍历mask的所有子集,包括不含1(即全0)的情况
do {
    System.out.println(Integer.toBinaryString(subset));
    subset = (subset - 1) & mask;
} while (subset != mask);
/**输出结果为:
 * 101010
 * 101000
 * 100010
 * 100000
 * 1010
 * 1000
 * 10
 * 0
 */

解释:第二种遍历,当subset减为0之后,subset-1-1,而-1的二进制位全为1,-1 & mask之后就等于mask了,于是while退出。

8、掩码运算

笔者最早接触掩码这个词,应该是IP地址的子网掩码。这里说的掩码也是同一个意思。

一句话来描述掩码的含义和用途:一个操作数和提前设计好的某个掩码值进行按位与运算,能够得到需要的结果

补充一句:将内网IP地址和子网掩码进行按位与运算能够得出网络标识(网络号)。

//举一个简单的例子: 我要判断一个byte数值的某个bit是0还是1
//先定义好各个bit的掩码
public static final byte[] MASK = {
        (byte)(1<<7),
        1<<6,
        1<<5,
        1<<4,
        1<<3,
        1<<2,
        1<<1,
        1<<0,
};

public static boolean isSet(byte val, int index) {
    int mask = MASK[index&7];//index的有效范围是0~7
    return (val&mask) == mask;
}

//比如,24这个数
byte val = 0b0001_1000;//24
System.out.println(isSet(val,0));//false,最高位,第0位不是1
System.out.println(isSet(val,3));//true,第3位是1(下标从0开始数的)
System.out.println(isSet(val,4));//true,第4位是1(下标从0开始数的)

几道小题目

1、判断一个整数的二进制表示中,为1的位的个数。

ps: 这是力扣第191题:191. 位1的个数

//第一种写法,每次判断最低一位是否为1,为1计数加1,
//然后n无符号右移1位,再次进行判断;直到n变为0。
public static int bit1Counts(int n) {
    int rc = 0;
    while(n!=0) {
        if((n&1) == 1) {
            rc++;
        }
        n>>>=1;
    }
    return rc;
}

//第二种写法,循环次数比前一种实现要少(前一种是所有位中的0和1都判断了,这种只判断为1的位)
public static int bit1Counts2(int n) {
    int count = 0;
    int right1;
    while(n!=0) {
        //取到最右边的1,其他位保留0
        right1 = n & (~n + 1);
        count++;
        //再把n最右边的1变成0,循环继续
        n ^= right1;
    }
    return count;
}

//第三种写法,简单直接,最优解
public static int bit1Counts3(int n) {
    int count = 0;
    while (n != 0) {
        //参考上文第5点n&(n-1)的理解:
        //n-1是把n的最右(低)位的1“打散”开来,
        //因此,n&(n-1)会将n的最低位的1抵消掉
        n &= (n - 1);
        count++;
    }
    return count;
}

// 第四种写法:每4位统计一次数量
public int bit1Counts4(int i) {
    //bit是0到15转化为二进制时,其中1的个数
    int bit[] = {0, 1, 1, 2, 1, 2, 2, 3, 1, 2, 2, 3, 2, 3, 3, 4};
    int count = 0;
    while (i != 0) {
    	//通过每4位计算一次,求出包含1的个数
        count += bit[i & 0xf];
        // i无符号右移4位
        i >>>= 4;
    }
    return count;
}

2、如果给你一堆int整数,告诉你其中仅有一个数出现了奇数次,其他数都出现了偶数次?问怎么找出这个数?

思路:将所有的数异或在一起,得到的结果即为要找的数。

​ 还是异或的性质,相同2个数异或结果为0,0和一个数异或结果是这个数本身。

3、位运算压轴出场

解决n皇后问题:在n*n的棋盘上,放置n个“皇后”,要求棋盘上每一条横线、竖线、斜线(包括向左下方向的斜线,向右下方向的斜线)上都至多只有一个皇后,问有多少种放法。

求解这道题的算法是**O(n^n)的时间复杂度**。没错,就是这么高的复杂度!!所以,一般求解这个问题的时候,n不会很大。准确地说,n会很小。请脑补一下,如果n达到32,求32的32次方将会是一个多大的数。

关于时间复杂度,可以这么来理解:

  • 按照出场顺序第i号皇后放在棋盘的第i行,但是它有n列可以选择。
  • 每一个皇后都是这样有n个选择,总共可能性的上限就是n个n相乘,即n^n。

这里请位运算压轴出场来解决这问题,这种解法能极大地优化算法执行的常数时间,绝对是最优解。

整个思路是从上往下,一行一行地尝试放置皇后。请看以下代码和注释。

class Solution {
    //求n皇后问题有多少种放法
    public int totalNQueens(int n) {
        int total = 0;
        //假设我们只求解n<=32的情况
        if(n>=1 && n<=32) {
            //limit的二进制表示中,低n位为1,高位为0(可以对应到棋盘中的一行)
            int limit = n == 32 ? -1 : ((1 << n) - 1);
            total = process1(limit, 0, 0, 0);
        }
        return total;
    }

    // limit: 问题规模为n,limit低n位(bit)为1,其他位为0
    // leftMask: 记录左斜线方向上是否有限制,并通过左移位将这种限制往更下一行进行传递
    // rightMask:记录右斜线方向上是否有限制,并通过右移位将这种限制往更下一行进行传递
    // colMask: 记录列方向上被占用的情况(二级制为1的位已被占用)
    public int process1(int limit, int leftMask, int colMask, int rightMask) {
        if(colMask==limit) {//所有列都已被占,说明n个皇后放置结束,得到一种方案
            return 1;
        }
        int res = 0;
        int pos = limit & ~(leftMask|colMask|rightMask);//得到哪些位置可以放皇后
        while(pos!=0) {
            //在pos不为0的情况下,找到pos中所有为1的位,进行放置皇后的尝试
            //找到pos中最有低位的1,记为mostRightOne
            int mostRightOne = pos & (~pos+1);

            //在mostRightOne的位置放皇后
            pos ^= mostRightOne;//等价于pos -= mostRightOne
            //继续尝试下一行放置,其leftMask变为(leftMask|mostRightOne)<<1
            //左移是告诉下一行放置皇后的时候不能占用这个位置
            //否则跟本行放置的皇后在左斜线上有冲突
            //rightMask同理
            res += process1(limit, (leftMask|mostRightOne)<<1,
                    colMask|mostRightOne, (rightMask|mostRightOne)>>>1);
        }
        return res;
    }
}

总结

位运算是计算机里面最快的操作,没有之一。

就因为快,位运算受到很多算法爱好者的热捧。

位运算为什么快呢?

  • 位运算直接操作二进制的1和0,对应到逻辑电路里面的高电平和低电平,高电平代表1,低电平代表0。计算机芯片在硬件层面(集成电路)针对高低电平的转换提供了各种各样的逻辑电路门,有与门(and)或门(or)非门(not)、**异或门(xor)**等等。

通常在编程的时候用到的位运算有哪些呢?以编程语言java为例,有6种。

  1. 与(and)

    • 按位与(&)
    • 如果两个相应的bit都为1,则结果值的该位为1,否则为0
    • eg. 0b0011 & 0b0010 => 0b0010
  2. 或(or)

    • 按位或(|)
    • 如果两个相应的bit有一个为1,则结果值的该位为1,否则为0
    • eg. 0b0011 | 0b0010 => 0b0011
  3. 非(not)

    • 按位取反(~)
    • bit位1变0,0变1
    • eg. ~0b0011 => 0b1100
  4. 异或(xor)

    • 按位异或(^)
    • 如果两个相应的bit位相同,则结果值的该位为0,否则为1
    • eg. 0b0011 ^ 0b0010 => 0b0001
  5. 左移

    • 按位左移(<<)
    • 用来将一个数的各二进制位全部左移指定的位数,右补0。左移会导致高位被丢弃。
    • eg. 0b0011<<2 => 0b1100
    // 补充一点,java中左移超过31位的话,实际只移动对32取模的位数
    System.out.println(1<<31);// -2147483648
    System.out.println(1<<32);//1,左移32%32=0位
    System.out.println(1<<33);//2,左移33%32=1位
    System.out.println(1<<34);//4,左移34%32=2位
    
  6. 右移

    • 右移会导致低位被丢弃。

    • 无符号右移(>>>)

    • 用来将一个数的各二进制位全部右移指定的位数,左补0(左边最高位是符号位,为0表示正数)。

    • eg. 0b0011>>>1 => 0b0001

    • 带符号右移(>>)

    • 用来将一个数的各二进制位全部右移指定的位数,左补1(如果原数最左位是1,即为负数),或者补0(如果原数最左位是0)。

    • eg1. 0b1100>>1 => 0b1110

    • eg2. 0b0100>>2 => 0b0010

怎么样,觉得有收获的话,在下方给作者一个赞吧!


感谢阅读。

你可能感兴趣的:(算法,位运算,强大位运算,面试位运算,x与上x-1,x与上-x)