#銀河麻雀 麻雀のルール解説と半マイティ牌を含んだ麻雀上がり判定アルゴリズム

## 元ネタ

https://www.youtube.com/watch?v=IDaKM7eU7zE&t=1s

## 作成した WEB ページ

https://minori-akizuki.github.io/Galaxy_Mahjong/

## ソースコード

https://github.com/Minori-Akizuki/Galaxy_Mahjong/tree/main/src/lib

## TL;TD

巷で溢れている麻雀上がり判定アルゴリズムではマイティ牌を含んだ牌に対応できなかったので結局総当たりに近い方法で麻雀の上がり形判定をやった話

## 麻雀星人になりたい！

麻雀がわかる人はまずは上の動画を見てほしい。麻雀がわからない人(がこの記事を見るのかはわからないが)むけのために一応麻雀とはなんなのかの説明をざっくりと説明しておく。

### 普通の麻雀

* 中国発祥の、通常4人(たまに3人)でやるテーブルゲームである
* 数の書かれたタイル(これを「牌」と呼ぶ)が3種類(萬子🀇、筒子🀚、索子🀒)、これがそれぞれ1から9まで計27種類ある
* 漢字の書かれた牌(「東南西北」の「風牌🀀」と「白發中」の「三元牌🀅」)が計7種類ある。
* これら合計34種類の牌が4枚ずつ、合計136枚の牌がある
* 手牌を13枚持ち、1枚引いて合計14枚で「あがりの形」(後述)を目指す。あがりになってなかったら1枚捨てる
    * 「あがりの形」とは14枚で「2枚+3枚+3枚+3枚+3枚」の形を作る事である
    * 「2枚」の物は「雀頭(じゃんとう)」と呼ばれ、「同一牌2枚」(🀉🀉等)でなければいけない
    * 「3枚」の物は「面子(めんつ)」と呼ばれ、「同一牌3枚」(🀖🀖🀖等、これは「刻子(こうつ)」と呼ばれる)か「同一種類の数の階段」(🀛🀜🀝等、これは「順子(しゅんつ)」と呼ばれる)でなければならない
* 「副露」という「他人の捨てた牌を面子の足しにして、その面子をオープンする」というシステムがあるので手牌は減る事もある。なので正確には手牌は「13枚 - 3n枚」である
* 1枚足りない状態でゲームが進むので、自分で引いた場合はもちろん、他人の捨てた牌で「あがりの形」になれば「あがり」を宣言できる。この「これがあれば上がりになる」という牌を俗に「待ち牌」と呼ぶ。

この後点数のつけかたなりなんなりあるのだが、それはまぁこの記事に直接関係無いので省く事にする。
麻雀がわからない人は何を言ってるかわからないと思うので実例をいくつか挙げる。

![2022-05-16 07.47.54 minori-akizuki.github.io b8d76cfc22d7.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/6817/38bbf700-1a5d-db2d-b97e-63a4fd723ba9.png)
2副露して(つまり6枚が手牌から抜かれている状態で)こういう形になったとしよう。これは筒子(丸いのが描いてあるやつ)の5か8が来れば「2 + 3 + 3」の形ができあがる。つまり「待ち牌」は「筒子の5か8」だ。
![2022-05-16 07.50.19 minori-akizuki.github.io 01ffdb1bc878.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/6817/f480503f-c9ed-1d2e-a574-311200c914e8.png)

少し複雑な形を呈示する。同一の枚数でこんな形になったとしよう。
![2022-05-16 07.54.41 minori-akizuki.github.io 043f6e9116ed.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/6817/676c75ab-3b26-913c-6c65-eb18f13c18bb.png)
これは雀頭の部分以外が「456 + 78」と「45 + 678」のどちらにも解釈できる。つまり「2 + 3 + 3」の形を作るには萬子(漢数字が描いてあるやつ)の「3、6、9」のどれかが必要である。
![2022-05-16 09.45.53 192.168.11.13 b6ff358979ec.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/6817/7bfef889-bc61-27d8-c184-ea2e948fac6c.png)

当然「3n + 1」の牌が残るケースもある。
![2022-05-16 10.13.49 192.168.11.13 ec9afe00bd7b.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/6817/357372c2-be79-a709-2259-e2fa0484d7ac.png)
![2022-05-16 10.02.00 192.168.11.13 48e514397fb8.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/6817/54c36f9e-c973-44ba-c201-d9082c3ec7f4.png)
説明を飛ばすためにこれも特殊なケースを呈示したが、残った1枚の牌は何でもいい。それで待てるというだけである。

