麻雀の「待ち」を出力するプログラム

麻雀のプログラムに関してネットを検索していたら、こんな記事を見つけてしまった。

2010年の記事なのでちょっと古いけど、なんかそんな話題があったような記憶はぼんやり残ってる。「できない人は“ほぼ確実に”優秀ではない」などと煽り気味の記事が出題する「史上最大のコーディングスキル判定」のお題は「麻雀の『待ち』を出力するプログラム」である。

ヘンテコな出題

出題の細かい内容は上記ページを見ていただくとして、大まかには

  • 入力は一色13枚の牌姿
  • テンパイしている場合、すべての「待ち」を出力する

というもの。この設問、麻雀アプリを書いたことがある者からするとちょっと奇異に思える。

  1. 13枚のテンパイしている手牌の待ち牌をすべて求める
  2. 14枚の和了可能な手牌からすべての和了形を求める

のどちらかならば、麻雀アプリでよく出てくる問題である。1 はフリテン判定に使われるし、2 は和了点計算の際に使われる。電脳麻将でも 1 は Majiang.Util.tingpai()、2.は Majiang.Util.hule_mianzi() として関数化しているくらいだ。けれど出題は、

  • 13枚のテンパイしている手牌からすべての和了形を求める

となっている。こんな問題「特殊なルールでのリーチ後の暗槓可否判断」くらいでしか遭遇しない。*1

なにはともあれ入力と出力は以下となる想定のようだ。

入力 出力 備考
1112224588899(111)(222)(888)(99)[45] 3 6 の両面待ち
1122335556799(123)(123)(555)(99)[67]
(123)(123)(55)(567)[99]
(123)(123)(567)(99)[55]
5 8 両面 + 5 9 の双碰待ち
1112223335559(123)(123)(123)(555)[9]
(111)(222)(333)(555)[9]
123は順子にも刻子にもとれる
1223344888999(234)(234)(888)(999)[1]
(123)(234)(888)(999)[4]
(123)(44)(888)(999)[23]
ノベタンかつ両面待ち
1112345678999(123)(456)(789)(99)[11]
(111)(456)(789)(99)[23]
(111)(345)(678)(999)[2]
(11)(345)(678)(999)[12]
(11)(123)(678)(999)[45]
(111)(234)(789)(99)[56]
(111)(234)(678)(999)[5]
(11)(123)(456)(999)[78]
(111)(234)(567)(99)[89]
(111)(234)(567)(999)[8]
(11)(123)(456)(789)[99]
九連宝燈9面待ち

最後の例がパッと見で9面待ちと分からないところに出題のヘンテコさが現れていると思う。

majiang-core を使った回答

@kobalab/majiang-core で実装済みの関数を使うと下記の回答になるだろう。

/*
 *  麻雀の「待ち」を出力するプログラム
 *    @kobalab/majiang-core 使用版
 */

const Majiang = require('@kobalab/majiang-core');

function tingpai_mianzi(paistr) {

    const rv = {};

    /* 文字列から Majiang.Shoupai のインスタンスを生成する */
    const shoupai = Majiang.Shoupai.fromString('m'+paistr);

    /* すべての待ち牌について以下を実行する */
    for (let p of Majiang.Util.tingpai(shoupai)||[]) {
        /* すべての和了形について以下を実行する */
        for (let mm of Majiang.Util.hule_mianzi(shoupai, p+'=')) {
            if (mm.length == 1) continue;   // 九蓮宝燈形の和了形を除外
            /* 出題の出力形式に正規化する */
            let key = mm.map(m=>m.match(/\!/) ? m.replace(/\d\=\!/, '')
                                                 .replace(/^m(.*)/, '[$1]')
                                              : m.replace(/^m(.*)/, '($1)'))
                        .sort().join('');
            rv[key] = 1;    // 重複を取り除く
        }
    }

    return Object.keys(rv);
}

console.log(tingpai_mianzi(process.argv[2]).join('\n'));

majiang-core を使わない回答

@kobalab/majiang-core を使わず、必要な部分だけ書き直すとこんな感じ。

/*
 *  麻雀の「待ち」を出力するプログラム
 *    @kobalab/majiang-core 未使用版
 */

