弄懂计算机中二进制的表示以及位运算的妙用

430 阅读8分钟

位运算就是将操作数转成二进制数进行运算,计算机在进行计算的时候,将数字转为二进制,然后再进行计算的,因此使用位运算的计算速度也是会比十进制要快上许多。平时我们写代码可能较少用到位运算,但是如果看过一些流行的JavaScript库的话,会发现里面很多地方是用到了位运算的。今天就来说说在实际的编程中,哪些地方可以巧妙的使用位运算。在说具体的运算之前,需要先复习一一些概念。

在 ECMAScript 中字面量创建的整数都是有符号整数,只有位运算才能创建无符号整数,另外是采用IEEE754 64位来存储数值,但是实际并不会操作64位数值,而是将其转换成了32位,执行完操作后,再转为64位显示

概念

原码

整数的二进制表示、最高位表示符号位,0表示正数,1表示负数

反码

正数的反码还是原码、负数的反码是除了符号位、其他位按位取反获得的结果

按照这个思路,其实所谓反码就是我们下面说的按位取反运算。这里有点迷惑的地方就是我们在 JavaScript 的控制台打印 -10 的二进制的时候,发现他并不是我们想象的那样第32位是1,而是-1010,这就涉及到了负数在计算机中的存储方式是以补码形式存储的,那么反码又是做什么的呢?

补码

正数的补码就是原码,负数的补码是反码加1

如上图所示,取的反码以后加 1 才是负数的存储格式,注意是存储格式,为什么这么说,其实上面说到了负数的二进制表示就是最高位为1,剩下的表示数值,这是表示格式,那为什么负数要用这样的格式来存储呢?实际这是为了解决正负数的运算以及 +0、-0的问题。

来看一个例子,假设用8位来表示一个整数:

// 使用原码进行计算
1 - 1 = 1 + (-1) = 0
1 - 1 = 00000001 + 10000001 = 10000010 = -2 // 结果是不正确的

// 使用反码计算
1 - 1 = 00000001 + 11111110 = 11111111 = 1000000 = -0

// 使用补码计算
1 - 1 = 00000001 + 11111111 = 00000000 = 0

解释一下上面的例子,不难看出如果使用原码进行计算的时候,显然得到的结果是不正确的,那么当负数使用反码表示,得到的结果 -0,好像是没问题的,但是 -0 的负号没有什么意义,于是引入了补码,这样得到的结果 0000 0000 就是 0 了,也就不存在负号的问题了。

有符号整数

在 ECMAScript 中使用31位表示整数的数值,32位表示整数的符号,0为正数,1为负数。有符号正数可以表示正数和负数,正数以正常32存储,前31位表示数值,第32位为0。负数则使用二进制补码来表示。

无符号整数

位32为数值位,表示的值为 2 ^ 31,不表示符号,因此无符号整数表示的范围是 0 ~ 4294967295,而有符号整数表示范围为 -2147483648 ~ 2147483647,所以对于小于 2147483647 来说,无符号整数显示和有符号整数是一样的,大于该值,则使用位31来表示数值。

位运算

位运算很简单,只要记住下面的规则就可以了,但是如果灵活的运用才是我们关注的地方。

与运算 &

同位都为1才为1,否则为0

或运算 |

同位都为0才为0,其他都为1

取反运算 ~

同位为1,取0,为0,取1

异或运算 ^

同位相同为0,不同为1

有符号左移 <<

将二进制数左移n个单位,即在二进制数后面添加n个0,其实就是某个数 * 2的n次方

3 << 2 = 12
// 相当于
3 * (2 ^ 2) = 12

有符号右移 >>

将二进制数右移n个单位,即在删除二进制数后面n位,其实就是某个数 / 2的n次方,跟左移操作刚好相反

12 >> 1 = 6
// 相当于
12 / (2 ^ 1) = 6

无符号右移 >>>

正数情况下,无符号右移的结果和有符号右移的结果一样,负数情况下,无符号右移则会得到负数的二进制补码,即会变成一个32位的正数,这个数是非常大的

-18 >>> 0 = 4294967278

