前言
谈起位运算,大部分前端 er 可能都会比较陌生,我第一次接触位运算还是在大学时搞通信剪网线,什么反码补码奇偶校验码,真值表卡诺图什么的,早就还给老师了。
在前端业务开发过程中,几乎很少使用位运算相关的内容,不过位运算其实一直是一种简单好用的黑魔法,能够高效的处理一些业务场景下的计算问题,将业务问题转化为数学问题,再用高效的数学工具解决问题,也是我非常向往的 coding 模式。
位运算简单介绍
对于熟悉二进制的小伙伴们肯定早都烂熟于心了,但是大多数人还是习惯于使用十进制的运算。所以这里就简单的介绍一下位运算的概念和一些简单的与或计算使用。
位操作是程序设计中对位数组或二进制数的一元和二元操作。在许多古老的微处理器上,位运算比加减运算略快,通常位运算比乘除法运算要快很多。在现代架构中,位运算的运算速度通常与加法运算相同(仍然快于乘法运算),但是通常功耗较小,因为资源使用减少。 ——维基百科
位运算中的与或运算
与运算就是两个二进制数进行与运算时,相同位的值全为 1 时结果为 1,其他情况为 0
1001
AND 0101
= 0001
或运算则相反,相同位的值有一个为1时结果为 1,其他情况为 0
1001
OR 0101
= 1101
其他位运算本篇不多赘述,可以查询相关 wiki。
JS 中的位运算
在 JS 中使用位运算时,JS 会现将操作数转化为带符号的 32 位二进制数的补码(计算机系统在存储数字时均采用 补码 形式),而在展示时为 符号+原码 的形式。例如 (-10).toString(2) 结果为 -1010,如果用补码表示,-10 应该为 11010。
你有可能看过这样一种 ~~(Math.random() * 10) 消除小数部分的做法 ,其实这里就是对操作数执行了两次非运算。由于位运算的操作数转化,使操作数丢失了小数位,而两次取非,也将整数部分还原了。
同样的,你也能使用 (Math.random() * 10) | 1 来消除小数部分。
与运算和 1248 的妙用
为什么是 1248 ?
有聪明的小伙伴就说了,1248 可以表示为 ,用二进制表示则是 0001, 0010, 0100, 1000。往下可以一直扩展,直到 JS 的最大位 。
但除此之外你可能没注意到,1248 四个数字相加总和为 15,而 15 以内的任何数字都可以用 1248 当中的两个数字相加获得且不会重复,本质上是因为 1248 用二进制表示刚好是错位各自相加不会产生进位操作,这也就意味着 15 和 1248 进行与运算都会得到被与的值本身。正是因为如此,我们才能得以妙用位运算来解决问题。
1111 1111 1111 1111 // 15
AND 0001 0010 0100 1000 // 1 2 4 8
= 0001 0010 0100 1000 // 1 2 4 8
简单的包含关系处理
前面我们介绍过与运算的计算过程和结果,在业务开发中我们可能要校验值与集合之间的包含关系,结合与运算的流程,如果我们能把集合处理为 15,而集合内的值处理为 1248 当中的任一值,集合外的值处理为除 1248 外的其他值,那么我们就可以轻松的通过与运算,获得包含关系。
前端现在较火的框架 React 中就有这么一段源码——
在设置节点的类型时,设置 node.type = NoFlags + PerformedWork + ... + Update
要判断一个值是否具有某种类型含义时就可以直接使用 (node.type & NoFlags) === NoFlags 来判断即可。而不需要在每个节点都去存入每种状态属性,这样就可以节省不少对象内存占用。
除此之外,我们在做业务上权限系统时会对每个功能都有增删改查的权限控制(因为后端数据库就是增删改查,所以权限的终点也是增删改查),那么我们就可以设计每个功能点的增删改查权限值设计为 1(读), 2(改), 4(增), 8(删),功能的总权限值就是各个权限值相加,例如某个列表的权限值为 1 + 2 + 4 = 7。
那么在判断这个用户是否可以对列表进行删除操作时,就可以用 (7 & 8) === 8 来判断是否具有删除权限,答案自然是 false。
复杂的包含关系处理
最近看到的一篇文章【速度提高几百倍,记一次数据结构在实际工作中的运用】(在微信群里看到的,在掘金上一搜居然是 2020 年的古早文章了),文中提出了作者在他的业务产生的问题以及对应的解决方案,详细可以点进去查阅,这里简单的提取一下需求要点。
在商品选购界面中,经常有商品的品类、颜色进行选择,例如选择 42码绿色的女性运动鞋,其中 42码、绿色、女性,都是商品可选的品类,但是仓库里并不会什么品类都有,有可能只有 42 码黄色的运动鞋,没有绿色的。所以当选中了 42码 之后绿色的选项就需要置灰不可选中。
// 源数据
const merchandise = {
variations: [
{
name: "颜色",
values: [
{ name: "白色" }, // 1
{ name: "红色" } // 2
]
},
{
name: "尺码",
values: [
{ name: "39" }, // 4
{ name: "40" } // 8
]
},
{
name: "性别",
values: [
{ name: "男" }, // 16
{ name: "女" } // 32
]
},
],
products: [
{
id: 1,
variationMappings: [
{ name: "颜色", value: "白色" },
{ name: "尺码", value: "39" },
{ name: "性别", value: "男" },
], // 1 + 4 + 16 = 21
},
],
}
虽然这种场景如果实时性要求很高的话,应该每次选择都请求后端直接返回数据更合理。
需求导致作者在判断某个种类是否可选时,都要遍历产品列表,逐个取出之后和已选中的种类进行比较,这样就需要花较多的遍历计算,对性能的负担较大。而随着种类数量以及产品数量的增多,这个计算过程也就越来越长,就可能阻塞了用户输入。
作者的解决方式是空间换时间,在获取数据之后,给数据建了一个 3 层索引树,等到判断的时候,直接判断索引树中是否有该组合种类的索引。
// 作者生成的索引树格式
const tree = {
"颜色:白色": {
"尺码:39": { productId: 1 },
"尺码:40": { productId: 2 }
},
"颜色:红色": {
"尺码:39": { productId: 3 },
"尺码:40": { productId: 4 }
}
}
// 判断是否有种类存在
tree['颜色:白色']['尺码:41'].productId
这种方式虽然"一步到位"时间复杂度直接拉到 O(1),但是空间复杂度就非常夸张了,而且代码较为死板,限制很多,例如必须要先选颜色后选尺码、无法拓展其他数量层数的种类等等。
那么可不可以用 与运算 的方式来处理这种包含关系呢?
答案是肯定的,只要将所有单个种类都转化为 ,然后商品种类值就是所有种类值的叠加。
再将所有商品遍历生成一个商品种类总值的数组,然后遍历进行与运算就可以了。
// { '颜色-白色': 1, '颜色-红色': 2, '尺码-39': 4, '尺码-40': 8, ... }
let power = 0
const veriationTypeMap = merchdis.variations.reduce((acc, variation) => {
variation.values.forEach(value => {
const key = variation.name + '-' + value.name
if (!acc[key]) {
acc[key] = Math.pow(2, power)
power ++
}
})
return acc
}, {})
const variationBinaryValues = merchdise.products.map((prod) => {
return prod.variationMapping.reduce((acc, cur) => {
return acc += veriationTypeMap[cur.name + '-' + cur.value]
}, 0)
})
例如,当我选中了 白色,我要判断是否可以选中 39 码,白色的种类值为 1, 39码的种类值为 4 判断是否可以选 39 码就遍历这个数组将每个总值和目标种类值进行与运算就能得出结果了。
const typeValue = veriationTypeMap['尺码-39'] + veriationTypeMap['颜色-白色']
const hasValue = variationBinaryValues.some(value => (value & typeValue) === typeValue)
这样就不需要创建偌大一个字典索引树了,性能不错的同时也很通用,可以应用很多场景。
JS 中二进制的限制延拓
在上面的解决方案中,唯一一个缺陷就是由于 JS 位运算仅支持最大 32 位的二进制运算,而且最高位用于表示符号不可用于运算,所以我们要控制种类总数在 31 个以内,这在业务场景中可能稍微有点捉襟见肘,当超出了 31 种之后,位运算的计算结果将是错误的。
这种情况,我们可以考虑基于二进制变换数据结构以及对应的加法和与运算来延拓我们的二进制计算范围。
例如用数组来分开表示一个超过 31 位的二进制数,如下所示
将0b 1000 0000 0000 0000 0000 0000 0000 0000 01(这是一个 34 位的二进制数)
表示为
\[0b 0000 0000 0000 0000 0000 0000 0000 001, 0b100](每隔 31 位,向前进一)
做加法时就是相同索引的二进制数相加,然后处理一下进位,做与运算时,由于与运算不会产生进位,我们可以直接对数组各项做与运算即可。
封装一下
我这里封装设计是拟定以十进制的传参来获取二进制的计算结果:
const a = 1234
const b = 123
const c = 12
const d = 1
const setBin = plusBinArr(toBinArr(a), toBinArr(b), toBinArr(c))
// 1111 & 0001 === 0001
equalBinArr( andBinArr(setBinArr, toBinArr(a)), toBinArr(a)) // true
equalBinArr( andBinArr(setBinArr, toBinArr(b)), toBinArr(b)) // true
equalBinArr( andBinArr(setBinArr, toBinArr(c)), toBinArr(c)) // true
equalBinArr( andBinArr(setBinArr, toBinArr(d)), toBinArr(d)) // false
实现
type BinArr = number[]
const MAX_31_VALUE = 0b1111111111111111111111111111111
// 2^30 二进制刚好是 31 位
function toBinArr (index: number) {
if (index < 1) return [0]
const power = index - 1
const numOfZero = ~~(power / 30)
const rest = power % 30
const arr = numOfZero ? new Array(numOfZero).fill(0) : []
arr.push(Math.pow(2, rest))
return arr
}
function plusBinArr (...args: BinArr[]): BinArr {
const length = Math.max(...args.map((arr) => arr.length))
const result = new Array(length).fill(0)
result.forEach((n, i) => {
args.forEach((arr) => {
result[i] += arr[i]
})
// 处理进位
while (result[i] > MAX_31_VALUE) {
result[i + 1] += 1
result[i] -= MAX_31_VALUE
}
})
return result.length === 0 ? [0] : result
}
function andBinArr (a: BinArr, b: BinArr): BinArr {
const length = Math.max(a.length, b.length)
const result = new Array(length).fill(0)
result.forEach((n, i) => {
result[i] = a[i] & b[i]
})
while (result[result.length - 1] === 0) {
result.pop()
}
return result.length === 0 ? [0] : result
}
function equalBinArr(a: BinArr, b: BinArr) {
return a.every((value, index) => value === b[index])
}
到浏览器里运行一下
这样一来我们就可以使用不止 31 种的种类包含运算了。
应用到前面的需求里只需要代码进行简单的改造即可。
let power = 0
const veriationTypeMap = merchdis.variations.reduce((acc, variation) => {
variation.values.forEach(value => {
const key = variation.name + '-' + value.name
if (!acc[key]) {
acc[key] = toBinArr(power)
power ++
}
})
return acc
}, {})
const variationBinaryValues = merchdise.products.map((prod) => {
return plusBinArr(...prod.variationMapping.map(cur => veriationTypeMap[cur.name + '-' + cur.value])
})
const typeValue = veriationTypeMap['尺码-39'] + veriationTypeMap['颜色-白色']
const hasValue = variationBinaryValues.some(value => equalBinArr( andBinArr(value, typeValue), typeValue))
结语
以上就是本篇文章分享的全部内容了。
这里是 Xekin(/zi:kin/)。喜欢的掘友们可以点赞关注点个收藏~
最近摸鱼时间比较多,写了一些奇奇怪怪有用但又不是特别有用的工具,不过还是非常有意思的,之后会一一写文章分享出来,感谢各位支持。