位运算及其在算法中的应用

162 阅读9分钟

一、前言

  1. 位运算是系统所提供的直接对数字的数位进行操作的运算,效率很高。

  2. 在 JavaScript 语言层面不区分整数与浮点、符号与无符号。 所有数字都是 Number 类型,统一应用浮点数算术。 (具体了解 JavaScript 种数字的底层表示可以点击跳转查看或者自行查找资料)

  3. 在 JavaScript 中,位运算会将操作数当作 32 位的二进制串进行计算,如果二进制串超过 32 位,则只保留低位的 32 位进行计算。

    // 1110 0110 1111 1010 0001 0010 0000 0110 0000 0000 0001 # 输入的二进制串
    //                1010 0001 0010 0000 0110 0000 0000 0001 # 实际使用的二进制串// 举个例子:
    let s1 = "10101010101000000000000000000000000000000100";
    let s2 = "00000000000000000000000000000100";
    console.log(parseInt(s1,2));// 11725260718084
    console.log(parseInt(s2,2));// 4let s3 = "10101010101000000000000000000000000000000110";
    let s4 = "00000000000000000000000000000110";
    console.log(parseInt(s3,2));// 11725260718086
    console.log(parseInt(s4,2));// 6
    

    小疑问: 二进制串超过 32 位,则只保留低位的 32 位进行计算,由s1(11725260718084) 和 s3 (11725260718086)的输出可知,其低位32位输出的数值跟 s2 (4)和 s4(6) 输出的结果是一样的,但其前面的那些数不知道是怎么来的?

二、运算符介绍

  1. 按位与(&) :简单的说,参与运算的两个数据,按照二进制位进行”与“运算,两位同时为“1”,结果才为“1”,否则为0

    1 & 51 对应的二进制是:0000 0000 0000 0000 0000 0000 0000 0001
    5 对应的二进制是:0000 0000 0000 0000 0000 0000 0000 0101
    ​
    进行与运算:
    0000 0000 0000 0000 0000 0000 0000 0 1 0 1
    0000 0000 0000 0000 0000 0000 0000 0 0 0 1
    -------------------------------------------
    0000 0000 0000 0000 0000 0000 0000 0 0 0 1
    //那么1 & 5的结果就为 1
    
  2. 按位或(|) :简单的说,参与运算的两个数据,按照二进制位进行”或“运算,全0才为0,其余情况全为1

    1 | 51 对应的二进制是:0000 0000 0000 0000 0000 0000 0000 0001
    5 对应的二进制是:0000 0000 0000 0000 0000 0000 0000 0101
     
    进行与运算:
    0000 0000 0000 0000 0000 0000 0000 0 1 0 1
    0000 0000 0000 0000 0000 0000 0000 0 0 0 1
    -------------------------------------------
    0000 0000 0000 0000 0000 0000 0000 0 1 0 1
    //那么1 | 5的结果就为 5
    
  3. 非运算(~) :将参与运算的数据全部二进制位按位取反,0变成1,1变成0

    let num = 1;
    console.log(~1);// -21 对应的二进制是: 0000 0000 0000 0000 0000 0000 0000 0001
    按位取反后的结果:  1111 1111 1111 1111 1111 1111 1111 1110
    按位取反后的结果是负数(看符号位可知得到的是负数),负数在计算机中存储的是补码,需要转换成真码,然后才能传换成十进制
    ​
    //看符号位可知得到的是负数,这里涉及到计算机如何存储负数,具体原理这里先不多讲,只需要知道在计算机中负数就是存储的补码。
    ​
    补码转换成真码:
    (1)取出补码 1111 1111 1111 1111 1111 1111 1111 1110
    (2)得到反码 1111 1111 1111 1111 1111 1111 1111 1101 //补码-1就得到反码
    (3)得到真码 1000 0000 0000 0000 0000 0000 0000 0010 //符号位不变,其余全部取反,得到真码
    转换成十进制得到-2,所以 ~1 的结果为 -2
    

    小Tips:想要快速的到某个数取反后的结果,可以先让这个数字变成负数,然后减去1,就可以得到非运算结果了。比如上面的例子 ~1 ,将 1 变成负数,再减一,即 -1 - 1 = -2,所以 -2 就是 ~1 的运算结果。

  4. 异或运算(^) :简单的说,参与运算的两个数据,按照二进制位进行”异或“运算,不同为1,相同为0

    1 | 51 对应的二进制是:0000 0000 0000 0000 0000 0000 0000 0001
    5 对应的二进制是:0000 0000 0000 0000 0000 0000 0000 0101
     
    进行异或运算:
    0000 0000 0000 0000 0000 0000 0000 0 1 0 1
    0000 0000 0000 0000 0000 0000 0000 0 0 0 1
    -------------------------------------------
    0000 0000 0000 0000 0000 0000 0000 0 1 0 0
    //那么1 | 5的结果就为 4
    
  5. 左移(<<) :拿例子来说 (a<<b) ,则表示将 a 的二进制串向左移动 b 位,向左被移出的位被丢弃,右边空出的位全部填充为 0。

    1 << 21 对应的二进制是:0000 0000 0000 0000 0000 0000 0000 0001
    右移之后的二进制: 0000 0000 0000 0000 0000 0000 0000 0100//那么1 << 2的结果就为 4
    
  6. 右移(>>) :拿例子来说 (a>>b) ,则表示将 a 的二进制串向右移动 b 位,向右被移出的位被丢弃,左边空出的位全部填充为 0。因为右移由于左侧直接补 0,因此生成的数字必然是非负数

    18 >> 218 对应的二进制是: 0000 0000 0000 0000 0000 0000 0001 0010
    右移之后的二进制是: 0000 0000 0000 0000 0000 0000 0000 0100//那么18 >> 2的结果就为 4
    

