0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

バニラJavaScriptで完全完結するナンプレ(ナンバープレイス)自動生成&解答&問題共有Webアプリを作ってみた

0
Posted at

バニラJavaScriptで完全完結するナンプレ(ナンバープレイス)自動生成&解答&問題共有Webアプリを作ってみた


1. はじめに

ライブラリや外部フレームワーク(ReactやVueなど)を一切使わず、完全なバニラJavaScript(単一のHTMLファイルのみ)で動作する軽量な「ナンプレ(ナンバープレイス)自動生成&解答&問題共有ツール」を作成しました。

デモリンク

NumberPlaceQ000.png

二十数年くらい昔私がC++コンソールアプリで実装していたナンプレの解法アルゴリズム(ビットマスク演算を活用した高速探索)をJavaScriptに移植し、以下の機能をブラウザ上だけで完結するように実装しています。

  • 一意解(解が1つだけ存在する)を保証した問題自動生成
  • ビット演算による高速なソルバー&検証エンジン
  • 自作問題の妥当性チェック(解が存在するか/一意解か)
  • URLパラメータ(Base64エンコード)による問題のSNS/WEB共有機能
  • 初めてマスを入力した瞬間にスタートする自動プレイ時間計測

2. 主な機能と工夫したポイント

2-1. C++から移植したビットマスク探索ソルバー

ナンプレ(ナンバープレイス)の自動生成と検証において、最もコアとなるのが「高速な解法探索(ソルバー)」と「解が一意(1つだけ)かどうかの判定」です。

ここでは、C++で書かれたビット演算ベースの高速ソルバーをJavaScriptに移植したコードの仕組みを、4つのポイントに分けて詳しく解説します。


2-1-1. ビットマスクによる高速な候補数字の特定 (searchNumPl)

ナンプレにおいて最も処理が重くなるのが、「あるマスにどの数字が入るか(重複チェック)」の判定です。愚直に配列やループを回すと計算量が膨らむため、ビット演算(ビットマスク)を用いて高速化しています。

  • ビットフラグの定義:
    1〜9の数字をそれぞれ9ビットのフラグ(0x001 から 0x100)に対応させています。
    const NUM = [0x001, 0x002, 0x004, 0x008, 0x010, 0x020, 0x040,
                 0x080, 0x100];

    let currentQuestion = Array(81).fill(0);
    let currentSolution = Array(81).fill(0);
    let isCustomMode = false;

    function getNum(k, a) {
      let ret = 1, j = 0;
      for (let i = 1; i < 0x200; i *= 2, ret++) {
        if (k & i) j++;
        if (a && j === a) return ret;
      }
      return 0;
    }

    function searchNumPl(np, idx) {
      if (np[idx] !== 0) return { n: 0, k: 0, valid: false };

      const r = Math.floor(idx / 9);
      const c = idx % 9;
      const br = Math.floor(r / 3) * 3;
      const bc = Math.floor(c / 3) * 3;

      let used = 0;

      for (let a = 0; a < 9; a++) {
        const nr = np[r * 9 + a];
        if (nr) used |= NUM[nr - 1];

        const nc = np[a * 9 + c];
        if (nc) used |= NUM[nc - 1];

        const nb = np[(br + Math.floor(a / 3)) * 9 + (bc + (a % 3))];
        if (nb) used |= NUM[nb - 1];
      }

      let count = 0, mask = 0;
      for (let a = 0; a < 9; a++) {
        if (!(used & NUM[a])) {
          count++;
          mask |= NUM[a];
        }
      }

      return { n: count, k: mask, valid: true };
    }
  • 重複チェックのビットOR演算:
    対象のマスと同じ行・列・3×3ブロックに存在する数字を走査し、すでに使われている数字のビットを used |= NUM[nr - 1] のようにビットOR(論理和)でまとめていきます。
  • 空き候補の抽出:
    used の反転を取ることで、「そのマスに置ける数字」をビットマスク(mask)と候補数(count)として一撃で算出します。これにより、条件分岐を最小限に抑えた高速な判定を実現しています。

2-1-2. 再帰とバックトラックによる全域探索 (calcNumPl)


    function calcNumPl(np, bans = false) {
      let work = [...np];

      function solve() {
        let m = 10, j = -1, l = 0, emptyCount = 0;

        for (let i = 0; i < 81; i++) {
          const res = searchNumPl(work, i);
          if (res.valid) {
            if (res.n < m) {
              m = res.n;
              j = i;
              l = res.k;
            }
          } else {
            emptyCount++;
          }
        }

        if (emptyCount === 81) return true;
        if (m < 1 || m > 9) return false;

        if (m === 1) {
          work[j] = getNum(l, 1);
          return solve();
        } else {
          const loopRange = bans 
            ? Array.from({length: m}, (_, i) => m - i) 
            : Array.from({length: m}, (_, i) => i + 1);

          for (let a of loopRange) {
            const backup = [...work];
            work[j] = getNum(l, a);
            if (solve()) return true;
            work = backup;
          }
          return false;
        }
      }

      return solve() ? work : null;
    }

候補数が少ないマス(定員が少ないマス)を優先的に埋めながら、再帰的に解を探索するバックトラック法(深さ優先探索)のコアエンジンです。

  1. 候補数が最も少ないマス(Minimum Remaining Value法)を探索:
    盤面全体を見渡し、置ける数字の選択肢(res.n)が最も少ない空きマス(j)を特定します。
  2. 候補が1つの場合の確定処理 (m === 1):
    選択肢が1つしかないマスは迷わず確定させ、再帰的に次のマスを解き進めます(数独における「唯一の候補」の確定)。
  3. 分岐とバックトラック (m > 1):
    複数の選択肢がある場合は、実際に1つ仮置きして再帰呼び出し(solve())を行います。もし途中で破綻(解なし)に行き当たった場合は、バックアップから盤面を復元(バックトラック)して別の数字を試します。
  4. bans(逆順探索)フラグの役割:
    引数 bans を true または false に切り替えることで、ループの走査順序(正順または逆順)を反転させることができます。これは次の「一意解の検証」で極めて重要な役割を持ちます。

