AI Dev Lab
GridNine

GridNine ができるまで — 唯一解の数独をブラウザで生成する

数独ツールの開発ログ。 MRV 法のバックトラッキングで完成盤を作り、 マスを抜くたびに解が 1 つのままかを数えて穴を掘る手順と、 候補メモ・重複ハイライト・ヒント機能の実装を記録しています。

数独(ナンプレ)を作るのは、解くよりずっと難しい——作ってみて初めて分かったことです。

盤面に数字を適当に置いて空きを作れば問題になる、と思っていました。ところが数独には「解が 1 つだけ」という絶対のルールがあります。解が 2 通り以上ある盤面は、論理だけでは解けず、当てずっぽうが必要になる。それは数独ではありません。

GridNine は、必ず唯一解を持つ数独を自動生成するツールです。生成も、正誤判定も、ヒントも、全部ブラウザの中で計算します。この記事は、その「唯一解を保証する」ところの話が中心です。


#🎯 唯一解でなければ数独じゃない

数独の面白さは、「マスを一つひとつ論理で確定できる」ことにあります。空きマスに入る数字が、ルール(同じ行・列・3×3 ブロックに同じ数字は入らない)から一意に決まっていく。この一本道があるから、解いていて気持ちいい。

解が複数あると、この一本道が途中で分岐します。「ここは 3 か 7 か、どちらでも成立する」——こうなると論理では詰められず、勘で選ぶしかない。良い数独パズルは、絶対に唯一解でなければならない。生成の全設計が、この一点のためにあります。


#🏗️ まず「解く力」を作る

生成の前に、解くエンジンが要ります。唯一解かどうかを確かめるには、その盤面を実際に解いてみるしかないからです。

数独ソルバーの核は、候補が最も少ないマスから埋めるバックトラッキングです。

ts
export function solve(grid: number[]): number[] | null {
 const work = [...grid]
 const rec = : boolean => {
 let best = -1
 let bestC: number[] | null = null
 for (let i = 0; i < 81; i++) {
 if (work[i] !== 0) continue
 const c = candidatesAt(work, i) // そのマスに入りうる数字
 if (c.length === 0) return false // 入る数字がない → 行き止まり
 if (!bestC || c.length < bestC.length) {
 best = i
 bestC = c // 候補が少ないマスを優先
 }
 }
 if (best === -1) return true // 空きマスがない → 完成
 for (const d of bestC!) {
 work[best] = d
 if (rec) return true
 work[best] = 0 // ダメなら戻す(バックトラック)
 }
 return false
 }
 return rec ? work : null
}

ポイントは candidatesAt で「そのマスに入りうる数字」を先に絞り、最も候補の少ないマスから試すところです。これは制約伝播(constraint propagation)と呼ばれる定石で、探索の枝を劇的に減らします。候補が 1 つしかないマスがあれば、それは確定なので迷わず埋まる。9 択を総当たりするのに比べ、桁違いに速くなります。


#🔑 唯一解の判定:解を 2 つまで数える

「唯一解か?」を確かめるのに、全部の解を数える必要はありません。2 つ目が見つかった瞬間に、唯一解ではないと分かるからです。

ts
export function countSolutions(grid: number[], limit: number): number {
 let count = 0
 const work = [...grid]
 const rec = => {
 if (count >= limit) return // limit(=2) に達したら打ち切り
 // ... 候補最少マスを探す ...
 if (best === -1) {
 count++ // 完成盤に到達 = 解が 1 つ見つかった
 return
 }
 for (const d of bestC!) {
 work[best] = d
 rec
 work[best] = 0
 if (count >= limit) return
 }
 }
 rec
 return count
}

limit を 2 にして呼べば、解が 2 つ見つかった時点で探索を止められる。全解を数える必要がないので、「唯一解チェック」が現実的な速度で回ります。この関数が、生成の心臓です。


#⛏️ 生成:完成盤から穴を掘る

数独の生成は、空盤に数字を足すのではなく、完成盤から数字を抜くのが定石です。

まず、ソルバーと同じ手法で(候補をシャッフルしながら)ランダムな完成盤を作ります。

ts
function generateFull: number[] {
 // solve と同じ制約伝播 + バックトラック。
 // ただし候補を shuffle して、毎回違う完成盤を作る
 for (const d of shuffle(bestC!)) { ... }
}

そこから、唯一解を保ちながらマスを 0(空き)にしていきます。

ts
export function generate(diff: Difficulty): Puzzle {
 const solution = generateFull
 const puzzle = [...solution]
 const order = shuffle([...Array(81).keys])
 let givens = 81
 const target = TARGET_GIVENS[diff]
 for (const idx of order) {
 if (givens <= target) break
 const backup = puzzle[idx]
 puzzle[idx] = 0
 if (countSolutions(puzzle, 2) !== 1) {
 puzzle[idx] = backup // 一意でなくなる → 戻す
 } else {
 givens--
 }
 }
 return { puzzle, solution, givens: puzzle.map((v) => v !== 0) }
}

1 マス消すたびに countSolutions(puzzle, 2) を呼び、解が 1 つのままなら消したままに、2 つ以上になったら戻す。これを繰り返すことで、「これ以上消すと唯一解が崩れる」ギリギリまで数字を減らせます。難易度は、残す数字(givens)の目標数で調整します。

