背景
兄弟们,之前我开发了支持联机对战的五子棋、斗地主、UNO。在大家的呼吁之下,我准备开发「象棋」啦!
😄 联机象棋马上就能跟大家见面了!
之前的进展:
- 《用SVG画一个象棋棋盘》。
- 《基于svg和ttf(字体文件),我仅用6kb就画完了象棋所有棋子》。
- 《我用43个字符,就存下了象棋的棋盘状态》。
- 《JS实现象棋移动规则》。
- 《一种记录象棋历史记录的方案:平均每步仅占10bit位》。
- 《用JS实现平均每步仅占10bit位的象棋历史记录保存方案(encode篇)》。
- 《用JS实现平均每步仅占10bit位的象棋历史记录保存方案(decode篇)》。
- 《极致压缩:用2至5位二进制表示17种可能性》。
- 《车炮能移动17个位置,针对90种出发点,如何建立0-16和目标点的映射?》。
在开发过程中,我逐渐掌握了一种技巧:压缩存储空间。我意识到,除了历史记录的空间可以压缩,棋盘状态也可以压缩。
我今天把棋盘状态的存储空间足足压缩了40%!收益在于分享棋盘时,链接更短!写文给大家分享一下方案。
前文回顾
之前我在文章《我用43个字符,就存下了象棋的棋盘状态》中提到:
如何存储当前棋局
方案有3种:
- 象棋一共32个棋子,每个棋子有91种状态:死亡或位于0-89中任一位置。所以用长度为32的列表即可,每个数的值域是0-90,其中90代表死亡。
- 死亡的棋子不再占用空间,使用类似map的结构,key是棋子id,value是棋子位置(0-89)。
- 压缩空间的方案:将帅个子有9个可能在的位置,只需要0-9即可表示,需要5位二进制。士有5种位置,每个士只需要3位二进制。以此类推……占用空间相对最少。
其实方案三占用空间相对较少(其实还有占用空间更少的方案,但是计算难度过高,pass了),但是开发成本也较高,需要开发者去拼接二进制位。而方案二在棋子多的时候,占用空间较多,所以存储空间的大小不太稳定。
最终,我选择了方案一。
当时由于方案三太难,而且当时没想到「变长二进制」,认为方案三只能缩减10%的存储空间,收益不大,所以我放弃了存储空间更小的方案三。
但是在前天,我发布了文章《极致压缩:用2至5位二进制表示17种可能性》,已经掌握了「变长二进制」压缩空间的奥秘。在昨天,我发布了文章《车炮能移动17个位置,针对90种出发点,如何建立0-16和目标点的映射?》,掌握了走法和二进制的简便的映射方法。
现在,我有足够的能力来选择和实现方案三了。
重新认识「前缀可识别」的变长二进制
很多时候我们处于知识封闭区,我们在研究的问题可能已经被其他人解决了,并且可能有了更优秀的方案。难点在于,如何通过搜索引擎发现那些我们连名字都不知道的知识,突破自己的知识封闭区。
我想重新提一下文章《极致压缩:用2至5位二进制表示17种可能性》中的「变长二进制」,文中我自己提出了一个概念:「前缀可识别」,意思是我们从左往右遍历,有且仅有1种解析方法。并且我用编译原理的LR(0)文法做了类比。
我认为某门学科一定已经研究过这种东西了,只是我不知道名字,所以我尝试各种单词去搜索,最终找到了「前缀可识别」的标准叫法:Prefix-Free Code,也可以叫Prefix Code,它来源于信息论学科,维基百科:en.wikipedia.org/wiki/Prefix… 描述如下:
A prefix code is a type of code system distinguished by its possession of the "prefix property", which requires that there is no whole code word in the system that is a prefix (initial segment) of any other code word in the system. It is trivially true for fixed-length code, so only a point of consideration in variable-length code.
For example, a code with code words {9, 55} has the prefix property; a code consisting of {9, 5, 59, 55} does not, because "5" is a prefix of "59" and also of "55". A prefix code is a uniquely decodable code: given a complete and accurate sequence, a receiver can identify each word without requiring a special marker between words. However, there are uniquely decodable codes that are not prefix codes; for instance, the reverse of a prefix code is still uniquely decodable (it is a suffix code), but it is not necessarily a prefix code.
它举了个例子,针对集合{9, 5, 59, 55}就不是 prefix code,因为「5」有二义性,遇到5后,不知道该结束流程,还是继续读取后面的9或5。
哈夫曼编码 Huffman Coding
信息论中有个经典问题:给定一篇文章,如何用最短的二进制编码它。
解决方案就是:找出出现的所有单词集合(例如:I am good good good,出现了3个单词),计算每个单词出现频率,以某种方式,构造每个单词对应的二进制编码,满足条件:基于前缀就能知道它代表哪个单词。然后我们把这些前缀拼在一起,就成功编码了(并且是可以解码的)。
例如这种编码 good = 0, I = 10, am = 11,文章就表示为1011000。
这是最简短的编码了。构造方法就是通过构造一颗哈夫曼树,算法如下:
- 针对每一个单词(或组合),都有一个对应的频数,作为频数表。如果当前只有1个,就进入4,否则进入2。
- 找到频数最低的2个,作为表示一个组合,他们对应的频数就是两个单词(或组合)的频数之和,加入频数表(同时删除这2个单词或组合各自的频数)。
- 选取的2个单词(或组合),分别作为左子树和右子树,组成一个树。进入1。
- 现在得到了一个二叉树(叫做哈夫曼树),每个叶子结点代表一个单词。规定左分叉为0,右分叉为1,这个单词对应的
Prefix Code
就是根节点到它的路径。
例如上述编码对应的哈夫曼树就是:
对于我做象棋的启发
分析
在文章中《极致压缩:用2至5位二进制表示17种可能性》,我是自己构造了一种二进制编码。其实文章里描述的「 方案四:另一个前缀可识别方案,平均4.12位」,就是特殊情况下的哈夫曼编码:所有单词出现频率一致时,就会产生我提到的编码方式。文中我用4-5位二进制表示了17种可能性。
回到象棋棋盘状态的问题:
- 将帅有10个位置(包括死亡状态)。
- 士有6个位置(包括死亡状态)。
- 象有8个位置(包括死亡状态)。
- 马有91个位置(包括死亡状态)。
- 车有91个位置(包括死亡状态)。
- 炮有91个位置(包括死亡状态)。
- 兵有48个位置(包括死亡状态)。
不妨假设他们出现在各个位置的频率都一致,不难构造出对应的编码。(这样的编码是比较稳定的,无论棋局变成什么样子,都不会太长)
- 10个位置,需要3-4位。
- 6个位置,需要2-3位。
- 8个位置,需要3位。
- 48个位置,需要4-5位。
- 91个位置,需要6-7位。
我们以10个位置的情况,来探讨通用的编码生成方法。首先根据哈夫曼树,可以构造这样的编码:
- 000代表0
- 001代表1
- 010代表2
- 011代表3
- 100代表4
- 101代表5
- 1100代表6
- 1110代表7
- 1101代表8
- 1111代表9
随后容易发现这样的规律:
- 至于0-5,用3位二进制编码即可。
- 至于6-7,我们需要在3位的
6(110)
和7(111)
末尾新增0。 - 至于8-9我们需要在3位的
6
和7
末尾新增1。
可以利用数学归纳法,归纳总结出这样的算法:
- 针对X个位置的情况,计算Log2(X),分别向下取整和向上取整,得到A和B。
- 如果A=B,则用A位二进制表示这X个数即可,直接转换进制。
- 如果A<B,则用A位二进制表示位置
0
至2^A-1-(X-2^A)
;用B位二进制表示其它位置;针对位置2^A-(X-2^A)
至2^A-1
,编码为A位的进制转换,并在末尾拼接一位0
(共计B位);针对其它位置,编码为位置减去(X-2^A)
再转换二进制,并在末尾拼接一位1
(共计B位)。
可以发现,这种算法,位置编号小的比位置编号大的少了一位。也就是说,我们应该尽量把出现频率较高的位置放在前面。
生成各棋子的位置列表
解释:
- 我可以把将帅的「死亡」(127)调整到了最后一位,因为他们死亡是非常罕见的,这样可以节约2bit空间。
- 我刻意把棋子常见位置放在了数组前几位,尤其是将帅、士、兵,这样可以节约几bit空间。
- 兵的位置,红色和黑色不同,刚过河的一排放在前面,离河远的位置放在后面,可以节约几bit空间。
提前计算log
为了提高效率,我应该避免在JS中计算Math.log2,而要提前定义好运算结果。
按照编码规则encode
基于文章《用JS实现平均每步仅占10bit位的象棋历史记录保存方案(encode篇)》提到的concatBits
函数,我写了concatFlexibleBits
函数:
这里encode规则,就是按照上面提到的算法实现的。不过多解释了。
按照编码规则decode
基于文章《用JS实现平均每步仅占10bit位的象棋历史记录保存方案(decode篇)》的readBits
函数,我写了readFlexibleBits
函数:
这里decode规则,是按照上面算法解析的。先读取floorLog
位,如果总位置数就是2的次方,则结束。如果读取到的数比较小,也结束。如果读取到的数超过某个临界值,就需要多读取一位,决定它代表谁。
原先,存储棋盘状态在Base64编码后需要43个字符(见《我用43个字符,就存下了象棋的棋盘状态》)。现在,保存初始棋局的编码是hSQiASVGghCEIUkIgElRoIQhDw(第一位保存了当前轮到谁了,1表示红0表示黑),只需要26个字符,减少了(43-26)/43=40%的存储空间。
我是HullQin,公众号线下聚会游戏的作者(欢迎关注公众号,联系我,交个朋友),转发本文前需获得作者HullQin授权。我独立开发了《联机桌游合集》,是个网页,可以很方便的跟朋友联机玩斗地主、五子棋等游戏,不收费无广告。还独立开发了《合成大西瓜重制版》。还开发了《Dice Crush》参加Game Jam 2022。喜欢可以关注我噢~我有空了会分享做游戏的相关技术,会在这2个专栏里分享:《教你做小游戏》、《极致用户体验》。