2-1-3. 盤面のルール違反チェック (hasRuleViolation)


    function hasRuleViolation(board) {
      for (let i = 0; i < 9; i++) {
        let rowSet = new Set(), colSet = new Set(), blockSet = new Set();
        for (let j = 0; j < 9; j++) {
          let rv = board[i * 9 + j];
          if (rv !== 0) {
            if (rowSet.has(rv)) return true;
            rowSet.add(rv);
          }
          let cv = board[j * 9 + i];
          if (cv !== 0) {
            if (colSet.has(cv)) return true;
            colSet.add(cv);
          }
          let br = Math.floor(i / 3) * 3 + Math.floor(j / 3);
          let bc = (i % 3) * 3 + (j % 3);
          let bv = board[br * 9 + bc];
          if (bv !== 0) {
            if (blockSet.has(bv)) return true;
            blockSet.add(bv);
          }
        }
      }
      return false;
    }

ユーザーが自作問題を入力した際や、不正なデータを受け取った際に、ナンプレの基本ルール(行・列・3×3ブロック内に同じ数字が重複していないか)に違反していないかを事前にスキャンする関数です。

  • JavaScriptの Set オブジェクトを活用し、各行・各列・各ブロックを走査して重複値(Set.has(rv))が見つかった時点で即座に true(違反あり)を返します。
  • これにより、無駄なソルバーの実行を防ぎ、エラーメッセージを高速に返すことができます。

2-1-4. 一意解の保証(唯一解チェック)のカラクリ (solveAndVerifyUnique)

「ナンプレとして成立するか」の最大の条件は、「答えが世界にただ1つだけ存在する(一意解)」ことです(複数答えがある問題はクイズとして欠陥品になります)。これを完璧に判定しているのがこの関数です。

function solveAndVerifyUnique(np) {
  if (hasRuleViolation(np)) {
    return { solvable: false, unique: false, solution: null, reason: 'rule_violation' };
  }

  // 1回目:正順(1から順に候補を試す)で解を探索
  const ansForward = calcNumPl(np, false);
  if (!ansForward) return { solvable: false, unique: false, solution: null, reason: 'unsolvable' };

  // 2回目:逆順(大きい数字や逆から候補を試す)で解を探索
  const ansBackward = calcNumPl(np, true);
  
  // 両者の解が完全に一致するか比較
  const isUnique = ansForward.every((val, i) => val === ansBackward[i]);

  return {
    solvable: true,
    unique: isUnique,
    solution: ansForward,
    reason: isUnique ? 'valid' : 'multiple_solutions'
  };
}

  • なぜ正順・逆順で一意解がわかるのか?:
    もしパズルに複数の解(マルチプル・ソリューション)が存在する場合、バックトラックの探索順序(正から試すか、逆から試すか)によって最初にヒットする答えが変わる性質を利用しています。
  • ansForward(正順の解)と ansBackward(逆順の解)の全81マスを比較し、完全一致していれば「答えは1つだけ(一意解)」、不一致であれば「複数の答えが存在する」と数学的に厳密に判定できます。

2-1-5. ビットマスク探索ソルバーまとめ

このC++移植ソルバーによって、ブラウザのJavaScript上であっても、わずか数十ミリ秒で以下の高度な処理が可能になっています。

  • ルール違反の即座検知 (rule_violation)
  • 解が存在しない破綻した問題の検知 (unsolvable)
  • 複数解の検知 (multiple_solutions)
  • 高精度な一意解の保証&自動生成・自作問題の妥当性チェック (valid)

2-2. URLパラメータ(Base64)による軽量な問題共有

作成した問題や自作した問題は、81個の配列(0〜9の数値列)を文字列化してBase64エンコードし、URLセーフな形式に変換してクエリパラメータ(?data=...)として埋め込んでいます。

タイトルや出題者名(title, author)もパラメータに載せることができ、サーバーやデータベースを用意しなくてもURL1本で問題の共有が可能です。

function encodeBoard(board) {
  const str = board.join('');
  return btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}

function decodeBoard(encodedStr) {
  try {
    let base64 = encodedStr.replace(/-/g, '+').replace(/_/g, '/');
    while (base64.length % 4) base64 += '=';
    const str = atob(base64);
    if (str.length !== 81) return null;
    return str.split('').map(Number);
  } catch (e) {
    return null;
  }
}


2-3. ユーザー体験(UX)を意識したタイマー自動スタート

ゲーム画面を開いてすぐにタイマーが走るのではなく、「プレイヤーが最初の数字をマスに入力した瞬間」にタイマーが動き出します。

  • input イベントで初回の数字入力を検知して startTimer() を実行。
  • 「入力チェック」ボタンを押して全マス正解(クリア)になった瞬間に stopTimer() が走り、クリアタイムを表示。
  • 新規問題生成やリセット時にはタイマーも自動クリア。

3. まとめ・おわりに

フレームワークを使用しない「生(バニラ)のJavaScript」だけでも、ビット操作と効率的な探索アルゴリズムを組み合わせることで、非常に高速かつ機能豊富なパズルWebツールを作成することができます。

デモリンク

NumberPlaceQ000.png


作者

GitHub: https://github.com/NAS6mixfoolv
X(旧Twitter): https://x.com/NAS6_oxo
作者HP: https://nas6.net

気に入っていただけたら GitHub に ⭐ をいただけると嬉しいです!


0
0
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?