気まぐれな戯れ言 バックナンバー15


気まぐれな戯れ言バックナンバーです。全体の一覧はこちら

2012/05/10 麻雀得点計算アルゴリズム編−2
2010/12/30 麻雀得点計算アルゴリズム編−1



2012/05/10 麻雀得点計算アルゴリズム編−2(役の判定)[★★★◎◎]
前回、麻雀の面子列挙アルゴリズムを紹介しました。
今回は、列挙した面子の個々の得点を求めていきます。
実際のコードはVC#講座のページにあるサンプルを参照ください。

1. はじめに  2. 全体の方針  3. 面子の分解  4. 鳴き牌・上がり牌の処理
5. 麻雀の得点決定要素一覧  6. まとめ 
1. はじめに

説明のための前提事項を書いておきます。
前回同様、以下では牌に以下の表記を用います。
牌種別本記事上の表現プログラムの内部数値
萬子漢数字 一〜九01〜09
索子アラビア数字 1〜911〜19
筒子丸囲み数字 @〜H 21〜29
字牌東南西北 白發中31〜34および36〜39

役の判定で便利なので、サンプル中でも「14牌が指定した牌のみで構成されているかどうか」を判定する関数を準備しています。
この「指定した牌」を判定するのに以下の書式を利用しています。
Inc("111111111 111111111 111111111 1111 111")
この関数Incの引数は、萬子9個・索子9個・筒子9個・字牌7個に対応していて、1になっている牌だけを含むならTrue、それ以外の牌を含むならFalseを返します。
多くの役は、「〜〜の牌を含む」や「〜〜の牌を含まない」と言う形をとるので、このような判定が容易になります。


2. 得点計算の方針

前回書いた通り、方針は以下の通りです。
  1. 面子の分解・列挙
  2. 列挙した面子の役と符の判定
  3. 翻と符から得点計算
1番目は前回行ったので、今回は2番目以降を行います。
2番の役判定では、以下の手順を踏みます。
役満の判定→役満が1個でもあれば、得点を出して終了→役満が無ければ、役満以外の役を判定して計算

なので、まずは役満を判定していくことにします。
なお、前提として、前回の面子列挙を通過した段階で、面子はちゃんとそろっていることとします。


3. 役満の判定

役満では、同時に複数の役満を満たす場合があるので、常に全役満パターンを検索します。
では順に見ていくことにします。
基本的には、麻雀のルールに則り判定していくだけです。
天和・地和はわざわざ判定するまでもないのでここでは省略。

○○の牌だけで構成される系の役満
このパターンは先のInc関数を用いて判定できます。
役名判定法
字一色字牌だけで構成されればよいので、Inc("000000000 000000000 000000000 1111 111") == Trueなら成立
緑一色23468發だけで構成されれば良いので、Inc("000000000 011101010 000000000 0000 010") == Trueなら成立
清老頭19牌だけで構成されれば良いので、Inc("100000001 100000001 100000001 0000 000") == Trueなら成立

特定の構成を含む系の役満
このパターンは、各牌の数をカウントして判定できます。
九蓮宝燈のダブル役満判定がちょっと面倒ですね。
役名判定法
大三元白發中を3個以上含めばよい(槓子のパターンもあるので、4個もあり得る)
小四喜東西南北のうち1つは2個、残りは3個以上含めばよい(同上)
大四喜東西南北をいずれも3個以上含めばよい(同上)ただし、大四喜が成り立つ場合は小四喜は同時には成り立たない
大車輪ABCDEFGを2個ずつ含めばよい。
九蓮宝燈一九を3個以上、二〜八を1個以上含めばよい。1牌だけ左記の数を上回り、かつその牌が上がり牌と一致する場合、ダブル役満。

その他の役満
役名判定法
四槓子槓子が4つあればよい。
四暗刻暗刻または暗槓があわせて4つあればよい。上がり牌が雀頭ならダブル役満。
国士無双19字牌をいずれも1個以上含めばよい。上がり牌が2個ある牌と一致するならダブル役満。

4. 役満以外の役の判定

役満の役が出た場合、役満以外の判定は行いません。
役満の役がない場合、各役の判定を行っていきます。

と言っても、麻雀の役のルールをそのままプログラムで判定するだけですね。
最初の4面子1雀頭を作る処理に比べると容易です。役の種類が多い分面倒ですが。

