向聴数を求めるプログラム(一般手編(再々))

向聴数を求めるプログラム(一般手編(再)) - koba::blog での前置きが長くなったけど、いよいよ向聴数(シャンテン数)を求めるプラグラムの本編。

面子を抜き取る

mianzi(bingpai, n) は指定した色(萬子、筒子、索子)から面子を抜き取る関数。

function mianzi(bingpai, n) {

    if (n > 9) {    /* 面子の抜き取りが終わったら搭子の数を数える */
        var n_dazi = dazi(bingpai);
        return [[0, n_dazi], [0, n_dazi]];
    }
    
    /* まずは面子を抜かず位置を1つ進め試行 */
    var max = mianzi(bingpai, n+1);    // 仮の最適値とする
    
    /* 順子抜き取り */
    if (n <= 7 && bingpai[n] > 0 && bingpai[n+1] > 0 && bingpai[n+2] > 0) {
        bingpai[n]--; bingpai[n+1]--; bingpai[n+2]--;
        var r = mianzi(bingpai, n);    // 抜き取ったら同じ位置でもう一度試行
        bingpai[n]++; bingpai[n+1]++; bingpai[n+2]++;
        r[0][0]++; r[1][0]++;          // 各パターンの面子の数を1増やす
        /* 必要であれば最適値の入替えをする */
        if (r[0][0]* 2 + r[0][1] > max[0][0]* 2 + max[0][1]) max[0] = r[0];
        if (r[1][0]*10 + r[1][1] > max[1][0]*10 + max[1][1]) max[1] = r[1];
    }
    
    /* 刻子抜き取り */
    if (bingpai[n] >= 3) {
        bingpai[n] -= 3;
        var r = mianzi(bingpai, n);    // 抜き取ったら同じ位置でもう一度試行
        bingpai[n] += 3;
        r[0][0]++; r[1][0]++;          // 各パターンの面子の数を1増やす
        /* 必要であれば最適値の入替えをする */
        if (r[0][0]* 2 + r[0][1] > max[0][0]* 2 + max[0][1]) max[0] = r[0];
        if (r[1][0]*10 + r[1][1] > max[1][0]*10 + max[1][1]) max[1] = r[1];
    }
    
    return max;
}

bingpai麻雀の手牌のJavascript表現 - koba::blog で定義した兵牌(副露牌以外の打牌可能な手牌)の特定の色の部分。具体的には、四索五索赤五索六索 の場合

 [1,0,0,0,1,2,1,0,0,0]

となる。

n はバックトラック特有の現在位置を示す数字。1から始まって9で処理完了となる。

戻り値は以下の2次元配列の形式

 [ [ パターンAの面子の数, パターンAの搭子の数 ],
   [ パターンBの面子の数, パターンBの搭子の数 ] ]

パターンAは「面子の数 × 2 + 搭子の数」が最大となる基本パターン。パターンBは 面子の数が最大 となるパターン。その色の中ではパターンAが最適でも手牌全体では搭子過多により搭子が捨てられる可能性があるので、パターンBも求めておく必要がある。

搭子を数える

dazi(bingpai) は指定した色の中の搭子の数を数える関数。

function dazi(bingpai) {

    var n_pai  = 0;
    var n_dazi = 0;
    
    for (var n = 1; n <= 9; n++) {
        n_pai += bingpai[n];
        if (n <= 7 && bingpai[n+1] == 0 && bingpai[n+2] == 0) {
            n_dazi += Math.floor(n_pai / 2);
            n_pai = 0;
        }
    }
    n_dazi += Math.floor(n_pai / 2);
    
    return n_dazi;
}

bingpai は面子を抜き取ったときと同様に特定の色の兵牌。ただし面子が抜き取られた後なので残骸のように牌が残っている状態。

搭子も面子同様すべての組合わせを抜き取る必要があると思いがちだが、実は簡単な方法があってバックトラックは必要ない。ここで行っている処理は互いに独立した「搭子グループ」内の牌の数を2で割っているだけ。

例えば 二筒二筒四筒四筒五筒八筒九筒 が残っていた場合、二筒二筒四筒四筒五筒八筒九筒 が搭子グループになる。どのような組合わせにせよ前者からは搭子を2つ、後者からは搭子を1つ選ぶことができる。*1

手牌全体の面子・搭子の数を数える

mianzi_all(shoupai) は手牌全体での面子・搭子の数から向聴数を求める関数。

function mianzi_all(shoupai) {

    var r = {};
    
    /* 萬子、筒子、索子の各色ごとに面子・搭子の数を数える */
    r.m = mianzi(shoupai._bingpai.m ,1);
    r.p = mianzi(shoupai._bingpai.p ,1);
    r.s = mianzi(shoupai._bingpai.s ,1);
    
    /* 字牌の面子・搭子の数を数える */
    var z = [0, 0];
    for (var n = 1; n <=7; n++) {
        if (shoupai._bingpai.z[n] >= 3) z[0]++;
        if (shoupai._bingpai.z[n] == 2) z[1]++;
    }

    /* 副露牌は面子としてカウント */
    var n_fulou = shoupai._fulou.length;

    /* 最大の向聴数を仮に 8 とする */
    var min_xiangting = 8;
    
    /* 萬子、筒子、索子、字牌それぞれの面子・搭子の数についてパターンA、Bの
       組合わせで向聴数を計算し、最小値を解とする */
    for (var m of r.m) {
        for (var p of r.p) {
            for (var s of r.s) {
                var n_mianzi = m[0] + p[0] + s[0] + z[0] + n_fulou;
                var n_dazi   = m[1] + p[1] + s[1] + z[1];
                if (n_mianzi + n_dazi > 4) n_dazi = 4 - n_mianzi;
                                                // 搭子過多の場合は補正する
                var xiangting = 8 - n_mianzi * 2 - n_dazi;
                if (xiangting < min_xiangting) min_xiangting = xiangting;
            }
        }
    }
    
    return min_xiangting;
}

shoupai麻雀の手牌のJavascript表現 - koba::blog で定義した Majiang.Shoupaiインスタンス。ただし雀頭が抜き取られた状態で呼出される。

向聴数を求める

xiangting_yiban(shoupai) は一般手(七対子国士無双以外)の向聴数を求めるメイン関数。

function xiangting_yiban(shoupai) {
    
    /* 雀頭なしとした場合の向聴数を 最小値に仮置き */
    var min_xiangting = mianzi_all(shoupai);
    
    /* 可能な雀頭を抜き取り mianzi_all() を呼出し、向聴数を計算させる */
    for (var s of ['m','p','s','z']) {
        for (var n = 1; n <= shoupai._bingpai[s].length; n++) {
            if (shoupai._bingpai[s][n] >= 2) {
                shoupai._bingpai[s][n] -= 2;
                var xiangting = mianzi_all(shoupai) - 1;
                shoupai._bingpai[s][n] += 2;
                if (xiangting < min_xiangting) min_xiangting = xiangting;
            }
        }
    }
    
    return min_xiangting;
}

雀頭となり得る牌を順に抜き取って mianzi_all() に処理を任せるだけ。雀頭はすでに抜き取っているので、得られた向聴数から1を引く必要があることに注意。

全体のソースコードはこちら

Majiang.Util.xiangting() を呼出せば、七対子国士無双も考慮した向聴数を返します。

次は和了点計算の予定。

*1:実際には [22, 44, 89], [22, 45, 89], [24, 45, 89] の3パターンかな