位运算的相关性质和算法技巧

异或运算的“半加”特性可以理解为基于二进制的加法,但是不进位,也就是说加法溢出时丢弃进位

1.异或的性质

  • x^0 = x 且 x^x = 0

  • 交换律:x^y = y^x

  • 结合律:(x^y)^z = x^(y^z)

  • 自反性:x^y^y = x

2.运用异或交换两个a,b的值

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

3.异或应用的一些场景

3.1A集合里拿掉数x得到B集合,求x

XOR(X)表示将X集合内所有的数做异或

XOR(B)^XOR(A) = XOR(B)^XOR(B)^x = 0^x = x

3.2 A集合里拿掉数x、y得到B集合,求x和y

 按上一个的办法可以推导出

xor(A)^xor(B) = xor(B)^xor(B)^x^y = 0^x^y = x^y

x^y的二进制结果,第n位为1,说明x和y的第n位不相同

根据第n位是否为0把A里所有的数分成A1和A0两个数组(A1里的数的二进制第n位都是1,A0都是0)

A1和A0应该各包含了a或者b(这样第n位才能异或出1)

同理可以把B分成B1和B0两个数组

可以得到第一个数 x = A1^B1

第二个数可以y = A0^B0,当然也可以用x^y^x求得

另外如果x^y为0,即x == y,令SUM(X)为X集合内所有数求和

(SUM(A) - SUM(B)) / 2 = x

3.3集合A里只有数x出现1次,其余数全都重复出现2次,求x

xor(A) = x^y^y^…^z^z = x^(y^y^…^z^z) = x^0 = x

作为算法题的一个大类,位运算相关的题目常常出现在各大公司的面试/笔试题中,下面先说说位运算的基本原理。

位运算使得计算机可以直接对每个比特位进行计算,效率会非常的高。

位运算会将操作数当作 32 位的二进制串进行计算,如果二进制串超过 32 位,则只保留最后的 32 位进行计算,如:

11100110111110100000000000000110000000000001 # 输入的二进制串
            10100000000000000110000000000001 # 实际使用的二进制串
复制代码

位运算有 7 种运算符:

  1. 按位与(a & b):在 a, b 的位表示中,每一个对应的位都为 1 则返回 1,否则返回 0

# 15 & 9 -> 9
  0000 0000 0000 0000 0000 0000 0000 1111
& 0000 0000 0000 0000 0000 0000 0000 1001
  ---------------------------------------
  0000 0000 0000 0000 0000 0000 0000 1001
复制代码
  1. 按位或(a | b):在 a, b 的位表示中,每一个对应的位,只要有一个为 1 则返回 1,否则返回 0

# 15 | 9 -> 15
  0000 0000 0000 0000 0000 0000 0000 1111
| 0000 0000 0000 0000 0000 0000 0000 1001
  ---------------------------------------
  0000 0000 0000 0000 0000 0000 0000 1111
复制代码
  1. 按位异或(a ^ b):在 a, b 的位表示中,每一个对应的位,两个不相同则返回 1,相同则返回 0;异或运算的“半加”特性可以理解为基于二进制的加法,但是不进位,也就是说加法溢出时丢弃进位

# 15 ^ 9 -> 6
  0000 0000 0000 0000 0000 0000 0000 1111
| 0000 0000 0000 0000 0000 0000 0000 1001
  ---------------------------------------
  0000 0000 0000 0000 0000 0000 0000 0110
复制代码
  1. 按位非(~a):反转被操作数的位,即将每一位的 0 转为 1,1 转为 0
    -a = ~a+1

# ~15 -> -16
~ 0000 0000 0000 0000 0000 0000 0000 1111
  ---------------------------------------
  1111 1111 1111 1111 1111 1111 1111 0000
复制代码
  1. 左移(a << b):将 a 的二进制串向左移动 b 位,右边移入 0