### 銀河麻雀

* 同一牌4枚のうち、1枚ずつ青く塗られた牌がある。これを「銀河牌」と呼ぶ。「銀河牌」は次に挙げる性質をもつ
    * 数の書かれた牌(萬子、筒子、索子)であれば、 **同じ数字のどの牌として扱っても良い**
    * 風牌(東南西北)であれば、 **東南西北どの牌としても扱って良い**
    * 三元牌(白發中)であれば、 **白發中どの牌としても扱って良い**

いよいよ訳がわからなくなってきたと思う。つまり限定利用できるマイティ牌が存在している。「銀河麻雀」の名の通り「麻雀星人ならこれができるはずだ！」と動画の中の人は言っている。

例えばこういう形だ。

![2022-05-16 10.04.58 192.168.11.13 8b8fae5afe0a.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/6817/ae5dff7b-4022-f006-2543-61714cd3cdf7.png)
一見筒子の2か5があれば上がりになれる、と思うが、問題なのはこの **萬子の青い5** だ。これは **同じ数字でとしてであれば、萬子、筒子、索子、のどれにでもなる。**

つまりこの形は **筒子の3、4に萬子の青い5がくっつく事ができ** 、待ち牌はこうなる。

![2022-05-16 10.09.36 192.168.11.13 7829b4bd484f.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/6817/73235069-4625-5c91-e21d-bd121c87ea38.png)

これを踏まえた上で実際に動画で出てきた手牌をお見せしよう。

![2022-05-16 10.18.03 192.168.11.13 7ef9a49b96d6.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/6817/039e236f-aa22-bc6b-ca67-8b408f9c9634.png)

まだ簡単な方なので、麻雀に慣れている人なら数秒かければわかるかもしれない。
「北」が3枚あるのはわかる。(青い東南西北は東南西北どれにでもなる)
数牌に注目すれば、そのまま見ると筒子の4、7が待ちに見える。 **どうやら青い萬子の4はこの筒子にくっつく** 。そうすると、萬子の1が2枚余るから **萬子の1、4も待てる** 。だがよく見てほしい。この萬子の4枚は「1123」の形になっている。普通の麻雀なら1を2枚抜き出しておわりなのだが、青くない通常の牌で先に「123」を作ると **青い萬子の1が余る** 。そしてこれはどの種類の1にもなれる。つまり **1は萬子筒子索子いずれの牌も待てる** 。 
つまりまとめると以下のようになる。

![2022-05-16 10.20.12 192.168.11.13 7e847f8e0787.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/6817/b7486763-e88e-b16f-1026-9c3b253bb48e.png)

当然銀河牌(青い牌)が多くなればなるほどパターンが増え待ちも多くなる。これは確かに宇宙人じゃないとできないかもしれない。

**ならばなってやろう、文明の力で。宇宙人に。**

## 巷の麻雀上がり判定アルゴリズム

前置きが長くなってしまったが、適当に「麻雀 上がり アルゴリズム」とかで調べると大体以下のような方法が出てくる。

* 牌種×数字(便宜上「東南西北白發中」にも番号を振る)の配列を用意し、「どの牌を何枚持っているか」を管理する
* 配列を走査しながら2枚以上持っている牌を探し配列の数字から引く。つまり「雀頭」を確定させる。
* 配列を走査し「3枚組」の抽出をする
    * 持っている数が3以上であれば「刻子」としそれを抜く
    * 1枚以上、3連続で持っている牌があれば「順子」としそれを抜く
* 無事全ての牌が抜ければ上がり。そうでなければ別の2枚持っている牌を探し雀頭を変え同じプロセスを試行する

バリエーションはいくつかあるが、どれもおおむねこんな感じである。
通常の手牌(1枚足りない状態)から待ち牌を探索するには、手牌に34種類の牌を1枚ずつ足して同じ方法を行い、上がり形になったものが「待ち牌」である。

当然この方法はそのままでは使えない。そもそも通常牌と銀河牌を区別しなければいけないので同じ配列上に乗らない。
よしんば全てのパターンを考慮し銀河牌を通常の牌の配列に入れたパターンを作成しようとすると、そのオーダーは銀河牌の数を $g$ として少なく見積っても $O(3^g)$ 以上になる。変数が右肩に乗るオーダーは最悪と言っても過言ではない。

