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?

シャカシャカを解く — 「領域が全部長方形」という大域ルールは、格子点1個のルールとぴったり同じだった

0
Posted at

シャカシャカを、5 つのルールセット内蔵でブラウザに実装した。白マスに黒い直角三角形(マスの半分、直角は 4 隅のどれか)を置いて、残った白い部分がすべて長方形になるようにする。長方形は正立でも 45° 傾いていてもよい。黒マスの数字は、その上下左右 4 マスのうち三角形が入っているマスの数。ソルバー内蔵パズル第 32 弾。

デモ: https://sen.ltd/portfolio/shakashaka/
リポジトリ: https://github.com/sen-ltd/shakashaka

Shakashaka

このパズルを選んだのは、中心のルールがソルバーにとって最悪の形で書かれているからだ。「白い領域はすべて長方形」は連結成分についての文で、最後の 1 マスが埋まるまで何も聞けないし、やっと聞けるようになったときの答え合わせは flood fill だ。このシリーズの他のパズルは、たいてい伝播できる局所ルールをくれる。これは領域をくれる。

——と思っていた。このルールは、格子点 1 個における白の角度についての条件と「ぴったり」同値だった。8 ビットとテーブル引き。領域という概念はどこにも出てこない。それはソルバーの形を変える。

わかったことは 3 つ。

  • 大域ルールは局所ルールで、しかもそれは証明できる。 格子点まわりの白い 45° セクタの連続する塊は、必ず 90°・180°・360° のどれかになる。これを flood fill 側のオラクルと 1250 万通りで突き合わせた(3×3 の全盤・全配置 6^9 を含む)。不一致ゼロ。
  • 素朴な局所ルールは、両方向に間違っている。 「格子点の白の角度の合計が 90° の倍数」は、空の 4×4 盤で合法な絵が 23 通りしかないところに 13958 通りを通し、しかもその 23 通りのうち 21 通りを弾く。
  • 冗長な伝播器を 1 つ足したら、別の伝播器の ablation スコアが 27 ポイント動いた。 同じルール、同じ盤、同じ指標で。伝播器に「その」寄与などなく、あるのはあるセットに相対的な寄与だけだ。

伝播できないルール

ルールはこれだけ。マスは黒(与えられる。数字入りのこともある)か白。白マスには、マスの半分を占める黒い直角三角形を 1 つ、直角を 4 隅のいずれかに置くか、何も置かないか。つまり白マスの状態は 5 通り。

export const E = 0;    // 三角形なし: 端まで白
export const TNW = 1;  // 直角が左上
export const TNE = 2;
export const TSE = 3;
export const TSW = 4;

そして、残った白い部分は互いに素な長方形の集まりでなければならない。正立か 45° 傾き。

探索は「ダメ」をできるだけ早く聞きたい。このルールは全マスが決まるまで一言も喋らず、決まった瞬間に flood fill が「この 5 時間は無駄でした」と告げる。これは葉での検査であり、白マス 84 個を葉だけで検査する探索は 5^84 ≈ 10^59 状態を歩くことになる。

格子点まわりの 8 ビット

格子点——マスの角であって、マスではない——に立つ。そこには 4 つのマスが集まる。それぞれが 0°・45°・90° の白い角度を差し出す。そして、その場から見える情報はそれだけだ。

1 周を 45° ずつ 8 セクタに切り、北から時計回りに番号を振る。

セクタ 0,1 → 格子点の北東のマス     2,3 → 南東
       4,5 → 南西                   6,7 → 北西

各マスの状態は、自分の象限の 2 ビットを埋める。黒マスや盤外は何も埋めない。空の白マスは両方埋める。三角形は、直角が遠い隅なら両方、近い隅なら両方とも埋めず、それ以外は片方だけ。

export const PAT: number[][] = [
  // q0: 格子点の北東のマス。格子点はそのマスの南西の隅にあたる。
  //     E     TNW   TNE   TSE   TSW
  [0b11, 0b10, 0b11, 0b01, 0b00],
  [0b11, 0b00, 0b10, 0b11, 0b01],  // q1: 南東
  [0b11, 0b01, 0b00, 0b10, 0b11],  // q2: 南西
  [0b11, 0b11, 0b01, 0b00, 0b10],  // q3: 北西
];

主張はこうだ。格子点まわりの白セクタの極大な巡回連結塊をとる。すると:

