Orcantus

Guide

Loop(数字で描く一筆の輪)の生成ロジックを読む — その2: 制約伝播・一意解判定・ヒントの間引き

前編(その1)では、Loop(数字で描く一筆の輪)の答えが「穴のない連結な領域」としてランダムに育てられ、その境界線がそのまま答えの輪になることを確認しました。この記事では、その全数字入りの盤面が本当に一意解を持つかをどう確認しているか、プレイヤーに見せる問題を作るために数字をどう間引いているかを、loop/engine.jsのコードから読み解きます。

結論。Loopは「マスの数字・格子点の次数(0本か2本)・すでに確定した輪が閉じていないかの3種類の制約を伝播させ、絞り切れない箇所だけを最大20,000ノードまで探索して解の数を数える」ことで一意解を確認する。数字の間引きは、ランダムな順で1マスずつ数字を消し、消しても一意解が保てる場合だけ確定させる貪欲法であり、消す順序に依存する(=最大限に間引ける保証はない)。探索が打ち切られた問題(truncated: true)や複数解の問題は、生成にも間引きにも使われない。

制約伝播(propagate): 3種類のルールをすべて変化が止まるまで

前編で見た全辺(H・V)は、それぞれ[0](選ばれていない確定)・[1](選ばれている確定)・[0, 1](未確定)のいずれかの候補集合(domain)を持ちます。propagate(domains, n, geo, clues)は、この候補集合を3種類のルールでくり返し絞り込みます。

function propagate(domains, n, geo, clues) {
  domains = domains.map((d) => d.slice());
  let changed = true;
  while (changed) {
    changed = false;
    // 1. マス制約: 4辺のon確定数・未確定数と、クルーの数字を突き合わせる
    for (let r = 0; r < n; r++) for (let c = 0; c < n; c++) {
      const clue = clues[r * n + c];
      if (clue == null) continue;
      const edges = geo.cellEdges(r, c);
      let onCount = 0; const unknown = [];
      for (const e of edges) { if (domains[e].length === 1) { if (domains[e][0] === 1) onCount++; } else unknown.push(e); }
      if (onCount > clue || onCount + unknown.length < clue) return { domains, valid: false };
      if (unknown.length && onCount === clue) { for (const e of unknown) domains[e] = [0]; changed = true; }
      else if (unknown.length && onCount + unknown.length === clue) { for (const e of unknown) domains[e] = [1]; changed = true; }
    }
    // 2. ドット制約(格子点の次数は0本か2本): 未確定辺の全パターンを列挙して可否を判定
    for (let r = 0; r <= n; r++) for (let c = 0; c <= n; c++) {
      const edges = geo.vertexEdges(r, c);
      let onCount = 0; const unknown = [];
      for (const e of edges) { if (domains[e].length === 1) { if (domains[e][0] === 1) onCount++; } else unknown.push(e); }
      if (onCount > 2) return { domains, valid: false };
      if (!unknown.length) { if (onCount !== 0 && onCount !== 2) return { domains, valid: false }; continue; }
      const m = unknown.length, feasiblePer = unknown.map(() => new Set());
      let anyFeasible = false;
      for (let mask = 0; mask < (1 << m); mask++) {
        let extra = 0; for (let k = 0; k < m; k++) if (mask & (1 << k)) extra++;
        const total = onCount + extra;
        if (total === 0 || total === 2) { anyFeasible = true; for (let k = 0; k < m; k++) feasiblePer[k].add((mask >> k) & 1); }
      }
      if (!anyFeasible) return { domains, valid: false };
      unknown.forEach((e, k) => { if (feasiblePer[k].size === 1) { domains[e] = [...feasiblePer[k]]; changed = true; } });
    }
    // 3. 早すぎる閉路の検出: 確定onの辺だけで、すでに閉じた輪ができていないか
    const forcedOn = [];
    for (let e = 0; e < domains.length; e++) if (domains[e].length === 1 && domains[e][0] === 1) forcedOn.push(e);
    if (forcedOn.length) {
      const comp = closedComponent(forcedOn, geo);
      if (comp) {
        if (comp.edges.size !== forcedOn.length) return { domains, valid: false };
        let any = false;
        for (let e = 0; e < domains.length; e++) if (domains[e].length !== 1) { domains[e] = [0]; any = true; }
        if (any) changed = true;
      }
    }
  }
  return { domains, valid: true };
}

