用Python编写日麻牌理查询器

目录

    • 希望程序实现的功能
    • Part 0 关于日麻的基本概念
    • Part 1 输入部分
    • Part 2 计算手牌的向听数
      • 计算面子、搭子、对子的数量
        • 顺子
        • 刻子
        • 对子
        • 搭子
        • 最后的检查
      • 计算向听数
        • 国士无双
        • 七对子
        • 一般形
    • Part 3 计算进张
    • Part 4 处理输出和主函数
    • Part 5 效果

作为一个日麻爱好者,前两天刚学了Python的一些基础知识,想试着写一个类似于天凤牌理查询器的程序

一开始感觉这个程序不是很难写的亚子,结果实现起来发现比想象中要困难,而且自己想得到的算法也比较简单,很容易出bug,现在还有一些没解决的问题,但对一般的手牌来说还是可以算出来的

希望程序实现的功能

  • 检查输入是否正确,并将正确的输入转化为程序可读的手牌(字典形式)
  • 计算手牌的向听数
  • 给出向听数不增加的具体切牌及进张数

Part 0 关于日麻的基本概念

日麻牌有数牌和字牌两大类,数牌分为万(m)、饼(p)、索(s)三类,每类共有1-9的数字牌各4张,字牌则为东南西北白发中(分别记为:1z2z3z4z5z6z7z)各4张

日麻的手牌(在不副露的情况下)共有14张,分为面子、搭子和对子。面子分为顺子(三张相连的数牌,如1m2m3m)和刻子(三张相同的牌,如1z1z1z),(狭义的)搭子即差一张就能形成顺子的组合(如2m3m,差1m或4m就能形成顺子),对子即两张相同的牌(如1m1m)

日麻的和牌分为三种类型:

  1. 国士无双(即国麻中的十三幺),是一种固定的和牌方式。
    即19m19p19s1234567z加上其中的任意一张牌。
  2. 七对子,顾名思义就是七个对子。但这七个对子不允许重复(有重复的七对子中国部分地区称为龙七对)。
  3. 一般形,即4个面子+1个对子(称为雀头)的和牌形。

距离和牌差1张的状态称为听牌
距离听牌差x张则称为x向听(又称x上听),x称为向听数
能减少向听数的牌称为进张

Part 1 输入部分

利用正则表达式检验输入是否合法,并将输入转化为字典形式

关于这里的正则表达式,给不了解正则表达式的同学解释一下:\d是0-9十个数字之一,+表示大于等于1次的匹配,|就是或,[mps]和[1-7]是字符类,表示匹配mps三个字符之一和匹配1-7七个数字之一

import re
def get_handcards(string):
    '''
    检查输入是否合法,并读取手牌内容.
    若输入不合法,输出错误提示并返回None.
    '''
    fullmatch = re.fullmatch(r'(\d+[mps]|[1-7]+z)+', string)
    if not fullmatch:
        print('无效的输入!')
        return None
    match = re.findall(r'\d+[mps]|[1-7]+z', string)
    carddict = {
     
        'm':[], 'p':[], 's':[], 'z':[]
    }
    for cards in match:
        type_ = cards[-1]
        for i in cards[:-1]:
            if i == '0':# 红宝牌
                carddict[type_].append(5)
            else:
                carddict[type_].append(int(i))
        carddict[type_].sort()
    num = sum(len(carddict[tp]) for tp in 'mpsz')
    if num != 14:
        print('牌数量错误!手牌数必须为14张!')
        return None
    for tp in 'mpsz':
        for i in set(carddict[tp]):
            if carddict[tp].count(i) > 4:
                break
        else:
            continue
        print('牌数量错误!单种牌不得多于4张!')
        break
    else:
        return carddict
    return None

Part 2 计算手牌的向听数

首先定义HandCards类(似乎没有这个必要,但或许以后改代码会方便点):

class HandCards:
    def __init__(self, carddict):
        self.carddict = carddict
        self.num = sum(len(carddict[tp]) for tp in 'mpsz')

计算面子、搭子、对子的数量

在计算向听数之前,首先要计算手牌的搭子数

大概是整个代码最复杂的一块?但其实也不是很难,而且用的算法很简单,一定存在错误的反例(我感觉清一色一定存在很多反例),之后再慢慢填坑吧_(:з」∠)_

class Handcards:
    def taatsucount(self):
        '''计算手牌中的面子、搭子、对子数,并返回一个三元元组'''
        toitsu, taatsu, mentsu = (0, 0, 0)
        carddict_cpy = {
     
            'm':self.carddict['m'].copy(),
            'p':self.carddict['p'].copy(),
            's':self.carddict['s'].copy(),
            'z':self.carddict['z'].copy()
        }# 创建原手牌的拷贝以进行删除操作,防止破坏原手牌

这里解释下为什么要分别复制,因为直接对字典进行浅拷贝的话里面的列表则是深拷贝,还是同一个对象,所以要对每个列表进行浅拷贝,和嵌套列表是同理的。

顺子