白い部分が互いに素な長方形の集まり ⟺ すべての塊の長さが 2 か 4 か 8。

90°・180°・360°。角か、まっすぐな辺の上か、内部か。

なぜこれで十分か。白い部分の境界の頂点は必ず格子点に載る(三角形の斜辺は隅から隅へ引かれるので)。だから白い領域の境界は、頂点が格子点にある多角形だ。塊 4 はまっすぐな点、塊 8 は内部の点、塊 2 は 90° 曲がる角。それ以外を禁じると、領域の境界は「すべての角が同じ向きに 90° 曲がる単純多角形」になる。曲がりの総和が 360° でなければならないので、角はちょうど 4 つ。角 4 つ、すべて直角——長方形だ。辺はマスの辺かマスの対角線のどちらかで、隣り合う辺は直交するので、辺の向きは「全部軸平行」か「全部 45°」のどちらかに揃う。正立か 45°、その中間はない。

長さ 6 の塊は、L 字を L 字たらしめている凹角そのもの。奇数長の塊は 45° のくさび——孤立した三角形の尖った先だ。どちらもルールが禁じているものそのもので、第 3 の失敗モードは存在しない

というわけで、ルール全体が 256 エントリのテーブルになる。

export const VALID_FULL = table((arcs) => arcs.every((L) => L === 2 || L === 4 || L === 8));

「合計」ではなく「塊」——ここで危うくバグを出荷するところだった

最初に書きたくなるのは「格子点の白の角度の合計が 0°・90°・180°・360°」のほうだ。popcount 1 回。塊の分解なんて要らない。どう見ても同じことに見える。

同じことではないし、安全側の近似ですらない。両方向に間違っている

合法 素朴ルールが通す 合法なのに弾く 非合法なのに通す
空の 2×2 2 13 0 (0%) 11
空の 3×3 3 1 2 (67%) 0
空の 4×4 23 13958 21 (91%) 13956

空の 4×4 に合法な絵は 23 通り。素朴ルールは 13958 通りを通し、現実と一致するのはそのうち 2 通りだけだ。

両方向の失敗が、たった 1 つの事実から出ている。2 つの長方形は 1 点で接してよい。 1×1 の白マス 2 つが斜めに並び、残る対角に黒マスがある——このパズルで最もありふれた絵だ。共有する角では白の角度は 180°、ただし独立した直角 2 つに割れている。合計してしまうとまっすぐな辺と区別がつかないので:

  • 対角の象限に 45° + 45°、合計 90° → 通してしまう。実際は非合法なくさびが 2 つ。
  • 180° + 90° が 2 つの塊、合計 270° → 弾いてしまう。実際は合法な辺と合法な角が隣り合っているだけ。

これを「エッジケース」に分類したくなる。ならない。デモに収録した解の格子点を数えると:

格子点 角 1 つ 辺 1 つ 内部 塊 2 つ
6×6 784 10% 38% 16% 11% 25% (16/16 盤)
8×8 1296 8% 35% 16% 13% 28% (16/16 盤)
10×10 1936 5% 33% 13% 13% 35% (16/16 盤)

10×10 の解では、格子点の 3 分の 1 が「別々の白 2 つが 1 点で接する場所」だ。素朴ルールは、存在するすべての盤で間違っている。

こういう主張は、どうテストするのか

大域的な性質と局所的な性質の同値は、ユニットテストで直接アサートできるものではない。できるのは、もう一方の読みをきちんと独立に実装して、全部に両方かけることだ。

素朴なほう(=ルールブックどおりのほう)は、局所ルールが使う概念を一切使わないようにする必要があった。角度なし、角なし、セクタなし。行き着いた先はこれ。

各マスを両対角線で 4 つの四分三角形に切る。合法な半マス三角形はすべてそのうち 2 つの和なので、四分三角形が絵の原子になる。

黒い直角が NW → 黒い四分は N, W      NE → N, E
           SE → S, E                 SW → S, W

白い四分三角形を flood fill する。辺での隣接だけを使うので、1 点で接する 2 領域は 2 領域のまま残る。そして各領域について、長方形判定は面積でやる。

// 座標を 2 倍しておくと、四分三角形の面積はちょうど 1。
const upright = (Xmax - Xmin) * (Ymax - Ymin);
const tilted = (Umax - Umin) * (Vmax - Vmin);   // = 傾いた外接箱の面積 × 2
if (count !== upright && 2 * count !== tilted) { /* 長方形ではない */ }

