Orcantus

Guide

Number Place(9×9数字配置)の生成ロジックを読む — その1: 盤面表現・乱数・完成盤の生成

Orcantus Puzzlesの「Number Place」は、9×9の盤面に1〜9の数字を、各行・各列・3×3ブロックのそれぞれで一度ずつ入るように埋める、いわゆる数独です。この記事は、公開されているクライアント側エンジン(numberplace/engine.js。ビルドで圧縮されず、ブラウザからそのまま読める)を実際に読み、コードに書かれている手順だけを根拠に、生成ロジックを追跡可能な形でまとめたものです。前編(この記事)は盤面表現・乱数・完成盤(答え)の生成まで、後編(その2)は数字の除去・一意解判定・難易度分類・生成ループ全体・失敗時の扱いを扱います。

結論。Number Placeの答えは「シード付き乱数でMRV(最小残余値)バックトラッキングを行い、各マスの候補数字を毎回ランダムな順に試す」ことで、9×9の完成盤を1つ組み立てて作られる。 問題(出題盤)自体はこの完成盤から作るのではなく、後編で扱う「数字を1つずつ空けていく」処理の対象になる。乱数はシード文字列から決定的に導出されるため、同じシードなら同じ完成盤が再現する(実測済み)。

盤面表現: 行・列・3×3ブロックそれぞれをビットマスクで管理

盤面は9×9=81マスの1次元配列grid(0=空、1〜9=確定した数字)で表されます。マス番号iから行・列・ブロック番号への変換は次の通りです。

const N = 9;
const boxOf = (r, c) => Math.floor(r / 3) * 3 + Math.floor(c / 3);
// r = Math.floor(i / N), c = i % N

盤面が今どの数字を含んでいるかは、gridとは別に、行ごと・列ごと・3×3ブロックごとの3本の配列rows・cols・boxes(各9要素)で管理されます。それぞれの要素は9ビットの整数で、bit (v-1)が立っていれば「その行(列/ブロック)に数字vがすでに存在する」ことを表します。

function place(i, v) {
  const r = Math.floor(i / N), c = i % N, b = boxOf(r, c), bit = 1 << (v - 1);
  grid[i] = v; rows[r] |= bit; cols[c] |= bit; boxes[b] |= bit;
}
function unplace(i, v) {
  const r = Math.floor(i / N), c = i % N, b = boxOf(r, c), bit = 1 << (v - 1);
  grid[i] = 0; rows[r] &= ~bit; cols[c] &= ~bit; boxes[b] &= ~bit;
}

あるマスiに置ける数字の候補は、511(2進数で9個の1、2^9 - 1)からその行・列・ブロックですでに使われているビットを引いた残りです。

const mask = 511 & ~(rows[r] | cols[c] | boxes[b]);

maskの立っているビット数(popcount)が、そのマスにまだ置ける数字の個数です。0なら矛盾(どの数字も置けない)、1ならその数字で確定、というように、place/unplaceのたびに更新される3本のビットマスクだけで、行・列・ブロックの制約チェックがビット演算1回で済むようになっています。

乱数: Signal・Loopと共通のシード付き疑似乱数

rng(seed)は、他のOrcantus Puzzles作品(Signal・Loop)とまったく同じコードです。

function rng(seed) {
  let state = 2166136261;
  for (const c of String(seed)) state = Math.imul(state ^ c.charCodeAt(0), 16777619);
  return () => {
    state += 0x6D2B79F5;
    let t = Math.imul(state ^ state >>> 15, 1 | state);
    t ^= t + Math.imul(t ^ t >>> 7, 61 | t);
    return ((t ^ t >>> 14) >>> 0) / 4294967296;
  };
}

シード文字列をFNV-1a風のハッシュで32bit整数の初期状態へ畳み込み、以後は呼び出すたびに[0, 1)の浮動小数点数を1つ返します。同じシード文字列なら同じ数列が再現します(node --testのseed replay is deterministicで検証済み。後編で再掲)。この乱数生成器自体の性質(既存アルゴリズムとの同一性が未確認であること、暗号学的な乱数ではないこと)は、Signalの記事その1で述べた限界がそのまま当てはまります。ここでの用途も、Fisher–Yates風のシャッフルにrandom()を渡すshuffle(array, random)という共通ヘルパー1つだけです。

function shuffle(array, random) {
  for (let i = array.length - 1; i > 0; i--) {
    const j = Math.floor(random() * (i + 1));
    [array[i], array[j]] = [array[j], array[i]];
  }
  return array;
}

完成盤の生成: MRVバックトラッキングで候補をランダムな順に試す

完成盤(答え)を作るfillFullGrid(random, nodeLimit = 200000)は、空の81マスに対してsearch()という共通のバックトラッキング関数を呼び出します。このsearch()は、後編で扱う「一意解の判定(solve)」とも共有される同じ実装です。

