JS中的位运算

166 阅读15分钟

位运算

位运算 就是基于 整数 的二进制表示进行的运算

左移

左移运算由两个小于号表示(<<)。它把数字中的所有数位向左移动指定的数量。例如,把数字 2(等于二进制中的 10)左移 5 位,结果为 64(等于二进制中的 1000000):

var iOld = 2;		//等于二进制 10
var iNew = iOld << 5;	//等于二进制 1000000 十进制 64

移动任意数字 x 至左边 y 位,得出 x * 2 ** y。 所以例如:9 << 3 等价于 9 * 2³ = 9 * 8 = 72。2 << 5 等价于 2 * 2 ** 5 = 2 * 2^5 = 64

应用
取整

由于位运算是基于整数进行处理的,所以可以利用左移<<来进行取整处理

  2.6 << 0 // 2
 -2.6 << 0 // -2

右移(有符合右移)

是将一个操作数按指定移动的位数向右移动。 右边移出位将被丢弃,然后用最左边的这一位(符号位)填充左边的空位。 由于新的数字最左边位与之前数字的最左边位是相同值,故符号位(最左边的位)不会改变,因此被称为“符号位传播”(sign-propagating).

同样,移动数位后会造成空位。这次,空位位于数字的左侧,但位于符号位之后。ECMAScript 用符号位的值填充这些空位,创建完整的数字

     9 (十进制): 00000000000000000000000000001001 (二进制)
                  --------------------------------

9 >> 2 (十进制): 00000000000000000000000000000010 (二进制) = 2 (十进制)



     -9 (base 10): 11111111111111111111111111110111 (base 2)
                   --------------------------------

-9 >> 2 (base 10): 11111111111111111111111111111101 (base 2) = -3 (base 10)

移动任意数字 x 至右边 y 位,得出 x / (2 ** y )后取整。 所以例如:9 >> 2 等价于 9 / 2^2 = 9 / 4 = 2.25,然后取整就是2。-9 >> 2, 等价于 -9 / 4 = -2.25 ,取整 - 3

应用
取整

由于位运算是基于整数进行处理的,所以可以利用右移>>来进行取整处理

1.111 >> 0 // 1
2.344 >> 0 // 2
-2.555 >> 0 // -2

无符号右移

无符号右移使用 >>> 表示,和有符号右移区别就是它是三个大于号,它会将数值的所有 32 位字符都右移

对于正数,无符号右移会给空位都补 0 ,不管符号位是什么,这样的话正数的有符号右移和无符号右移结果都是一致的

负数就不一样了,当把一个负数进行无符号右移时也就是说把负数的二进制码包括符号为全部右移,向右被移出的位被丢弃,左侧用0填充,由于符号位变成了 0,所以结果总是非负的

那么可能就有人要问了,如果一个负数无符号右移 0 位呢,我们可以测试一下

让十进制 -1 进行无符号右移 0 位

-1 是负数,在内存中二进制存储是补码即 1111 .... 1111 1111,32 位都是 1,我们在程序中写入 -1 >>> 0 运行得到十进制数字 4294967295 ,再使用二进制转换工具转为二进制得到的就是 32 位二进制 1111 .... 1111 1111,所以就算无符号右移 0 位,得出的依然是一个很大的正数

应用
对正数取整

无符号右移和有符号右移以及左移都差不多,移 0 位都可取整,只不过无符号右移只能支持正数的取整,至于原理,说过无数遍了,相信你已经记住了,如下

1.323 >>> 0 // 1
2.324 >>> 0 // 2

按位非Not(~)

按位非操作符也可以叫按位取反,它使用 ~ 符号表示,作用是对位取反,1 变成 0 ,0 变成 1现在,我们看下以下数字的操作步骤,

按位取反分为正数和负数,因为符号不同,取反的过程也有点不同

正数按位取反