U = X + YV = X − Y。領域は必ず自分の外接箱に収まるので、面積が等しければ領域は箱そのものだ——形の比較は要らない。座標を 2 倍してあるのでマスの角は偶数、マスの中心は奇数に載り、全部整数演算で済む。正立長方形は (x, y) での箱、45° のものは (x+y, x−y) での箱。第 3 の向きは存在しない。

そのうえで、両方の読みを、払える限りのものにかけた。

配置数 対象 うち合法 不一致
625 空の 2×2 上の全配置 2 0
1,953,125 空の 3×3 上の全配置 3 0
46,656 2×3 の全盤(黒マス配置込み)上の全配置 46 0
10,077,696 3×3 の全盤(黒マス配置込み)上の全配置 328 0
400,000 黒の多いランダム 5×5 盤上のランダム配置 1 0
75,264 収録解すべての 1 マス・2 マス改変 232 0

4 行目は 6^9 だ。9 マスそれぞれが「黒」か「5 状態のどれか」。そのサイズのシャカシャカ盤すべてと、その上の絵すべて。

ただし本当に効いているのは最後の行だ。ランダム 5×5 の「うち合法」欄を見てほしい——40 万通りで1 通り。ランダム盤は主張の易しい側しか動かさない。両方の読みが、無関係な理由で大声で「ダメ」と言うだけだ。境界に座っているのは、本物の解から 1〜2 マスずれた盤のほうで、75264 通り中 232 通りが合法だった。1 つだけ信じろと言われたらこのスイープを取る。

テーブル自体を幾何に固定するテストも別にある。4 マスが格子点で出会う 1296 通りすべてについて、格子点から各セクタの真ん中方向へ髪の毛 1 本ぶん進み、その点が白かどうかを実際の絵——x + y > 1y > x——に聞く。PAT のビットが 1 つでも間違っていたら、上の全部が「間違ったものの自己無矛盾な検証」になってしまう。

それで何が手に入るのか

局所ルールは「5 値の変数 4 つ」に対する制約なので、そのまま arc consistency の伝播器になる。各マスの定義域は高々 4 通りの 2 ビットパターンに潰れるから、制約全体は 16 ビット入力・16 ビット出力。メモ化して終わり。

const packed = supported(rules.valid, key);
for (let q = 0; q < 4; q++) {
  const next = dom[cells[q]] & STATESET[q][(packed >> (4 * q)) & 15];
  if (next === 0) return false;
  ...
}

同じ探索、同じ盤、同じ答え。違うのは「ルールが葉より前を覗いてよいか」だけ。

白マス 伝播あり: ノード 葉だけ検査: ノード
3×3 6 0 0 19,531 19,531×
3×3 8 2 3 488,281 162,760×
4×4 11 1 1 61,035,156 61,035,156×

そしてデモが実際に配っている盤では、右側の列はもう印字できる数ではなくなる。

白マス 決着までのノード 枝刈りなしで歩く状態数
6×6 30 1(中央値) 5^30 ≈ 10^21
8×8 54 1.5(中央値) 5^54 ≈ 10^38
10×10 84 1(中央値) 5^84 ≈ 10^59

中央値 1 ノード。収録盤では、格子点上の arc consistency だけで、一度も推測せずに答えが決まる。

1 回あたりの検査コストは勝負の退屈なほうで、それも差はある——同じ完成盤に対して flood fill が 10.5 µs、テーブル引きが 3.2 µs。だが 3 倍速いことが要点ではない。1 割しか埋まっていない盤に対して、そもそも質問できることが要点だ。

伝播器の採点法は 2 つあって、答えが食い違う

ルール全体は、独立した局所ルール 2 つに割れる。tips(奇数長の塊なし= 45° のくさびなし)と reflex(長さ 6 の塊なし=逆向きに曲がる角なし)。この 2 つの共通部分が全体になる。テストにしておくと気持ちがいい。

expect(Array.from(intersect(VALID_TIPS, VALID_REFLEX))).toEqual(Array.from(VALID_FULL));

そこに 4 つ目を意図的に足した。parity — 「白の角度は直角の整数倍」、セクタがどこにあるかは見ない。塊の分解ではなく popcount 1 回。tips に含意されるので冗長で、そして「安く済ませたい人が手を伸ばす近似」そのものだ。

