バックトラックで麻雀の和了形一覧を求める

麻雀の役を判定をするときには、まず和了形を求める必要がある。

m1m1m1m2m2m2m3m3m3p8p9p9p9 p7

例えば上記の牌姿の場合は、以下の2つの和了形が存在する。

  • p9p9 m1m1m1 m2m2m2 m3m3m3 p7p8p9 (三暗刻)
  • p9p9 m1m2m3 m1m2m3 m1m2m3 p7p8p9 (純全帯幺九+一盃口)

このように和了形が複数あるときは和了点の高い方を採用する*1ので、全ての和了形をもれなく求める必要がある。そのためには バックトラック法 と呼ばれる手法を用いるのが一般的だ。ところがネット上で見かける和了形を求めるアルゴリズムは「アドホックな解法」ばかりでバックトラックを説明したものが見当たらない*2。ならばということで、本稿で説明することにした。

和了形を求める手順

4面子1雀頭形の場合*3、和了形を求める手順は以下となる。

  1. 2枚以上ある牌を雀頭候補として抜き取る
  2. 残った12枚から4面子となる組合せをすべて求める
  3. 和了牌を4面子1雀頭のどれに含めるか決定する

このそれぞれに複数の選択があり得るため、和了形が複数求まることがある。

(1) 雀頭はどれか

m2m2m3m4m4m5m5p2p3p4s2s3s4 m3

雀頭を m2 とするか(平和)、m5 とするか(三色同順)。

(2) 順子か刻子か

m1m1m1m2m2m2m3m3m3p8p9p9p9 p7

冒頭で紹介した形。萬子を順子とするか(純全帯幺九+一盃口)、刻子とするか(三暗刻)。

(3) 待ちは何か

m1m2m3m3m4p5p6p7s8s8z7z7z7 m2

m2 を両面待ちとするか(40符)、嵌張待ちとするか(50符)。

バックトラック法を用いるのは 2. 残った12枚から4面子となる組合せをすべて求める の部分なので、これについて説明する。面子は色をまたがることはないので、萬・筒・索子、字牌についてそれぞれ調べればよい。問題となるのは刻子・順子がともに現れる数牌なので、数牌について考える。

アドホックな解法

麻雀の和了形を求めるアルゴリズムの証明 #アルゴリズム - Qiita などで説明されている方法*4

  • 刻子をいくつか抜き、残りを全て順子として抜く

冒頭に示した手牌の場合は、萬子の m1m1m1m2m2m2m3m3m3 に対して、

  • 1, 2, 3 いずれも刻子として抜く
    m1m1m1 m2m2m2 m3m3m3
  • 刻子を一切抜かない
    m1m2m3 m1m2m3 m1m2m3

の2種類が解となる。

m1m1m1m2m3m3m3m4m4m4m5m5 の場合、刻子は 1, 3, 4 の3つあるが、

  • 1 のみを刻子として抜く
    m1m1m1 m2m3m4 m3m4m5 m3m4m5

のみが解となる。

牌が12枚の場合、刻子は最大で4つある可能性があるので、そのそれぞれについて 抜く・抜かない の2通り、つまり 2 x 2 x 2 x 2 = 16 通りを試せばよい。実際には、①刻子を抜かない、②最初の刻子のみ抜く、③最後の刻子のみ抜く、④全ての刻子を抜く の4通りを試すだけでよいとのことである*5

バックトラックによる解法

電脳麻将GitHub - kobalab/majiang-core: 麻雀基本ライブラリ で使用している方法。

  • 刻子として使う・使わないを 再帰的に 全てのパターンについて試みる

プログラムは以下としている。

function mianzi(s, bingpai, n = 1) {

    if (n > 9) return [[]];

    /* 面子をすべて抜き取ったら次の位置に進む */
    if (bingpai[n] == 0) return mianzi(s, bingpai, n+1);

    /* 順子を抜き取る */
    let shunzi = [];
    if (n <= 7 && bingpai[n] > 0 && bingpai[n+1] > 0 && bingpai[n+2] > 0) {
        bingpai[n]--; bingpai[n+1]--; bingpai[n+2]--;
        shunzi = mianzi(s, bingpai, n); // 抜き取ったら同じ位置で再試行する
        bingpai[n]++; bingpai[n+1]++; bingpai[n+2]++;
        for (let s_mianzi of shunzi) {  // 試行結果のすべてに抜いた順子を加える
            s_mianzi.unshift(s+(n)+(n+1)+(n+2));
        }
    }

    /* 刻子を抜き取る */
    let kezi = [];
    if (bingpai[n] == 3) {
        bingpai[n] -= 3;
        kezi = mianzi(s, bingpai, n+1); // 抜き取ったら次の位置に進む
        bingpai[n] += 3;
        for (let k_mianzi of kezi) {    // 試行結果のすべてに抜いた刻子を加える
            k_mianzi.unshift(s+n+n+n);
        }
    }

    /* 順子のパターンとと刻子のパターンをマージしてすべて返す */
    return shunzi.concat(kezi);
}

バックトラック法は再帰アルゴリズムであり、mianzi() はその中で再度 mianzi() を呼び出す 再帰関数 となっている。冒頭に示した例の場合は、mianzi('m', [0,3,3,3,0,0,0,0,0,0]) を実行すればよい*6。すると以下のように探索が行われ*7、解が求まる。

  1. 123 を抜く。
  2. 再度 123 を抜く。
  3. 再度 123 を抜く。
  4. 123, 123, 123 が解として得られる。
  5. 1 にバックトラックして刻子を試す。
  6. 111 を抜き、2 に進む。
  7. 222 を抜き、3 に進む。
  8. 333 を抜く。
  9. 111, 222, 333 が2つ目の解として得られる。

2つ目の例の場合は、mianzi('m', [0,3,1,3,3,2,0,0,0,0]) の実行で、以下のように探索が行われる。

  1. 123 を抜く。
  2. 再度 123 を抜こうとするが、2 がないので失敗し、バックトラックする。
  3. 111 を抜き、2 に進む。
  4. 234 を抜き、3 に進む。
  5. 345 を抜く。
  6. 再度 345 を抜く。
  7. 111, 234, 345, 345 が解として得られる。

先頭から探索を行っているので 333 や 444 を試さず探索が完了する。このようにバックトラック法では無駄なく少ない試行で解を求めることができる。

再帰アルゴリズムは難易度が高いのでアドホックなやり方に逃げがちになるが「競プロ」では定番の手法になっているはずである*8。ここで紹介した和了形を求めるアルゴリズムを含め、麻雀アプリ作成に必要なアルゴリズムについては以下の書籍で解説したので、興味ある方は手にとっていただければ幸いである。

*1:これを高点法と呼ぶ

*2:バックトラックを使っていても説明は省略されている、あるいは誤った手法をバックトラックと称しているなど

*3:これ以外にも七対子形、国士無双形などの和了形がある

*4:ただし、ここで紹介されている『3. 和了と聴牌 - 麻雀アルゴリズム』は別の解法であり、「アドホックな解法」に当たらない

*5:正にアドホック

*6:添え字0は赤牌の枚数を示してるが、和了形の算出には使用しない

*7:全てをウォークスルーすると長くなるので途中を省いています

*8:私自身は競プロの経験はないが、再帰は競プロの華であろうことは想像に難くない