牌取得時の状態で決まる役
以下の役は、手元の牌ではなく、牌を取得した状態で決まる役です。
 
役名判定法
リーチ1飜リーチしている
ダブルリーチ2飜ダブルリーチしている
一発1飜一発で上がる。(要リーチ)
門前清自摸和1飜明槓・ポン・チーせず、ツモ上がり
嶺上開花1飜リンシャン牌で上がる。(要明槓or暗槓)
槍槓1飜チャンカンで上がる。
海底摸月1飜海底でツモ上がり。
河底撈魚1飜河底でロン上がり。


○○の牌で構成される系の役

先ほどのInc関数だけで判定できるものは意外と少ないです。
役名判定法
断ヤオ九1飜19字牌を含まないのでInc("011111110 011111110 011111110 0000 000") == Trueで成立
混老頭2飜19字牌だけで構成されるのでInc("100000001 100000001 100000001 1111 111") == Trueで成立

特定牌だけで構成される役で、Inc関数だけでは判定できないのは下記の通り。
チャンタの説明でなぜ「7」が出るか不思議かもしれないですが、今回は順子のデータ構造として先頭の牌の数値で管理していることに注意。
例えば、7で始まる順子は、789の並びを意味しているので問題ない。
役名判定法
混全帯2飜(食い下がり1飜)19@H一九の刻子・槓子または17@F一七で始まる順子または字牌のみで構成される。純全帯・混老頭・清老頭成立時はそちら優先で混全帯は成立しない。
純全帯3飜(食い下がり2飜)19@H一九の刻子・槓子または17@F一七で始まる順子のみで構成される。清老頭成立時はそちら優先で純全帯は成立しない。

順子系の役
あとは役固有の並びをしているか判定するので、個別にみていくしかないですね。
役名判定法
平和1飜すべて順子で鳴いておらず、雀頭に符が付かず(白發中および風牌でない)、符が付かない上がり(両面・シャボ)である。
一盃口1飜鳴いておらず、同じ順子が2つある。二盃口とは同時に成立しない。
二盃口2飜鳴いておらず、同じ順子が2つ×2組ある。
三色同順2飜(食い下がり1飜)同じ数牌で始まる順子が3色揃っている。
一気通貫2飜(食い下がり1飜)同じ色の順子で1・4・7で始まる順子がそろっている。

刻子系の役
役名判定法
対々和2飜刻子・槓子が4つある。
三暗刻2飜暗刻が3つある。
三色同刻2飜同じ数字の刻子が3色ある。
三槓子2飜暗槓・明槓合わせて3つある。
小三元2飜白發中のうち2つが刻子/槓子で、あと1つが雀頭である。

その他の役
これまでのパターンには含まれないような役です。
七対子だけは4面子1雀頭じゃないので、ちょっと特殊ですね。
役名判定法
七対子2飜7つの牌が2つずつある。
清一色6飜(食い下がり5飜)1色の牌だけで構成されている。
混一色3飜(食い下がり2飜)1色+字牌だけで構成されている。
役牌1飜字牌の刻子・槓子がある。

以上の役を順々に処理していくと、合計の飜数が決まります。

6. 符の計算

さて、役が終わったら符の計算ですね。
とはいえ符の計算は麻雀の点数計算のルールに則って粛々とやるだけですね。
一応説明していきます。

得られる符は、下記項目を足し合わせ、最後に10の位で切り上げる。
  1. まず、上がった場合には基本点の副底20符が付く
  2. 1度も鳴かず、メンゼンでロン上がり時は+10符
  3. 刻子・槓子に符が付く。明刻・暗刻・明槓・暗槓で順に2符・4符・8符・16符で、一九字牌ならさらに倍
  4. ツモ上がりなら+2符。ただし平和の場合はつかないルールもある
  5. カンチャン・ペンチャン・単騎待ちなら+2符。両面・シャボ待ちにはつかない
  6. 雀頭が役牌の場合+2符。連風牌なら+4符

刻子・槓子は上がり牌に複数含まれる可能性があるので少し面倒だけど、それ以外は1つ1つ確認すればよいね。


7. 翻と符からの得点計算

さて、翻と符が決まるとようやく最終的な得点が決まります。
まずは役満の得点だけ先に終えてしまいます。

N重役満の時の得点は以下の通りです。
上がり条件得点補足
ロン上がり時(8000×4)×N×PPは自分が親なら1.5、子なら1
ツモ上がり時1人当たり 8000×N×PPは自分または相手が親なら1.5、子なら1

