天下一反省会!

電子工作、プログラミング、読書など

\今月のイチオシ記事/

おススメ記事1
おススメ記事2
おススメ記事3
おススメ記事4

【python】待ち牌判定ツールを作ってみた

13枚の手牌を与えるとその配牌がテンパイしているかどうか、テンパイしている場合は待ち牌の種類を出力するプログラムを作ったので紹介する。

作成したプログラム

とりあえず先にプログラムを載せておく。変数名が分かりにくい点はご容赦ください...
なお、使用した2つのライブラリはどちらも標準ライブラリであるため、コピペすればすぐに動かせるハズ。

import copy
from itertools import combinations

hand_13 = ["1m","1m","1m","2m","3m","4m","5m","5m","6m","6m","6m","6m","7m"]

kind = ["1m","2m","3m","4m","5m","6m","7m","8m","9m","1p","2p","3p","4p","5p","6p","7p","8p","9p",
        "1s","2s","3s","4s","5s","6s","7s","8s","9s","ton","nan","sha","pe","haku","hatsu","chun"]


skip_tile = [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]#4枚持っている牌入れていく配列
weit_tile = [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]#待ち牌候補を入れていく配列
hand13_counter = [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]#13枚の手牌を種類ごとに枚数カウント用
result = []#結果出力用
for i in range(len(hand_13)):#ここのfor文では、各牌の枚数が何枚ずつかを計測
        for j in range(len(kind)):
            if hand_13[i] == kind[j]:
                hand13_counter[j] += 1
#print("13枚の牌の種類")
#print(hand13_counter)#13枚の牌の種類ごとの枚数を出力
for i in range(len(hand13_counter)):
    if hand13_counter[i] == 4:
        skip_tile[i] = True

