同事曾问我麻将判定输赢有没有什么高效的方法,他说他随手写的三个癞子的情况下判定要6秒多。我当时只想他是需要循环 34 * 34 * 34(共有 34 种麻将)
次并依次判定输赢,这肯定不是个好方法,后来才意识到不过 39304 次循环,不至于要这么长时间,问题应该是他判定麻将输赢的效率略低吧。关于如何优化并减少三个癞子的循环次数后文也有我的想法,反正我答应他尝试实现下,本文就是整理相关内容。
在我未查阅相关资料时,最初我有两种想法(本文只深入讨论第二种想法)
* 像我当初做斗地主智能出牌机器人拆解手牌那样,拆解手牌后判定是否符合条件进而判定输赢。
* 组合出所有赢的手牌,构造 map,判定输赢只需查表即可,键值初步设想的是排序并拼接成的 string。
查阅资料,知乎 Thinkraft 回答,对我影响很大,不知为何方法打心底佩服,但是效率并未得到显著提升(这里并非没有提升,可以参考后面测试数据,提升的效率应该源于数据条目的减少吧),可能是 Golang map 查找算法相当高效吧,即便如此采用这种方法可以有效的降低内存占用,详细请看我提供的源码。
麻将共 34 种牌,Wiki-Mahjong 维基-麻将
1 - 9 饼,1 - 9 条,1 - 9 万,东,南,西,北,红中,发财,白板(剩余类型牌与本文算法无关,这里不予讨论)。
var tiles = []byte{
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, // Dots
0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, // Bamboo
0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, // Characters
0x31, 0x41, 0x51, 0x61, 0x71, 0x81, 0x91, // East South West North Red Green White
}
麻将若想赢,必须要 4 组 1 对(本文不考虑其它赢的可能,譬如 7 小对,再譬如存在 1 杠/碰的前提下,3 组 1 对即可赢),若想组合出所有赢的手牌,那自然是要找出所有的对和所有的组。
对:共 34 对,每类型均可取 1 对。
组:共 34 + (9 - 2) * 3 组,每类型可取 1 相同牌组有 34 组,饼、条、万每类型可再取 9 - 2 顺序牌组有 21 组,共 55 组。
func findPairs() [][]byte {
pairs := make([][]byte, 0, len(tiles))
for _, v := range tiles {
pair := []byte{v, v}
pairs = append(pairs, pair)
}
return pairs
}
func findGroups() [][]byte {
groups := make([][]byte, 0, len(tiles)+(9-2)*3)
// find three identical tiles
for _, v := range tiles {
group := []byte{v, v, v}
groups = append(groups, group)
}
// find three sequence tiles
for i := 2; i < len(tiles); i++ {
if tiles[i-2]+1 == tiles[i-1] && tiles[i-1] == tiles[i]-1 {
group := []byte{tiles[i-2], tiles[i-1], tiles[i]}
groups = append(groups, group)
}
}
return groups
}
虽然找出十分容易,但如何组合我当时着实迷糊了一会,问题出在 55 组里面 34 组相同牌组在组合的时候同 1 组肯定只能出现 1 次,但是另外 21 组顺序牌组在组合的时候同 1 组最多能出现 4 次(玩家就是不想杠呢!),总想着效率至上,但是相同列表里的组我却要做不同的处理,我都想过把这 55 组列表拆分成两个列表,复杂度骤升。最后释然,当前是数据准备阶段,考虑什么效率,最终拿到正确结果才是王道。暴力组合即可!!!
通过这个函数校验手牌有效,直接排序使它变得简单容易理解,后面你会发现有效的手牌早晚是要排序的。
func checkValid(win []byte) bool {
sort.Sort(byteSlice(win))
for i := 4; i < len(win); i++ {
if win[i] == win[i-4] {
return false
}
}
return true
}
这里明确遇到效率问题,是我高估了 Golang 标准库里 bytes.Equal() 函数。执行 composeWin 运行时间目测要 1 小时以上(我并未运行完成过,从插入分段日志猜测时间会很长)。不过也不能怪它,思路本身都存在问题,随着组合结果越来越多,执行 notExist 代价将越来越大。
func notExist(win []byte, wins [][]byte) bool {
for _, v := range wins {
if bytes.Equal(win, v) {
return false
}
}
return true
}
func composeWin(pairs, groups [][]byte) [][]byte {
wins := make([][]byte, 0, 11498658)
tmp := make([]byte, 14)
for _, pair := range pairs {
for _, group1 := range groups {
for _, group2 := range groups {
for _, group3 := range groups {
for _, group4 := range groups {
copy(tmp, pair)
copy(tmp[2:], group1)
copy(tmp[5:], group2)
copy(tmp[8:], group3)
copy(tmp[11:], group4)
if checkValid(tmp) && notExist(tmp, wins) {
win := make([]byte, 0, 14)
win = append(win, tmp...)
wins = append(wins, win)
}
}
}
}
}
}
return wins
}
通过下面这种方法,我将确认是否存在相同赢手牌的工作交给了 Golang map,几分钟就可得出结果。
我并未使用 string 类型做 map 键类型,其实这个方法并没有比 string 类型做键类型提升多少效率。反而多写了代码,增加了复杂度,后文会有测试数据。
type twoUint64 struct {
H uint64 // High
L uint64 // Low
}
func composeWinEx(pairs, groups [][]byte) map[twoUint64][]byte {
wins := make(map[twoUint64][]byte)
var key twoUint64
tmp := make([]byte, 14)
for _, pair := range pairs {
for _, group1 := range groups {
for _, group2 := range groups {
for _, group3 := range groups {
for _, group4 := range groups {
copy(tmp, pair)
copy(tmp[2:], group1)
copy(tmp[5:], group2)
copy(tmp[8:], group3)
copy(tmp[11:], group4)
if checkValid(tmp) {
key.H = uint64(tmp[0])
key.L = uint64(tmp[6])
for _, v := range tmp[1:6] {
key.H = key.H<<8 + uint64(v)
}
for _, v := range tmp[7:] {
key.L = key.L<<8 + uint64(v)
}
if _, ok := wins[key]; !ok {
win := make([]byte, 0, 14)
win = append(win, tmp...)
wins[key] = win
}
}
}
}
}
}
}
return wins
}
接下来说明 Thinkraft 提出的一位日本人的算法,请读者尽量去阅读 Thinkraft 的回答和日本人发布的 文章,我这里只对不易理解的地方作补充
判定赢牌时需要注意两点
* 该相同的要相同
* 该连续的要连续
举例说明
1 1 1 2 2 2 2 3 3 3 3 4 4 4
2 2 2 3 3 3 3 4 4 4 4 5 5 5
排序后的两副手牌都是赢,它们看着是否非常相似,如何概括这种赢类型,Thinkraft 把这叫做 牌型
计算相同牌数量,若连续则继续计算相同牌数量,若不连续中间用数字 0 分割
1 1 1 -> 3
1 1 1 2 2 2 2 -> 3 4(1 2 连续)
1 1 1 2 2 2 2 3 3 3 3 -> 3 4 4(2 3 连续)
1 1 1 2 2 2 2 3 3 3 3 4 4 4 -> 3 4 4 3(3 4 连续)
同理可得 2 2 2 3 3 3 3 4 4 4 4 5 5 5 -> 3 4 4 3
1 1 1 2 3 4 6 7 8 东 东 东 西 西
1 1 1 -> 3
1 1 1 2 -> 3 1(1 2 连续)
1 1 1 2 3 -> 3 1 1(2 3 连续)
1 1 1 2 3 4 -> 3 1 1 1(3 4 连续)
1 1 1 2 3 4 6 -> 3 1 1 1 0 1(4 6 不连续,用 0 分割)
1 1 1 2 3 4 6 7 -> 3 1 1 1 0 1 1(6 7 连续)
1 1 1 2 3 4 6 7 8 -> 3 1 1 1 0 1 1 1(7 8 连续)
1 1 1 2 3 4 6 7 8 东 东 东 -> 3 1 1 1 0 1 1 1 0 3(8 东 不连续,用 0 分割)
1 1 1 2 3 4 6 7 8 东 东 东 西 西 -> 3 1 1 1 0 1 1 1 0 3 0 2(东 西 不连续,用 0 分割)
同理可得 1 2 3 5 6 7 一 二 三 五 六 七 西 西 -> 1 1 1 0 1 1 1 0 1 1 1 0 1 1 1 0 2
接下来是将其二进制化,采用如下规则
1 -> 0
2 -> 1 1 0
3 -> 1 1 1 1 0
4 -> 1 1 1 1 1 1 0
10 -> 1 0
20 -> 1 1 1 0
30 -> 1 1 1 1 1 0
40 -> 1 1 1 1 1 1 1 0
如此编码的好处就是编码后每张牌只占用 1 到 2 位二进制空间,如何理解这点?数字 1 2 3 4 分别代表相同牌数量,举例来说,规则中 4 或 40 代表了四张相同牌(区别仅是该相同牌是否和后面的连续),编码后的长度分别是 7 位(1 1 1 1 1 1 0)或 8 位(1 1 1 1 1 1 1 0),7 / 4 = 1.75,8 / 4 = 2,所以每张牌只占用 1 到 2 位二进制空间啦。
在 Thinkraft 的回答评论里,有人认为这是改进的霍夫曼编码,我顺道学习一下霍夫曼编码。wiki-huffman 维基-霍夫曼
若真按照霍夫曼编码进行编码,反而无法保证将 14 张手牌数据存入 int32 里面,这里推演一番。
根据方才计算相同牌数量,不连续以 0 分割,共会出现 0 1 2 3 4 五种字符,粗略统计出现次数如下[4:2755728 2:14386266 3:26038905 0:34871796 1:43069053],将会得到如下霍夫曼编码
4 -> 0 0 0
2 -> 0 0 1
3 -> 0 1