# 9 << 2 -> 36
<<  0000 0000 0000 0000 0000 0000 0000 1001
    ---------------------------------------
    0000 0000 0000 0000 0000 0000 0010 0100
复制代码
  1. 有符号右移(a >> b):把 a 的二进制表示向右移动 b 位,向右被移出的位被丢弃,拷贝最左侧的位以填充左侧。这种右移由于保留最左侧的二进制位,因此可以保留数字原本的正负符号

# 9 >> 2 -> 2
>>  0000 0000 0000 0000 0000 0000 0000 1001
    ---------------------------------------
    0000 0000 0000 0000 0000 0000 0010 0010
# -9 >> 2 -> -3
>>  1111 1111 1111 1111 1111 1111 1111 0111
    ---------------------------------------
    1111 1111 1111 1111 1111 1111 1111 1101
复制代码
  1. 无符号右移(a >>> b):把 a 的二进制表示向右移动 b 位,向右被移出的位被丢弃,左边空出的位全部填充为 0。这种右移由于左侧直接补 0,因此生成的数字必然是非负数

# 19 >>> 2 -> 4
>>>  0000 0000 0000 0000 0000 0000 0001 0011
     ---------------------------------------
     0000 0000 0000 0000 0000 0000 0010 0010
# -19 >>> 2 -> 1073741819
>>>  1111 1111 1111 1111 1111 1111 1110 1101
     ---------------------------------------
     0011 1111 1111 1111 1111 1111 1111 0011
复制代码

常用性质

在使用位运算技巧解的算法题中,有以下这些常用的性质:

  1. a 与自身之间的操作

a & a = a
a | a = a
a ^ a = 0

复制代码

a 与 0 之间的操作

a & 0 = 0
a | 0 = a
a ^ 0 = a

复制代码

按位与、按位或的还原计算

a | ( a & b ) = a
a & ( a | b ) = a

复制代码

通过异或完成变量值交换

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

复制代码
  1. 判断奇偶(通过 & 1 取出最后一个二进制位以达到模 2 的效果)

# 位运算效率更高
a & 1 === a % 2

复制代码
  1. 比较两值是否相等(a ^ a === 0)

a ^ b === 0

复制代码

  1. 将第 i + 1 个二进制位设为 1

a |= 1 << i

复制代码
  1. 将第 i + 1 个二进制位设为 0

a &= ~(1 << i)

复制代码
  1. 取出第 i + 1 个二进制位上的数值

a & (1 << i)

复制代码
  1. 在 a 第 i + 1 个二进制位,插入 b 对应位置的二进制位

a |= 1 << i
a & (b & 1 << i)

复制代码
  1. 删除二进制序列中最后一个值为 1 的位置

a &= (a - 1)

复制代码
  1. 计算 a 的相反数

-a === ~a + 1

复制代码
  1. 保留 a 在二进制位中最后一个 1

a &= (-a)

复制代码
  1. 生成二进制位全为 1 的数

~0

复制代码
  1. 保留 a 二进制序列中最后的 i - 1 位,其余补 0

a & ((1 << i) - 1)

复制代码
  1. 将 a 二进制序列中最后 i - 1 位全部置为 0

a & ~((1 << i) - 1)

复制代码
  1. 判断 a 的二进制序列最高位是否为 1

a < 0 # 最高位为 1 必然是负数

复制代码
  1. 在二进制序列中,仅保留最高位的 1,其他设为 0,输出该数

a = a | (a >> 1)
a = a | (a >> 2)
a = a | (a >> 4)
a = a | (a >> 8)
a = a | (a >> 16)
return (a + 1) >> 1
复制代码

下面,我们通过一些简单-中等的题目来体验一下位运算解题的巧妙。

题目

78 - 子集