for i in range(len(kind)):#全種類の牌を1枚ずつ挿入していく
    #print("13枚の手牌")
    #print(hand13_counter)
    #print("iの数値")
    #print(i)
    #print(kind[i]+ "を持っている枚数")
    #print(hand13_counter[i])
    if skip_tile[i] == True:
        #print(kind[i]+"は4枚所持しているためその操作をパス")
        pass
    else:
        hand13_counter = [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]#13枚の手牌を種類ごとに枚数カウント用
        hand_counter = [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]#種類ごとに枚数カウント用
        head_sarcher = [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]#頭検出用、対子以上なら1
        head_counter = 0#七対子識別用
        hand_14 = copy.copy(hand_13)#手牌のコピーを作成
        hand_14.append(kind[i])#一種類ずつ加えて検証
        print(kind[i]+"を加えた場合")
        print("検証する手牌")
        print(hand_14)#1枚加えて14枚にした手牌を出力
        for j in range(len(hand_14)):#ここのfor文では、各牌の枚数が何枚ずつかを計測
            for k in range(len(kind)):
                if hand_14[j] == kind[k]:
                    hand_counter[k] += 1
        print("種類ごとの枚数")
        print(hand_counter)#で正しくカウントできていることを確認。2枚以上ある所をアタマ候補とする
        for j in range(len(hand_counter)):
            if hand_counter[j] == 4:
                skip_tile[j] = True#4枚持ちの場合はスキップ
        for j in range(len(hand_counter)):#対子を探す
            if hand_counter[j] >= 2:
                head_counter += 1
                head_sarcher[j] = 1#対子候補をマーク
                #print("雀頭候補:"+ kind[j])
            elif hand_counter[j] >= 2:
                head_sarcher[j] = 1#対子候補をマーク
                #print("雀頭候補:"+ kind[j])
        if head_counter == 7:#七対子成立時
            weit_tile[i] = True#待ち牌を保存
            break#一番外側のループから脱出
        elif ( 
            hand_counter == [2,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1] or
            hand_counter == [1,0,0,0,0,0,0,0,2,1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1] or
            hand_counter == [1,0,0,0,0,0,0,0,1,2,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1] or
            hand_counter == [1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,2,1,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1] or
            hand_counter == [1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,2,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1] or
            hand_counter == [1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,2,1,1,1,1,1,1,1] or
            hand_counter == [1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,2,1,1,1,1,1,1] or
            hand_counter == [1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,1,2,1,1,1,1,1] or
            hand_counter == [1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,1,1,2,1,1,1,1] or
            hand_counter == [1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,1,1,1,2,1,1,1] or
            hand_counter == [1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,1,1,1,1,2,1,1] or
            hand_counter == [1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,1,1,1,1,1,2,1] or
            hand_counter == [1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,1,1,1,1,1,1,1,2] ):#国士無双の判定
            weit_tile[i] = True#待ち牌を保存
        else:#七対子、国士無双以外の場合
            for j in range(len(hand_counter)):
                if head_sarcher[j] == 1:#2枚以上持っている場合
                    hand_counter_copy = copy.copy(hand_counter) #アタマ候補ごとに手牌のコピーを作成し、それを操作する。
                    print(kind[j]+"を雀頭とする場合")
                    print("元の手牌")
                    #print(hand_counter_copy)
                    hand_counter_copy[j] -= 2 #アタマとして2枚抜き出す

                    print("アタマを抜いた手牌")
                    print(hand_counter_copy)
                    hand_counter_copy2 =copy.copy(hand_counter_copy)#雀頭固定した状態のコピー作成
                    for k in range(len(hand_counter_copy2) - 9):#刻子抜かず順子のみ抜く場合
                            if k == 7 or k == 8 or k == 16 or k == 17:#マンズ、ソーズ、ピンズを跨がないように
                                pass
                            else:
                                if hand_counter_copy2[k] >= 1 and hand_counter_copy2[k+1] >= hand_counter_copy2[k] and hand_counter_copy2[k+2] >= hand_counter_copy2[k]:#順子先頭の枚数分順子を抜きだす
                                    if hand_counter_copy2[k] != 0:#先頭の枚数がゼロでない場合
                                        Number_to_take_out = hand_counter_copy2[k]
                                        hand_counter_copy2[k] -= Number_to_take_out
                                        hand_counter_copy2[k+1] -= Number_to_take_out
                                        hand_counter_copy2[k+2] -= Number_to_take_out
                    if (hand_counter_copy2 == [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]):
                            weit_tile[i] = True#待ち牌を保存
                    kotsu_finder = []#刻子を抜く場合についても検討する
                    for k in range(len(hand_counter_copy)):#残りの牌から、刻子を探す
                        if hand_counter_copy[k] >= 3:#3枚以上持っている場合
                            kotsu_finder.append(k)
                    #print(kotsu_finder)
                    for k in range(len(kotsu_finder)):
                        kotsu_pattern_list = list(combinations(kotsu_finder,k+1))#抜き出す刻子のパターンを全通り生成
                        #print(kotsu_pattern_list)
                        for l in range(len(kotsu_pattern_list)):#刻子パターンを1つずつ検証
                            hand_counter_copy2 =copy.copy(hand_counter_copy)#雀頭固定した状態のコピー作成
                            #print(len(kotsu_pattern_list))
                            #print(kotsu_pattern_list[j])
                            #print(len(kotsu_pattern_list[j]))
                            #print(int(kotsu_pattern_list[j]))
                            kotsu_pattern_part = kotsu_pattern_list[l]#抜きだす刻子パターンを1つ指定
                            print("抜き出す刻子")
                            print(kotsu_pattern_part)
                            for m in range(len(kotsu_pattern_part)):
                                #print(kotsu_pattern_part[m])
                                hand_counter_copy2[kotsu_pattern_part[m]] -= 3#指定された刻子パターンを抜き出す

                            print(kotsu_pattern_part)
                            print("を刻子として抜いた後の手牌")
                            print(hand_counter_copy2)
                            for m in range(len(hand_counter_copy2) - 9):#数牌、順子の先頭候補となる牌について(順子を抜き出していく)
                                if m == 7 or m == 8 or m == 16 or m == 17:#マンズ、ソーズ、ピンズを跨がないように
                                    pass
                                else:
                                    if hand_counter_copy2[m] >= 1 and hand_counter_copy2[m+1] >= hand_counter_copy2[m] and hand_counter_copy2[m+2] >= hand_counter_copy2[m]:#順子先頭の枚数分順子を抜きだす
                                        if hand_counter_copy2[m] != 0:#先頭の枚数がゼロでない場合
                                            Number_to_take_out = hand_counter_copy2[m]
                                            hand_counter_copy2[m] -= Number_to_take_out
                                            hand_counter_copy2[m+1] -= Number_to_take_out
                                            hand_counter_copy2[m+2] -= Number_to_take_out
                            if (hand_counter_copy2 == [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]):
                                weit_tile[i] = True#待ち牌を保存

if weit_tile == [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]:
    print("テンパイしていません。")
else:
    for i in range(len(weit_tile)):#全種類の牌について
        if weit_tile[i] == True:
            result.append(kind[i])
    print("待ち牌")
    print(str(result))

