0x00 问题来由
最近在做一个斗地主网游的AI。由于斗地主这种游戏,同样数字不同花式的牌是等效的,所以,很多计算我们不需要知道具体是哪几张牌,只需要关心每种牌有多少张。
于是,我引入了一个数据结构,姑且叫做CardsVector,表示每种牌的数量。为了方便描述,本文约定以下方式来表示一个CardsVector,与具体实现无关
X = {3:3, 4:2} // X为一个CardsVector,包含3张3,2张4
其中,X[3] == 3, X[4] == 2
这种数据结构除了初始化,还有两个主要的操作:
- 计算一个CardsVector是否包含另一个CardsVector。场景为判断某种出牌组合在不在当前手牌中。比如:
设A={3:2, 4:2, 5:2},X={3:1, 4:1, 5:1},Y={4:3,5:3},Z={4:1, 5:1, 6:1}
那么,A包含A、X,不包含Y、Z
- 从一个CardsVector中,减去一个CardsVector。场景为出牌后从手牌中移除出掉的牌,如:
设A={3:2, 4:2, 5:2},X={3:1, 4:1},A移除X后为{3:1, 4:1, 5:2}
0x01 初步方案
一开始,我是这样定义CardsVector的:
type CardsVector [16]int
很简单粗暴,用一个16个元素的int数组来保存,每个元素的值表示下标对应的牌的数量。“判断包含”和“移除”算法如下:
// Contains 判断包含
func (v CardsVector) Contains(other CardsVector) bool {
for i := 1; i < 16; i++ {
if v[i] < other[i] {
return false
}
}
return true
}
// Remove 移除
func (v CardsVector) Remove(other CardsVector) (res CardsVector) {
for i := 1; i < 16; i++ {
res[i] = v[i] - other[i]
}
}
嗯,简单粗暴,性能凑合。如果是一般场合,也就这样算了。可是,对这个数据结构的操作,在我写的棋牌AI中,属于最高频的调用,优化性能收益非常大。所以,身为强迫症患者的我决定进一步压榨性能
0x02 第一次优化
根据我们的需求,总共只有15种牌(A/2/3/4/5/6/7/8/9/10/J/Q/K/小王/大王),不会多也不会少。很多大牛说过,for循环展开有助提高性能。姑且试试。
但有没有性能优化,还是要拉出来溜溜看。于是啪啪啪的写unittest,go test之
go test -bench=.VecContains.
BenchmarkVecContainsUsingForLoop-2 20000000 73.9 ns/op
BenchmarkVecContainsNoForLoop-2 100000000 34.0 ns/op
go test -bench=.VecRemove.
BenchmarkVecRemoveUsingForLoop-2 20000000 82.7 ns/op
BenchmarkVecRemoveNoForLoop-2 20000000 57.3 ns/op
不错不错,有了大幅提升!但,还能不能更好呢?
0x03 不屈不挠的折腾
其实,以我个人性格,如果只提升1倍左右的话,我是懒得专门写文章记录的
注意到这么一点:我们的场景是记录每种牌的数量。一副扑克牌中,每种数字的牌,最多只有4张。上面的数据结构,简单粗暴的用一个[]int来保存。我们能否从这方面入手优化呢?
用[]int8的方案我自己先否决了。一个int刚好就是cpu一个字长,处理速度会更快些,即使在不同环境,cpu缓存等不确定因素可能有影响,也不会有太大的提升。
重要事情再默念三遍——每种最多只有4张!每种最多只有4张!每种最多只有4张!。
也就是说,我们可以只用3个位来表示一种牌的数量。3X16=48,貌似一个uint64能解决问题。
但,能表示数据只是一个前提,这个数据结构是否可用,还取决于能否实现上面两个关键操作。而如果每张牌的数据单独通过连串位运算单独提取出来,重复上面的算法,固然是能实现功能的。但这么繁琐的操作,必定令性能大打折扣。这不符合我们的初衷。于是,我们的思路就去到,通过神器的位运算,实现多个位的同时操作
- 判断包含
对于问题的描述,我们可以给出以下的等价表达
a) A包含B
b) 对于任意一种牌x,A[x] >= B[x]
c) 对于任意牌x,有A[x]-B[x] >= 0
对于一个k进制多位数的减法,我们可以表示为(打公式无能,仅用一个4位数示意):
A = a3*k^3+a2*k^2+a1*k^1+a0*k^0 (任意0 <= ax < k)
B = b3*k^3+b2*k^2+b1*k^1+b0*k^0 (任意0 <= bx < k)
A-B=(a3-b3)*k^3+(a2-b2)*k^2+(a1-b1)*k^1+(a0-b0)*k^0
设 cx = ax - bx
当任意ax>=bx时,A-B可以表示为“c3 c2 c1 c0”这个4位k进展数
回到我们的上文的设定,我们一个CardsVector用一个48位uint表示,我们可以视为一个16位的8进制数。但这样,我们拿两个不确定包含关系的CardsVector进行比较时,无法保证任意ax>=bx,相减后的结果无法判断是否符合要求
好吧,我们做一个修正(此处跳过各种不靠谱的草稿本尝试),我们用4位表示一种牌的数量。4X15=60,仍然能用一个uint64表示。这时候,每种牌数量二进制的最高位,必然是0的。然后,我们对上面描述c进一步转化:
d) 对任意牌x,有A[x] + C - B[x] >= C (C为任一常数)
我们在对两个A和B这两个CardsVector相减前,先将A中每种牌的数量最高位置为1,相当于加8。由于原本每种牌最多只有4张,所以必然有A[x]+8>B[x]。但如果A[x]<B[x],则有0 < A[x]+8-B[x] < 8。回到位运算中,只需要判断:
((A[x] | 0x08) - B[x]) & 0x08 == 0x08
说到这里,我们的Contains函数就呼之欲出了
type CardsVector uint64
const mask CardsVector = 0x8888888888888888 // 多么吉利的数字
func (v CardsVector ) Contains(other CardsVector ) bool {
return ((v | mask) - other) & mask == mask
}
- 移除
有了上述的推断,在保证A包含B的前提下,A移除B,就刚好等于A-B了,代码就懒得写了
看起来万事俱备,只欠bench了。不废话,来
go test -bench=.Contains
BenchmarkVecContainsUsingForLoop-2 20000000 64.3 ns/op
BenchmarkVecContainsNoForLoop-2 30000000 44.5 ns/op
BenchmarkVecBContains-2 2000000000 1.09 ns/op
go test -bench=.Remove
BenchmarkVecRemoveUsingForLoop-2 20000000 92.8 ns/op
BenchmarkVecRemoveNoForLoop-2 100000000 63.9 ns/op
BenchmarkVecBRemove-2 2000000000 1.15 ns/op
0x04 结论
我觉得我不需要说什么了