function search(grid, rows, cols, boxes, options) {
  const { nodeLimit, maxSolutions, order, pickCandidates } = options;
  let nodes = 0, truncated = false;
  const solutions = [];
  function visit() {
    if (solutions.length >= maxSolutions || truncated) return true;
    if (++nodes > nodeLimit) { truncated = true; return true; }
    let best = -1, bestMask = 0, bestCount = N + 1;
    for (const i of order) {
      if (grid[i]) continue;
      const r = Math.floor(i / N), c = i % N, b = boxOf(r, c);
      const mask = 511 & ~(rows[r] | cols[c] | boxes[b]);
      const count = popcount(mask);
      if (count === 0) return false;
      if (count < bestCount) { bestCount = count; best = i; bestMask = mask; if (count === 1) break; }
    }
    if (best < 0) { solutions.push(grid.slice()); return solutions.length >= maxSolutions; }
    const candidates = pickCandidates(bestMask);
    for (const v of candidates) {
      place(best, v);
      const stop = visit();
      unplace(best, v);
      if (stop) return solutions.length >= maxSolutions || truncated;
    }
    return false;
  }
  visit();
  return { solutions, nodes, truncated };
}

要点は次の3つです。

function fillFullGrid(random, nodeLimit = 200000) {
  const grid = Array(N * N).fill(0);
  const rows = Array(N).fill(0), cols = Array(N).fill(0), boxes = Array(N).fill(0);
  const order = [...Array(N * N).keys()];
  const randomized = (mask) => {
    const out = [];
    for (let v = 1; v <= 9; v++) if (mask & (1 << (v - 1))) out.push(v);
    return shuffle(out, random);
  };
  const result = search(grid, rows, cols, boxes, { nodeLimit, maxSolutions: 1, order, pickCandidates: randomized });
  if (result.truncated || result.solutions.length !== 1) return null;
  return result.solutions[0];
}

maxSolutions: 1なので、最初に見つかった完成形1つで探索を止めます。候補の試行順がランダムであるため、どの数字がどのマスに入るかはrandom()の消費順に依存しますが、「MRVでどのマスを埋めるか」自体はマスの候補数という決定的な基準で選ばれており、乱数は使われません。 ノード数がnodeLimit(既定20万)を超えた場合、または(理論上起こり得ないはずですが)解が見つからなかった場合はnullを返し、呼び出し元(後編で扱うgenerate)はその試行を諦めて次の試行に進みます。

擬似コードと実装の差: 教科書的な数独ソルバーの説明では「候補を試す」「行き詰まったら戻る」という記述にとどまることが多いですが、この実装は次の点で具体的です。(1) 候補が0個になった時点でforループ自体をreturn falseで打ち切る(全マスを走査し尽くさない)。(2) 候補が1個のマスを見つけた時点でそれ以上マスを探さずbreakする。(3) place/unplaceはgridだけでなくrows/cols/boxesの3本のビットマスクを同時に更新・復元する。いずれも計算量を減らすための具体的な最適化であり、コードを読まなければ分からない詳細です。

再現可能な具体例

シード"notes-example"・難易度"medium"でE.generate('notes-example', 'medium')を実行した結果です(Node.jsでnumberplace/engine.jsをrequireして実行。1回目の試行(attempt: 1)でそのまま条件を満たしました。この関数自体は後編で扱いますが、内部で最初に呼ぶのが本記事のfillFullGridです)。

{"n": 9, "seed": "notes-example", "level": "medium", "version": 1, "attempt": 1, "blanks": 50, "cells": 81}

完成盤(answer)は次の9×9盤です。

5 4 9 3 8 1 2 7 6
8 7 2 6 5 4 3 1 9
6 1 3 9 2 7 4 5 8
4 8 1 5 3 6 9 2 7
2 3 6 7 9 8 5 4 1
7 9 5 1 4 2 6 8 3
3 2 4 8 1 9 7 6 5
9 6 8 4 7 5 1 3 2
1 5 7 2 6 3 8 9 4

各行・各列・各3×3ブロックに1〜9が過不足なく1回ずつ現れることは、node --testのthe planted answer is itself a valid completed gridで機械的に検証されています。同じ入力(E.generate('notes-example', 'medium'))を再度呼ぶと、この完成盤を含め結果のすべてのフィールドが1回目と完全一致します(実行して確認済み)。これはrng(seed)が決定的であることの直接的な帰結です。

この記事が確認していないこと

この結果が言っていないこと

  • 検証はnumberplace/engine.js(Number Placeエンジンv1)を対象にしており、他のOrcantus Puzzlesの作品(Signal・Loop・Pattern・Color Sort)には別のエンジンがあり、この記事の内容は当てはまりません。
  • 乱数生成器のコード自体はSignal・Loopと共有(同一のコード片)ですが、numberplace/engine.jsは独立したファイルであり、将来どちらか一方だけが変更される可能性があります。再現性が保証されるのは、記事中に明記した版(v1)・同一シードの組み合わせのみです。