3つのルールを順に説明します。

  1. マス制約。 マス(r,c)にクルー(数字)があれば、その4辺のうち確定on本数(onCount)・未確定本数(unknown.length)とクルーを突き合わせます。onCountがクルーを超えていたり、未確定分をすべてonにしてもクルーに届かない場合はvalid: false。未確定分がなくてもクルーに一致するなら残り全部を0に、onCount + unknown.lengthがちょうどクルーに一致するなら残り全部を1に確定させます。
  2. ドット制約(格子点の次数は0本か2本)。 輪は各格子点を「通らない(0本)」か「通過する(2本)」かのどちらかで、3本以上集まったり1本だけで終わったりしてはいけません。その格子点につながりうる最大4本の辺のうち、未確定なm本すべてのon/offの組み合わせ(2^m通り、最大16通り)を総当たりで試し、「合計本数が0か2になる」組み合わせが存在しない値は候補から除外します。
  3. 早すぎる閉路の検出。 確定onの辺だけを取り出してclosedComponent(後述)に渡し、それが「ちょうどそのエッジ集合と同じ大きさの閉じた輪」になっていれば、他の未確定な辺はすべて輪の外(0)に確定させます。輪は1本だけなので、確定部分だけで既に閉じているなら、それ以外の辺は輪に参加できないからです。

3つのどれかで矛盾(valid: false)が起きるか、どのマス・どの格子点の候補も変化しなくなるまで(changedが立たなくなるまで)、このループ全体をくり返します。擬似コードと実装の差: マス制約とドット制約はどちらも制約充足問題(CSP)の弧無矛盾(arc consistency)に相当しますが、教科書的なAC-3のようにキューで変化のあった箇所だけを再チェックするのではなく、毎ラウンド全マス・全格子点を走査し直す素朴な実装です。結果(到達する不動点)は同じですが、計算量はAC-3より多くなりえます。マス数36程度の規模なので実用上の問題にはなっていません。

closedComponent(forcedOnEdges, geo)は、確定onの辺だけでグラフを作り、連結成分ごとに「全頂点の次数が2」かつ「辺数=頂点数」(=単純な閉路)になっている成分を探します。

function closedComponent(forcedOnEdges, geo) {
  const adj = new Map();
  for (const e of forcedOnEdges) {
    const [a, b] = geo.endpoints(e);
    if (!adj.has(a)) adj.set(a, []); if (!adj.has(b)) adj.set(b, []);
    adj.get(a).push([b, e]); adj.get(b).push([a, e]);
  }
  const seen = new Set();
  for (const v of adj.keys()) {
    if (seen.has(v)) continue;
    const vertices = new Set([v]), edges = new Set(), queue = [v];
    seen.add(v);
    for (let i = 0; i < queue.length; i++) {
      const cur = queue[i];
      for (const [nb, e] of adj.get(cur)) {
        edges.add(e);
        if (!vertices.has(nb)) { vertices.add(nb); seen.add(nb); queue.push(nb); }
      }
    }
    const allDegree2 = [...vertices].every((u) => adj.get(u).length === 2);
    if (allDegree2 && edges.size === vertices.size && vertices.size > 0) return { vertices, edges };
  }
  return null;
}

これはsolveが最終的な完成盤を検証するisClosedLoop・validateLoop(後述)でも同じ関数がそのまま再利用されている、盤面判定の共通部品です。

伝播の動作例(実行して確認)

n=2の最小盤で、マス(0,0)のクルーだけを0にすると、マス制約だけでそのマスの4辺(H(0,0)・H(1,0)・V(0,0)・V(0,1))が即座に0へ確定します。

const clues = [0, null, null, null];
const state = E.propagate(Array(12).fill(0).map(() => [0, 1]), 2, E.makeGeo(2), clues);
// state.domains[9] (格子点(1,0)につながる辺V(1,0)) も [0] に確定する

実行すると、マス制約で確定した4辺に加えて、格子点(1,0)につながる辺V(1,0)(辺番号9)も[0]に確定します。これはマス制約では説明できません。格子点(1,0)にはH(1,0)(=辺2、マス制約で0に確定済み)・V(0,0)(=辺6、同じく0に確定済み)・V(1,0)(=辺9、未確定)の3本が接続しており、ドット制約が「確定分が0本の状態で残り1本をonにすると合計1本になり、0本にも2本にもならない」と判定してV(1,0)を0に強制するためです。マス制約とドット制約が互いの確定結果を使ってさらに絞り込む、という相互作用の一例です。

前編で示したn=4の全数字入り盤面(clues = [1,3,2,2, 0,2,1,2, 0,1,1,1, 0,1,2,2])に対してpropagateを1回実行すると、分岐探索を一切行わずに40本全辺が確定し、前編で示した答えの輪と完全一致します(実行して確認)。この例では、後述のsolveが候補選択・バックトラックに入る前の段階で解が一意に定まっています。

一意解の判定(solve): 探索打ち切り付きバックトラック