leetcode-cn.com/problems/su…
给你一个整数数组 nums,数组中的元素互不相同。返回该数组所有可能的子集(幂集)。
解集不能包含重复的子集。你可以按任意顺序返回解集。
示例 1:
输入: nums = [1,2,3]
输出: [[],[1],[2],[1,2],[3],[1,3],[2,3],[1,2,3]]
从位运算出发去思考,如果用 0 和 1 标记一个数是否存在于子集中,这样列下来,例子中的输入 [1,2,3] 就会产生下表所示的子集与 0/1 序列的关系,因此我们可以直接遍历这个 0/1 序列,去构建每个子集的数据。

代码的具体实现上,可以通过 1 << nums.length 获取子集的总数;可以利用性质 9 取出相应二进制位上的数值用以构建子集的具体结构。具体的代码实现如下:

/**
 * @param {number[]} nums
 * @return {number[][]}
 */
var subsets = function(nums) {
var res = [], len = nums.length
for (var i = 0; i < (1 << len); i++) {
var currSubset = []
for (var j = 0; j <= len; j++) {
if (i & (1 << j)) currSubset.push(nums[j])
    }
    res.push(currSubset)
  }
return res
};

复制代码

复杂度分析:
时间复杂度为 O(n2^n))O(n2n)),子集的总数为 1 << n 即 2n 种,得 O(2^n)O(2n),构造每个子集时需要遍历一次原数组,得 O(n)O(n)。
空间复杂度为 O(n)O(n),只有构造子集时使用的临时数组需要额外空间的开销。

136 - 只出现一次的数字

leetcode-cn.com/problems/si…
给定一个非空整数数组,除了某个元素只出现一次以外,其余每个元素均出现两次。找出那个只出现了一次的元素。
说明:
你的算法应该具有线性时间复杂度。你可以不使用额外空间来实现吗?
示例 1:
输入: [2,2,1]
输出: 1
示例 2:
输入: [4,1,2,1,2]
输出: 4

从位运算出发去思考,我们可以找到性质 1、2,两个相等的数异或之后为 0,一个数异或 0 等于它本身,那么将数组中所有元素相异或之后,出现 2 次的数字就会全部被约去,剩下只出现 1 次的数字,代码如下:

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

复制代码

复杂度分析:
时间复杂度为 O(n)O(n),因为只有一次遍历数组操作。
空间复杂度为 O(1)O(1),没有额外空间开销。

169 - 多数元素

leetcode-cn.com/problems/ma…
给定一个大小为 n 的数组,找到其中的多数元素。多数元素是指在数组中出现次数大于 n/2 的元素。
你可以假设数组是非空的,并且给定的数组总是存在多数元素。
示例 1:
输入: [3,2,3]
输出: 3
示例 2:
输入: [2,2,1,1,1,2,2]
输出: 2

题干要求的多数元素,出现次数大于 n/2,从位运算出发去思考可以推断,如果将每一个数字都用 32 位二进制序列列出,去统计每一个二进制位是 0 多还是 1 多,则由每一位的多数元素组成的二进制序列,就可以组成最终那个多数元素的数值。代码如下:

/**
 * @param {number[]} nums
 * @return {number}
 */
var majorityElement = function(nums) {
var res = 0, len = nums.length
for (var i = 0; i < 32; i++) {
var ones = 0, zero = 0
for (var j = 0; j < len; j++) {
if (ones > len / 2 || zero > len / 2) {
break
      }
if ((nums[j] & (1 << i)) === 0) {
        zero++
      } else {
        ones++
      }
    }
if (ones > zero) res |= 1 << i
  }
return res
};

复制代码

复杂度分析:
时间复杂度为 O(n)O(n),一次枚举二进制序列上所有的位(32 位),一次遍历整个数组。
空间复杂度为 O(1)O(1),没有额外空间开销。

342 - 4 的幂

leetcode-cn.com/problems/po…
给定一个整数,写一个函数来判断它是否是 4 的幂次方。如果是,返回 true;否则,返回 false。
整数 n 是 4 的幂次方需满足:存在整数 x 使得 n == 4 ^ x。
示例 1:
输入: n = 16
输出: true
示例 2:
输入: n = 5
输出: false
示例 3:
输入: n = 1
输出: true

