数独ジェネレーター:穴を掘り、唯一解を保つ、そしてページを固めたバグ

完成した解を先に作り、一マスずつ掘りながら唯一解を検証します。求解器で 0 を「無制限」と解釈していたため、空盤では全解を列挙し、新規ゲームが固まりました。

数独 の問題は固定ではなく、新しいゲームのたびにブラウザで生成します。手順は 2 段階で、完成した解を 1 つ作り、そこから一マスずつ掘りながら唯一性を検証します。

第 1 段階:完成した解を作る

MRV(候補が最小のマスを選ぶ)付きのバックトラックを使います。空盤では最初のマスが候補 9 個、次が 8 個と減っていきます。完全な割り当てはすべて解なので、貪欲な経路が行き止まりに当たることはほとんどなく、ミリ秒で終わります。

第 2 段階:掘削と唯一性

81 マスをランダムな順に並べ、順に掘ってみます。1 つ外し、解の個数を数える器で数え、2 以上なら戻します。残った空きマスの数が難易度です。

難易度 目標の空きマス数
易しい 42
普通 50
難しい 56

17 個の手がかりで唯一解になるのが理論上の下限で、実務でそれを難易度の起点にはしません。

バグ:0 は「無制限」ではなかった

数える側と解く側は同じバックトラック関数を共有し、引数 1 つだけが違います。解く側は 0 で「解を 1 つ」、数える側は 2 で「2 つまで」を意味します。問題は search が limit <= 0 を上限なしと読んでいたことです。

export function solve(grid, rng) {
  if (conflictSet(grid).length) return null;
  return search(grid, rng, 1).board;
}

// limit 是「数到几个就停」:0 不是「不限」,而是「一个就够」
const cap = limit > 0 ? limit : 1;

その結果、空盤を解くことが「すべての数独解を列挙する」ことになりました。その数は約 6.7×10^21 個です。新規ゲームのボタンは回り続け、第 1 段階で止まり、掘削には決して到達しません。

修正は limit <= 0 を 1 に正規化するだけです。意味としても素直で、**解くという行為の意味は「1 つで足りる」**です。上限が要るのは数えるときだけです。

981 件のアサーションが捕まえられなかった理由

空盤を解くテストがなかったからです。既知の問題だけで走らせており、既知の問題は解がちょうど 1 つなので、列挙と早期終了の差は「少し遅い」だけで、誤りには見えません。いまは生成した問題が解けること、解が完全であること、解が一意であることを同時に留めています。

1 つの引数が 2 つの意味を負うとき、0 や null や「無制限」は無限ループの巣になります。

← 記事一覧に戻る

コメント

…