koba::blog

小林聡: プログラマです

麻雀の打牌選択アルゴリズム(8)

ドラ __m1____
m6m6m6m7m8p3p4s3s0s7s7z7z7 m9

から何を切るか?*1

候補となるのは、打 s3 (待ち p2 p5 s6 s7 z7 16枚)、打 s7 (待ち p2 p5 s4 s6 16枚)。麻雀の副露判断アルゴリズム(4) - koba::blog までのアルゴリズムでは z7 がポンできることを重視し s3 だが、麻雀の打牌選択アルゴリズム(7) - koba::blogアルゴリズムは2向聴以降は鳴きを考慮しないため s7 を選択してしまう。

今回は2向聴以降も鳴きを考慮できるよう改良する。

評価値の検討

上記牌姿から s3 もしくは s7 を打牌した場合、聴牌形は以下のいずれかになる。

No聴牌待ち枚数打点 評価値
1m6m6m6m7m8m9p2p3p4s0s7z7z7
m6m6m6m7m8m9p3p4p5s0s7z7z7
s6 4 40001,333.33
2m6m6m6m7m8m9p3p4p0s0s7z7z7s6 4 79002,633.33
3m6m6m6m7m8m9p3p4s3s4s0z7z7
m6m6m6m7m8m9p3p4s0s6s7z7z7
p2 4 40002,991.67
p0 1 7900
p5 3 4000
4m6m6m6m7m8m9p3p4s7s7s7z7z7p2 4 27002,008.33
p0 1 5200
p5 3 2700
5m6m6m6m7m8m9p3p4s7s7z7z7z7p2 4 52003,700.00
p0 1 8000
p5 3 5200

s3s7 からそれぞれ上記テンパイに至る枚数は、

打牌聴牌形への待ち牌 評価値
No.1 No.2 No.3 No.4 No.5
s3p2 p5 7枚p0 1枚s6 4枚 s7 2枚z7 2枚 490.97
s7p2 p5 7枚p0 1枚s4 s6 8枚 498.61

となり、前回までのアルゴリズムでは打 s7 が最高の評価値となる。しかし、打 s3 の場合は z7 ポンでも以下の聴牌形をとることができる。

No聴牌待ち枚数打点 評価値
6m6m6m6m7m8m9p3p4s7s7 z7z7-z7p2 4 1100 808.33
p0 1 2000
p5 3 1100

これを含めると評価値は

打牌聴牌形への待ち牌 評価値
No.1 No.2 No.3 No.4 No.5 No.6
s3p2 p5 7枚p0 1枚s6 4枚 s7 2枚z7 2枚z7 2×3枚 588.89
s7p2 p5 7枚p0 1枚s4 s6 8枚 498.61

となり*2、打 s3 が有利となる。

プログラム実装

まず。副露を評価するメソッド eval_fulou() を追加する。

Majiang.Player.prototype.eval_fulou = function(shoupai, p, paishu) {

    /* 現在の向聴数を求める */
    var n_xiangting = Majiang.Util.xiangting(shoupai);
    if (n_xiangting <= 0) return;       // テンパイ以降は評価しない

    /* ポンした場合の評価値を求める */
    var r = 0, peng_max = 0;
    for (var m of get_peng_mianzi(shoupai, p+'+')) {
        var new_shoupai = shoupai.clone();      // 手牌を複製する
        new_shoupai.fulou(m);                   // 実際に鳴いてみる
        if (Majiang.Util.xiangting(new_shoupai) >= n_xiangting) continue;
                                                // 向聴数が戻る場合は評価対象外
        var ev = this.eval_shoupai(new_shoupai, paishu);
                                                // 鳴いた手牌の評価値を求める
        if (ev > peng_max) peng_max = ev;       // peng_max を最高の評価値とする
    }
    
    /* チーした場合の評価値を求める */
    var chi_max = 0;
    for (var m of get_chi_mianzi(shoupai, p+'-')) {
        var new_shoupai = shoupai.clone();      // 手牌を複製する
        new_shoupai.fulou(m);                   // 実際に鳴いてみる
        if (Majiang.Util.xiangting(new_shoupai) >= n_xiangting) continue;
                                                // 向聴数が戻る場合は評価対象外
        var ev = this.eval_shoupai(new_shoupai, paishu);
                                                // 鳴いた手牌の評価値を求める
        if (ev > chi_max) chi_max = ev;         // chi_max を最高の評価値とする
    }

    /* ポンの評価値が高い場合: 評価値 = ポンの評価値 × 3
       チーの評価値が高い場合: 評価値 = ポンの評価値 × 2 + チーの評価値 */
    return (peng_max > chi_max) ? peng_max * 3 : peng_max * 2 + chi_max;
}

