作为一个日麻爱好者,前两天刚学了Python的一些基础知识,想试着写一个类似于天凤牌理查询器的程序
一开始感觉这个程序不是很难写的亚子,结果实现起来发现比想象中要困难,而且自己想得到的算法也比较简单,很容易出bug,现在还有一些没解决的问题,但对一般的手牌来说还是可以算出来的
日麻牌有数牌和字牌两大类,数牌分为万(m)、饼(p)、索(s)三类,每类共有1-9的数字牌各4张,字牌则为东南西北白发中(分别记为:1z2z3z4z5z6z7z)各4张
日麻的手牌(在不副露的情况下)共有14张,分为面子、搭子和对子。面子分为顺子(三张相连的数牌,如1m2m3m)和刻子(三张相同的牌,如1z1z1z),(狭义的)搭子即差一张就能形成顺子的组合(如2m3m,差1m或4m就能形成顺子),对子即两张相同的牌(如1m1m)
日麻的和牌分为三种类型:
距离和牌差1张的状态称为听牌
距离听牌差x张则称为x向听(又称x上听),x称为向听数
能减少向听数的牌称为进张
利用正则表达式检验输入是否合法,并将输入转化为字典形式
关于这里的正则表达式,给不了解正则表达式的同学解释一下:\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
首先定义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)
思路很简单,已经写好了计算向听数的函数,只需要把所有可能的牌遍历一遍,计算使得向听数减少的牌即可
先写切掉某张特定的牌(作为参数传入)计算进张的函数,输出时再遍历:
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 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
用一个特殊的牌例和一个随机的牌例做测试:
再用它和天凤自带的牌理计算工具进行比较:
结果均符合
多数牌例应该都可以计算_(:з」∠)_但肯定存在bug(上文已经提到过一点,还有清一色情况可能存在的各种各样的bug)
不过刚学Python的菜狗能做出这么个玩意儿已经很满意了,所以还是想写篇博客纪念一下