位运算的优先级

  1. 位运算的优先级还要低于相等运算符,也包括 >>=、<<=、&=、|=、^=这样的复合运算,优先级都是最低的.
  2. 位运算中 <<、>>、>>> 等移位操作符优先最高,其次取反运算,与运算,异或运算,或运算按先后进行计算

下面这些运算有括号和没有括号结果是完全不同的

1 & 1 === 1  // 1
(1 & 1) === 1 // true

1 | 1 === 0 // 1
(1 | 1) === 0 // false

1 ^ 1 === 1 // 0
(1 ^ 1) === 1 // false

1 | 0 << 1 & 2 // 1
(1 | 0) << 1 & 2 // 2
4 ^ 2 & 1 // 4
(4 ^ 2) & 1 // 0

上面所说的优先级都是只在 JavaScript 中适用

运用

下面是部分截取自leetcode上面的题目,也是我觉得可能会在平时写代码中能用的上的一些关于位运算的运用,并且也会持续更新记录更多适用的运用。

变量交换

变量交换是我们编码中使用较多的,除了使用临时变量这样的方法外,也能使用位运算来进行,利用的就是异或运算,两个数进行异或运算,异或运算的结果再异或两个数中的一个,就等于另外一个。这里推广一下,和使用加法是一样的,但是位运算的速度还是会比普通加法更快的。

let a = 13, b = 17;
a = a ^ b;
b = a ^ b;
a = a ^ b;
console.log(a); // 17
console.log(b); // 13

判断奇偶

利用奇数的二进制末位是1,偶数的二进制末位是0的条件来判断奇偶,以后判断奇偶就可以使用这样的方式啦,看起来感觉高大了许多啊!

num & 1 = 1; // num是奇数
num & 1 = 0; // num是偶数

计数

  1. 数组中只出现一次的元素
// leetcode 136
function singleNumber (nums) {
  let a = 0;
  // 异或运算
  // 任何数异或0等于本身,本身异或本身等于0
  // 如果某个数出现两次,则结果为0,那么0再去异或出现一次的数则结果为出现一次的数
  // 推论:任何数异或0的结果再去异或偶数次的任何数,最后还是为0
  for (let i = 0; i < nums.length; i++) {
    a ^= nums[i];
  }
  return a;
};
console.log(singleNumber([2,2,2,2,1,8,8,6,6,6,6])) // 1

利用异或运算的规律,例如 0 ^ 5 = 5,5 ^ 5 = 0,这样如果是偶数次的异或结果肯定是0,如果是奇数的话,也就是题意说的只出现一次,那么最后异或的结果一定是这个奇数。

  1. 数组中多数元素(多数元素就是元素出现的次数大于数组长度的一半的元素)
// leetcode 169
// 找多数元素其实就是找数组中元素二进制1的个数多于数组长度的一半的那个元素
function majorityElement (nums) {
  // mid获取数组中位数
  let res = 0, mid = nums.length >> 1;
  for (let j = 0; j < 32; j++) {
    let count = 0;
    for (let i = 0; i < nums.length; i++) {
      // 找到数组中每个元素二进制中1的个数累加起来
      count += nums[i] >> j & 1;
      // 如果结果是大于数组长度的一半
      if (count > mid) {
        // 将当前位的值累加起来
        res += 1 << j;
        break;
      }
    }
  }
  return res;
};
console.log(majorityElement([2,2,1,1,1,2,2,2,1,1,1])); // 1
  1. 二进制数中1的个数
// leetcode 191
// 计算一个无符号整数二进制表示中1的个数,也被称作汉明重量
function hammingWeight (n) {
  let sum = 0;
  while (n !== 0) {
    sum++;
    n &= (n - 1);
  }
  return sum;
};
console.log(hammingWeight(15)) // 5

注意这里的输入值 n 是一个无符号整数。利用 n & (n - 1) 的值其实就是去掉 n 的一个最低位1, 这样如果最后为0了,那么表示n 中没有1,之前计数的 sum 就是这个数 n 中 1的个数。

本文使用 mdnice 排版