1向聴および2向聴の場合に、鳴いた場合の評価値を加える。シャンテン戻しの検討中(dapai にシャンテン戻しの打牌が設定されている)は副露の評価はしない。*3

Majiang.Player.prototype.eval_shoupai = function(shoupai, paishu, dapai) {

    /* ………… */

    if (n_xiangting == -1) {
        /* ………… */
    }
    else if (shoupai._zimo) {
        /* ………… */
    }
    else if (n_xiangting < 3) {
    
        var r = 0;
        for (var p of add_hongpai(Majiang.Util.tingpai(shoupai))) {

            /* ………… */

            var ev = this.eval_shoupai(new_shoupai, paishu, dapai);
            if (! dapai && n_xiangting > 0)     // シャンテン戻しの検討中および
                                                // テンパイ時は鳴きを考慮しない
                ev += this.eval_fulou(shoupai, p, paishu);
                                                // 副露を評価する
            /* ………… */
        }
        rv = r / width[n_xiangting];
    }
    else {
        /* ………… */
    }

    /* ………… */
}

これで鳴きも評価できるようになったが、またも計算速度が遅くなってしまった。次の2つの方法で速度向上をはかる。

  1. 和了打点キャッシュし、同じ牌姿を二度計算しないようにする
  2. 2向聴では役がある鳴きのみ考慮するようにする

評価値はすでにキャッシュしているがこれに加えて和了打点もキャッシュするようにした。評価値は残牌数もパラメータとなるので一手毎にキャッシュをクリアしているが、和了打点は牌姿だけで求められる*4ので、一局毎にキャッシュをクリアすればよい。

Majiang.Player.prototype.qipai = function(data) {

    /* ………… */

    this._defen_cache = {};     // 配牌時にキャッシュをクリアする
}

/* ………… */

Majiang.Player.prototype.get_defen = function(shoupai) {

    /* すでに和了打点計算済みの牌姿の場合、それを返す */
    var paistr = shoupai.toString();
    if (this._defen_cache[paistr]) return this._defen_cache[paistr];

    /* ………… */
    
    this._defen_cache[paistr] = hule.defen;     // 和了打点をキャッシュする

    return hule.defen;
}

鳴いた場合の評価値は、そのほとんどが0点となる(役なしテンパイとなる場合が多い)ため、2向聴の場合は「役がある場合」のみ評価値を計算する。役の有無は 麻雀の副露判断アルゴリズム(2) - koba::blog で作成した向聴数計算ルーチンで判断するが、このルーチンは喰い三色などは考慮していないため、2向聴からの喰い三色は評価されなくなってしまう(が速度向上のためやむなし)。

まず副露を評価するメソッド eval_fulou() に向聴数計算ルーチンを指定できるようにする。