将 32 个二进制位铺开来看,一个数为 4 的幂意味着有唯一的数字 1 在序列的奇数位置出现。

0000 0000 0000 0000 0000 0000 0000 0001 # 1
0000 0000 0000 0000 0000 0000 0000 0100 # 4
0000 0000 0000 0000 0000 0000 0001 0000 # 16
0000 0000 0000 0000 0000 0000 0100 0000 # 64
复制代码

所以可以利用性质 11,根据消去最后一个 1 之后结果是否为 0,可以判断数字的二进制序列中是否存在唯一的 1;另外在循环中使用 >>> 右移操作符直到数字为 0,可以统计出 1 的初始位置。因此代码如下:

/**
 * @param {number} n
 * @return {boolean}
 */
var isPowerOfFour = function(n) {
// 是否唯一的 1
var onlyOne = (n & (n - 1)) === 0
// 求 1 的位置
var pos = 0, temp = n
while (temp !== 0) {
    temp >>>= 1
    pos++
  }
// 若有唯一的 1,且 1 是奇数,则认为其是 4 的幂
return onlyOne && ((pos & 1) !== 0)
};
复制代码

复杂度分析:
时间复杂度为 O(1)O(1),解中唯一的循环,是在二进制序列上移动,移动到数字本身为 0,即可求出唯一 1 原来的位置。
空间复杂度为 O(1)O(1),没有额外空间开销。

461 - 汉明距离

leetcode-cn.com/problems/ha…
两个整数之间的汉明距离指的是这两个数字对应二进制位不同的位置的数目。
给出两个整数 x 和 y,计算它们之间的汉明距离。
注意: 0 ≤ x, y < 231.
示例:
输入: x = 1, y = 4
输出: 2
上面的箭头指出了对应二进制位不同的位置。

首先列出示例中两个数的二进制序列:

0000 0000 0000 0000 0000 0000 0000 0001
0000 0000 0000 0000 0000 0000 0000 0100
复制代码

很明显,只要把两数相异或,二进制位不同的位在结果中即为 1,再使用性质 11 统计结果中 1 的个数,即可得解。因此代码如下:

/**
 * @param {number} x
 * @param {number} y
 * @return {number}
 */
var hammingDistance = function(x, y) {
var xorRes = x ^ y, count = 0
while (xorRes !== 0) {
    xorRes &= (xorRes - 1)
    count++
  }
return count
};
复制代码

时间复杂度与空间复杂度均为 O(1)O(1)。

1356 - 根据数字二进制下 1 的数目排序

leetcode-cn.com/problems/so…
给你一个整数数组 arr 。请你将数组中的元素按照其二进制表示中数字 1 的数目升序排序。
如果存在多个数字二进制中 1 的数目相同,则必须将它们按照数值大小升序排列。
请你返回排序后的数组。
示例 1:
输入: arr = [0,1,2,3,4,5,6,7,8]
输出: [0,1,2,4,8,3,5,6,7]
解释: [0] 是唯一一个有 0 个 1 的数。
[1,2,4,8] 都有 1 个 1。
[3,5,6] 有 2 个 1。
[7] 有 3 个 1 。
按照 1 的个数排序得到的结果数组为 [0,1,2,4,8,3,5,6,7]

最暴力的方法,直接调用语言自带的排序函数,对比函数中则使用性质 11 统计 value1 和 value2 二进制序列中 1 的个数,最终 1 的个数不同时按照 1 的个数升序,相同时按照数值大小升序。因此代码如下:

/**
 * @param {number[]} arr
 * @return {number[]}
 */