从左到右遍历,存在三张相连的数牌则全部删除:

        # 因为一个面子能使向听数-2,选择先计算面子的贪心算法
        # step 1: 顺子
        for tp in 'mps':
            for i in range(1, 8):
                if i in carddict_cpy[tp]:
                	if i + 1 in carddict_cpy[tp] and i + 2 in carddict_cpy[tp]:
	                    carddict_cpy[tp].remove(i)
	                    carddict_cpy[tp].remove(i + 1)
	                    carddict_cpy[tp].remove(i + 2)
	                    mentsu += 1

这里会产生一个bug,比如11234m(11为雀头)则会先删除123m,原先的雀头11m会被破坏。(先留作一个坑,之后再修复)

刻子

从左到右遍历,存在三张相同的牌则全部删除:

        # step 2: 刻子
        for tp in 'mpsz':
            for i in range(1, 10):#不存在的89z不影响
                if carddict_cpy[tp].count(i) >= 3:
                    carddict_cpy[tp].remove(i)
                    carddict_cpy[tp].remove(i)
                    carddict_cpy[tp].remove(i)
                    # 4张字牌额外删除1张以便step5的检验
                    if tp == 'z' and carddict_cpy[tp].count(i) == 1:
                        carddict_cpy[tp].remove(i)
                    mentsu += 1

对子

先从左到右遍历,存在两张相同的牌则删除:

        # step 3: 对子
        toitsu_dict = {
     'm':[], 'p':[], 's':[]}# 用于解决step3.1的问题
        for tp in 'mps':
            for i in range(1, 10):
                if carddict_cpy[tp].count(i) == 2:
                    carddict_cpy[tp].remove(i)
                    carddict_cpy[tp].remove(i)
                    toitsu_dict[tp].append(i)
                    toitsu += 1
        for i in range(1, 8):
            if carddict_cpy['z'].count(i) == 2:
                carddict_cpy['z'].remove(i)
                carddict_cpy['z'].remove(i)
                toitsu += 1

但对子有时不能视为对子,如2446这种形状,视为24和46两个搭子更好。故添加如下代码:

        # step 3.1: 解决类似2446的问题
        for tp in 'mps':
            for i in toitsu_dict[tp]:
                if toitsu == 0:
                    break
                neighbor = [i - 2, i - 1, i + 1, i + 2]
                if sum(carddict_cpy[tp].count(j) for j in neighbor) >= 2:
                    # 删除其中的两个,改为搭子
                    count = 2
                    for j in neighbor:
                        if count == 0:
                            break
                        if j in carddict_cpy[tp]:
                            carddict_cpy[tp].remove(j)
                            count -= 1
                    toitsu -= 1
                    taatsu += 2

搭子

从左到右遍历,只要存在一个比i大1或2的就从cpy中删除:

        # step 4: 搭子
        for tp in 'mps':
            for i in range(1, 9):
                if i in carddict_cpy[tp]:
                    if i + 1 in carddict_cpy[tp]:
                        carddict_cpy[tp].remove(i)
                        carddict_cpy[tp].remove(i + 1)
                        taatsu += 1
                    elif i + 2 in carddict_cpy[tp]:
                        carddict_cpy[tp].remove(i)
                        carddict_cpy[tp].remove(i + 2)
                        taatsu += 1

最后的检查

考虑特例:11112222333344z,此时123z已没有靠张,成为废牌。故加上一段代码检查:

        # step 5: 剩余手牌检验
        # 若剩余的手牌均不存在靠张,将其删除
        # 若删除后手牌数+block数小于5(一般为4),则向听数+1
        # 数牌一般不存在完全没有靠张的情况,故只讨论字牌
        # 我们在step2中的操作已经完成了删除无效字牌的操作
        if mentsu + toitsu + taatsu + sum(len(carddict_cpy[tp]) for tp in 'mpsz') < 5:
            taatsu -= 1# 对函数本身而言这会导致搭子数计算错误,但能让向听数计算正确
        return (toitsu, taatsu, mentsu)

计算向听数

class HandCards:
    def shanten(self):
        '''计算手牌的向听数'''

国士无双

先把每种幺九牌删除一遍,若存在多余的幺九牌,向听数再-1:

        # case 1: 国士无双
        st_kokushi = 13
        if self.num == 14:
            carddict_cpy = {
     
                'm':self.carddict['m'].copy(),
                'p':self.carddict['p'].copy(),
                's':self.carddict['s'].copy(),
                'z':self.carddict['z'].copy()
            }
            for tp in 'mps':
                if 1 in carddict_cpy[tp]:
                    st_kokushi -= 1
                    carddict_cpy[tp].remove(1)
                if 9 in carddict_cpy[tp]:
                    st_kokushi -= 1
                    carddict_cpy[tp].remove(9)
            for i in range(1,8):
                if i in carddict_cpy['z']:
                    st_kokushi -= 1
                    carddict_cpy['z'].remove(i)
            if carddict_cpy['z']:
                st_kokushi -= 1
            else:
                for tp in 'mps':
                    if 1 in carddict_cpy[tp] or 9 in carddict_cpy[tp]:
                        st_kokushi -= 1
                        break