例如,我们将5按位取反,~5 = -6,过程如下:

  • 将十进制转换为二进制,5转换为二进制后是0000 0101

  • 二进制原码按位取反,将0000 0101 => 1111 1010

  • 因为最高位为1,代表负数,将负的二进制转换为整数,需要取反后+1,负数取反就是符号为不动,其他位取反,那么此时 1111 1010 => 1000 0101

  • 取反后+1,1000 0101 => 1000 0110

  • 最高位为1,是符号位,所以第1位不看,计算其他7位的值,结果为6,加上符号位,结果就是-6

正数按位取反总结:

  1. 将十进制转换为二进制
  2. 二进制原码按位取反
  3. 最高位为1,符号位保留,其他位取反+1
  4. 将二进制转换为十进制
负数按位取反

负数按位取反与正数的按位取反不同,主要是在步骤2,3调整一下

例如,我们将-5按位取反,~-5 = 4

  • 将十进制转换为二进制,-5转换为二进制后是1000 0101
  • 符号位保留,其他位取反1000 0101 => 1111 1010,取反后+1,1111 1010 => 1111 1011
  • 将刚刚得到的取反,1111 1011 => 0000 0100
  • 将二进制转换为十进制,0000 0100 => 4

负数按位取反总结

  1. 将十进制转换为二进制

  2. 将转换后的二进制,除了符号最高位,其他位取反+1

  3. 将取反后的二进制码进行取反

  4. 二进制原码转为十进制

应用
判断字符串是否存在

按位非在项目中的使用频率还是蛮高的

let str = "abcdefg"
// 对于不存在的字符串,str.indexOf("n")为-1,~(-1)为0,!(0) = true
// 对于存在的字符串,!(~str.indexOf('a')) = false
if(!~str.indexOf("n")){
	console.log("字符串 str 中不存在字符 n")
}

// 等同于

if(str.indexOf("n") == "-1"){
  console.log("字符串 str 中不存在字符 n")
}
取整

按位非的骚操作中,还有一个比较普遍的就是位运算双非取整了,如下所示

~~3.14 == 3
// 解释
/**
~x = -x - 1
~(~x) = -(-x-1) - 1 = x + 1 - 1 = x
由于位运运算是对整数操作,所以 ~~3.14 = 3

**/

按位与And(&)

按位与操作符也就是符号 & ,它有两个操作数,其实就是将两个操作数的二进制每一位进行对比,两个操作数相应的位都为 1 时,结果为 1,否则都为 0,如下

求 25 & 3 ,即求十进制 25 和 十进制 3 的与操作值

我们分别求出 25 和 3 的二进制进行比对即可

25 = 0000 0000 0000 0000 0000 0000 0001 1001
 3 = 0000 0000 0000 0000 0000 0000 0000 0011
--------------------------------------------
&  = 0000 0000 0000 0000 0000 0000 0000 0001

如上所示,最终我们比对的二进制结果为 0000 ... 0001,即十进制数字 1

应用
判断奇偶

根据二进制数的规律,我们可以得出一个结论,当一个数末尾的二进制位1时,那么这个数为基数,当一个数二进制为0时,那么这个数为偶数。因为根据二进制转换为十进制而言,2的任意次幂必定为偶数,如果最后一位是1,那么结果是....+ 1 * 2^0 = 1,偶数 + 末尾1 = 奇数,如果末尾是0,那么...... + 0 * 2 ^ 0 = 0, 偶数 + 末尾0 = 偶数。

对于按位与来说,只有同时为1时,才会为1 ,否则就为0,所以

奇数 & 1 = 1
偶数 & 1 = 0
判断一个数是否为2的整数幂

先来看下2的整数幂

0000 0001  -> 1  	// 2^0
0000 0010  -> 2		// 2^1
0000 0100  -> 4		// 2^2
0000 1000  -> 8		// 2^3
0001 0000  -> 16	// 2^4

如上代码所示,2的整数幂是一个1后面跟着1

// 例如,8是2的3次幂,8的二进制码是 0000 1000
 0000 1000
-
         1
———————————
 0000 0111
8 - 1 = 0000 0111,减去1后,原来的1位变成了0,而之前的1
 8 & 7 = (0000 1000) & (0000 0111) = 0