solve(clues, n, nodeLimit = 20000)は、propagateで絞り込んだ上で、それでも未確定な辺が残る場合にバックトラック探索で解の数を数えます。

function solve(clues, n, nodeLimit = 20000) {
  const geo = makeGeo(n);
  const solutions = [];
  let nodes = 0, truncated = false;
  function visit(domains) {
    if (solutions.length >= 2 || truncated) return;
    if (++nodes > nodeLimit) { truncated = true; return; }
    const state = propagate(domains, n, geo, clues);
    if (!state.valid) return;
    let choice = -1;
    state.domains.forEach((d, i) => { if (d.length > 1 && (choice < 0 || d.length < state.domains[choice].length)) choice = i; });
    if (choice < 0) {
      const on = state.domains.map((d) => d[0]);
      if (validateLoop(on, n, geo, clues)) solutions.push(on);
      return;
    }
    for (const v of state.domains[choice]) visit(state.domains.map((d, i) => (i === choice ? [v] : d)));
  }
  const initial = Array(geo.total).fill(0).map(() => [0, 1]);
  const init = propagate(initial, n, geo, clues);
  if (init.valid) visit(init.domains);
  return { solutions, nodes, truncated };
}

要点はSignal後編のsolveと同じ3つです。

候補がすべて確定した葉ノードでは、isSolvedのような単純な整合性チェックではなくvalidateLoopを呼びます。

function validateLoop(on, n, geo, clues) {
  for (let r = 0; r < n; r++) for (let c = 0; c < n; c++) {
    const clue = clues[r * n + c];
    if (clue == null) continue;
    const [t, b, l, ri] = geo.cellEdges(r, c);
    if (on[t] + on[b] + on[l] + on[ri] !== clue) return false;
  }
  return isClosedLoop(on, geo);
}
function isClosedLoop(on, geo) {
  const forcedOn = [];
  on.forEach((v, e) => { if (v === 1) forcedOn.push(e); });
  if (!forcedOn.length) return false;
  const comp = closedComponent(forcedOn, geo);
  return !!comp && comp.edges.size === forcedOn.length;
}

validateLoopは「全クルーと一致するか」に加えてisClosedLoopで「onの辺すべてが単一の閉じた輪を構成しているか」を確認します。closedComponentは最初に見つかった閉路成分だけを返すため、comp.edges.size === forcedOn.lengthという比較は「onの辺が、複数の輪に分かれておらず、かつ分岐(次数3以上の格子点)も無く、全部がその1つの閉路に含まれている」ことを保証します。前編で触れた「輪は1本だけ」というルールは、答えの構築段階(isSimplyConnected)だけでなく、この検証段階でも独立にチェックされています。

実測での確認。 tests.cjsには、このvalidateLoopが「特定の1つの答え」だけでなく「クルーを満たす任意の輪」を受理することを示すテストがあります。手作業で選んだクルー集合(n=4)が実際に2つの異なる輪を許すことをsolveで確認し、両方がvalidateLoopを通ることを検証しています(node --test loop/tests.cjsで実行し、20件のテストすべて成功を確認済み)。また、開いた経路(輪が閉じていない)・分岐点(次数3の格子点)・互いに素な2つの輪、といった「輪として不正な形」をvalidateLoopが正しくfalseと判定することも、それぞれ専用のテストで確認できます。

プレイ中の途中チェックとの違い。 clueViolations(on, n, geo, clues)という別の関数もありますが、これは「現在選ばれている辺の本数がクルーと矛盾しているマス」を列挙するだけで、輪が閉じているかは見ません。生成・一意解判定には使われず、loop/app.jsがプレイ中に「数字を超過しています」といった警告を出すためだけに使う、UI側の便宜関数です(生成ロジックの一部ではありません)。

ヒントの間引き(generate内): ランダムな順で1マスずつ、消せる数字だけ消す

前編で見たfullCluesは全マスに数字が入った状態です。実際にプレイヤーへ出題する問題は、この数字の一部をnull(非表示)にしたものです。この間引きはgenerate(n, seed)の内部で行われます。

const order = shuffle([...Array(n * n).keys()], random);
for (const idx of order) {
  const backup = clues[idx];
  clues[idx] = null;
  const check = solve(clues, n, 20000);
  if (check.truncated || check.solutions.length !== 1) clues[idx] = backup;
}

手順は次のとおりです。

  1. 全マスの番号(0〜n²-1)をFisher–Yates風のシャッフルでランダムな順に並べる。
  2. その順で1マスずつ、数字をnullにしてみる。
  3. solveを実行し、それでも一意解が保てるならnullのまま確定。探索が打ち切られたか、解が0個・2個以上になったなら、その数字を元に戻す。
  4. 全マスを1回ずつ試し終えたら終了。