## アプローチ1 : いっそしらみつぶしの方が早い

最初に組んだのは「上がり判定」プログラムだった。色々と考えたが結局しらみつぶしに近い方法を採択した。つまり

* 手牌から2枚採択したパターンを全て列挙し、 `[雀頭とみなせる牌, 残りの手牌]` のパターンを全て保持しておく
    * 3枚以上あった場合同じパターンが出てくる。適時枝刈りをする
* `残りの手牌` から3枚抜き出したパターンを全て列挙し、それが面子とみなせるかを判定する
* 成立したら先と同じように `[面子とみなした牌, 残りの手牌]` を保持しておく
* この「3枚抜き」を再帰的に行う
    * 抜き出す順番によって同じパターンが出てくるので既に抜き出した面子と比較しながら枝刈りをする
* 「上がり牌判定」なので、1枚少ない状態から34種全ての牌を1枚ずつ足したパターンを生成し、全てに対してこれを行う

ソースコードを抜粋すると以下のような感じだ。
変数名に `Mianzi` 等見慣れない単語があると思うが中国語の用語をそのままピンイン[^pinyin]で変数名をしている箇所がいくつか存在する。

[^pinyin]:中国語の発音をアルファベットと声調記号で示したもの。声調記号は流石にコードに書けないのでアルファベットだけで書いているが。

また、ルールクラスのメソッドがいちいち `protected` なのは「普通の麻雀のルール」と「拡張麻雀のルール」を分けて実装したためである。実はこれの他に `GalaxyMahjongRule` なるクラスが存在し、 `makeMianzi` 等はそちらのクラスでオーバーライドされている。

```typescript:util.ts
/*
 * 素朴な処理を行うユーティリティクラス
 */
export class _ {

  /**
   * 両者を辞書式に比較し、その比較結果を返す
   * @param arrA 配列A
   * @param arrB 配列B
   * @param compare 要素に対する比較対象 ( @see Array.prototype.sort() )
   * @returns 比較結果、A の方が小さければ負の数
   */
  static compareArray<T> (arrA:T[], arrB:T[], compare:(a:T, b:T) => number):number {
    for (let i = 0; i < arrA.length; i++) {
      if (i >= arrB.length) {
        return 1
      }
      const compared = compare(arrA[i], arrB[i])
      if (compared !== 0) {
        return compared
      }
    }
    if (arrA.length < arrB.length) {
      return -1
    }
    return 0
  }

  /**
   * 配列から n 個抽出したパターンを全て列挙する。
   * 抽出したものは compare によって ソートされ同一の物が抽出されたパターンは排除される
   * @param arr 配列
   * @param n 抽出個数
   * @param compare ソートのための比較関数
   * @returns [[抽出対象][配列の残り]]
   */
  static extractAllN<T> (
    arr:T[],
    n:number,
    compare:(a:T, b:T) => number
  ):[T[], T[]][] {
    return this._extractAllN(arr, n, compare, 0)
  }

  /**
   * extractAllN の 被ラップ関数
   * 取得開始インデックスから n 個の抽出を試みる
   * @param arr 配列
   * @param n なんこ取ってくるか
   * @param compare 比較関数
   * @param startIndex: 取得開始インデックス
   */
  static _extractAllN<T> (
    arr:T[],
    n:number,
    compare:(a:T, b:T) => number,
    startIndex:number
  ):[T[], T[]][] {
    if (n === 0) {
      // 取るべきものがもう無かったら残りの配列を返す
      return [[[], [...arr]]]
    }
    const ret:[T[], T[]][] = []
    for (let index = startIndex; index <= arr.length - n; index++) {
      // 取得開始インデックスから走査を開始する
      const _arr = [...arr] // 配列のコピーを用意する
      const takenIs = _arr.splice(index, 1) // 現在インデックスからひとつとる
      const takenP = this._extractAllN([..._arr], n - 1, compare, index) // 取得開始インデックスの次から n-1 個の抽出を試みる
      // 枝刈り処理
      takenP.forEach(([taken, remaingArr]) => {
        const _taken:T[] = takenIs.concat(taken) // パターンが列挙されているのでそれを平たくする
        if (ret.every(
          ([retTaken, remain]) => !(_.compareArray(retTaken.sort(compare), _taken.sort(compare), compare) === 0)
        )) {
          // 既に取得したパターンでなければパターンに追加する
          ret.push([_taken, remaingArr])
        }
      })
    }
    return ret
  }
}
```