七对子

先计算对子数和四张相同牌的数量:

        # case 2: 七对
        toitsu = 0
        dragon = 0 # 
        if self.num == 14:
            for tp in 'mpsz':
                for i in range(1,10):
                    if i in self.carddict[tp] and self.carddict[tp].count(i) == 4:
                        dragon += 1
                    elif i in self.carddict[tp] and self.carddict[tp].count(i) >= 2:
                        toitsu += 1

还是考虑含龙七对且没有有效孤张的情况(如11112222334455z):

        # case 2.1: 塞满了,例如11112222333344z
        if toitsu + 2 * dragon == 7:
            st_chitoi = 2 * dragon - 1
        else:
            st_chitoi = 6 - toitsu - dragon

一般形

        # case 3: 一般形
        tuple_ = self.taatsucount()
        block = sum(tuple_)
        toitsu, taatsu, mentsu = tuple_
        if toitsu == 0:
            st_ippan = 8 - 2 * mentsu - taatsu + max(0, block - 4)
        else:
            st_ippan = 8 - 2 * mentsu - taatsu - toitsu + max(0, block - 5)

        return min(st_kokushi, st_chitoi, st_ippan)

Part 3 计算进张

思路很简单,已经写好了计算向听数的函数,只需要把所有可能的牌遍历一遍,计算使得向听数减少的牌即可

先写切掉某张特定的牌(作为参数传入)计算进张的函数,输出时再遍历:

class HandCards:
    def jinzhang(self, card_num, card_tp):# 真的不知道进张的日文是啥啊啊啊
        '''
        计算打某张牌的具体的进张以及进张数.
        '''
        ret_str = ''
        num = 0
        carddict_cpy = {
     
            'm':self.carddict['m'].copy(),
            'p':self.carddict['p'].copy(),
            's':self.carddict['s'].copy(),
            'z':self.carddict['z'].copy()
        }
        handcards_cpy = HandCards(carddict_cpy)
        for tp in 'mpsz':
            handcards_cpy.carddict[card_tp].remove(card_num)
            for i in range(1,10):
                if tp == 'z' and i > 7:
                    break
                if self.carddict[tp].count(i) == 4:
                    continue
                handcards_cpy.carddict[tp].append(i)
                handcards_cpy.carddict[tp].sort()
                if handcards_cpy.shanten() < self.shanten():
                    ret_str += str(i)
                    ret_str += tp
                    num += 4 - self.carddict[tp].count(i)
                handcards_cpy.carddict[tp].remove(i)
            handcards_cpy.carddict[card_tp].append(card_num)
        return (ret_str, num)

Part 4 处理输出和主函数

用Part 3中计算进张的函数,写一个遍历全部切牌和进张的输出,并做好排序。

class HandCards:
    def print_jinzhang(self):
        strlist = []
        for tp in 'mpsz':
            set_ = set(self.carddict[tp])
            for card_num in set_:
                drawcard, num = self.jinzhang(card_num, tp)
                if num > 0:
                    discard = str(card_num) + tp
                    strlist.append((discard, drawcard, num))
        # 将输出按一定顺序排列
        strlist.sort(key = lambda tuple_: tuple_[0][0])
        strlist.sort(key = lambda tuple_: tuple_[0][1])
        strlist.sort(key = lambda tuple_: tuple_[2], reverse = True)
        for tuple_ in strlist:
            str_ = '打' + tuple_[0] + ' 摸{} 共{}枚'.format(tuple_[1], tuple_[2])
            print(str_)

最后是主函数:

if __name__ == '__main__':
    print(
    '''欢迎使用牌理查询器!请按如下规则输入您要查询的手牌:
    m = 万, p = 饼, s = 索, z = 字(1z~7z分别对应东南西北白发中), 0 = 红宝牌
    (查询器暂不支持含副露手牌查询, 敬请谅解)
    例如: 111m234067p88999s
    直接输入回车以退出''')
    while True:
        string = input('请输入您的手牌: ')
        if string:
            if get_handcards(string):
                handcards = HandCards(get_handcards(string))
                if handcards.shanten() < 0:
                    print('胡了还搁这查牌理呢,爬!')
                elif handcards.shanten() == 0:
                    print('聴牌です!')
                    handcards.print_jinzhang()
                else:
                    print('您的手牌为{}向听.'.format(handcards.shanten()))
                    handcards.print_jinzhang()
        else:
            break

Part 5 效果

用一个特殊的牌例和一个随机的牌例做测试:
用Python编写日麻牌理查询器_第1张图片
再用它和天凤自带的牌理计算工具进行比较:
用Python编写日麻牌理查询器_第2张图片
用Python编写日麻牌理查询器_第3张图片
结果均符合

多数牌例应该都可以计算_(:з」∠)_但肯定存在bug(上文已经提到过一点,还有清一色情况可能存在的各种各样的bug)

不过刚学Python的菜狗能做出这么个玩意儿已经很满意了,所以还是想写篇博客纪念一下

你可能感兴趣的:(Python随便玩玩,python)