さて、役満以外の得点です。
一般的には満貫未満のスコアは得点表を用いることが多いですが、実はすべて計算で算出可能です。

まず、F符H翻の時の基本得点Sは以下のように算出されます。
S = F×(2^(2+F))
例えば30符3翻ならS=30×(2^5)=960ですね。
これを踏まえて、得点は以下のようになります。
[数式]は100単位の繰り上げになります。
また、Sが2000を超える場合は満貫になります。
上がり条件得点補足
ロン上がり時[S×4×P]Pは自分が親なら1.5、子なら1
ツモ上がり時1人当たり [S×P]Pは自分または相手が親なら2、子なら1

上の数式を符と翻で表にしてみます。満貫とかありえないマスも、ひとまず上記に従って計算してみます。
これは子の場合。
1翻2翻3翻4翻
20符 S = 160
ロン:700点
ツモ:子200・親400
S = 320
ロン:1300点
ツモ:子400・親700
S = 640
ロン:2600点
ツモ:子700・親1300
S = 1280
ロン:5200点
ツモ:子1300・親2600
30符 S = 240
ロン:1000点
ツモ:子300・親500
S = 480
ロン:2000点
ツモ:子500・親1000
S = 960
ロン:3900点
ツモ:子1000・親2000
S = 1920
ロン:7700点
ツモ:子2000・親3900
40符 S = 320
ロン:1300点
ツモ:子400・親700
S = 640
ロン:2600点
ツモ:子700・親1300
S = 1280
ロン:5200点
ツモ:子1300・親2600
S = 2560
ロン:10300点
ツモ:子2600・親5200
(実際は満貫)
50符 S = 400
ロン:1600点
ツモ:子400・親800
S = 800
ロン:3200点
ツモ:子800・親1600
S = 1600
ロン:6400点
ツモ:子1600・親3200
S = 32000
ロン:12800点
ツモ:子3200・親6400
(実際は満貫)

これで見慣れた点数表が出てくることがわかります。

最後に、リーチ棒やツミ棒に関する得点を合わせて終了です。
複数の上がり方がある場合は、もっとも高い上がりを取ればよいでしょう。

8. まとめ

以上で得点計算を終わります。
前回の面子の列挙の方がアルゴリズム的には面白かったかな。
今回の内容は麻雀の役や点数計算を単純にプログラムで辿るだけなので。
麻雀の役判定はかなりややこしいので、これができればポーカーとかは楽にできそうですね。


2010/12/30 麻雀得点計算アルゴリズム編−1(面子の列挙)[★★★◎◎]
先日、VC#講座のページに麻雀の得点計算のサンプルを掲載しました。
ここでは、実装を解説していきたいと思います。

1. はじめに  2. 全体の方針  3. 面子の分解  4. 鳴き牌・上がり牌の処理
5. 麻雀の得点決定要素一覧  6. まとめ 
1. はじめに

元々麻雀の得点計算をしようと思ったのは、あなたのスキルで飯は食えるか? 史上最大のコーディングスキル判定(ITmedia)の記事がきっかけです。
ここに出てくる問題(清一色の上がり牌探索)に挑戦してみたら意外とすんなり解けたので、ついでに得点計算までやってみようと思い立ちました。

さて、麻雀の得点計算のアルゴリズムですが、ネットで調べても意外と出てきません。
少し調べて見つかったのは以下の2つ。
前者は会員登録しないと詳細が読めず、後者はアルゴリズムの高速化がメインです。
どちらも1からアルゴリズムを理解するには不便ですね。

ここでは、自分で作ってみた計算アルゴリズムの概要を説明していきます。
細かい実装や、実動作は上記サンプルを見てもらえばよいかと。

なお、以下では牌に以下の表記を用います。
牌種別本記事上の表現プログラムの内部数値
萬子漢数字 一〜九01〜09
索子アラビア数字 1〜911〜19
筒子丸囲み数字 @〜H 21〜29
字牌東南西北 白發中31〜34および36〜39

今回はC++風の疑似コードを用いてアルゴリズム表記をしますが、その際以下の表記を用います。
表記意味
s牌。上記の数値形式で表す。
m1つの面子。面子内の先頭の牌と、面子種別(雀頭・対子・刻子・順子・槓子)で表す。
S牌の集合
M面子・雀頭の集合
foreach(s,S)Sにおける各牌sにおいてループ
S.add(s)、S.remove(s)Sにsを加えるまたは1つ削除
S.count(s)Sにおける牌sの数
S.exists(s)Sが牌sを含む
S.sizeSが含む牌の数
S.minSのうち一番数値の小さな牌


