programming 2026.01.12

麻雀の点数計算をツリー構造で理解する再帰アルゴリズム

約20分で読めます

麻雀の役判定をプログラミングのツリー構造で実装。再帰的探索アルゴリズムとJavaScriptでの実践例を詳しく解説します。

なぜ麻雀の点数計算は難しいのか?

「アプリに麻雀の点数計算機能を実装したいけれど、ロジックが複雑すぎて困っている」「役の判定を効率的にプログラミングする方法が分からない」というご相談を、Fivenine Designでもよく受けます。

麻雀の点数計算は、複数の役が組み合わさり、さらに場の状況によって点数が変動する複雑なシステムです。プログラマーにとって、この複雑さを整理して実装することは決して簡単ではありません。

特に以下のような課題に直面していませんか?

  • 役の優先度をどう判定するか分からない
  • 複数の役が成立する場合の処理が複雑
  • 手牌の組み合わせを効率的に探索する方法がない
  • コードが煩雑になり保守性が悪い

これらの問題を解決する鍵は、ツリー構造を使った再帰的な探索アルゴリズムです。

麻雀の役判定が複雑な理由

複数の判定軸が存在する

麻雀の役判定が困難な理由は、判定に必要な要素が多岐にわたることです。以下のような複数の軸で同時に判定する必要があります:

flowchart TD
    A[手牌] --> B[牌の組み合わせ]
    A --> C[数字の連続性]
    A --> D[同種牌の有無]
    A --> E[特殊牌の組み合わせ]
    
    B --> F[対子・刻子・順子]
    C --> G[一気通貫・三色同順]
    D --> H[一盃口・七対子]
    E --> I[役牌・字一色]
    
    F --> J[最終役判定]
    G --> J
    H --> J
    I --> J

あるクライアント企業での実例

以前、ゲーム開発会社のクライアントから「麻雀ゲームの点数計算機能を高速化したい」という依頼がありました。既存のコードは、すべての役を順番にチェックする単純な実装で、以下のような問題が発生していました:

  • 処理速度の問題: 1回の役判定に平均450ミリ秒
  • 保守性の問題: 新しい役を追加するたびにコード全体を見直し
  • 拡張性の問題: 地方ルールへの対応が困難

ツリー構造による解決アプローチ

基本的な考え方

ツリー構造を使って麻雀の役判定を行う基本的なアイデアは、判定プロセスを階層化することです。ルートノードから始まって、各段階で手牌の特徴を分析し、最終的な役を決定します。

class MahjongHandAnalyzer {
  constructor(hand) {
    this.hand = hand; // 手牌の配列
    this.yaku = [];
    this.fu = 30; // 基本符
  }

  // メインの解析処理
  analyze() {
    const rootNode = this.createDecisionTree();
    return this.traverseTree(rootNode);
  }

  // 決定木を構築
  createDecisionTree() {
    return {
      type: 'root',
      condition: () => true,
      children: [
        this.createSpecialHandNode(),
        this.createRegularHandNode()
      ]
    };
  }

  // 特殊役(七対子、国士無双など)のノード
  createSpecialHandNode() {
    return {
      type: 'special',
      condition: () => this.isSpecialHandPattern(),
      children: [
        {
          type: '七対子',
          condition: () => this.isSevenPairs(),
          score: () => ({ han: 2, fu: 25 }),
          children: []
        },
        {
          type: '国士無双',
          condition: () => this.isKokushimusou(),
          score: () => ({ han: 13, fu: 0 }),
          children: []
        }
      ]
    };
  }

  // 通常役のノード
  createRegularHandNode() {
    return {
      type: 'regular',
      condition: () => this.isRegularPattern(),
      children: [
        this.createSequenceNode(),
        this.createTripletNode(),
        this.createHonorTileNode()
      ]
    };
  }

  // 再帰的にツリーを探索
  traverseTree(node) {
    if (!node.condition()) {
      return null;
    }

    // 葉ノード(最終的な役)の場合
    if (node.children.length === 0) {
      return {
        yaku: node.type,
        score: node.score ? node.score() : null
      };
    }

    // 子ノードを再帰的に探索
    const results = [];
    for (const child of node.children) {
      const result = this.traverseTree(child);
      if (result) {
        results.push(result);
      }
    }

    return results.length > 0 ? results : null;
  }
}

具体的な役判定の実装

実際の役判定では、手牌の分析を段階的に行います。以下は主要な判定メソッドの実装例です:

class MahjongHandAnalyzer {
  // ... 前述のコード

  // 七対子の判定
  isSevenPairs() {
    const pairCount = this.countPairs();
    return pairCount === 7;
  }

  // 対子の数をカウント
  countPairs() {
    const tileCount = {};
    
    // 各牌の出現回数をカウント
    for (const tile of this.hand) {
      tileCount[tile] = (tileCount[tile] || 0) + 1;
    }
    
    // 対子の数を数える
    let pairs = 0;
    for (const count of Object.values(tileCount)) {
      if (count === 2) pairs++;
    }
    
    return pairs;
  }

  // 順子(連続する数字)の判定
  isSequence(tiles) {
    if (tiles.length !== 3) return false;
    
    const numbers = tiles.map(tile => this.getTileNumber(tile)).sort((a, b) => a - b);
    return numbers[1] === numbers[0] + 1 && numbers[2] === numbers[1] + 1;
  }

  // 刻子(同じ牌3枚)の判定
  isTriplet(tiles) {
    return tiles.length === 3 && tiles.every(tile => tile === tiles[0]);
  }