var sortByBits = function(arr) {
return arr.sort((v1, v2) => {
var temp1 = v1, temp2 = v2, count1 = 0, count2 = 0
while (temp1 !== 0) {
      temp1 &= temp1 - 1
      count1++
    }
while (temp2 !== 0) {
      temp2 &= temp2 - 1
      count2++
    }
return count1 === count2 ? v1 - v2 : count1 - count2
  })
};
复制代码

复杂度分析:
开销全部来自于系统自带的排序函数,JS 的 Array.prototype.sort 是使用快排实现的,因此复杂度与快排一致,时间复杂度为 O(nlogn)O(nlogn),空间复杂度为 O(n)O(n)。

总结

在上面列出的题目中,我认为 78 - 子集 和 169 - 多数元素 是很有代表性的题目,这类问题的共通性在于,题干都可以拆分为多个是非问题的组合。78 - 子集 是被拆分为了数组中的每一个数是否存在的问题的集合;而 169 - 多数元素 则是被拆分为统计二进制序列中每一个位置上的多数值。因此在解题时可以从这个方向进行思考。

位掩码

虽然本篇是从算法角度切入,但是位运算在日常开发中还有一些实用的小技巧,比如知名 JS 函数库 Lodash 中使用到的位掩码技术。

位掩码通常用于处理多个布尔变量的组合,在 Lodash 中,JS 对象拷贝相关函数的基础函数 baseClone 就使用了位掩码来控制不同的克隆方式。让我们直接来解读一下它的核心代码。

首先,我们需要几个预设的掩码,它们都是由一个仅有唯一 1 的二进制序列组成的(因此它们都是 2 的幂),每个二进制位代表一个开关,而后续的代码中,则可以通过某些二进制运算,来表示各种关系的不同组合:

const CLONE_DEEP_FLAG = 1    // 0001:深拷贝
const CLONE_FLAT_FLAG = 2    // 0010:拷贝原型链标志位
const CLONE_SYMBOLS_FLAG = 4 // 0100:拷贝 Symbol 类型标志位
复制代码

然后是 cloneDeep 的代码,它调用了 baseClone,并传入了掩码 CLONE_DEEP_FLAG | CLONE_SYMBOLS_FLAG,这个表达式运算的结果是 5,也就是二进制的 0101。我们发现,通过 | 运算将两个掩码组合,就可以让两个标志位同时变成 1:

function cloneDeep(value) {
// cloneDeep 需要深克隆和克隆 Symbol
// 1 | 4 -> 0001 | 0100 -> 0101
return baseClone(value, CLONE_DEEP_FLAG | CLONE_SYMBOLS_FLAG)
}
复制代码

最后找到了 baseClone 的核心代码,这里将刚才传入的 CLONE_DEEP_FLAG | CLONE_SYMBOLS_FLAG 和三个掩码分别进行 & 操作,突然发现这里正是用到了常用性质中的性质 3!这时会发现,我们只要将传入的掩码 bitmask 与各个标志的掩码进行 & 操作,则如果 bitmask 中相应的二进制位为 1,则结果就是该掩码的值;如果 bitmask 中相应的二进制位为 0,则结果就是 0。这样就可以为每个标志位生成其对应的布尔变量,用于后续的函数判断了:             

// 刚才我们传入的值是 1 | 4 = 5
// 因此 isDeep 的值即为 5 & 1 = 1
//   0101
// & 0001
// ------
//   0001
// 可以发现,当传入掩码的二进制序列中存在相应标志位时
// & 操作就会直接返回当前标志位的掩码
// 这样,如果返回的结果为 0,则意味着掩码中不存在相应标志位,结果为 0 == false
// 如果返回的结果大于 0,则意味着掩码中存在相应标志位,结果为任意数字,转换为布尔值为 true
function baseClone(value, bitmask, customizer, key, object, stack) {
const isDeep = bitmask & CLONE_DEEP_FLAG
const isFlat = bitmask & CLONE_FLAT_FLAG
const isFull = bitmask & CLONE_SYMBOLS_FLAG
  ...
}
复制代码
  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值