ラダーを登る側——そのルールは、より弱いルールにできないことができるか:

ルールセット 削減 完解
10×10 (なし) 0.0% 0%
10×10 clues 5.3% 0%
10×10 clues + parity 24.0% 0%
10×10 clues + parity + tips 74.7% 0%
10×10 clues + parity + tips + reflex 93.8% 27%

1 つ抜く側——そのルールは、より強いセットにできないことができるか:

抜いたもの 削減 完解 フルセット比
10×10 (なし) 93.8% 27%
10×10 clues 68.8% 0% −25.0% 削減, −27 盤
10×10 parity 93.8% 27% −0.0% 削減, −0 盤
10×10 tips 32.9% 0% −60.9% 削減, −27 盤
10×10 reflex 74.7% 0% −19.1% 削減, −27 盤

増分で見ると parity はラダー 2 番目の跳ね幅で、clues 単独の削減を 4 倍以上にする。ablation で見ると、全盤サイズで最後の桁までゼロ。ここまでは想定内だった。構成上冗長だし、前作でも同じ形の結果が出ている。

想定外だったのは逆方向だ。tips を、2 つの異なるセットに対して ablation で採点してみる。

セット tips を抜くと 削減の落ち幅
10×10 clues + tips + reflex 6.1% −87.7%
10×10 clues + parity + tips + reflex 32.9% −60.9%

同じルール。同じ盤。同じ指標。それでも数字が 27 ポイント動く。別のルール——それ自身の申告ではゼロ点のルール——が後ろに控えていて、こぼれ球を拾うからだ。ablation は伝播器を測っていない。特定の控え選手の顔ぶれに対する相対値を測っている。冗長なルールをセットに足すと、それが重複している相手の ablation スコアが静かに目減りする。

これは自分がすでに信じていたことの、より正確な言い方でもある。伝播器に「その」寄与はない。両方向を報告しないなら、それは測定ではなく好みの表明だ。

答えを一意にしているのは誰か

解が 1 つしかない盤を取り、ルールを 1 つ切って数え直す。

盤数 数字を全部落とす: 一意のまま 形のルールを tips まで弱める: 一意のまま
6×6 60 5 (8%), 中央値 6.5 通り 20 (33%)
8×8 60 0 (0%), 中央値 52.5 通り 4 (7%)
10×10 36 0 (0%), 中央値 335 通り 0 (0%)

どちらも効いていて、形のルールのほうが大きい。だからこの盤たちは数字をほとんど載せていない。ジェネレータは全黒マスに解から数字を振り、そのあと答えが一意なままである限り数字を剥がし続ける

黒マス 残った数字 割合 内訳
6×6 6.0 1.7 28% 0:6% 1:37% 2:40% 3:16% 4:0%
8×8 10.0 3.3 33% 0:9% 1:25% 2:45% 3:16% 4:5%
10×10 16.0 5.5 35% 0:6% 1:29% 2:41% 3:21% 4:2%

数字の 3 分の 2 は、幾何がすでに知っていたことだった。ここの 10×10 盤は、数字 5 個ほどで解が 1 つに定まる。

相互照合

すべてのカウントは 3 通りの方法で出していて、食い違えばテストが落ちる。

  1. 伝播つき探索 — 格子点制約と数字制約に対する arc consistency で駆動する、定義域最小優先のバックトラック。
  2. 伝播器を外した同じ探索 — 行優先でマスを埋め、ルールは葉でしか検査しない。答えは同じ、ノードは最大 6100 万倍。
  3. validate を通すブルートフォース — 全割り当てを列挙して、ルールブックを一から読み直す(数字の算術 → flood fill → 外接箱)。許した枝刈りは「4 まで数える」ことだけなので、検証対象そのものには手を触れていない。

ラダーのラベルも、信じるのではなく検査している。生成した盤の難易度ラベルを取り、そのランクを走らせ、到達した定義域が意図した解とマスごとに一致することをアサートする。さらに健全性テストとして、すべてのランクが、すべての盤で、すべての本物の解を定義域の中に残すことを確認する。これは伝播器が推測ではなく伝播器であるための性質そのものだ。

テストは 26 本。TypeScript、ランタイム依存ゼロ。


リポジトリ: https://github.com/sen-ltd/shakashaka
デモ: https://sen.ltd/portfolio/shakashaka/

0
0
0

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?