// 例如,10不是2的整数幂,10的二进制码是 0000 1010

  0000 1010
-
          1
  ———————————
  0000 1001
10 - 1 = 0000 1001
 10 & 9 = (0000 1010) & (0000 1001) = (0000 1000) = 8
  • 所以,判断一个数是不是2的整数幂,可以用以下公式,这个数需要是正整数
a & (a - 1) //   a不是2的整数幂
b & (b - 1) // 0 b2的整数幂
  • 所以,此外,还可以用以下的公式:
 n & (-n) = n

例如:

8的原码是 0000 1000 , -8的原码是 1000 1000, 8 & (-8) = 0000 1000 = 8

10的原码是 0000 1010 , -10的原码是1000 1010,10 & (-10) = 0000 1000 = 8

16的原码是 0001 0000 , -16的原码是1001 0000,16 & (-16) = 0001 0000 = 16

从上面的例子中,我们可以看出,因为&只有同为1时,才会为1,正数和负数的原码只有最高位不同,其余位相同,将正数与负数进行&运算,只有是2的整数次幂才会出现 正数n & 负数(-n) = n

按位或Or(|)

按位或用符号 | 来表示,它也有两个操作数,按位或也是将两个操作数二进制的每一位进行对比,只不过按位或是两边只要有一个 1 ,结果就是 1,只有两边都是 0 ,结果才为 0,如下

例如,求 25 | 3

25 = 0000 0000 0000 0000 0000 0000 0001 1001
 3 = 0000 0000 0000 0000 0000 0000 0000 0011
--------------------------------------------
|  = 0000 0000 0000 0000 0000 0000 0001 1011

将最终的结果转换为十进制是27

应用
使用|取整

取整的时候我们也可以使用按位或取整

1.111 | 0 // 1
2.234 | 0 // 2

如上所示,只需要将小数同 0 进行按位或运算即可

原理也简单,位运算操作的是整数,相当于数值的整数部分和 0 进行按位或运算

0 的二进制全是 0 ,按位或对比时 1 和 0 就是 1 ,0 和 0 就是 0,得出的二进制就是我们要取整数值的整数部分

使用|代替Math.round()

我们上面知道按位或可以取整,其实四舍五入也就那么回事了,即正数加 0.5,负数减 0.5 进行按位或取整即可,道理就是这么简单,如下

let a1 = 1.1
let a2 = 1.6
a1 + 0.5 | 0 // 1
a2 + 0.5 | 0 // 2

let b1 = -1.1
let b2 = -1.6
b1 - 0.5 | 0 // -1
b2 - 0.5 | 0 // -2

按位异或XOR(^)

按位异或使用字符 ^ 来表示,按位异或和按位或的区别其实就是在比对时,按位异或只在一位是 1 时返回 1,两位都是 1 或者两位都是 0 都返回 0,如下

例如,求 25 ^ 3

25 = 0000 0000 0000 0000 0000 0000 0001 1001
 3 = 0000 0000 0000 0000 0000 0000 0000 0011
--------------------------------------------
|  = 0000 0000 0000 0000 0000 0000 0001 1010

将上面的二进制转换为10进制为26

应用总结
判断整数是否相等
let a = 1
let b = 1
a ^ b // 0

1 ^ 1 // 0
2 ^ 2 // 0
3 ^ 3 // 0

可以得出结论: a ^ a = 0 , a ^ 0 = a

a ^ a , 每一位都相同,每位可能都是1或者0,同时为1或0,那么结果都是0,全部为0的话,就是0,例如:0101 ^ 0101 = 0000

a ^ 0 ,0的每一位都是0,a的值与0异或时,如果当前为是1 ^ 0 = 1,当前为是0,0 ^ 0 = 0,那么计算出的结果就是和a的完全相同,例如 0101 ^ 0000 = 0101

我们也可以用作判断两个小数的整数部分是否相等,如下