三、常用的性质

  1. 自身与自身之间使用位运算

    // 与运算
    let a = 2;
    console.log(a & a);// 2// 或运算
    console.log(a | a);// 2// 异或运算:不同为 1,相同为 0,所以任何数与自己进行异或运算都为 0。
    console.log(a ^ a);// 0
    
  2. 自身与 0 之间使用位运算

    let a = 2;
    // 与运算:同时 1 才为 1,所以任何数与 0 进行与运算都是 0
    console.log(a & 0);// 0// 或运算:同时 0 才为 0,所以任何数与 0 进行或运算都是本身
    console.log(a | 0);// 2// 异或运算
    console.log(a ^ 0);// 2
    
  3. 双重非运算可以使得小数快速取整。

    console.log(~~3.14159);// 3
    console.log(~~4.53);// 4
    
  4. 利用按位与和按位或可以进行还原计算:

    let a = 1;
    let b = 2;
    ​
    console.log(a | ( a & b ));// 1
    console.log(a & ( a | b ));// 1console.log(b | ( b & a ));// 2
    console.log(b & ( b | a ));// 2
    
  5. 可以通过异或完成变量值交换

    let a = 1;
    let b = 2;
    ​
    a ^= b
    b ^= a
    a ^= b
    ​
    console.log(a);// 2
    console.log(b);// 1
    
  6. 利用与 1 进行与运算可以用来判断数的奇偶

    let a = 1;
    let b = 2;
    ​
    console.log(a & 1);// 1
    console.log(b & 1);// 0let c = 5;
    if(c & 1 === c % 2){
        console.log(c & 1);
    }// 1
    
  7. 利用异或运算可以比较两值是否相等(由上面的知识点可以知道任何数与自己进行异或运算都为 0,可以以两值异或后是否等于 0 判断得出两值是否相等。)

    let e = 3;
    let f = 4;
    let g = 3;
    ​
    console.log((e ^ f) === 0);// false
    console.log((e ^ g) === 0);// true
    
  8. (a |= 1 << i) :将第 i + 1 个二进制位设为 1。

    let a = 1;
    ​
    1 对应的二进制是:0000 0000 0000 0000 0000 0000 0000 0001
    a |= 1 << 2;
    此时对应的二进制为:0000 0000 0000 0000 0000 0000 0000 0101
    // 此时a的值为5
    
  9. [ a &= ~(1 << i) ] :将第 i + 1 个二进制位设为 0。

    let a = 5;
    ​
    5 对应的二进制是:0000 0000 0000 0000 0000 0000 0000 0101
    a &= ~(1 << 2) 
    此时对应的二进制为:0000 0000 0000 0000 0000 0000 0000 0001
    // 此时a的值为1
    
  10. [ a & (1 << i) ] :取出第 i + 1 个二进制位上的所代表的十进制数值。

    let a = 5;
    ​
    5 对应的二进制是:0000 0000 0000 0000 0000 0000 0000 0101
    console.log(a & (1 << 2))// 4let b = 2
    2 对应的二进制是:0000 0000 0000 0000 0000 0000 0000 0010
    console.log(b & (1 << 1))// 2
    

四、 常见算法应用

  1. 求子集:(利用性质 10 取出相应二进制位上的所代表的十进制数值用以构建子集的具体结构)

    /**
     * @param {number[]} nums
     * @return {number[][]}
     */
    var subsets = function(nums) {
        let res = [];
        let len = nums.length;
        // 1 << len:获取子集的总数
        for (var i = 0; i < (1 << len); i++) {
            var currSubset = [];// 本轮子集
            for (var j = 0; j <= len; j++) {
                // 取出第 j + 1 个二进制位上的所代表的十进制数值,看是否要加当前 num[j] 的值
                if (i & (1 << j)){
                    currSubset.push(nums[j]);
                }
            }
            res.push(currSubset);
        }
        return res
    };
    

    举个例子解释一下以上代码:nums = [a, b, c]; 利用 let len = nums.length; 可以知道子集的总数有8个;并且可以由子集个数的每个数值所代表的二进制编码来得到当前的子集所取得元素。

    0 (000):二进制编码都为 0 ,故不去数组的元素所以,此代表的子集为:{}。

    1 (001):二进制编码第 0 位为 1 ,所代表的十进制数值是 1 ,所以取 nums 数组的第一个元素当本次的子集,即{a}。

    2 (010):二进制编码第 1 位为 1 ,所代表的十进制数值是 2 ,所以取 nums 数组的第二个元素当本次的子集,即{b}。

    3 (011):二进制编码第 0 位为 1 和第 1 位为 1 ,所代表的十进制数值分别是 1 和 2 ,所以取 nums 数组的第一个元素和第二个元素当本次的子集,即{ab}。

    以此类推可以得:4 (100):{c},5 (101):{a,c},6 (110):{b,c},7 (111):{a,b,c}。

  2. 只出现一次的数字:(利用性质 1 和 2 的异或性质,两个相等的数异或之后为 0,一个数异或 0 等于它本身,那么将数组中所有元素相异或之后,出现 2 次的数字都会全部被消掉,剩下的就是只出现 1 次的数字)

    /**
     * @param {number[]} nums
     * @return {number}
     */
    var appearOnce = function(nums) {
        var res = 0
        for (var i = 0; i < nums.length; i++) {
            res ^= nums[i];
        }
        return res;
    };