ガリの判定方法について

さて、いきなり待ち牌を当てる仕組みの解説をする前に、まずは"14枚の手牌があがりの形を満足しているかどうか"を判定する方法について考えてみよう。


先駆者の方々の記事によれば、アガリ判定をする際のセオリーのようなものがあるようで、手牌に対し次のような手順で処理を行えば上手く判定できるようだ。


①一番最初に雀頭を固定する
刻子を抜き出す
④順子を抜きだす
⑤手牌をすべて抜き出せたらアガリとする

参考にした記事のリンクを載せておきます。

naomichi.work


最初はこの方法でアガリ判定を行っていたのだが、清一色7面待ちのような待ちの多い手牌になるにつれて検出漏れが目立つようになってきた


上手く処理できない形について


処理の途中途中を可視化することで原因を探ってみると、どうやら次のような形をメンツに分解する際に不都合が生じるようだ。



なお、今回はすでに雀頭+1メンツが確定していて、残りの手牌を3メンツに分解したいという場合を想定する。

この形を3つのメンツに分解するためには次のように3つの順子に分ける必要がある


この形に対し、上で示した順の処理を行うと刻子である3mを真っ先に抜いてしまうことになる。そうなると残りの牌は


となり、3つのメンツに分解することができなくなってしまう。


じゃあ毎回順子→刻子の順で抜くようにすればいいじゃん 、と思われるかもしれないが、そうすると次のような形が処理できない。



この形に対し、刻子よりもさきに順子を抜き出してしまうと、次のような形が残ってしまう。




なぜこのような形になってしまうのかというと、このプログラムでは仕様上、"順子や刻子は左から順番に検出されるから"である。(プログラムに関する細かい解説は後ほど。)


今回の場合で言うと、一番左の牌である3mから見て、隣の牌である4m、さらにその隣の5mも所持しているため、プログラムは"345の順子が抜き出せる!"と判断してしまうのである。

刻子を抜き出すか否かで振り分ける方法

さて、順子から抜き出しても、刻子から抜き出しても、不具合が生じてしまうパターンが存在するわけだが、ここで上で挙げた例をもう一度よく見てほしい。


どちらの例も抜いてはいけない刻子を抜いてしまった、あるいは抜くべき刻子を抜かなかったことが原因で不具合が生じているということが分かる。つまり、手牌を正しくメンツに分解していくためには刻子を抜く場合と抜かない場合のどちらについても検討を行う必要があるわけである。


ここまで考えると、処理の方法については

①あらかじめ手牌から刻子となりうる箇所を探してマークしておく(この時点ではまだ抜かない)
② ①で検出できた刻子候補について、抜き出す刻子の組み合わせを全パターン探索する(例えば、刻子となりうる牌が3m,1sの2種類であった場合、3mのみ刻子として抜き出す、1sのみ刻子として抜き出す、3mと1sの両方を刻子として抜き出す、どちらも刻子として扱わない、の4パターンが考えられる。)
③ ②で見つけた刻子の抜き出しパターンの中から1つ選び、そのとおりに刻子を抜いてから、残りを順子ごとに分解できるか検証
④ ③を全刻子パターンについて検証
⑤ ④できれいにメンツに分解できればアガリの形として判定

とすれば良い。文字だけでの説明では少々理解が難しいかもしれないので、実際の手牌を用いて処理の手順を追ってみよう。


刻子の処理例

雀頭を除いた12枚の手牌を考えてみよう。


先ほど述べた手順通りに操作をすれば、正しくメンツを分解できるか試してみる。まず上の手牌のなかから、刻子となる可能性のある個所を探す。今回の場合、刻子となりうる、すなわち3枚以上持っている牌は2mと5mである。よって、刻子の抜き出し方としては、①2mのみ刻子として抜き出す ②5mのみ刻子として抜き出す ③2mと5mの両方を刻子として抜き出す ④2m,5mのどちらも刻子としては扱わない(順子の一部として扱う) の計4パターンが考えられる。

これらすべてのパターンを試してみると

①2mのみ刻子として抜き出す場合

②5mのみ刻子として抜き出す場合


③2mと5mの両方を刻子として抜き出す場合


④2m,5mのどちらも刻子としては扱わない場合


この中で、刻子を抜き出した後の形が順子のみとなるのは、③2mと5mの両方を刻子として抜き出す場合 のみである。このことから、先ほどの12枚の手牌を4メンツに分解するためには2m,5mの両方を刻子として扱う必要があるということが分かった。


