位运算的高频算法题

关卡名

位运算的高频算法题

我会了✔️

内容

1.理解位运算如何统计1的个数的

✔️

2.理解位运算如何实现加法

✔️

3.理解递归乘法是如何实现的

✔️

1 位移的妙用 

位移操作是一个很重要的问题,可以统计数字中1的个数,在很多高性能软件中也大量应用,我们看几个高频题目。

1.1 位1的个数

LeetCode191 编写一个函数,输入是一个无符号整数(以二进制串的形式),返回其二进制表达式中数字位数为 '1' 的个数。
拓展问题:16进制时怎么统计0000 00de

示例1:
输入:00000000000000000000000000001011
输出:3
解释:输入的二进制串 00000000000000000000000000001011 中,共有三位为 '1'。

示例2:
输入:00000000000000000000000010000000
输出:1
解释:输入的二进制串 00000000000000000000000010000000 中,共有一位为 '1'。

首先我们可以根据题目要求直接计算,题目给定的 n 是 32 位二进制表示下的一个整数,计算位 1 的个数的最简单的方法是遍历 n 的二进制表示的每一位,判断每一位是否为 1,同时进行计数。
那问题就是如何通过位运算来识别到1,例如:00001001001000100001100010001001,首先我们注意到要识别到最低位的1,可以这么做:

00001001001000100001100010001001

& 00000000000000000000000000000001

= 00000000000000000000000000000001

也就说将原始数字和1进行&运算就能知道最低位是不是1了,那其他位置怎么算呢?
我们可以有两种思路,让1不断左移或者将原始数据不断右移。例如将原始数据右移就是:

00000100100100010000110001000100

& 00000000000000000000000000000001

= 00000000000000000000000000000000

很明显此时就可以判断出第二位是0,然后继续将原始数据右移就可以依次判断出每个位置是不是1了。因此是不是1,计算一下(n>>i) & 1就可以了,所以代码顺理成章: 

public int hammingWeight(int n) {
        int count = 0;
        for (int i = 0; i < 32; i++) {
            count += (n >> i) & 1;
        }
        return count;
}

这个题也可以通过将1左移来实现的,该问题作为一个作业题,请你改造上面的代码来实现。
上面的代码写出来,这个题基本就达标了,但这还不是最经典的解法,我们继续分析:
按位与运算有一个性质:对于整数 n,计算n & (n−1) 的结果为将 n 的二进制表示的最后一个 1 变成 0。利用这条性质,令 n=n & (n−1),则 n 的二进制表示中的 1 的数量减少一个。重复该操作,直到 n 的二进制表示中的全部数位都变成 0,则操作次数即为 n 的位 1 的个数。什么意思呢?我们继续看上面的例子:

n: 00000100100100010000110001000100

n-1: 00000100100100010000110001000011

n&(n-1)= 00000100100100010000110001000000

可以看到此时n&(n-1)的结果比n少了一个1,此时我们令n=n&(n-1),继续执行上述操作:

n: 00000100100100010000110001000000

n-1: 00000100100100010000110000111111

n&(n-1)= 00000100100100010000110000000000

可以看到此时n&(n-1)的结果比上一个n又少了一个1,所以我们令n=n&(n-1),循环执行上述操作,我们统计一下循环执行的次数就能得到结果了。
那循环该什么时候停下呢?很显然当n变成0的时候,否则说明数据里面还有1,可以继续循环。所以当且仅当 n=0 时,n 的二进制表示中的全部数位都是 0,代码也很好写了:

public int hammingWeight(int n) {
    int count=0;
    while(n!=0){
        n=n&(n-1);
        count++;
    }
    return count;
}

上面两种解法,第一种的循环次数取决于原始数字的位数,而第二种的取决于1的个数,效率自然要高出不少,使用n = n & (n - 1)计算是位运算的一个经典技巧,该结论可以完美用到下面的题目中: 

1.2 比特位计数 

LeetCode338.给你一个整数 n ,对于 0 <= i <= n 中的每个 i ,计算其二进制表示中 1 的个数 ,返回一个长度为 n + 1 的数组 ans 作为答案。

示例1:

输入:n = 2

输出:[0,1,1]

解释:0到n有 0 1 2 三个数字,每个数字含有1的个数分别为0 1 1 个,如下:

0 --> 0

1 --> 1

2 --> 10

示例2:

输入:n = 5

0 1 2 3 4 5

101

输出:[0,1,1,2,1,2]

解释:0到n有 0 1 2 3 4 5 六个数字,每个数字含有1的个数分别为0,1,1,2,1,2个,如下:

0 --> 0

1 --> 1

2 --> 10

3 --> 11

4 --> 100

5 --> 101

最直观的方法是对从 0 到 num 的每个数直接计算"一比特数"。每个int 型的数都可以用 32 位二进制数表示,只要遍历其二进制表示的每一位即可得到1 的数目。 

public int[] countBits(int num) {
    int bits=new int[num+1];
    for(int i=0;i<=num;i++){
        for(int j=0;j<32;j++){
            bits[i]+=(i>>j)&1;
        }
    }
    return bits;
}

利用位运算的技巧,可以提升计算速度。按位与运算(&)的一个性质是:对于任意整数 x,令 x=x&(x−1),该运算将 x 的二进制表示的最后一个 1 变成 0。因此,对 x 重复该操作,直到 x 变成0,则操作次数即为 x 的「一比特数」。

public int[] countBits(int num) {
        int[] bits = new int[num + 1];
        for (int i = 0; i <= num; i++) {
            bits[i] = countOnes(i);
        }
        return bits;
    }

    public int countOnes(int x) {
        int ones = 0;
        while (x > 0) {
            x &= (x - 1);
            ones++;
        }
        return ones;
}

 有没有发现比特位计数和位1的个数计算规则完全一样? 这就是为什么我们说研究清楚一道题,可以干掉一大票的题目。

1.3 颠倒无符号整数

LeetCode190 .颠倒给定的 32 位无符号整数的二进制位。 提示:输入是一个长度为32的二进制字符串。

示例1:

输入:n = 00000010100101000001111010011100

输出:964176192 (00111001011110000010100101000000)

解释:输入的二进制串 00000010100101000001111010011100 表示无符号整数 43261596,

因此返回 964176192,其二进制表示形式为 00111001011110000010100101000000。

示例2:

输入:n = 11111111111111111111111111111101

输出:3221225471 (10111111111111111111111111111111)

解释:输入的二进制串 11111111111111111111111111111101 表示无符号整数 4294967293,

因此返回 3221225471 其二进制表示形式为 10111111111111111111111111111111 。

首先这里说是无符号位,那不必考虑正负的问题,最高位的1也不表示符号位,这就省掉很多麻烦。
我们注意到对于 n 的二进制表示的从低到高第 i 位,在颠倒之后变成第 31-i 位( 0≤i<32),所以可以从低到高遍历 n 的二进制表示的每一位,将其放到其在颠倒之后的位置,最后相加即可。
看个例子,为了方便我们使用比较短的16位演示:

原始数据:1001 1111 0000 0110(低位)

第一步:获得n的最低位0,然后将其右移16-1=15位,得到:

reversed: 0*** **** **** ****

n右移一位: 0100 1111 1000 0011

第二步:继续获得上面n的最低位1,然后将其右移15-1=14位,并与reversed相加得到:

reversed:01** **** **** ****

n右移一位:0010 0111 1100 0001

继续,一直到n全部变成0: 

理解之后,实现就比较容易了。由于 Java不存在无符号类型,所有的表示整数的类型都是有符号类型,因此需要区分算术右移和逻辑右移,在Java 中,算术右移的符号是 >>,逻辑右移的符号是 >>>。

public int reverseBits(int n) {
        int reversed = 0, power = 31;
        while (n != 0) {
            reversed += (n & 1) << power;
            n >>>= 1;
            power--;
        }
        return reversed;
}

 本题的解法还有很多,例如还有一种分块的思想, n 的二进制表示有 32 位,可以将 n 的二进制表示分成较小的块,然后将每个块的二进制位分别颠倒,最后将每个块的结果合并得到最终结果。这分治的策略,将 n 的 32 位二进制表示分成两个 16 位的块,并将这两个块颠倒;然后对每个 16 位的块重复上述操作,直到达到 1 位的块。为了方便看清楚,我们用字母代替01,如下图所示。