/*
 *  すべての面子の組合せを調べる
 *    pai: 牌姿、n: 開始位置
 */
function get_mianzi(pai, n = 1) {

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

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

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

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

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

/*
 *  和了牌を抜き取る
 *    pai: 牌姿、p: 和了牌
 */
function del_hulepai(mianzi, p) {

    const regexp = new RegExp(`${p}`);  // 和了牌を見つける正規表現

    /* 和了形の面子の中から和了牌を見つけマークする */
    let new_mianzi = [];
    for (let i = 0; i < mianzi.length; i++) {
        if (i > 0 && mianzi[i] == mianzi[i-1]) continue;    // 重複して処理しない
        let m = mianzi[i].replace(regexp, '');              // 和了牌を抜き取る
        if (m == mianzi[i]) continue;                       // できなければ次へ
        let tmp_mianzi = mianzi.concat();        // 和了形を複製する
        tmp_mianzi[i] = m.replace(/\((\d+)\)/, '[$1]');
                                                // 抜き取った面子と置き換える
        new_mianzi.push(tmp_mianzi);            // 結果に追加する
    }
    return new_mianzi;
}

/*
 *  一般形の和了形を求める
 *    pai: 牌姿、p: 和了牌
 */
function hule_mianzi_yiban(pai, p) {

    /* すべての牌について雀頭あるかチェックする */
    let mianzi = [];
    for (let n = 1; n <= 9; n++) {
        if (pai[n] < 2) continue;
        pai[n] -= 2;    // 2枚以上ある牌を雀頭候補として抜き取る
        /* 残りの手牌から4面子となる組合せをすべて求める */
        for (let mm of get_mianzi(pai)) {
            if (mm.length != 4) continue;   // 4面子以外は和了形ではない
            mm.unshift(`(${n}${n})`);       // 雀頭を差し込む
            /* 和了牌を牌姿から抜き取る */
            mianzi = mianzi.concat(del_hulepai(mm, p));
        }
        pai[n] += 2;
    }
    return mianzi;
}

/*
 *  七対子形の和了形を求める
 *    pai: 牌姿、p: 和了牌
 */
function hule_mianzi_qidui(pai, p) {

    /* すべての牌について対子があるかチェックする */
    let mianzi = [];
    for (let n = 1; n <= 9; n++) {
        if (pai[n] == 0) continue;  // 0枚の場合は継続
        if (pai[n] == 2) {          // 2枚(対子)の場合
            mianzi.push(n == p ? `[${n}]` : `(${n}${n})`);
        }
        else return [];             // それ以外は七対子にならない
    }
    return (mianzi.length == 7) ? [ mianzi] : [];
}

/*
 *  和了形を求める
 *    pai: 牌姿、p: 和了牌
 */
function hule_mianzi(pai, p) {

    pai[p]++;                   // 和了牌を牌姿に加える
    if (pai[p] > 4) return [];  // 不正な牌姿の場合

    return [].concat(hule_mianzi_yiban(pai, p))     // 一般形
             .concat(hule_mianzi_qidui(pai, p));    // 七対子形
}

/*
 *  すべてのテンパイ形を求める
 *    paistr: 一色13枚の牌姿
 */
function tingpai_mianzi(paistr) {

    const rv = {};

    /* 入力から牌姿を作る */
    let pai = [0,0,0,0,0,0,0,0,0,0];
    for (let n of paistr.match(/\d/g)) {
        pai[n]++;
        if (pai[n] > 4) return [];  // 不正な牌姿の場合
    }

    /* 1〜9 について以下を実行する */
    for (let p = 1; p <= 9 ; p++) {
        /* すべての和了形について以下を実行する */
        for (let mm of hule_mianzi(pai.concat(), p)) {
            let key = mm.sort().join('');
            rv[key] = 1;    // 重複を取り除く
        }
    }

    return Object.keys(rv);
}

console.log(tingpai_mianzi(process.argv[2]).join('\n'));

入力の13枚に1枚を加えた14枚で和了形をすべて調べてから加えた1枚を抜くという実装にしているが、13枚のままで調べ上げる方法もあるかもしれない。おそらく出題はそちらの回答を意図してそうに思える。

*1:天鳳にこのルールはない