さて、問題なのは刻子候補が複数ある場合、刻子の抜き出し方の全パターンをどのように数え上げるかである。今回は、高校数学でも登場する"組み合わせ(conbination)"を求める関数を使うことにした。


例えば手牌の中の刻子候補が、刻子A、刻子B、刻子Cの3種類である場合を考える。ここで、刻子の抜き出し方をすべて求めるためには、

刻子候補のなかから1種類だけ抜き出す場合(Aのみ、Bのみ、Cのみ の3通り)
刻子候補のなかから2種類抜き出す場合(AB、BC、ACの3通り)
刻子候補のなかから3種類抜き出す場合(ABCの1通り)
④どの刻子候補も抜き出さない場合(1通り)

というように数え上げればよい。また、これを数学のコンビネーション記号を用いて表せば,、"3Cn"(3つの中からn個選ぶ)のnを0から3まで計算してやればよいことになる。


このように、数学のコンビネーションの考え方を用いれば、比較的簡単に刻子の全抜き出しパターンを検索することができる。


ちなみに、pythonの標準ライブラリであるitertoolsのcombinations関数を用いれば、任意の配列の全要素の中からn個取り出すときの組み合わせをすべて表示することができるため、非常に便利である。


プログラムの解説

さて、刻子を抜き出すか否かによって生じる不具合は解決された。よってここからは冒頭で紹介した"待ち牌判定プログラム"が、実際にはどのように動いているかをざっくりではあるが紹介する。

手順① 13枚の手牌について、牌の種類ごとに数え上げる

まず最初に、プログラムに13枚の手牌を与える。冒頭ののプログラム例では、

hand_13 = ["1m","1m","1m","2m","3m","4m","5m","5m","6m","6m","6m","6m","7m"]

と、手牌を指定している。


次に行うのは、"どの牌を何枚持っているか"を数え上げる操作。今回の例では1mを3枚、6mを4枚持っているので、牌の枚数カウント用の配列の中身は、
[3,1,1,1,2,4,1,0,........]のようになっている。(先頭の要素から順に、1mの枚数、2mの枚数、.....となっている)

手順②

牌の種類ごとの枚数を数え上げたら、この時点で4枚以上持っている牌を探し、別の配列にマークしておく。今回の例では6mを4枚持っているため、4枚もちの牌を表す配列は[0,0,0,0,0,True,0,0,.....]のようになる。


手順③ 13枚の手牌に新たに1種類牌を加え、14枚の手牌にする

次に、1mから中まで全34種の牌の中から1種類選び、その牌を一枚手牌に加え、手牌を14枚にする。この時、手順②ですでに4枚持っていると判定された牌は加えずにスキップする(ある牌を4枚持っている状態で5枚目をツモってくることはあり得ないため)。今回は6mをすでに4枚持っているため、6mを新たにもう一枚手牌に加えるという処理は行われない。その後、この14枚の手牌が上がりの形を満たしているか判定し、もし上がりの形であれば、ここで加えた牌は待ち牌であると判定できる。今回は例として、3mを加えた場合について考えることにしよう。この場合、枚数カウント用の配列は、
[3,1,2,1,2,4,1,0,........]のようになる(3番目の要素を1つ増やした)。



手順④ 雀頭候補を探し出す

手牌を14枚にしたら、手牌の中で2枚以上持っている箇所を雀頭候補としてマークする。今回の場合、枚数カウント用の配列は[3,1,2,1,2,4,1,0,........]となっているため、1m,5m,6mが雀頭候補となる。雀頭候補は別の配列に保存しておく雀頭候補を表す配列は、
[1,0,0,0,1,1,0,......](1となっている箇所が雀頭候補)となる。



次に、雀頭候補の中から1つ選び、選んだ対子を雀頭として抜き出す。この操作は、牌の枚数カウント用の配列において、雀頭として指定した牌に対応する要素を-2することと言いかえられる。

今回は1mを雀頭として抜き出すことにしよう。抜き出した後のカウント用配列は、

[1,1,2,1,2,4,1,0......]となる(1mに対応する0番目の要素が-2されている)。


手順④ 刻子候補を探し出す

雀頭を抜き出した後は、刻子候補を探していく。刻子となりうるのは3枚以上持っている牌、すなわち、枚数カウント用配列において3要素の値が3以上である箇所だ。今回の場合は3枚以上持っているのは6mなので雀頭候補は6mのみとなる。雀頭候補は別の配列にて保存し、その配列は、
[0,0,0,0,0,True,0,0,.....]となる(6mに対応する要素がTrueとなっており、刻子候補であることを表している)。