これは典型的な貪欲(greedy)アルゴリズムです。「今消せるなら消す、消せないなら諦めて次へ進む」という判断を1マスずつ確定させ、一度確定した判断(消した・戻した)はやり直しません。そのため、この手順が返す間引き結果は、消せる数字の最大数を保証しません。消す順序が異なれば、最終的に残る数字の個数や配置が変わりうるという性質があります(README・コードのどちらにも「最大限間引く」という主張はなく、実装上もそのような探索は行われていません)。

ヒントの間引きの再現可能な具体例

前編と同じn=4・シード"notes-example"で、generate内部と同じ手順(growRegion→solve(全数字での事前確認)→シャッフル→間引きループ)を再現した結果です。

シャッフル後の間引き試行順: [6, 0, 8, 4, 11, 15, 5, 14, 1, 7, 10, 9, 2, 13, 12, 3]
実際に消せた(nullのまま確定した)マス番号: [6, 0, 8, 4, 11, 15, 5, 7, 13]

16マス中9マスがnullになり、最終的なクルー配列は次のとおりです(行優先、nullは空欄)。

+   +---+---+   +
  . | 3   2 | 2
+   +---+   +---+
  .   . | .   . |
+   +   +   +   +
  .   1 | 1   . |
+   +   +   +   +
  0   . | 2   . |
+   +   +---+---+

試行順のうち[6, 0, 8, 4, 11, 15, 5, 7, 13]は最後までnullのまま残り、[14, 1, 10, 9, 2, 12, 3]は一度nullにした後、一意解が崩れる(または探索が打ち切られる)ため元の数字に戻されています。同じn・同じシードでgenerate(4, 'notes-example')を再度呼ぶと、試行順・最終的な間引き結果を含めすべてのフィールドが1回目と完全一致します(実行して確認済み)。

tests.cjsには、この間引き結果が「一意解を保つために本当に必要な最小限の情報を持っている」ことを裏付けるテストがあります。nullになっている各マスへ、元の(正しい)数字を1つずつ戻しても、解は変わらず一意のままであることを25シード分検証しています(blank clues never drop below the number needed for a unique solution)。これは「間引き後の数字だけで一意解が確認済み」であることの直接的な帰結であり、情報を足しても壊れないという一貫性の確認であって、「間引きが最大限行われている」ことの証明ではありません。

生成ループ(generate): 最大500試行、失敗時は例外

前編・この記事で見た各部品を組み合わせるのがgenerate(n, seed)です。

function generate(n, seed) {
  if (![4, 5, 6].includes(n) || typeof seed !== 'string' || seed.length > 80) {
    throw new Error('サイズは4〜6、シードは80文字以内で指定してください。');
  }
  const random = rng(seed), geo = makeGeo(n);
  for (let attempt = 1; attempt <= 500; attempt++) {
    const region = growRegion(n, random);
    if (!region) continue;
    const answer = loopEdges(region, n, geo);
    const clues = fullClues(answer, geo, n);
    const fullCheck = solve(clues, n, 20000);
    if (fullCheck.truncated || fullCheck.solutions.length !== 1) continue;
    const order = shuffle([...Array(n * n).keys()], random);
    for (const idx of order) {
      const backup = clues[idx];
      clues[idx] = null;
      const check = solve(clues, n, 20000);
      if (check.truncated || check.solutions.length !== 1) clues[idx] = backup;
    }
    const finalCheck = solve(clues, n, 20000);
    if (finalCheck.truncated || finalCheck.solutions.length !== 1) continue;
    return { n, seed, version: 1, answer, clues, attempt, blanks: clues.filter((c) => c == null).length, cells: n * n };
  }
  throw new Error('500試行で成立する問題が見つかりませんでした。サイズやシードを変更してください。');
}

重要な点は次の3つです。

性能の実測値

node --test loop/tests.cjsにはn=4,5,6それぞれ25シードずつ、合計75回generateを呼ぶテストが含まれます。手元の実行では、このテストを含む全20件が約3.2秒で完了しました(generated puzzles are uniquely solvable...単体は約2.8秒)。1問あたり平均すると数十ミリ秒程度ですが、これは特定の実行環境・シード集合での実測値であり、あらゆるサイズ・シードでの分布を保証するものではありません。

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

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

  • 検証はloop/engine.js(エンジンv1)を対象にしています。将来の版で数値・手順が変更される可能性があります。
  • この記事で示したJSON・テキスト出力は、記事執筆時点で手元のNode.js環境からloop/engine.jsを直接requireして実行した結果です。ブラウザ実行(Web Worker経由、loop/worker.js)と処理内容は同一のはずですが、実行環境の違いによる差異までは検証していません。