位运算在压缩数据方向的应用

1,368 阅读9分钟

位运算是在二进制层面上对位(bit)进行操作的一种运算。位运算符是用来对二进制位进行操作的符号。位运算在许多场合都很有用,特别是在处理底层数据、压缩存储和优化性能等方面。

背景

起因是在cocos社区中看到了有人发布了一个《羊》类游戏的开发框架,框架介绍中有这么一段介绍引起了我的注意:

看到了几个位运算符,发现作者可能是在利用位运算做一些数据的转换。所以在ChatGPT的帮助下,了解到了这两个函数的含义:

不用再做过多解释了,应该就是用一个数字,存储了四个属性的信息。

应用和实践

在我制作的《整理厨房》(“羊”类三消游戏)关卡时,我定义的关卡数据结构,格式清晰但是体积较大(TypeScript & Go 双语):

type LevelData =  {
  pos: number // 代表所在的9*9棋盘格中的位置,范围0—80
  floor: number // 代表所在层数
  group?: number // 是否是编组,点击编组的方块会一起添加到卡槽
  hide?: number //  是否是隐藏的方块,在被遮挡的情况下 
}[]
type LevelDataItem struct {
        Pos    int  `json:"pos"`     // Represents the position in the 9*9 grid, range 0-80
        Floor  int  `json:"floor"`   // 
        Group  *int `json:"group"`   // Whether it is a group, click on the square of the group will be added to the card slot together
        Hide   *int `json:"hide"`    // Whether it is a hidden block, when it is covered
}

type LevelData []LevelDataItem

编辑器输出的也是这样的数据格式

在一个方块数量为285个的JSON文件中,占用大小达到了14KB(如右图所示)

只看单个文件,也不是很大,但如果关卡数量增多,整个的关卡数据占用将会增大,一个可行的方法是使用CDN,从远程加载单关的数据。

当然首先想到资源压缩方法可能是输出为二维数组,去除对象格式信息(TypeScript & Go 双语):

type LevelData = [number,number,number,number][]
type LevelDataItem [4]int

type LevelData []LevelDataItem

然后在前端读取到信息后,进行一个处理。

但还是用到了4个数字。

这里我们使用上述的压缩方法,将4个属性的4个数字转换为一个数字存储。

依然是借助ChatGPT,将方法改造成适合我们数据的方法。

告诉ChatGPT需求:(Floor-1 是因为我定义的Floor从1开始)

Go版(ChatGPT转换的,如有错误请指出):

// 压缩方法
func levelData2Number(dataItem LevelDataItem) int {
        ldNumber := dataItem.Pos & 0x7F // 与十六进制数0x7F进行按位与操作。这样可以保留pos属性的低7位,将其他位设置为0。
        ldNumber |= (dataItem.Floor - 1) << 7 // floor属性减1,然后左移7位,将结果与ldNumber进行按位或操作。这实际上将floor属性(减1后的值)存储在ldNumber的第8至第12位。
        
        if dataItem.Group != nil {
                ldNumber |= *dataItem.Group << 12 // 如果group属性存在,将group左移12位,然后与ldNumber进行按位或操作。这实际上将group属性存储在ldNumber的第13至第19位。
        }
        
        if dataItem.Hide != nil {
                ldNumber |= 1 << 19 //若 hide属性为真,将1左移19位,然后与ldNumber进行按位或操作。这实际上将ldNumber的第20位设置为1。
        }
        return ldNumber
}

// number2LevelData 函数接收一个整数 n
func number2LevelData(n int) LevelDataItem {
    // pos 是 n 的最低7位,表示 Pos 属性
    pos := n & 0x7F
    
    // floor 是 n 的第8位到第12位,表示 Floor 属性,原始值需要加1
    floor := ((n & 0x3F80) >> 7) + 1
    
    // groupFlag 是 n 的第20位,如果为1,则表示存在 Group 属性
    groupFlag := (n & 0x80000) >> 19
    
    // 初始化 group 指针,用于存储 Group 属性
    var group *int
    if groupFlag != 0 {
        // 如果 groupFlag 不为0,则取 n 的第13位到第19位作为 Group 属性
        g := (n & 0x7F000) >> 12
        group = &g
    }
    
    // hide 是 n 的第20位,如果为1,则表示存在 Hide 属性
    hide := n&0x80000 != 0

    // 创建一个 LevelDataItem 结构体,填充 Pos 和 Floor 属性
    levelData := LevelDataItem{
        Pos:   pos,
        Floor: floor,
    }

    // 如果 group 指针不为 nil,将 group 指针赋值给 LevelDataItem 结构体的 Group 属性
    if group != nil {
        levelData.Group = group
    }

    // 如果 hide 为 true,则创建一个新的 bool 变量,并将其地址赋值给 LevelDataItem 结构体的 Hide 属性
    if hide {
        levelData.Hide = &hide
    }
    return levelData
}

经过优化后,JSON文件缩小到了2KB(14KB-> 2KB)

这样前端在遍历levelData时,对当前的item做一次数据转换(调用number2LevelData函数)即可。

当然还可以对键名做缩短(如levelData用L代替),达到进一步压缩体积的效果。