2.1 ^ 2.5 // 0
2.2 ^ 2.6 // 0
2.1 ^ 3.1 // 1
算法

有一个数组,只有一种数出现了奇数次,其它所有树出现了偶数次,怎么找出出现奇数次的这个数

可以利用异或来处理,因为偶数出现的数字,自身异或后为0,奇数的部分,就是奇数 ^ 0 = 奇数

const arr = [2,3,4,5,6,5,5,6,4,3,2];
let number = arr[0];
for (var i = 1; i < arr.length; i ++) {
    number = number ^ arr[i]
}

console.log('number=>', number);
使用^完成值交换

我们也可以使用按位异或来进行两个变量的值交换,如下

let a = 1
let b = 2
a ^= b
b ^= a
a ^= b
console.log(a)   // 2
console.log(b)   // 1

解析:

  1. a = a ^ b
  2. b = b ^ a
  3. a = a ^ b

看第一步: a = a ^ b

看第二步:b = b ^ a = b ^ a ^ b = b ^ b ^ a = 0 ^ a = a,也就是说第二步时,b = a

看第三步:a = a ^ b = (a ^ b) ^ a = a ^ a ^ b = b,a = b

不过这里使用 ^ 来做值交换不如用 ES6 的解构,因为 ES6 解构更方便易懂

使用^切换0和1

切换 0 和 1,即当变量等于 0 时,将它变成 1,当变量等于 1 时,将它变成 0

常用于 toggle 开关状态切换,做开关状态更改时

使用按位异或更简单

let toggle = 0

toggle ^= 1

原理也简单, toggle ^= 1 等同于 toggle = toggle ^ 1,我们知道 0 ^ 1 等于 1,而 1 ^ 1 则为 0

判断两数符号是否相同

我们可以使用 (a ^ b) >= 0 来判断两个数字符号是否相同,也就是说同为正或同为负

let a = 1
let b = 2
let c = -2

(a ^ b) >= 0 // true
(a ^ c) >= 0 // false

原理也很简单,同为正数的话,那么开头都是0,同为0的话,那么0 ^ 0 = 0,0开头是正数,后面位是值的位,结果肯定是>=0的;对于负数而言,开头都是1,1 ^ 1 = 0,0开头是正数,后面值是值的位,结果肯定是>=0;如果两个数符号不同,那么 0 ^ 1 = 1,1开头是负数,负数 < 0

应用总结

位运算的实际应用很广,主要有以下的实际使用

对数字进行取整

主要原理是因为位运算主要是处理整数、整数、整数

2.5 << 0 // 2
-2.5 << 0 // -2
1.2 << 0 // 1
-1.2 << 0 // -1
2.5 >> 0 // 2
-2.5 >> 0 // -2
1.2 >> 0 // 1
-1.2 >> 0 // -1
// 无符号右移,主要处理正数
2.5 >> 0 // 2
1.2 >> 0 // 1
~~3.14 = 3
~~(-3.14) = -3
~~4.5 = 4
~~-4.5 = -4
1.12 | 0 = 1
-1.1.2 | 0 = -1
1.6 | 0 = 1
-1.6 | 0 = -1

判断字符串中是否存在某个值

利用非Not(~)

let str = 'abcd'
const idx = str.indexOf('f')
if(!~idx){
  console.log('-不存在进入此分支-')
}

判断奇偶

利用与&

奇数 & 1 = 1
偶数 & 1 = 0

判断一个数是否是2的整数幂

利用与&

n&(n-1) === 0

// 或者

n&(-n) === n

值交换

利用^

a ^=b
b ^=a
a ^=b

toggle切换

利用异或^

let toggle = 0
toggle ^=1

判断两数是否相等

利用异或^

a ^ a === 0 // 相等
a ^ b !== 0 // 不相等

判断符号是否相等

利用异或^

a ^ b >=0 // 同一符号

基于位运算的权限系统

我们以四种权限的 CRUD 来举例,使用 4 位的 bit 来进行。这里有一点需要注意,单一权限有且只有一位为 1