```typescript:rule_base.ts
/**
 * 麻雀ルール、シングルトンで提供される。
 */
export class MahjongRule {
  /*
  コンストラクタ等省略
  */
  /**
   * 手牌から n 枚で構成される面子/対子を抜き出した全てのパターンを返却する
   * @param number 抜き出す枚数、対子なら2枚、など
   * @param tiles 手牌
   * @returns 考えうる面子/対子と残りの牌の組
   */
  protected takeMianziNorm (number: number, tiles: MahjongTile[]):[IMianzi, MahjongTile[]][] {
    // n 枚無い時は空配列を返す
    if (tiles.length < number) {
      return []
    }
    // 牌を n 枚抜き出し面子(雀頭)判定をする
    const candidateMianzi = _.extractAllN(
      tiles,
      number,
      (ta, tb) => this.compareTileByNumber(ta, tb)
    )
    // 抜き出した物が面子/対子か判定し、成立しなかったものは排除する
    // makeMianzi は対象の2枚か3枚が面子/対子として成立するかを判定し、想定される面子の色等を全て返すメソッドである。
    // 便宜上麻雀上で扱う牌で組になるものは全て「IMianzi」という interface に集約している
    const _mianzis = candidateMianzi
      .map((t:[MahjongTile[], MahjongTile[]]):[IMianzi[], MahjongTile[]] => [this.makeMianzi(t[0], false), t[1]])
      .filter((t:[IMianzi[], MahjongTile[]]) => t[0].length !== 0)
    // 銀河牌等の要因により複数の候補がある面子構成を開いて別々にする
    // [[m1, m2, m3], Ts] -> [[m1, Ts], [m2, Ts], [m3, Ts]]
    const mianzis:[IMianzi, MahjongTile[]][] = []
    _mianzis.forEach(_m => {
      const remaingTile = _m[1]
      _m[0].forEach(mianzi => {
        mianzis.push([mianzi, remaingTile])
      })
    })
    return mianzis
  }
}

  /**
   * 手牌から対子を抜き出せるパターンを列挙する
   * @param tiles 手牌
   * @returns 対子と残りの牌の組
   */
  protected takeDuizi (tiles: MahjongTile[]):[IMianzi, MahjongTile[]][] {
    return this.takeMianziNorm(2, tiles)
  }

  /**
   * 手牌から面子を抜き出せるパターンを列挙する
   * @param tiles 手牌
   * @returns 面子と残りの牌の組
   */
  protected takeMianzi (tiles: MahjongTile[]):[IMianzi, MahjongTile[]][] {
    return this.takeMianziNorm(3, tiles)
  }

  /**
   * 手牌から可能な限り対子か面子か雀頭を取得し続け、取得可能なパターンを全て列挙する
   * なお「XZi」は造語である。
   * @param intermediateHand 既に抜いてある面子/雀頭
   * @param takeXZi 面子を取るか雀頭を取るか
   * @returns 取得可能な全ての手牌構成の列と残りの手牌
   */
  protected takeRecursivelyXZi (
    intermediateHand:[IMianzi[], MahjongTile[]][], takeXZi:(tiles: MahjongTile[]) => [IMianzi, MahjongTile[]][]
  ):[IMianzi[], MahjongTile[]][] {
    if (
      intermediateHand[0][1].length === 0
      // 全ての牌が抜き取られこれ以上とる牌が無い場合(余らない枚数の牌が与えられていた事が前提のガード節)
    ) {
      return intermediateHand
    }
    // 結果保存用の変数
    const concatMianzi:[IMianzi[], MahjongTile[]][] = []
    intermediateHand.forEach(i => {
      // 残りの手牌から面子のパターンを抽出
      const takenXZi = takeXZi(i[1])
      takenXZi.forEach(t => {
        // [[...既にある面子, 今とった面子], [残った手牌]]
        const candidateHand:[IMianzi[], MahjongTile[]] = [[...i[0], t[0]], t[1]]
        // 現在取った面子
        const currentMianzi = candidateHand[0]
        // 今迄に取った面子の列
        const existongConcatMianzi = concatMianzi.map(c => c[0])
        if (
          existongConcatMianzi
            .every(
              ms => !_.equalSetArray(
                ms,
                currentMianzi,
                (a, b) => this.compareMianzi(a, b)) // 面子を比較するための比較関数
            )) {
          // 同一の面子(雀頭)の組み合わせが無い場合のみ結果に追加する
          concatMianzi.push(candidateHand)
        }
      })
    })
    if (concatMianzi.length === 0) {
      // これ以上取得が不可能であった場合
      // 残った牌と一緒に抜いた面子構成を返却する
      return intermediateHand
    }
    return this.takeRecursivelyXZi(concatMianzi, takeXZi)
  }

  /**
   * 晒されていない手牌から通常の上がり形(1雀頭n面子)のパターンを列挙する
   * (通常じゃない上がり形というのがあるがここでは省略する)
   * @param tiles 手牌
   * @returns 通常の上がり形として考えられる形
   */
  protected solveNormalHand (tiles: MahjongTile[]): IMianzi[][] {
    // まず雀頭をとる
    const _intermediateHand:[IMianzi, MahjongTile[]][] = this.takeDuizi(tiles)
    // [[面子構成], 手牌] の配列に開く
    const intermediateHand:[IMianzi[], MahjongTile[]][] = _intermediateHand.map(i => [[i[0]], i[1]])
    // 残った手牌から面子をとり続ける
    const arrangedManzi = this.takeRecursivelyXZi(intermediateHand, (ts) => this.takeMianzi(ts))
    // 残った手牌が0枚のパターンを「上がり成立」として列挙する
    return arrangedManzi.filter(([mianzis, remaindHand]) => remaindHand.length === 0).map(mt => mt[0])
  }
```