总结

  • 位运算对于萌新,理解方面还是稍有难度,还好有ChatGPT的帮助,得以完成这次的转化任务。经过以上的处理后,无论是将关卡数据放置在游戏包体内,还是存于CDN,都有着不错的体积大小,从而达到优化效果。对于更大型的文件,压缩效果可能更加明显。

  • 很多老手估计早已在项目中熟练运用位运算来解决实际问题,其实难度也不大还快,推荐大家多多使用。

  • cocos商城里售卖源码的作者为提高卖点,会将性能优化、功能模块划分等方面做到极致,感谢此作者在介绍中的无偿提供,源码我就不买了(#^.^#),帮作者宣传一下吧。 Cocos Store - 消除小羊羊

常用的位运算符

  1. 按位与(&):对应位都为1时,结果为1,否则为0
  2. 按位或(|):对应位只要有一个为1,结果为1,否则为0。
  3. 按位异或(^):对应位不相同时,结果为1,否则为0。
  4. 按位非(~):将每个位取反,0变为1,1变为0。
  5. 左移(<<):将位向左移动指定的位数,右侧用0填充。
  6. 有符号右移(>>):将位向右移动指定的位数,左侧用符号位填充(正数用0填充,负数用1填充)。
  7. 无符号右移(>>>):将位向右移动指定的位数,左侧用0填充,不考虑符号位。 下面是这些位运算符的一些基本示例:

// 按位与(&)
console.log(5 & 3); // 输出 1,因为 0101 & 0011 = 0001

// 按位或(|)
console.log(5 | 3); // 输出 7,因为 0101 | 0011 = 0111

// 按位异或(^)
console.log(5 ^ 3); // 输出 6,因为 0101 ^ 0011 = 0110

// 按位非(~)
console.log(~5); // 输出 -6,因为 ~0101 = 1010,表示 -6(补码形式)

// 左移(<<)
console.log(5 << 2); // 输出 20,因为 0101 << 2 = 10100

// 有符号右移(>>)
console.log(-5 >> 2); // 输出 -2,因为 1011 >> 2 = 1110,表示 -2(补码形式)

// 无符号右移(>>>)
console.log(-5 >>> 2); // 输出 1073741822,因为 1011 >>> 2 = 00101111111111111111111111111110

Q & A

  • Q: 为什么这里边使用16进制数来表示

  • A: 在编程和计算机科学中,16进制(十六进制)数通常用于表示字节和内存地址,因为它们可以更简洁地表示二进制数。一个16进制数字(例如0x7F)可以表示4个二进制位(例如01111111),而一个10进制数字可能需要多达8个二进制位(如255,它的二进制表示为11111111)。 当我们提到将一个整数与10进制数127或16进制数0x7F进行按位与操作时,我们实际上是将这些数都视为二进制数进行按位与操作。例如,将一个整数与127(即十进制)进行按位与操作,就相当于将该整数与0x7F(即十六进制)进行按位与操作,因为它们都等价于二进制数01111111。 所以,不管我们使用10进制数还是16进制数表示数字,最终进行按位与操作时,都是针对这些数的二进制形式进行的。

  • Q: 按位与有哪些应用

  • A: 1. 限制整数范围:如之前所述,可以通过将整数与某个特定的掩码进行按位与操作,以确保结果在预期范围内。例如,与0x7F相与可以限制范围在0-127。 2. 提取特定位:可以使用按位与操作从一个较大的整数中提取某些特定位。例如,假设我们有一个整数,其低4位表示一个特定的数值,我们可以将该整数与0x0F(二进制表示00001111)相与,以提取这些位。 3. 检查特定位是否设置:按位与操作可以用来检查一个整数的特定位是否为1。例如,假设我们想检查一个整数的第3位是否为1,我们可以将该整数与掩码0x04(二进制表示00000100)相与,如果结果不为零,则表示第3位为1。 4. 将特定位清零:可以通过将整数与某个特定掩码的按位非结果(即按位取反)相与,将整数的某些特定位清零。例如,如果我们想将整数的最低位清零,可以将整数与掩码0xFE(二进制表示11111110)相与。 5. 实现位掩码:按位与操作常用于位掩码,即将一个整数的某些位设置为1或0,而保持其他位不变。例如,我们可以使用按位与操作和按位或操作结合实现位掩码功能。

  • Q: 按位或的应用场景
  • A: 1. 设置特定位:按位或操作可以将整数的特定位设置为1,而保持其他位不变。例如,如果我们想将一个整数的第3位设置为1,我们可以将该整数与掩码0x04(二进制表示00000100)相或。 2. 合并标志位:在编程中,我们经常使用位标志来表示不同的状态。通过按位或操作,我们可以将多个标志合并到一个整数中。例如,如果有两个标志位A(二进制表示00000001)和B(二进制表示00000010),我们可以通过将这两个标志位进行按位或操作来合并它们:A | B = 00000011。 3. 实现位掩码:如上一篇回答所述,按位或操作可以与按位与操作结合使用,实现对整数的某些位进行设置或清除,这被称为位掩码。通过使用适当的掩码与一个整数进行按位与和按位或操作,我们可以控制整数的各个位的值。 4. 快速计算最小值:对于无符号整数,按位或操作可以用于计算两个整数的最小值。假设我们有两个无符号整数A和B,则min(A, B) = A & B | A ^ B & -(A < B),其中&表示按位与,^表示按位异或,|表示按位或。 5. 求两个集合的并集:在某些情况下,可以使用位向量表示集合。此时,按位或操作可用于求两个集合的并集。例如,集合A = {1, 3}可以表示为二进制位向量0101,集合B = {2, 3}可以表示为0110。那么A和B的并集 = {1, 2, 3},其表示为二进制位向量0111,即 A | B。

参考文献

GPT4.0

2022.5.16