二进制表示法: 0b 或者 0B

八进制表示法: 0 或者 0o 或 00

十六进制表示法: 0x 或者0X


八进制: 0 或 0o 或 0O
十六进制: 0x 或 0X
二进制: 0b 或者 0B
变量二进制描述
C(Create)0b0001
D(Delete)0b0010
U(Update)0b0100
R(Read)0b1000
添加权限: |
let curPermission = 0b0001 // 当前用户只有[增]的权限
// 给当前用户添加[删][改]的权限
curPermission = curPermission | D | U ; //  0b0111
校验权限: &
const curPermission = 0b0111 // 当前用户有[增][删][改]的权限
// curPermission & C == C 为真就是拥有某种权限
const allowCreate = curPermission & C = 0b0111 & 0b0001 = 0b0001 = C
const allowRead = curPermission & R = 0b0111 & 0b1000 = 0b0000 = 0 
删除权限

删除权限的本质其实是将指定位置上的 1 重置为 0。 上个例子里用户权限是 0b0111,拥有[增][删][改]三个权限,现在想删除[增]的权限,本质上就是将第一位的 1 重置为 0,变为 0b0110:

进行权限删除时,主要有以下两种方法,&(~code) 与异或(^),这里推荐使用&(~code)的方式,因为^的方式再第一次进行删除后操作后,再次进行^操作,就会再次拥有删除的权限

&(~code)

先取反,在执行&操作

let C = 0b0001
let D = 0b0010
let U = 0b0100
let R = 0b1000

let curPermission = 0b0111
// 现在删除C的权限
curPermission = curPermission & (~C) 
              = 0b0111 & (~C)
              = 0b0111 & 0b1110
              = 0b0110
异或^
let C   = 0b0001
let D   = 0b0010
let U   = 0b0100
let R   = 0b1000 

let curPermission = 0b0111
curPermission = curPermission ^ r 
              = 0b0111 ^ 0b0001
              = 0b0110

缺点: 此例中若再执行一次异或操作, 会导致又拥有了r的权限

let C   = 0b0001
let D   = 0b0010
let U   = 0b0100
let R   = 0b1000 

let curPermission = 0b0110
curPermission = curPermission ^ r 
              = 0b0110 ^ 0b0001
              = 0b0111

算法应用

只出现一次的数字

给你一个 非空 整数数组 nums ,除了某个元素只出现一次以外,其余每个元素均出现两次。找出那个只出现了一次的元素。

你必须设计并实现线性时间复杂度的算法来解决此问题,且该算法只使用常量额外空间。

示例 1 :

输入:nums = [2,2,1]

输出:1

示例 2 :

输入:nums = [4,1,2,1,2]

输出:4

示例 3 :

输入:nums = [1]

输出:1

const nums = [4,1,2,1,2]
var singleNumber = function(nums) {
    let num = nums[0]
    for(let i = 1;i<nums.length;i++){
      num = num ^ nums[i]
    }
    return num
};
console.log(singleNumber(nums))

只出现一次的数字 II

给你一个整数数组 nums ,除某个元素仅出现 一次 外,其余每个元素都恰出现 三次 。请你找出并返回那个只出现了一次的元素。

你必须设计并实现线性时间复杂度的算法且不使用额外空间来解决此问题。

示例 1:

输入:nums = [2,2,3,2] 输出:3

示例 2:

输入:nums = [0,1,0,1,0,1,99] 输出:99

判断2的幂次方

给你一个整数 n,请你判断该整数是否是 2 的幂次方。如果是,返回 true ;否则,返回 false 。

如果存在一个整数 x 使得 n == 2x ,则认为 n 是 2 的幂次方。

利用&

var isPowerOfTwo = function(n) {
    return n > 0 && (n & (n - 1)) === 0;
};
var isPowerOfTwo = function(n) {
    return n > 0 && (n & -n) === n;
};

参考资料

基于位运算的权限设计

JavaScript 中的位运算和权限设计(前端权限控制实现方案)

硬核JS」令你迷惑的位运算