Majiang.Player.prototype.eval_fulou = function(shoupai, p, paishu, xiangting) {
                                        // パラメータ xiangting を追加し向聴数
                                        // 計算ルーチンを変更できるようにした

    xiangting = xiangting || Majiang.Util.xiangting;
                                        // 指定されない場合汎用の向聴数計算
                                        // ルーチンを用いる

    var n_xiangting = xiangting(shoupai);       // 指定された向聴数計算ルーチン
                                                // を使用
    if (n_xiangting <= 0) return;

    var r = 0, peng_max = 0;
    for (var m of get_peng_mianzi(shoupai, p+'+')) {
        var new_shoupai = shoupai.clone();
        new_shoupai.fulou(m);
        if (xiangting(new_shoupai) >= n_xiangting) continue;
                                                // 指定された向聴数計算ルーチン
                                                // を使用
        var ev = this.eval_shoupai(new_shoupai, paishu);
        if (ev > peng_max) peng_max = ev;
    }
    
    var chi_max = 0;
    for (var m of get_chi_mianzi(shoupai, p+'-')) {
        var new_shoupai = shoupai.clone();
        new_shoupai.fulou(m);
        if (xiangting(new_shoupai) >= n_xiangting) continue;
                                                // 指定された向聴数計算ルーチン
                                                // を使用
        var ev = this.eval_shoupai(new_shoupai, paishu);
        if (ev > chi_max) chi_max = ev;
    }

    return (peng_max > chi_max) ? peng_max * 3 : peng_max * 2 + chi_max;
}

eval_shoupai() では2向聴の場合、役を考慮した向聴数計算ルーチンを使用する。

Majiang.Player.prototype.eval_shoupai = function(shoupai, paishu, dapai) {

    var self = this;

    /* ………… */

    if (n_xiangting == -1) {
        /* ………… */
    }
    else if (shoupai._zimo) {
        /* ………… */
    }
    else if (n_xiangting < 3) {
    
        var r = 0;
        for (var p of add_hongpai(Majiang.Util.tingpai(shoupai))) {

            /* ………… */

            var ev = this.eval_shoupai(new_shoupai, paishu, dapai);
            if (! dapai) {
                if (n_xiangting > 1)
                    ev += this.eval_fulou(shoupai, p, paishu,
                                    function(s){return self.xiangting(s)});
                                    // 役を考慮した向聴数計算ルーチンを指定
                else if (n_xiangting > 0)
                    ev += this.eval_fulou(shoupai, p, paishu);
            }

            /* ………… */
        }
        rv = r / width[n_xiangting];
    }
    else {
        /* ………… */
    }

    /* ………… */
}

対戦結果

例によって今回の鳴きを考慮した思考ルーチン(鳴き考慮)と ver.0.8.8 の思考ルーチンを対戦させてみた。

ver.0.8.8向聴戻し 鳴き考慮 ver.0.8.8向聴戻し 鳴き考慮
対戦数 1,000 1,000 1,000 総局数 10,674 10,609 10,928
1位率 .244 .232 .265 和了 .199 .190 .199
2位率 .244 .272 .251 放銃率 .118 .118 .119
3位率 .249 .256 .241 立直率 .248 .246 .249
4位率 .263 .240 .243 副露率 .269 .253 .257
平均順位 2.53 2.50 2.46 平均打点 5,290 5,764 5,807

和了ver.0.8.8向聴戻し 鳴き考慮 和了ver.0.8.8向聴戻し 鳴き考慮
ドラ 49.13% 55.76% 53.61% 断幺九 22.32% 20.04% 21.21%
赤ドラ 45.07% 48.64% 49,56% 一盃口 2.74% 2.82% 2.99%
裏ドラ 23.22% 23.40% 21.95% 三色同順 1.56% 2.13% 3.08%
立直 52.52% 53.59% 53.84% 一気通貫 0.24% 1.09% 0.92%
一発 10.43% 10.44% 9.66% 七対子 3.96% 4.06% 4.00%
門前清自摸 26.52% 28.40% 28.07% 対々和 0.14% 0.30% 0.46%
翻牌 36.67% 38.89% 37.51% 混一色 0.80% 1.53% 1.01%
平和 13.50% 14.65% 15.51% 清一色 0.00% 0.10% 0.09%

予想していたことだが、やはり成績に大きな影響はない模様。次回は副露判断に評価値を使用することを試みる。

*1:麻雀 傑作「何切る」300選 での正解は s3

*2:ポンは3人からできるため、枚数は3倍に評価する

*3:鳴くためにシャンテン戻しした訳ではなかろう

*4:牌姿以外の条件は 麻雀の打牌選択アルゴリズム(4) - koba::blog で述べたように単純化している