説明に必要そうなところだけ抜粋したので、足りないところもあるかもしれない。興味のある人は是非 [ソースコード](https://github.com/Minori-Akizuki/Galaxy_Mahjong/tree/main/src/lib) を読んでほしい。

さて、これで上がり判定ができた。あとは

> * 「上がり牌判定」なので、1枚少ない状態から34種全ての牌を1枚ずつ足したパターンを生成し、全てに対してこれを行う

を行うだけだ。そう思っていた。

結論から言うとこれが **バカ遅かった** 。とりあえず最初の目標にしていたこの牌形の判定に **15秒** 程かかった。開発用のかなりスペックのいいパソコンで、しかもまだ画面を実装していない UT の段階でだ。

![2022-05-16 10.18.03 192.168.11.13 7ef9a49b96d6.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/6817/039e236f-aa22-bc6b-ca67-8b408f9c9634.png)

正直薄々感じてはいた。通常の麻雀ならパターンそのものがそんなに多くないが、この麻雀はそもそもの面子構成が銀河牌の数によって膨れ上がる。上がり判定だけでも結構な負荷であるのにそれを **34回くりかえす** のは流石に Greedy であった。何事も横着はいけない。

## アプローチ2 : ちゃんと「待ち」を見ましょう。

こうなったら愚直にやるしかない。一応前提とした麻雀のルールを少し追加で説明する。

麻雀の待ちには(通常の形で) 3 系統 5 種類ある。説明のために3副露(9枚手牌から抜かれている)して手牌が4枚の状態を例にとって説明する。

* 順子ができるのを待つパターン
    * 「xx34」等が残り、両側の牌が来れば順子になる「両面(りゃんめん)待ち」
    * 「xx46」等が残り、間の牌が来れば順子になる「嵌張(かんちゃん)待ち」
    * 「xx12」や「xx89」が残り片側(3や7)を待てる「辺張(ぺんちゃん)待ち」
        * 補足として、麻雀において1と9は繋らない事を添えておく
        * また、これらの「あと1枚あれば順子になる」という2枚の事を「塔子(たーつ)」と呼ぶ
* 刻子ができるのを待つパターン
    * 「xxyy」の牌が残り、xyどちらかが来ればそれが刻子になり残った方が雀頭になる「双椪(しゃんぽん)待ち」
* 雀頭ができるのを待つパターン
    * 「xyyy」等の形が残り、xを雀頭の2枚目の牌として待つ「単騎(たんき)待ち」

また、冒頭で少しお見せしたようにこれらは複合あるいは重複する事がある。いくつか例を挙げる。

* 「xx23456」という形は「xx + 23 + 456」とも「xx + 234 + 56」ともとれる
* 「1234」という形は「1 + 234」とも「123 + 4」ともとれる
* 「5556」という形は「55 + 56」とも「555 + 6」 ともとれる
* 「xx44456」という形は「xx + 44 + 456」とも「xx + 444 + 56」ともとれる

場合によってはもっと複雑になる。通常の上がり形なら 1-9 まで全ての牌が待てるパターンすら存在する。

もとい、

つまり先に説明した「3 系統 5 種」の待ちを選別、及び列挙すればいい。具体的には以下のプロセスを踏む。

* 手牌から面子の抽出を可能な限り行う。
    * 1 枚だけ残ればそれは「単騎待ち」である
    * 4 枚残って、対子が抽出できて、残った牌が塔子に相当すればそれが待ちである
* 手牌から対子を 2 つ抜いてから面子の抽出を可能な限り行う。全ての牌が抽出できればそれは「双椪待ち」である
* これらを全パターン列挙する。ね、簡単でしょ。

幸いにも面子/対子を抜き出す部分というのはもうできあがっているので、「どの順番で抜くか」、あとは「任意の牌2枚が順子のパーツと成りえるか」と「待つべき牌は何か」の部分を作ればいい。

以下ソースコードの抜粋を掲載する。例によって変数名/関数名にピンインが採用されている箇所があるので少々読み辛いところがあるかもしれないがご了承いただきたい。

```typescript
export class MahjongRule {

  /**
   * 手牌から通常形の上がり形と上がり牌を算出する
   * @param hand 手牌
   * @returns [抽出された面子, 上がり牌, 待ち形]
   */
  protected solveHuleTileCommoon (hand:MahjongTile[]):[IMianzi[], MahjongTile[], waitForm][] {
    // 「Hule」は麻雀で「上がり」を意味する「和了」のピンインである
    return [
      ...this.solveHuleTileCommonMianziFirst(hand),
      ...this.solveHuleTileCommonDuiziFirst(hand) // 「Duizi」とは「対子」のピンインである。
    ]
  }

  /**
   * 待ち牌の検索。面子抜き出し優先で行う
   * @param hand 手牌
   * @returns 面子構成、待ち、待ち形 の列
   */
  protected solveHuleTileCommonMianziFirst (hand:MahjongTile[]):[IMianzi[], MahjongTile[], waitForm][] {
    // まずは面子をできるだけ抜く
    // 同一パターンの刈り取りはこの時点でされている筈
    const completePartial = this.takeRecursivelyXZi([[[], hand]], (tiles) => this.takeMianzi(tiles))
    const ret:[IMianzi[], MahjongTile[], waitForm][] = []
    completePartial.forEach(([takenMianzis, remaingTiles]) => {
      if (remaingTiles.length === 1) {
        // 単騎待ちの場合(「Guli」は「単騎」のピンイン)
        const gulis = this.takeGuli(remaingTiles[0])
        gulis.forEach(([guli, none]) => {
          // 単騎待ちの形を配列に追加する
          const [waitTile, waitForm] = this.deriveHuleTileCommonFromWait([guli])
          ret.push([[...takenMianzis, guli], waitTile, waitForm])
        })
      } else if (remaingTiles.length === 4) {
        // 4枚残っている場合。双椪か 対子 + 塔子 のはず
        // 対子の抽出を試みる
        const triedTakeDuizis = this.takeRecursivelyXZi([[[], remaingTiles]], (tiles) => this.takeDuizi(tiles))
        triedTakeDuizis.forEach(([mianzis, remaingTiles]) => {
          // 塔子が残る場合は solveHuleTileCommonDuiziFirst でカバーする
          if (mianzis.length === 2) {
            // 双椪待ちの場合
            // 待ちを算出して配列に追加する
            const [waitTile, waitForm] = this.deriveHuleTileCommonFromWait(mianzis)
            ret.push([takenMianzis.concat(mianzis), waitTile, waitForm])
          }
        })
      }
      return ret
    })
    // 最後に結果の列を返す
    return ret
  }

  /**
   * 対子優先で待ち牌の算出を行う
   * @param hand 手牌
   * @returns [面子構成, 待ち牌, 待ち形] の列
   */
  protected solveHuleTileCommonDuiziFirst (hand:MahjongTile[]):[IMianzi[], MahjongTile[], waitForm][] {
    /**
     * 塔子をいままで抜いた面子にくっつける
     * @param takenMianzis 既に抜いた面子
     * @param tiles 牌(2枚)
     * @returns 面子構成、待ち牌、待ち形の配列
     */
    const conbineMianzisAndTazi = (takenMianzis:IMianzi[], tiles:MahjongTile[]):[IMianzi[], MahjongTile[], waitForm][] => {
      const ret:[IMianzi[], MahjongTile[], waitForm][] = []
      const triedMakeTazi = this.makeTazi(tiles)
      triedMakeTazi.forEach(tazi => {
        const [waitTile, waitForm] = this.deriveHuleTileCommonFromWait([tazi])
        ret.push([[...takenMianzis, tazi], waitTile, waitForm])
      })
      return ret
    }
    // 対子を一つ抜く
    const takenDuiziPatternsFromHand = this.takeDuizi(hand)
    const ret:[IMianzi[], MahjongTile[], waitForm][] = []
    takenDuiziPatternsFromHand.forEach(([duizi, remaindTiles]) => {
      // 面子を可能な限り取得する
      const completePartial = this.takeRecursivelyXZi([[[duizi], remaindTiles]], (tiles) => this.takeMianzi(tiles))
      completePartial.forEach(([mianzis, remaindTiles]) => {
        if (remaindTiles.length === 2) {
          // 残ってる牌が2枚だった場合
          // 塔子の生成を試みる
          ret.push(...conbineMianzisAndTazi(mianzis, remaindTiles))
        }
      })
    })
    return ret
  }

  /**
   * 塔子、双椪から待ちの種類と待ち牌を算出する
   * @param ms 単騎牌か塔子1つか対子2つ
   * @returns [待ち牌, 待ち形] の列
   */
  public deriveHuleTileCommonFromWait (ms:IMianzi[]):[MahjongTile[], waitForm] {
    if (ms.length === 2 && ms[0].kind === MianziKind.duizi && ms[1].kind === MianziKind.duizi) {
      // 双椪
      const [m1, m2] = ms
      const tiles:MahjongTile[] = [
        // 今更初出だが麻雀牌を定義するクラスである。種別、数字の他にオプション(「銀河牌である」とか)が指定できる。
        new MahjongTile(m1.color, m1.number, {}),
        new MahjongTile(m2.color, m2.number, {})
      ]
      return [tiles, waitForm.shuangPong]
    }
    const m = ms[0]
    if (m.kind === MianziKind.guli) {
      // 孤立牌(単騎)
      return [[new MahjongTile(m.color, m.number, {})], waitForm.danqi]
    }
    const [t1, t2] = m.tiles.sort(this.compareTileByNumber)
    if (t1.number + 2 === t2.number) {
      // 嵌張
      return [[new MahjongTile(m.color, t1.number + 1, {})], waitForm.quianZhang]
    }
    if (t1.number + 1 === t2.number) {
      // 連続した牌の場合(本当はこの条件いらんけど一応)
      if (t1.number === 8) { // TODO: マジックナンバーなのが気に食わない
        // 89の辺張の場合
        return [[new MahjongTile(m.color, t1.number - 1, {})], waitForm.bianShang]
      }
      if (t1.number === 1) {
        // 12の辺張の場合
        return [[new MahjongTile(m.color, t2.number + 1, {})], waitForm.bianShang]
      }
      return [
        [
          new MahjongTile(m.color, t1.number - 1, {}),
          new MahjongTile(m.color, t2.number + 1, {})
        ],
        waitForm.ligngMain
      ]
    }
    throw Error('deriveHuleTileCommon: Illigal wait form')
  }
}
```

という訳でめでたく「面子構成の抽出」と「待ち牌の算出」からのアプローチが実装された。結果例の15秒かかっていた形の算出は **3秒** にまで短縮された。
ここでは抜粋したコードのみ載せているが、肝心の面子を作成する部分や牌の同一性、連続性のチェック、また各種ユーティリティ関数を結構書いている。是非 [ソースコード](https://github.com/Minori-Akizuki/Galaxy_Mahjong/tree/main/src/lib) 全体を読んでほしい。

正直な話、実は点数計算等を行おうとするとこちらからのアプローチは必須だったので、まぁ必要な物を結局創ったという話なだけであるのだが。

## おわりに

結局速度の面はこれ以上早くなる方法が思いつかなかった。コンピュータですらこの数秒かかるこの麻雀、人間にはやはりハードルが高い。宇宙は広い。宇宙はすごい。

……だれか……速度改善にいいアイデア……ありませんか……