2. 全体の方針

得点計算は以下の手順で行っていきます。
  1. 面子の分解・列挙
  2. 列挙した面子の役判定
  3. 翻と符から得点計算

まず、14牌からあり得る全パターンの面子の組み合わせを考えます。
そしてそれぞれについて役を判定し、得点を計算します。
各面子の得点の中から、最も得点が高い組み合わせが得点となります。
以下、少しずつ説明していきます。


ここで次に行く前に少し補足。
上記サンプルでは、上がり牌を指定しない場合に、あり得る上がり牌と得点の一覧を出すようにしています。
この実装は単純で、13牌に対し34種の牌をそれぞれ上がり牌として加えて得点計算を繰り返す、というだけです。これはテンパイ判定にも使えますね。
当然34回得点計算をするので処理としては若干重たいですが、現在のPCなら問題ないです。携帯機ではわかりませんが…。


3. 面子の分解

麻雀では、基本的に4面子+1雀頭を構成することになります。
14牌が与えられたとき、そこから構築可能な4面子+1雀頭のパターンを列挙することを考えます。
この構成から外れる役としては七対子と国士無双がありますが、これは別途記載します。

まずは一番シンプルに、鳴きがない状態を考えます。
上がり牌とそうでない牌の区別もしません。

まず、雀頭を確定させてみます。
手牌の中で、2つ以上ある牌を探し、それを雀頭として抜き出した時、残り12牌から4面子を構成できるかチェックします。
List<M> G  // 上がり形の一覧、グローバル変数

function 上がり形列挙(S) {
  
  G.clear();
  
  foreach(s, S) {
    if (S.count(s) >= 2) {
      // sが2つ以上あるなら、面子の集合を作り、雀頭を加える
      M = new 面子集合
      M.add(new 面子(sが2つの雀頭));
      
      // s 2つを取り除いて、残りから面子を列挙
      S.remove(s)
      S.remove(s)
      
      面子列挙(S,M)
      
      // s 2つをもとに戻す
      S.add(s)
      S.add(s)
    }
  }
}
さて、これで後は残り12牌から4面子を抜き出せばよいです。
ここでは、深さ優先探索で検索します。残った牌集合の一番小さな牌が、順子または刻子を構成できるか調べていきます。
残り牌が0にできたら4面子が構築できたので、その面子一覧を上がり形一覧に加えていきます。

function 面子列挙(S,M) {
  
  if (S.size == 0) {
    // 4面子列挙したので、上がり形一覧に追加
    G.add(M)
    return
  }
  
  s = S.min
  
  if (count(s,S) >= 3) {
      // sが3つ以上あるなら、刻子と判断する。
      m = new 面子(sの刻子))
      M.add(m)
      
      // s 3つを取り除いて、残りから面子を列挙
      S.remove(s)
      S.remove(s)
      S.remove(s)
      
      面子列挙(S,M)
      
      // s 3つをもとに戻す
      S.add(s)
      S.add(s)
      S.add(s)
      // 加えた面子をもとに戻す
      M.remove(m)
  }
  
  if (s < 30 && S.exists(s) && S.exists(s+1) && S.exists(s+2)) {
      // 数牌において、最小の牌が次の牌と次の次の牌を持つなら
      // 順子と判断する
      m = new 面子(sの順子))
      M.add(m)
      
      // 順子の牌を取り除いて、残りから面子を列挙
      S.remove(s)
      S.remove(s+1)
      S.remove(s+2)
      
      面子列挙(S,M)
      
      // 順子の牌をもとに戻す
      S.add(s)
      S.add(s+1)
      S.add(s+2)
      // 加えた面子をもとに戻す
      M.remove(m)
  }
}
再帰関数を用いていますが、考え方は割と簡単ですね。
最初に書いたITmediaの問題も、これと同等の考えで答えが出せます。

念のため、なぜ最小の牌でのみ面子構成をチェックするか書いておきます。
確かに、最小の牌でなくてもSの全牌に対し刻子・順子をチェックしてもよいです。
ただ、この場合同一内容で並びが異なるだけのMが出てしまいます。
麻雀においては(上がり牌を除けば)面子構成の順番は考える必要がないので、これが無駄になります。