具体做法是:
下面的代码中,每一行分别将 n 分成16 位、8 位、4 位、2 位、1 位的块,即把每个块分成两个较小的块,并将分成的两个较小的块颠倒。同样需要注意,使用 Java 实现时,右移运算必须使用逻辑右移。由于是固定的32位,我们不必写循环或者递归,直接写:

  reverseBits(int n) {
        n = (n >>> 16) | (n << 16);
        n = ((n & 0xff00ff00) >>> 8) | ((n & 0x00ff00ff) << 8);
        n = ((n & 0xf0f0f0f0) >>> 4) | ((n & 0x0f0f0f0f) << 4);
        n = ((n & 0xcccccccc) >>> 2) | ((n & 0x33333333) << 2);
        n = ((n & 0xaaaaaaaa) >>> 1) | ((n & 0x55555555) << 1);
        return n;
    }

 这种方法在JDK、Dubbo等源码中都能见到,特别是涉及协议解析的场景几乎都少不了位操作。积累相关的技巧,可以方便面试,也有利于阅读源码。

面试算法和工程算法

2 位实现加减乘除专题

在计算机中,位运算的效率比加减乘数效率更高,因此在高性能软件的源码中大量应用,而且计算机里各种运算本质上都是位运算。本专题我们就研究几个相关问题。

2.1 位运算实现加法

LeetCode371 给你两个整数 a 和 b ,不使用 运算符 + 和 - ,计算并返回两整数之和。

示例1:

输入:a = 1, b = 2

输出:3

既然不能使用+和-,那只能使用位运算了。我们看一下两个二进制位相加的情况:

[1] 0 + 0 = 0

[2] 0 + 1 = 1

[3] 1 + 0 = 1

[4] 1 + 1 = 0 (发生了进位,应该是10的)

两个位加的时候,我们无非就考虑两个问题:进位部分是什么,不进位部分是什么。从上面的结果可以看到,对于a和b两个数不进位部分的情况是:相同为0,不同为1,这不就是a⊕b吗?
而对于进位,我们发现只有a和b都是1的时候才会进位,而且进位只能是1,这不就是a&b=1吗?然后位数由1位变成了两位,也就是上面的[4]的样子,那怎么将1向前挪一下呢?手动移位一下就好了,也就是(a & b) << 1。所以我们得到两条结论:

  • 不进位部分:用a⊕b计算就可以了。
  • 是否进位,以及进位值使用(a & b) << 1计算就可以了。

于是,我们可以将整数 a 和 b 的和,拆分为 a 和 b 的无进位加法结果与进位结果的和,代码就是: 

public int getSum(int a, int b) {
        while (b != 0) {
            int sign = (a & b) << 1;
            a = a ^ b;
            b = sign;
        }
        return a;
}

2.2 递归乘法 

LeetCode里面试08.05,递归乘法。 写一个递归函数,不使用 * 运算符, 实现两个正整数的相乘。可以使用加号、减号、位移,但要吝啬一些。

示例1:

输入:A = 1, B = 10

输出:10

如果不让用*来计算,一种是将一个作为循环的参数,对另一个进行累加,但是这样效率太低,所以我们还是要考虑位运算。
首先,求得A和B的最小值和最大值,对其中的最小值当做乘数(为什么选最小值,因为选最小值当乘数,可以算的少),将其拆分成2的幂的和,即min = a_0 * 2^0 + a_1 * 2^1 + ... + a_i * 2^i + ...其中a_i取0或者1。其实就是用二进制的视角去看待min,比如12用二进制表示就是1100,即1000+0100。例如:
13 * 12 = 13 * (8 + 4) = 13 * 8 + 13 * 4 = (13 << 3) + (13 << 2);
上面仍然需要左移5次,存在重复计算,可以进一步简化:
假设我们需要的结果是ans,
定义临时变量:tmp=13<<2 =52计算之后,可以先让ans=52
然后tmp继续左移一次tmp=52<<1=104,此时再让ans=ans+tmp
这样只要执行三次移位和一次加法,实现代码:

public int multiply(int A, int B) {
    int min = Math.min(A,B);
    int max = Math.max(A,B);
    int ans = 0;
    for(int i=0; min!=0; i++){
        if((min&1)==1){
            ans += max;
        }
        min >>= 1;
        max += max;
    }
}

 

你可能感兴趣的:(算法)