  // 一気通貫の判定(再帰を使った例)
  checkIkkitsukan(suits) {
    for (const suit of ['m', 'p', 's']) {
      if (this.hasConsecutiveSequence(suits[suit], [1, 4, 7])) {
        this.yaku.push('一気通貫');
        return true;
      }
    }
    return false;
  }

  // 連続する順子の存在確認(再帰的チェック)
  hasConsecutiveSequence(suitTiles, targetStarts, currentIndex = 0) {
    if (currentIndex >= targetStarts.length) {
      return true; // すべての開始点で順子が見つかった
    }

    const start = targetStarts[currentIndex];
    const sequence = [start, start + 1, start + 2];
    
    if (this.hasSequenceInSuit(suitTiles, sequence)) {
      return this.hasConsecutiveSequence(suitTiles, targetStarts, currentIndex + 1);
    }
    
    return false;
  }
}

パフォーマンス最適化

大量の手牌パターンを効率的に処理するため、メモ化(memoization)を活用します:

class OptimizedMahjongAnalyzer extends MahjongHandAnalyzer {
  constructor(hand) {
    super(hand);
    this.cache = new Map();
  }

  // メモ化を使った役判定
  analyzeWithCache() {
    const handKey = this.generateHandKey();
    
    if (this.cache.has(handKey)) {
      return this.cache.get(handKey);
    }

    const result = this.analyze();
    this.cache.set(handKey, result);
    
    return result;
  }

  // 手牌のユニークキーを生成
  generateHandKey() {
    return this.hand.slice().sort().join(',');
  }

  // 定期的にキャッシュをクリア(メモリ使用量管理)
  clearCache() {
    if (this.cache.size > 10000) {
      this.cache.clear();
    }
  }
}

よくある失敗パターンと対処法

1. 全役を線形チェックする非効率な実装

失敗例:

// 非効率なアプローチ
function checkAllYaku(hand) {
  const results = [];
  
  if (isRiichi(hand)) results.push('リーチ');
  if (isTanyao(hand)) results.push('断么九');
  if (isPinfu(hand)) results.push('平和');
  // ... すべての役を順番にチェック
  
  return results;
}

問題点:

  • 不要な判定処理が多い
  • 役の優先度が考慮されない
  • 新しい役の追加が困難

改善策: ツリー構造により、関連する役のみを効率的にチェック:

// 効率的なアプローチ
function checkYakuEfficiently(hand) {
  // まず手牌のパターンを分類
  const pattern = analyzeHandPattern(hand);
  
  switch(pattern.type) {
    case 'pairs':
      return checkPairBasedYaku(hand);
    case 'sequences':
      return checkSequenceBasedYaku(hand);
    case 'honors':
      return checkHonorBasedYaku(hand);
  }
}

2. 再帰の深度制限を考慮しない実装

失敗例: 深い再帰により、スタックオーバーフローが発生する可能性があります。

対処法: 反復的な実装への変換、または再帰深度の制限を設定:

class SafeMahjongAnalyzer {
  constructor(maxDepth = 100) {
    this.maxDepth = maxDepth;
    this.currentDepth = 0;
  }

  traverseTreeSafe(node) {
    if (this.currentDepth > this.maxDepth) {
      throw new Error('再帰深度制限に達しました');
    }

    this.currentDepth++;
    try {
      const result = this.traverseTree(node);
      return result;
    } finally {
      this.currentDepth--;
    }
  }
}

3. 地方ルールへの対応不足

課題: 標準ルール以外の地方ルールに対応するとき、コードの修正範囲が広がりすぎる問題がありました。

解決策: ルール定義を外部化し、設定可能な構造に:

class ConfigurableMahjongAnalyzer {
  constructor(ruleConfig) {
    this.rules = ruleConfig;
    this.loadRuleSet();
  }

  loadRuleSet() {
    // ルール設定からツリー構造を動的生成
    this.decisionTree = this.buildTreeFromConfig(this.rules);
  }

  buildTreeFromConfig(config) {
    // 設定ファイルに基づいて判定ツリーを構築
    return {
      type: config.rootType,
      children: config.yakuList.map(yaku => this.createYakuNode(yaku))
    };
  }
}

実装効果の測定結果

ツリー構造を導入したクライアント企業での効果測定結果をご紹介します:

定量的な改善効果

0%
処理速度向上
0%
コード行数削減
0%
バグ発生率低下

特に印象的だったのは、新しい役を追加する際の工数が従来の1/3に短縮された点です。ツリー構造により、影響範囲が明確になり、テストケースも体系的に作成できるようになりました。

無料相談受付中

お気軽にご相談ください(初回無料)

詳しく見る

まとめと次のステップ

麻雀の点数計算をツリー構造で実装することで、以下のメリットが得られます:

  • 処理速度の大幅向上: 不要な判定をスキップし、効率的な探索が可能
  • 保守性の改善: 役ごとの判定ロジックが独立し、修正影響範囲が限定的
  • 拡張性の確保: 新しい役やルールバリエーションに柔軟対応
  • テスタビリティ: 各ノードを独立してテスト可能

導入チェックリスト

実装を始める前に、以下の項目を確認してください:

次のアクション

本記事の内容を実装する際は、まず小規模なプロトタイプから始めることをお勧めします。基本的な役(リーチ、タンヤオ、平和など)のツリー構造を作成し、動作を確認してから段階的に拡張していきましょう。

より複雑なゲームロジックの実装や、高性能なWebアプリケーション開発でお困りの場合は、Fivenine Designまでお気軽にご相談ください。20年以上の実績を活かし、最適なソリューションをご提案いたします。

この記事をシェア

この記事の内容でお困りですか?

無料でご相談いただけます

Webサイトの改善、システム開発、AI導入など、 お気軽にご相談ください。初回相談は無料です。

無料相談してみる
AIに無料相談