また、麻雀は手牌すべてを用いて面子を作るゲームなので、最小の牌を選んだ場合、その牌はいずれかの面子に含まれなければなりません。
よって、最小の牌を順子・刻子をチェックすれば十分だと言えます。


4. 鳴き牌・上がり牌の処理

上記のコードは鳴き牌・上がり牌を考慮していませんでした。
ここではこれらの要素を考慮した上がり形列挙を考えます。


鳴き牌の場合、最初に面子Mのうち鳴き牌分が確定した状態で、残りの牌に対し上記「上がり形列挙」を実行すればよいです。
上記では「上がり形列挙」の中で面子集合Mを初期化してますが、代わりにこの関数を呼ぶ側で鳴き牌の面子を加えたMを与えればよいです。
コードとして書くと下記のように表せます。
function 上がり形列挙(S,M) {
  
  foreach(s, S) {
    if (S.count(s) >= 2) {
      // sが2つ以上あるなら、面子の集合を作り、雀頭を加える
      m = new 面子(sが2つの雀頭)
      M.add(m)
      
      // s 2つを取り除いて、残りから面子を列挙
      S.remove(s)
      S.remove(s)
      
      面子列挙(S,M)
      
      // s 2つをもとに戻す
      S.add(s)
      S.add(s)
      
      // 雀頭を削除
      M.remove(m)
    }
  }
}

麻雀は14牌のうちどれが上がり牌かにより得点が変わるので、上がり牌の処理も重要です。
上がり牌の処理は以下の方式が考えられます。どちらでもうまく行くでしょう。
  1. 最初に、上がり牌を含む対子・順子・刻子を構成できるかチェックし、その後残り牌について上記上がり形列挙を行う。
  2. 上がり形列挙を行った上がり形集合Gの各面子Mが、上がり牌を含むかチェック。
どちらも若干面倒です。
まず前者は、既に上がり形列挙・面子列挙で対子・順子・刻子をチェックするコードを書いているのに、上がり牌チェック用にまた似たような処理のチェック用関数を作らないといけません。

後者は、上がり牌が複数使われている場合、得点が変わる可能性があるので、上がり形をコピーして別々に覚えておく必要があります。
例えば、が上がり牌で手牌が455667の場合、456567どちらの一部かによって得点が変わります。
本サンプルでは前者で実装しました。

5. 麻雀の得点決定要素一覧

役の判定は次回に行うとして、ここでは参考までに麻雀の上がり時の得点計算に必要な情報を記述しておきます。
これらを以下にプログラムで表現するかは、プログラマのデータ構造の作り方次第ですね。
  • 手牌および上がり牌
  • 手牌のうち鳴き牌
  • 自風および場風
  • リーチの有無
  • 上がり牌の取得タイミング
    • ツモかロンか
    • 1巡目かどうか
    • 海底かどうか
    • 一発かどうか
    • 嶺上牌かどうか
    • 槍槓かどうか
  • 表ドラおよび裏ドラ
  • 積み棒の数

参考までに、サンプルでのデータ構造を記載しておきます。
あまり綺麗ではないですが…。
なお、1巡目上がりは無条件役満でわざわざ得点計算をする必要がないので、ここには含んでいません。
    //状態
    public class MJState {
        public bool TsumoAgari = false;  //ツモかどうか
        public int Richi = 0;            //リーチ・ダブルリーチかどうか
        // 一発・海底・嶺上・槍槓の有無
        public bool Ippatsu = false, Haitei = false, Rinshan = false, Chankan = false;

        public Kaze Jikaze = Kaze.TON;  //自風
        public Kaze Bakaze = Kaze.TON;  //場風
        public List ArrayNaki = new List();  // 鳴き牌
        public List OmoteDora = new List();  //表ドラ
        public List UraDora = new List();    //裏ドラ
        public List Jihai = new List();      //手牌(上がり牌・鳴き牌除く)
        public Hai AgariHai = Hai.EMPTY;               //上がり牌
    }

6. まとめ

今回は、麻雀の面子列挙を行ってみました。
再帰関数による深さ優先探索は、いかにもコンピュータのアルゴリズムっぽいですね。
次回は、各種役の判定を行っていきます。ただ、こちらは麻雀の役ルールに則り延々チェックしていくだけなのであまり面白味はないかもしれませんね。


目次へ