手順⑤ 刻子を一切抜き出さず、順子のみ抜き出していく

まずは、雀頭を除く12枚の手牌がすべて順子に分解できると仮定する。

残りの手牌を順子に分解する際には、次のような処理を行っていく。


まずは枚数カウント配列のi番目の要素に注目する。今回は例としてi = 0の場合を考えてみよう。

なお、今回の例におけるこの時点での枚数カウント配列は[1,1,2,1,2,4,1,0......]である。

ここで、この配列の0番目、1番目、2番目の要素で順子を構成できるかどうか判断するためにはどうすればよいだろうか。

今回のプログラムでは次のように判断した。

「もし、1番目の要素>= 1 かつ、 2番目の要素 >= 1番目の要素 かつ 3番目の要素 >= 1番目の要素 となれば、これらの要素は順子を構成する。」


例えば枚数カウント配列が[1,2,2,1,.....]という場合、これは上に示した条件をすべて満たしている。麻雀の牌に直すと,



となるが、確かにこれらの牌は1m2m3mと2m3m4mという2組の順子に分解できる。


これに対し、枚数カウント配列が[2,1,2,1]という場合、i = 0の時この配列は2番目の要素 >= 1番目の要素という条件を満たさないため,
これらの牌は絶対に順子に分解できないということが分かる。


そして、順子が構成できると判断できる部分については、枚数カウント配列において、i番目の要素と同じ数だけi番目の要素、i+1番目の要素、i+2番目の要素をそれぞれ減らすことにより、順子を抜き出すという操作を表現する。


例えばカウント配列が[2,2,2,1,1,1,...]の時は i = 0のとき上記の条件を満たすため、0番目の要素と同じ数だけ0,1,2番目の要素の値を減らす。

その結果[0,0,0,1,1,1,...]となる。その後i = 3で再び上記の条件を満たすので、3,4,5番目の要素を3番目の要素の値と同じだけ減らせば、

[0,0,0,0,0,0,...]となる。最終的にカウント配列の要素は0が並ぶ形となるため、この部分はすべてきれいに順子に分解できたということが分かる。

iの値を動かしながらこの操作をくり返し、最終的に枚数カウント配列の全要素が0、すなわち

枚数カウント配列 = [0,0,0,0,0,0,0,0,0,0,0,0,.....]となれば、順子のみの4メンツに分解できた、すなわち、手順③で加えた牌は当たり牌であると判定できる。


手順③で加えた牌が当たり牌と判定できた場合、当たり牌の種類を格納しておくための配列に、当たり牌の種類を保存しておく。

今回の例では枚数カウント配列は[1,1,2,1,2,4,1,.....]であるため、1m2m3mを順子として抜くと[0,0,1,1,2,4,1,...]、その後3m4m5mを順子として抜くと、[0,0,0,0,1,4,1,...]、さらに5m6m7mを順子として抜き出せば,[0,0,0,0,0,3,0,....]となるが、6mに対応した要素の値が0ではない(6mが余ってしまう)ことから、残りの手牌を順子のみで分解することは不可能であるということが判断できる。



手順⑥ 刻子を抜き出す場合についても検討する

次に、手順④にて探し出した刻子候補について、全ての抜き出しパターンを検討する。

上でも述べたように、手順④で見つけた刻子候補をconmination関数に代入することで、"n個の刻子のなかからm個の刻子を抜き出す"という全パターンを調べ上げることができる。


今回の例では枚数カウント配列は[1,1,2,1,2,4,1,.....]であるため、刻子候補となりうる牌は6mのみである。よって、6mを抜く場合についてのみ考えればよい。


6mを抜き出した後のカウント配列は、[1,1,2,1,2,1,1,...]となる。


この後、先ほどと同様に順子を抜き出していく処理を行っていく。残りの手牌は1m2m3m, 3m4m5m, 5m6m7mという3組の順子に分解できるため、最終的なカウント配列は[0,0,0,0,0,0,0,0,0,.....]となり、全要素が0となる。したがって、今回検証した14枚の手牌は上がりの形を満たしており、手順③で加えた牌(今回の例では3m)は当たり牌であると判定することができる。


以上が、このプログラムの動作のざっくりした解説である。筆者の国語力不足により分かりにくい部分も多々あったかもしれないが、疑問点等あればコメント欄にて指摘していただきたい。