带癞子麻将查表判断胡牌高效率低内存算法

同事曾问我麻将判定输赢有没有什么高效的方法,他说他随手写的三个癞子的情况下判定要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

  • 3
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 5
    评论
评论 5
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值