穴を掘るたびに唯一解チェックが走るので、生成には解く処理が何十回も呼ばれる——ソルバーの速さが、そのまま生成の速さになります。


#😓 苦労したところ

#難易度は「残す数字の数」だけでは決まらない

givens を減らせば難しくなる、と単純には言えません。同じ 30 マスでも、必要な解法テクニックによって体感難易度は大きく変わります。「候補が 1 つに確定するマスを追うだけ」で解ける盤面もあれば、複数マスの相互関係を読む必要がある盤面もある。

GridNine は givens の数を主軸にしつつ、あくまで「目安の難易度」としています。厳密な難易度分類(要求される解法テクニックによる分類)は、それ自体が一つの研究テーマで、そこまでは踏み込んでいません。正直に「難易度は目安」としているのはこのためです。

#生成が止まらないケースを避ける

穴掘りは「消せるだけ消す」ので、極端に少ない givens を狙うと、唯一解を保てずに戻す処理が延々と続きかねません。目標 givens を現実的な範囲に設定し、order をシャッフルして探索順を散らすことで、妥当な時間で生成が終わるようにしています。

#リアルタイムの重複チェック

解くだけでなく、プレイ中に「今このマスがルール違反か」を即座に示す必要があります。これは生成・求解とは別の軽い処理で、行・列・ブロックの重複を集合で持ち、入力のたびに違反マスをハイライトします。作る・解く・遊ぶで、必要な計算がそれぞれ違うのが面白いところでした。


#🔭 今後の拡張

  • 解法テクニックによる難易度分類 — 「naked single だけ」「X-Wing が必要」等で難易度を厳密化
  • 対称な盤面 — givens を点対称に配置した、見た目の美しいパズル
  • ヒントの段階化 — 答えを出す前に「次に確定できるマス」だけを示す
  • 手筋の解説 — なぜそのマスが確定するかを説明する

#💡 このサービスから言えること

「作る」ために「解く」が要る、という入れ子構造。 数独生成の核心は、生成アルゴリズムそのものより、唯一解を判定するソルバーでした。良い問題を作るには、その問題を解ける力が先に要る。これはパズルに限らず、テスト問題でも、ゲームのレベルデザインでも通じる構造だと思います。

技術的には、制約伝播で探索を絞るという一手が効きました。9 マス総当たりでは生成が現実的な速度に乗りませんが、「候補が最少のマスから」に変えるだけで桁違いに速くなる。そして「解を 2 つまで数えて打ち切る」ように、目的に必要な最小限しか計算しない設計が、重い処理を軽く見せます。全解を数える必要はどこにもなかった、というのが一番の学びでした。

[ ./next_action ]

読んだら、 GridNine を実際に動かす。

この開発ログは GridNine をどう作ったかの記録です。 読み終わったらそのままサービス本体へ戻って、 実物で価値を確かめてください。

[ ./related_logs ]

関連する開発ログ

all logs →
ToonCast

ToonCast ができるまで — AnimeGANv2 をブラウザで動かす

AnimeGANv2 の小さな ONNX (約9MB) を onnxruntime-web (単一スレッド WASM=COOP/COEP不要、 color-revive で承認済みライブラリの再利用) で実行。 512x512・[-1,1] 正規化で推論し、 結果を元解像度に戻して表示する設計記録。 写真は端末内処理。

read log →
ColorRevive

ColorRevive ができるまで — onnxruntime-web で白黒写真をカラー化

DeOldify の量子化 ONNX を onnxruntime-web (CDN side-load・単一スレッド WASM=COOP/COEP不要) で実行。 256x256 でモデル推論し、 輝度は元写真・色だけ AI を YCbCr で再合成して輪郭を保つ設計記録。 写真は端末内処理。

read log →
PhotoTwin

PhotoTwin ができるまで — CLIP画像埋め込みで似た写真を見つける

CLIP (Xenova/clip-vit-base-patch32) の image-feature-extraction を transformers.js の CDN ESM で side-load し、 各写真を正規化ベクトル化。 cosine 類似度で重複・似た写真をブラウザ内で検出する設計記録 (新ライブラリ追加なし=what-cam と同じ CLIP の再利用)。

read log →
AkinFind

AkinFind ができるまで — ブラウザ内で完結する embeddings 意味検索

多言語の文章埋め込みモデル (Xenova/multilingual-e5-small) を transformers.js の CDN ESM で side-load し、 各文を正規化ベクトル化。 cosine 類似度で意味検索と似ている文ペア検出を全て端末内で行う設計記録。

read log →
WhatCam

WhatCam ができるまで — CLIP のゼロショット画像分類をブラウザで動かす

CLIP (Xenova/clip-vit-base-patch32) を transformers.js の CDN ESM で side-load し、 写真と候補ラベルの近さをブラウザ内で計算。 日本語ラベルを英語プロンプトに変換し、 図鑑と自由入力の両モードで「これ何?」を判定する設計記録。

read log →
DepthCast

DepthCast ができるまで — 1枚の写真をAIの深度推定で立体にする

Depth Anything (transformers.js) を CDN ESM で side-load し、 1枚の写真から深度マップを推定。 WebGL2 フラグメントシェーダで深度に比例した視差 (iterative backward parallax) を作り、 赤青アナグリフ / WebM 書き出しまで端末内で完結させた設計記録。

read log →