clone

fifteen puzzle games for the terminal, with daily puzzles, weekly ladders and xp, built on bubble tea

perf: optimize sudoku hashiwokakero and shikaku generation

Dami committed bab8251
commit
bab825189c7966ddef801ffc92f35b686b55224a
parent
fd8090b7115726eb8333f22161cca1253b68611c
author
Dami <dami.etoile@gmail.com>
11 files changed, +1133 -139
Ahashiwokakero/benchmark_test.go89++++++++++
Ahashiwokakero/generation_invariants_test.go159+++++++++++++++++
Mhashiwokakero/generator.go86+++++----
Mhashiwokakero/hashiwokakero_test.go9+-
Mhashiwokakero/island.go65+++++--
Ashikaku/benchmark_test.go75++++++++
Ashikaku/generation_invariants_test.go135+++++++++++++++
Mshikaku/generator.go219++++++++++++++++++++----
Asudoku/benchmark_test.go132++++++++++++++
Msudoku/generator.go277+++++++++++++++++++++++++-----
Asudoku/uniqueness_test.go26+++
A hashiwokakero/benchmark_test.go +89 -0
@@ -0,0 +1,89 @@
1+package hashiwokakero2+3+import (4+ "math/rand/v2"5+ "testing"6+)7+8+func benchmarkHashiModesByBoardSize() []HashiMode {9+ return []HashiMode{10+ NewMode("Hard 7x7", "benchmark", 7, 7, 17, 20),11+ NewMode("Hard 9x9", "benchmark", 9, 9, 28, 32),12+ NewMode("Hard 11x11", "benchmark", 11, 11, 42, 48),13+ NewMode("Hard 13x13", "benchmark", 13, 13, 59, 68),14+ }15+}16+17+func BenchmarkGeneratePuzzleByBoardSize(b *testing.B) {18+ for modeIndex, mode := range benchmarkHashiModesByBoardSize() {19+ mode := mode20+ modeIndex := modeIndex21+ b.Run(mode.Title(), func(b *testing.B) {22+ b.ReportAllocs()23+ for i := 0; i < b.N; i++ {24+ var (25+ puzzle Puzzle26+ err error27+ )28+ for attempt := range 32 {29+ rng := rand.New(rand.NewPCG(uint64(i+1), uint64(modeIndex*10+attempt+1)))30+ puzzle, err = GeneratePuzzleSeeded(mode, rng)31+ if err == nil {32+ break33+ }34+ }35+ if err != nil {36+ b.Fatalf("GeneratePuzzleSeeded failed: %v", err)37+ }38+ if puzzle.Width != mode.Width || puzzle.Height != mode.Height {39+ b.Fatalf("wrong dimensions: got %dx%d, want %dx%d", puzzle.Width, puzzle.Height, mode.Width, mode.Height)40+ }41+ }42+ })43+ }44+}45+46+func BenchmarkBuildSpanningTree(b *testing.B) {47+ for modeIndex, mode := range benchmarkHashiModesByBoardSize() {48+ mode := mode49+ modeIndex := modeIndex50+ b.Run(mode.Title(), func(b *testing.B) {51+ base, err := GeneratePuzzleSeeded(mode, rand.New(rand.NewPCG(uint64(modeIndex+50), uint64(modeIndex+60))))52+ if err != nil {53+ b.Fatalf("seed puzzle generation failed: %v", err)54+ }55+56+ islands := make([]Island, len(base.Islands))57+ copy(islands, base.Islands)58+59+ b.ReportAllocs()60+ b.ResetTimer()61+ successes := 062+ for i := 0; i < b.N; i++ {63+ ok := false64+ for attempt := range 32 {65+ islandCopy := make([]Island, len(islands))66+ copy(islandCopy, islands)67+68+ p := Puzzle{69+ Width: mode.Width,70+ Height: mode.Height,71+ Islands: islandCopy,72+ }73+74+ ok = buildSpanningTreeSeeded(75+ &p,76+ rand.New(rand.NewPCG(uint64(i+1), uint64(modeIndex*100+attempt+1))),77+ )78+ if ok {79+ successes++80+ break81+ }82+ }83+ }84+ if successes == 0 {85+ b.Fatal("buildSpanningTreeSeeded never succeeded")86+ }87+ })88+ }89+}
A hashiwokakero/generation_invariants_test.go +159 -0
@@ -0,0 +1,159 @@
1+package hashiwokakero2+3+import (4+ "math/rand/v2"5+ "testing"6+)7+8+func testBridgeLayoutByMode(t *testing.T, mode HashiMode) Puzzle {9+ t.Helper()10+11+ for attempt := range 64 {12+ rng := rand.New(rand.NewPCG(uint64(mode.Width*100+attempt+1), uint64(mode.Height*1000+attempt+1)))13+ islandCount := mode.MinIslands + rng.IntN(mode.MaxIslands-mode.MinIslands+1)14+ islands := placeIslandsSeeded(mode.Width, mode.Height, islandCount, rng)15+ if islands == nil {16+ continue17+ }18+19+ p := Puzzle{20+ Width: mode.Width,21+ Height: mode.Height,22+ Islands: islands,23+ }24+25+ if !buildSpanningTreeSeeded(&p, rng) {26+ continue27+ }28+ addExtraBridgesSeeded(&p, rng)29+ return p30+ }31+32+ t.Fatalf("failed to build bridge layout for %s", mode.Title())33+ return Puzzle{}34+}35+36+func bridgeAxisSpan(p *Puzzle, b Bridge) (x1, y1, x2, y2 int, horizontal, ok bool) {37+ a := p.FindIslandByID(b.Island1)38+ c := p.FindIslandByID(b.Island2)39+ if a == nil || c == nil {40+ return 0, 0, 0, 0, false, false41+ }42+43+ if a.Y == c.Y {44+ return a.X, a.Y, c.X, c.Y, true, true45+ }46+ if a.X == c.X {47+ return a.X, a.Y, c.X, c.Y, false, true48+ }49+ return 0, 0, 0, 0, false, false50+}51+52+func rangeContainsInterior(v, bound1, bound2 int) bool {53+ min, max := bound1, bound254+ if min > max {55+ min, max = max, min56+ }57+ return v > min && v < max58+}59+60+func bridgesCross(p *Puzzle, a, b Bridge) bool {61+ if a.Island1 == b.Island1 || a.Island1 == b.Island2 || a.Island2 == b.Island1 || a.Island2 == b.Island2 {62+ return false63+ }64+65+ ax1, ay1, ax2, ay2, aHorizontal, aok := bridgeAxisSpan(p, a)66+ bx1, by1, bx2, by2, bHorizontal, bok := bridgeAxisSpan(p, b)67+ if !aok || !bok || aHorizontal == bHorizontal {68+ return false69+ }70+71+ hx1, hy, hx2 := ax1, ay1, ax272+ vx, vy1, vy2 := bx1, by1, by273+ if !aHorizontal {74+ hx1, hy, hx2 = bx1, by1, bx275+ vx, vy1, vy2 = ax1, ay1, ay276+ }77+78+ return rangeContainsInterior(vx, hx1, hx2) && rangeContainsInterior(hy, vy1, vy2)79+}80+81+func assertNoBridgeCrossings(t *testing.T, p *Puzzle) {82+ t.Helper()83+84+ for i := 0; i < len(p.Bridges); i++ {85+ for j := i + 1; j < len(p.Bridges); j++ {86+ if bridgesCross(p, p.Bridges[i], p.Bridges[j]) {87+ t.Fatalf("bridge %d crosses bridge %d", i, j)88+ }89+ }90+ }91+}92+93+func TestGeneratePuzzleRequiredCountsByBoardSize(t *testing.T) {94+ for modeIndex, mode := range benchmarkHashiModesByBoardSize() {95+ mode := mode96+ modeIndex := modeIndex97+98+ t.Run(mode.Title(), func(t *testing.T) {99+ puzzle, err := GeneratePuzzleSeeded(mode, rand.New(rand.NewPCG(uint64(modeIndex+900), uint64(modeIndex+901))))100+ if err != nil {101+ t.Fatalf("GeneratePuzzleSeeded failed: %v", err)102+ }103+ if puzzle.Width != mode.Width || puzzle.Height != mode.Height {104+ t.Fatalf("dimensions = %dx%d, want %dx%d", puzzle.Width, puzzle.Height, mode.Width, mode.Height)105+ }106+ if len(puzzle.Bridges) != 0 {107+ t.Fatalf("expected cleared bridges, got %d", len(puzzle.Bridges))108+ }109+ for _, island := range puzzle.Islands {110+ if island.Required < 1 || island.Required > 8 {111+ t.Fatalf("island %d required=%d out of range", island.ID, island.Required)112+ }113+ }114+ })115+ }116+}117+118+func TestBridgeLayoutConstraintInvariantsByBoardSize(t *testing.T) {119+ for _, mode := range benchmarkHashiModesByBoardSize() {120+ mode := mode121+ t.Run(mode.Title(), func(t *testing.T) {122+ p := testBridgeLayoutByMode(t, mode)123+124+ if !p.IsConnected() {125+ t.Fatal("expected connected bridge layout")126+ }127+128+ seen := make(map[[2]int]bool, len(p.Bridges))129+ for _, b := range p.Bridges {130+ if b.Count < 1 || b.Count > 2 {131+ t.Fatalf("bridge count out of range: %d", b.Count)132+ }133+ key := [2]int{b.Island1, b.Island2}134+ if seen[key] {135+ t.Fatalf("duplicate bridge pair: %v", key)136+ }137+ seen[key] = true138+139+ i1 := p.FindIslandByID(b.Island1)140+ i2 := p.FindIslandByID(b.Island2)141+ if i1 == nil || i2 == nil {142+ t.Fatalf("bridge references unknown island: %+v", b)143+ }144+ if !isDirectlyConnectable(&p, *i1, *i2) {145+ t.Fatalf("bridge between non-directly-connectable islands: %+v", b)146+ }147+ }148+149+ assertNoBridgeCrossings(t, &p)150+151+ for _, island := range p.Islands {152+ count := p.BridgeCount(island.ID)153+ if count < 1 || count > 8 {154+ t.Fatalf("island %d bridge count=%d out of range", island.ID, count)155+ }156+ }157+ })158+ }159+}
M hashiwokakero/generator.go +50 -36
@@ -7,6 +7,11 @@ import (
77 88 const maxGenerateAttempts = 20099 10+type islandPair struct {11+ id1 int12+ id2 int13+}14+1015 // GeneratePuzzle creates a solvable hashiwokakero puzzle for the given mode.1116 func GeneratePuzzle(mode HashiMode) (Puzzle, error) {1217 rng := rand.New(rand.NewPCG(rand.Uint64(), rand.Uint64()))
@@ -37,11 +42,12 @@ func tryGenerateSeeded(width, height, islandCount int, rng *rand.Rand) *Puzzle {
3742 Islands: islands,3843 }3944 40- if !buildSpanningTreeSeeded(p, rng) {45+ pairs := connectableIslandPairs(p)46+ if !buildSpanningTreeWithPairsSeeded(p, pairs, rng) {4147 return nil4248 }4349 44- addExtraBridgesSeeded(p, rng)50+ addExtraBridgesWithPairsSeeded(p, pairs, rng)4551 4652 for i := range p.Islands {4753 p.Islands[i].Required = p.BridgeCount(p.Islands[i].ID)
@@ -119,13 +125,11 @@ func placeIslandsSeeded(width, height, count int, rng *rand.Rand) []Island {
119125 // buildSpanningTreeSeeded connects all islands using a randomized approach.120126 // Returns false if unable to connect all islands.121127 func buildSpanningTreeSeeded(p *Puzzle, rng *rand.Rand) bool {122- if len(p.Islands) <= 1 {123- return true124- }125-126- type pair struct{ id1, id2 int }127- var pairs []pair128+ return buildSpanningTreeWithPairsSeeded(p, connectableIslandPairs(p), rng)129+}128130 131+func connectableIslandPairs(p *Puzzle) []islandPair {132+ pairs := make([]islandPair, 0, len(p.Islands)*2)129133 for i := range len(p.Islands) {130134 for j := i + 1; j < len(p.Islands); j++ {131135 a := p.Islands[i]
@@ -136,13 +140,26 @@ func buildSpanningTreeSeeded(p *Puzzle, rng *rand.Rand) bool {
136140 }137141 138142 if isDirectlyConnectable(p, a, b) {139- pairs = append(pairs, pair{a.ID, b.ID})143+ pairs = append(pairs, islandPair{id1: a.ID, id2: b.ID})140144 }141145 }142146 }147+ return pairs148+}143149 144- rng.Shuffle(len(pairs), func(i, j int) {145- pairs[i], pairs[j] = pairs[j], pairs[i]150+func buildSpanningTreeWithPairsSeeded(p *Puzzle, pairs []islandPair, rng *rand.Rand) bool {151+ if len(p.Islands) <= 1 {152+ return true153+ }154+ if len(pairs) == 0 {155+ return false156+ }157+158+ order := make([]islandPair, len(pairs))159+ copy(order, pairs)160+161+ rng.Shuffle(len(order), func(i, j int) {162+ order[i], order[j] = order[j], order[i]146163 })147164 148165 // Union-Find to build spanning tree
@@ -175,7 +192,7 @@ func buildSpanningTreeSeeded(p *Puzzle, rng *rand.Rand) bool {
175192 }176193 177194 edgesAdded := 0178- for _, pr := range pairs {195+ for _, pr := range order {179196 if find(pr.id1) == find(pr.id2) {180197 continue181198 }
@@ -197,38 +214,33 @@ func buildSpanningTreeSeeded(p *Puzzle, rng *rand.Rand) bool {
197214 198215 // addExtraBridgesSeeded adds additional bridges beyond the spanning tree for complexity.199216 func addExtraBridgesSeeded(p *Puzzle, rng *rand.Rand) {200- var pairs []struct{ id1, id2 int }201-202- for i := 0; i < len(p.Islands); i++ {203- for j := i + 1; j < len(p.Islands); j++ {204- a := p.Islands[i]205- b := p.Islands[j]206-207- if a.X != b.X && a.Y != b.Y {208- continue209- }210-211- if !isDirectlyConnectable(p, a, b) {212- continue213- }217+ addExtraBridgesWithPairsSeeded(p, connectableIslandPairs(p), rng)218+}214219 215- existing := p.GetBridge(a.ID, b.ID)216- if existing != nil && existing.Count >= 2 {217- continue // already maxed218- }220+func addExtraBridgesWithPairsSeeded(p *Puzzle, pairs []islandPair, rng *rand.Rand) {221+ eligible := make([]islandPair, 0, len(pairs))222+ degrees := make(map[int]int, len(p.Islands))223+ for _, bridge := range p.Bridges {224+ degrees[bridge.Island1] += bridge.Count225+ degrees[bridge.Island2] += bridge.Count226+ }219227 220- pairs = append(pairs, struct{ id1, id2 int }{a.ID, b.ID})228+ for _, pr := range pairs {229+ existing := p.GetBridge(pr.id1, pr.id2)230+ if existing != nil && existing.Count >= 2 {231+ continue // already maxed221232 }233+ eligible = append(eligible, pr)222234 }223235 224- rng.Shuffle(len(pairs), func(i, j int) {225- pairs[i], pairs[j] = pairs[j], pairs[i]236+ rng.Shuffle(len(eligible), func(i, j int) {237+ eligible[i], eligible[j] = eligible[j], eligible[i]226238 })227239 228240 // Add bridges to ~30% of eligible pairs229- limit := len(pairs) / 3241+ limit := len(eligible) / 3230242 added := 0231- for _, pr := range pairs {243+ for _, pr := range eligible {232244 if added >= limit {233245 break234246 }
@@ -252,11 +264,13 @@ func addExtraBridgesSeeded(p *Puzzle, rng *rand.Rand) {
252264 if existing != nil {253265 addAmount = newCount - existing.Count254266 }255- if p.BridgeCount(pr.id1)+addAmount > 8 || p.BridgeCount(pr.id2)+addAmount > 8 {267+ if degrees[pr.id1]+addAmount > 8 || degrees[pr.id2]+addAmount > 8 {256268 continue257269 }258270 259271 p.SetBridge(pr.id1, pr.id2, newCount)272+ degrees[pr.id1] += addAmount273+ degrees[pr.id2] += addAmount260274 added++261275 }262276 }
M hashiwokakero/hashiwokakero_test.go +6 -3
M hashiwokakero/island.go +48 -17
@@ -39,6 +39,7 @@ type Puzzle struct {
3939 cellCache [][]cellInfo // lazily built, invalidated on bridge changes4040 posIndex map[[2]int]int // (x,y) → island slice index; lazily built4141 bridgeCounts []int // per-island bridge count; lazily built42+ bridgeIndex map[[2]int]int // (island1,island2) -> Bridges index; lazily built4243 }4344 4445 // FindIslandAt returns the island at (x,y), or nil.
@@ -59,6 +60,21 @@ func (p *Puzzle) buildPosIndex() {
5960 }6061 }6162 63+func bridgeKey(id1, id2 int) [2]int {64+ a, b := id1, id265+ if a > b {66+ a, b = b, a67+ }68+ return [2]int{a, b}69+}70+71+func (p *Puzzle) buildBridgeIndex() {72+ p.bridgeIndex = make(map[[2]int]int, len(p.Bridges))73+ for i, b := range p.Bridges {74+ p.bridgeIndex[bridgeKey(b.Island1, b.Island2)] = i75+ }76+}77+6278 // FindIslandByID returns the island with the given ID, or nil.6379 func (p *Puzzle) FindIslandByID(id int) *Island {6480 if id >= 0 && id < len(p.Islands) && p.Islands[id].ID == id {
@@ -75,13 +91,13 @@ func (p *Puzzle) FindIslandByID(id int) *Island {
7591 7692 // GetBridge returns the bridge between two islands (by ID), or nil.7793 func (p *Puzzle) GetBridge(id1, id2 int) *Bridge {78- a, b := id1, id279- if a > b {80- a, b = b, a94+ if p.bridgeIndex == nil {95+ p.buildBridgeIndex()8196 }82- for i := range p.Bridges {83- if p.Bridges[i].Island1 == a && p.Bridges[i].Island2 == b {84- return &p.Bridges[i]97+98+ if idx, ok := p.bridgeIndex[bridgeKey(id1, id2)]; ok {99+ if idx >= 0 && idx < len(p.Bridges) {100+ return &p.Bridges[idx]85101 }86102 }87103 return nil
@@ -186,27 +202,35 @@ func (p *Puzzle) SetBridge(id1, id2, count int) {
186202 a, b = b, a187203 }188204 189- p.invalidateCache()205+ p.invalidateBridgeCaches()206+ if p.bridgeIndex == nil {207+ p.buildBridgeIndex()208+ }209+ key := bridgeKey(a, b)190210 191211 if count == 0 {192212 // Remove bridge193- for i := range p.Bridges {194- if p.Bridges[i].Island1 == a && p.Bridges[i].Island2 == b {195- p.Bridges = append(p.Bridges[:i], p.Bridges[i+1:]...)196- return197- }213+ idx, ok := p.bridgeIndex[key]214+ if !ok {215+ return216+ }217+218+ p.Bridges = append(p.Bridges[:idx], p.Bridges[idx+1:]...)219+ delete(p.bridgeIndex, key)220+ for i := idx; i < len(p.Bridges); i++ {221+ p.bridgeIndex[bridgeKey(p.Bridges[i].Island1, p.Bridges[i].Island2)] = i198222 }199223 return200224 }201225 202226 // Update or create bridge203- for i := range p.Bridges {204- if p.Bridges[i].Island1 == a && p.Bridges[i].Island2 == b {205- p.Bridges[i].Count = count206- return207- }227+ if idx, ok := p.bridgeIndex[key]; ok {228+ p.Bridges[idx].Count = count229+ return208230 }231+209232 p.Bridges = append(p.Bridges, Bridge{Island1: a, Island2: b, Count: count})233+ p.bridgeIndex[key] = len(p.Bridges) - 1210234 }211235 212236 // CellContent returns what occupies the given grid cell, using a lazily-built cache.
@@ -264,6 +288,13 @@ func (p *Puzzle) invalidateCache() {
264288 p.cellCache = nil265289 p.posIndex = nil266290 p.bridgeCounts = nil291+ p.bridgeIndex = nil292+}293+294+// invalidateBridgeCaches marks bridge-derived caches as stale.295+func (p *Puzzle) invalidateBridgeCaches() {296+ p.cellCache = nil297+ p.bridgeCounts = nil267298 }268299 269300 // IsConnected checks if all islands form a single connected component via bridges.
A shikaku/benchmark_test.go +75 -0
@@ -0,0 +1,75 @@
1+package shikaku2+3+import (4+ "math/rand/v2"5+ "testing"6+)7+8+func benchmarkShikakuModes() []ShikakuMode {9+ return []ShikakuMode{10+ NewMode("Mini 5x5", "benchmark", 5, 5, 5),11+ NewMode("Easy 7x7", "benchmark", 7, 7, 8),12+ NewMode("Medium 8x8", "benchmark", 8, 8, 12),13+ NewMode("Hard 10x10", "benchmark", 10, 10, 15),14+ NewMode("Expert 12x12", "benchmark", 12, 12, 20),15+ }16+}17+18+func makeUncoveredGenerationGrid(width, height int) [][]int {19+ g := make([][]int, height)20+ for y := range height {21+ g[y] = make([]int, width)22+ for x := range width {23+ g[y][x] = -124+ }25+ }26+ return g27+}28+29+func BenchmarkGeneratePuzzleBySize(b *testing.B) {30+ for modeIndex, mode := range benchmarkShikakuModes() {31+ mode := mode32+ modeIndex := modeIndex33+ b.Run(mode.Title(), func(b *testing.B) {34+ b.ReportAllocs()35+ for i := 0; i < b.N; i++ {36+ var (37+ puzzle Puzzle38+ err error39+ )40+ for attempt := range 3 {41+ rng := rand.New(rand.NewPCG(uint64(i+1), uint64(modeIndex*10+attempt+1)))42+ puzzle, err = GeneratePuzzleSeeded(mode.Width, mode.Height, mode.MaxRectSize, rng)43+ if err == nil {44+ break45+ }46+ }47+ if err != nil {48+ b.Fatalf("GeneratePuzzleSeeded failed: %v", err)49+ }50+ if puzzle.Width != mode.Width || puzzle.Height != mode.Height {51+ b.Fatalf("wrong dimensions: got %dx%d, want %dx%d", puzzle.Width, puzzle.Height, mode.Width, mode.Height)52+ }53+ }54+ })55+ }56+}57+58+func BenchmarkPartitionGrid(b *testing.B) {59+ for modeIndex, mode := range benchmarkShikakuModes() {60+ mode := mode61+ modeIndex := modeIndex62+ b.Run(mode.Title(), func(b *testing.B) {63+ b.ReportAllocs()64+ for i := 0; i < b.N; i++ {65+ rng := rand.New(rand.NewPCG(uint64(i+1), uint64(modeIndex+100)))66+ grid := makeUncoveredGenerationGrid(mode.Width, mode.Height)67+ partitions := make([]genRect, 0, mode.Width*mode.Height/2)68+ ok := partitionGrid(grid, mode.Width, mode.Height, mode.MaxRectSize, &partitions, rng)69+ if !ok {70+ b.Fatalf("partitionGrid failed at iteration %d", i)71+ }72+ }73+ })74+ }75+}
A shikaku/generation_invariants_test.go +135 -0
@@ -0,0 +1,135 @@
1+package shikaku2+3+import (4+ "math/rand/v2"5+ "testing"6+)7+8+func TestPartitionGridCoverageBySize(t *testing.T) {9+ for modeIndex, mode := range benchmarkShikakuModes() {10+ mode := mode11+ modeIndex := modeIndex12+13+ t.Run(mode.Title(), func(t *testing.T) {14+ grid := makeUncoveredGenerationGrid(mode.Width, mode.Height)15+ partitions := make([]genRect, 0, mode.Width*mode.Height/2)16+17+ ok := partitionGrid(18+ grid,19+ mode.Width,20+ mode.Height,21+ mode.MaxRectSize,22+ &partitions,23+ rand.New(rand.NewPCG(uint64(modeIndex+700), uint64(modeIndex+701))),24+ )25+ if !ok {26+ t.Fatal("partitionGrid returned false")27+ }28+ if len(partitions) == 0 {29+ t.Fatal("expected at least one partition")30+ }31+32+ covered := make([][]bool, mode.Height)33+ for y := range mode.Height {34+ covered[y] = make([]bool, mode.Width)35+ }36+37+ totalArea := 038+ for _, r := range partitions {39+ if r.w <= 0 || r.h <= 0 {40+ t.Fatalf("invalid partition size: %+v", r)41+ }42+ if r.x < 0 || r.y < 0 || r.x+r.w > mode.Width || r.y+r.h > mode.Height {43+ t.Fatalf("partition out of bounds: %+v", r)44+ }45+ if r.w*r.h > mode.MaxRectSize {46+ t.Fatalf("partition area %d exceeds max %d", r.w*r.h, mode.MaxRectSize)47+ }48+49+ totalArea += r.w * r.h50+ for dy := range r.h {51+ for dx := range r.w {52+ x, y := r.x+dx, r.y+dy53+ if covered[y][x] {54+ t.Fatalf("overlap at (%d,%d)", x, y)55+ }56+ covered[y][x] = true57+ }58+ }59+ }60+61+ if totalArea != mode.Width*mode.Height {62+ t.Fatalf("partition area sum=%d, want %d", totalArea, mode.Width*mode.Height)63+ }64+65+ for y := range mode.Height {66+ for x := range mode.Width {67+ if !covered[y][x] {68+ t.Fatalf("uncovered cell at (%d,%d)", x, y)69+ }70+ if grid[y][x] < 0 {71+ t.Fatalf("grid cell (%d,%d) left uncovered in generation grid", x, y)72+ }73+ }74+ }75+ })76+ }77+}78+79+func TestGeneratePuzzleClueInvariantsBySize(t *testing.T) {80+ for modeIndex, mode := range benchmarkShikakuModes() {81+ mode := mode82+ modeIndex := modeIndex83+84+ t.Run(mode.Title(), func(t *testing.T) {85+ puzzle, err := GeneratePuzzleSeeded(86+ mode.Width,87+ mode.Height,88+ mode.MaxRectSize,89+ rand.New(rand.NewPCG(uint64(modeIndex+800), uint64(modeIndex+801))),90+ )91+ if err != nil {92+ t.Fatalf("GeneratePuzzleSeeded failed: %v", err)93+ }94+ if puzzle.Width != mode.Width || puzzle.Height != mode.Height {95+ t.Fatalf("dimensions = %dx%d, want %dx%d", puzzle.Width, puzzle.Height, mode.Width, mode.Height)96+ }97+ if len(puzzle.Rectangles) != 0 {98+ t.Fatalf("expected no pre-placed rectangles, got %d", len(puzzle.Rectangles))99+ }100+101+ seenPos := make(map[[2]int]bool, len(puzzle.Clues))102+ seenID := make(map[int]bool, len(puzzle.Clues))103+ sum := 0104+105+ for _, clue := range puzzle.Clues {106+ if clue.X < 0 || clue.X >= mode.Width || clue.Y < 0 || clue.Y >= mode.Height {107+ t.Fatalf("clue out of bounds: %+v", clue)108+ }109+ if clue.Value < 1 || clue.Value > mode.MaxRectSize {110+ t.Fatalf("clue value out of range: %+v", clue)111+ }112+113+ pos := [2]int{clue.X, clue.Y}114+ if seenPos[pos] {115+ t.Fatalf("duplicate clue position: (%d,%d)", clue.X, clue.Y)116+ }117+ seenPos[pos] = true118+119+ if seenID[clue.ID] {120+ t.Fatalf("duplicate clue ID: %d", clue.ID)121+ }122+ seenID[clue.ID] = true123+124+ sum += clue.Value125+ }126+127+ if sum != mode.Width*mode.Height {128+ t.Fatalf("sum of clue values=%d, want %d", sum, mode.Width*mode.Height)129+ }130+ if len(seenID) != len(puzzle.Clues) {131+ t.Fatal("clue IDs are not unique")132+ }133+ })134+ }135+}
M shikaku/generator.go +185 -34
@@ -12,6 +12,29 @@ type genRect struct {
1212 x, y, w, h int1313 }1414 15+type dim struct {16+ w, h int17+}18+19+type templateCacheKey struct {20+ x, y int21+ maxW int22+ maxH int23+ maxArea int24+}25+26+type failedStateKey struct {27+ x, y int28+ maxW int29+ maxH int30+ remaining int31+}32+33+type partitionSearchState struct {34+ templateCache map[templateCacheKey][]dim35+ failedStates map[failedStateKey]bool36+}37+1538 // GeneratePuzzle creates a Shikaku puzzle with random RNG.1639 func GeneratePuzzle(width, height, maxRectSize int) (Puzzle, error) {1740 rng := rand.New(rand.NewPCG(rand.Uint64(), rand.Uint64()))
@@ -39,24 +62,31 @@ func tryGenerate(width, height, maxRectSize int, rng *rand.Rand) *Puzzle {
3962 }4063 }4164 42- var partitions []genRect43-65+ partitions := make([]genRect, 0, width*height/2)4466 if !partitionGrid(grid, width, height, maxRectSize, &partitions, rng) {4567 return nil4668 }4769 4870 // Place clues: pick a random cell within each partition.4971 clues := make([]Clue, len(partitions))72+ allSingles := true5073 for i, r := range partitions {5174 cx := r.x + rng.IntN(r.w)5275 cy := r.y + rng.IntN(r.h)76+ area := r.w * r.h77+ if area > 1 {78+ allSingles = false79+ }5380 clues[i] = Clue{5481 ID: i,5582 X: cx,5683 Y: cy,57- Value: r.w * r.h,84+ Value: area,5885 }5986 }87+ if allSingles {88+ return nil89+ }6090 6191 return &Puzzle{6292 Width: width,
@@ -65,61 +95,182 @@ func tryGenerate(width, height, maxRectSize int, rng *rand.Rand) *Puzzle {
6595 }6696 }6797 68-// partitionGrid recursively fills the grid with non-overlapping rectangles.69-func partitionGrid(grid [][]int, width, height, maxRectSize int, partitions *[]genRect, rng *rand.Rand) bool {70- // Find first uncovered cell (top-left scan).71- fx, fy := -1, -172- for y := range height {73- for x := range width {74- if grid[y][x] == -1 {75- fx, fy = x, y76- break77- }78- }79- if fx >= 0 {80- break81- }98+func isAnchorCell(grid [][]int, x, y int) bool {99+ if grid[y][x] != -1 {100+ return false82101 }102+ if x > 0 && grid[y][x-1] == -1 {103+ return false104+ }105+ if y > 0 && grid[y-1][x] == -1 {106+ return false107+ }108+ return true109+}83110 84- if fx < 0 {85- return true // all cells covered111+func anchorExtents(grid [][]int, x, y, width, height int) (maxW, maxH int) {112+ maxW = 0113+ for cx := x; cx < width && grid[y][cx] == -1; cx++ {114+ maxW++86115 }116+ maxH = 0117+ for cy := y; cy < height && grid[cy][x] == -1; cy++ {118+ maxH++119+ }120+ return maxW, maxH121+}87122 88- type dim struct{ w, h int }89- var candidates []dim123+func candidateTemplatesForAnchor(124+ state *partitionSearchState,125+ x,126+ y,127+ maxW,128+ maxH,129+ maxRectSize int,130+) []dim {131+ key := templateCacheKey{132+ x: x,133+ y: y,134+ maxW: maxW,135+ maxH: maxH,136+ maxArea: maxRectSize,137+ }138+ if templates, ok := state.templateCache[key]; ok {139+ return templates140+ }90141 91- // Enumerate all rectangle sizes that fit at (fx, fy).92- for w := 1; w <= width-fx; w++ {93- for h := 1; h <= height-fy; h++ {142+ templates := make([]dim, 0, maxW*maxH)143+ for w := 1; w <= maxW; w++ {144+ for h := 1; h <= maxH; h++ {94145 if w*h > maxRectSize {95146 continue96147 }97- if fits(grid, fx, fy, w, h) {98- candidates = append(candidates, dim{w, h})148+ templates = append(templates, dim{w: w, h: h})149+ }150+ }151+152+ state.templateCache[key] = templates153+ return templates154+}155+156+func selectMostConstrainedAnchor(157+ grid [][]int,158+ width,159+ height,160+ maxRectSize int,161+ state *partitionSearchState,162+) (x, y, maxW, maxH int, candidates []dim, found bool) {163+ bestCount := width*height + 1164+ bestX, bestY := -1, -1165+ bestW, bestH := 0, 0166+167+ for y := range height {168+ for x := range width {169+ if !isAnchorCell(grid, x, y) {170+ continue171+ }172+173+ curW, curH := anchorExtents(grid, x, y, width, height)174+ templates := candidateTemplatesForAnchor(state, x, y, curW, curH, maxRectSize)175+ valid := make([]dim, 0, len(templates))176+ for _, c := range templates {177+ if fits(grid, x, y, c.w, c.h) {178+ valid = append(valid, c)179+ }180+ }181+182+ if len(valid) == 0 {183+ return x, y, curW, curH, nil, true184+ }185+ if len(valid) < bestCount {186+ bestCount = len(valid)187+ bestX, bestY = x, y188+ bestW, bestH = curW, curH189+ candidates = valid190+ found = true191+ }192+ if bestCount == 1 {193+ return bestX, bestY, bestW, bestH, candidates, true99194 }100195 }101196 }102197 103- // Shuffle for randomness.104- rng.Shuffle(len(candidates), func(i, j int) {105- candidates[i], candidates[j] = candidates[j], candidates[i]198+ if !found {199+ return 0, 0, 0, 0, nil, false200+ }201+ return bestX, bestY, bestW, bestH, candidates, true202+}203+204+// partitionGrid recursively fills the grid with non-overlapping rectangles.205+func partitionGrid(grid [][]int, width, height, maxRectSize int, partitions *[]genRect, rng *rand.Rand) bool {206+ state := partitionSearchState{207+ templateCache: make(map[templateCacheKey][]dim, width*height),208+ failedStates: make(map[failedStateKey]bool, width*height),209+ }210+ return partitionGridRec(grid, width, height, maxRectSize, partitions, rng, &state, width*height)211+}212+213+func partitionGridRec(214+ grid [][]int,215+ width,216+ height,217+ maxRectSize int,218+ partitions *[]genRect,219+ rng *rand.Rand,220+ state *partitionSearchState,221+ remaining int,222+) bool {223+ x, y, maxW, maxH, candidates, found := selectMostConstrainedAnchor(224+ grid,225+ width,226+ height,227+ maxRectSize,228+ state,229+ )230+ if !found {231+ return true // all cells covered232+ }233+234+ failKey := failedStateKey{x: x, y: y, maxW: maxW, maxH: maxH, remaining: remaining}235+ if state.failedStates[failKey] {236+ return false237+ }238+ if len(candidates) == 0 {239+ state.failedStates[failKey] = true240+ return false241+ }242+243+ order := make([]dim, len(candidates))244+ copy(order, candidates)245+ rng.Shuffle(len(order), func(i, j int) {246+ order[i], order[j] = order[j], order[i]106247 })107248 108249 id := len(*partitions)109- for _, c := range candidates {250+ for _, c := range order {110251 // Place rectangle.111- place(grid, fx, fy, c.w, c.h, id)112- *partitions = append(*partitions, genRect{fx, fy, c.w, c.h})252+ place(grid, x, y, c.w, c.h, id)253+ *partitions = append(*partitions, genRect{x, y, c.w, c.h})113254 114- if partitionGrid(grid, width, height, maxRectSize, partitions, rng) {255+ if partitionGridRec(256+ grid,257+ width,258+ height,259+ maxRectSize,260+ partitions,261+ rng,262+ state,263+ remaining-(c.w*c.h),264+ ) {115265 return true116266 }117267 118268 // Backtrack.119269 *partitions = (*partitions)[:len(*partitions)-1]120- clearGrid(grid, fx, fy, c.w, c.h)270+ clearGrid(grid, x, y, c.w, c.h)121271 }122272 273+ state.failedStates[failKey] = true123274 return false124275 }125276
A sudoku/benchmark_test.go +132 -0
@@ -0,0 +1,132 @@
1+package sudoku2+3+import (4+ "fmt"5+ "math/rand/v2"6+ "testing"7+)8+9+func benchmarkSudokuModes() []SudokuMode {10+ return []SudokuMode{11+ NewMode("Beginner", "benchmark", 45),12+ NewMode("Easy", "benchmark", 38),13+ NewMode("Medium", "benchmark", 32),14+ NewMode("Hard", "benchmark", 27),15+ NewMode("Expert", "benchmark", 22),16+ NewMode("Diabolical", "benchmark", 17),17+ }18+}19+20+func BenchmarkGenerateProvidedCellsByMode(b *testing.B) {21+ for modeIndex, mode := range benchmarkSudokuModes() {22+ mode := mode23+ modeIndex := modeIndex24+ b.Run(mode.Title(), func(b *testing.B) {25+ b.ReportAllocs()26+ for i := 0; i < b.N; i++ {27+ rng := rand.New(rand.NewPCG(uint64(i+1), uint64(modeIndex+10_000)))28+ cells := GenerateProvidedCellsSeeded(mode, rng)29+ if len(cells) < mode.ProvidedCount {30+ b.Fatalf("provided count %d below target %d", len(cells), mode.ProvidedCount)31+ }32+ }33+ })34+ }35+}36+37+func BenchmarkCountSolutionsByDifficulty(b *testing.B) {38+ for modeIndex, mode := range benchmarkSudokuModes() {39+ mode := mode40+ modeIndex := modeIndex41+ b.Run(mode.Title(), func(b *testing.B) {42+ rng := rand.New(rand.NewPCG(uint64(modeIndex+1), uint64(modeIndex+2)))43+ cells := GenerateProvidedCellsSeeded(mode, rng)44+ source := newGrid(cells)45+ if got := countSolutions(&source, 2); got != 1 {46+ b.Fatalf("expected unique seed puzzle for %s, got %d", mode.Title(), got)47+ }48+49+ b.ReportAllocs()50+ b.ResetTimer()51+52+ for i := 0; i < b.N; i++ {53+ g := source54+ if got := countSolutions(&g, 2); got != 1 {55+ b.Fatalf("countSolutions(%s) = %d, want 1", mode.Title(), got)56+ }57+ }58+ })59+ }60+}61+62+func BenchmarkGenerateProvidedCellsSeededStable(b *testing.B) {63+ mode := NewMode("Hard", "benchmark", 27)64+ b.ReportAllocs()65+ for i := 0; i < b.N; i++ {66+ rng := rand.New(rand.NewPCG(uint64(i+1), uint64(i+100)))67+ cells := GenerateProvidedCellsSeeded(mode, rng)68+ if len(cells) < mode.ProvidedCount {69+ b.Fatalf("generated %d clues, want >= %d", len(cells), mode.ProvidedCount)70+ }71+ }72+}73+74+func BenchmarkCountSolutionsFixedPuzzle(b *testing.B) {75+ mode := NewMode("Expert", "benchmark", 22)76+ cells := GenerateProvidedCellsSeeded(mode, rand.New(rand.NewPCG(42, 84)))77+ source := newGrid(cells)78+ if got := countSolutions(&source, 2); got != 1 {79+ b.Fatalf("seed puzzle not unique: %d", got)80+ }81+82+ b.ReportAllocs()83+ b.ResetTimer()84+ for i := 0; i < b.N; i++ {85+ g := source86+ count := countSolutions(&g, 2)87+ if count != 1 {88+ b.Fatalf("iteration %d: countSolutions=%d, want 1", i, count)89+ }90+ }91+}92+93+func BenchmarkIsValidAllCells(b *testing.B) {94+ mode := NewMode("Medium", "benchmark", 32)95+ cells := GenerateProvidedCellsSeeded(mode, rand.New(rand.NewPCG(7, 11)))96+ g := newGrid(cells)97+98+ b.ReportAllocs()99+ b.ResetTimer()100+ for i := 0; i < b.N; i++ {101+ for y := range gridSize {102+ for x := range gridSize {103+ val := g[y][x].v104+ if val == 0 {105+ val = ((x + y) % gridSize) + 1106+ }107+ if !isValid(&g, val, x, y) && g[y][x].v == val {108+ b.Fatalf("unexpected invalid self-check at (%d,%d)", x, y)109+ }110+ }111+ }112+ }113+}114+115+func BenchmarkCountSolutionsModesSummary(b *testing.B) {116+ modes := benchmarkSudokuModes()117+ b.ReportAllocs()118+ for i := 0; i < b.N; i++ {119+ mode := modes[i%len(modes)]120+ rng := rand.New(rand.NewPCG(uint64(i+1), uint64(i+2)))121+ cells := GenerateProvidedCellsSeeded(mode, rng)122+ g := newGrid(cells)123+ if got := countSolutions(&g, 2); got != 1 {124+ b.Fatalf("mode %s uniqueness check failed: %d", mode.Title(), got)125+ }126+ }127+}128+129+func ExampleBenchmarkGenerateProvidedCellsByMode() {130+ fmt.Println("run with: go test ./sudoku -run '^$' -bench BenchmarkGenerateProvidedCellsByMode -benchmem")131+ // Output: run with: go test ./sudoku -run '^$' -bench BenchmarkGenerateProvidedCellsByMode -benchmem132+}
M sudoku/generator.go +228 -49
@@ -2,11 +2,156 @@ package sudoku
22 33 import "math/rand/v2"44 5+const allDigitsMask uint16 = 0x3FE6+7+type solverMasks struct {8+ row [gridSize]uint169+ col [gridSize]uint1610+ box [gridSize]uint1611+}12+513 // newRNG creates a fresh non-deterministic RNG.614 func newRNG() *rand.Rand {715 return rand.New(rand.NewPCG(rand.Uint64(), rand.Uint64()))816 }917 18+func valueBit(val int) uint16 {19+ return uint16(1 << val)20+}21+22+func boxIndex(x, y int) int {23+ return (y/3)*3 + (x / 3)24+}25+26+func buildMasks(g *grid) (solverMasks, bool) {27+ var masks solverMasks28+ for y := range gridSize {29+ for x := range gridSize {30+ val := g[y][x].v31+ if val == 0 {32+ continue33+ }34+ if val < 0 || val > gridSize {35+ return solverMasks{}, false36+ }37+38+ bit := valueBit(val)39+ box := boxIndex(x, y)40+ if masks.row[y]&bit != 0 || masks.col[x]&bit != 0 || masks.box[box]&bit != 0 {41+ return solverMasks{}, false42+ }43+44+ masks.row[y] |= bit45+ masks.col[x] |= bit46+ masks.box[box] |= bit47+ }48+ }49+ return masks, true50+}51+52+func canPlace(masks *solverMasks, val, x, y int) bool {53+ bit := valueBit(val)54+ box := boxIndex(x, y)55+ return masks.row[y]&bit == 0 && masks.col[x]&bit == 0 && masks.box[box]&bit == 056+}57+58+func placeValue(g *grid, masks *solverMasks, val, x, y int) {59+ bit := valueBit(val)60+ box := boxIndex(x, y)61+ g[y][x].v = val62+ masks.row[y] |= bit63+ masks.col[x] |= bit64+ masks.box[box] |= bit65+}66+67+func clearValue(g *grid, masks *solverMasks, val, x, y int) {68+ bit := valueBit(val)69+ box := boxIndex(x, y)70+ g[y][x].v = 071+ masks.row[y] &^= bit72+ masks.col[x] &^= bit73+ masks.box[box] &^= bit74+}75+76+func candidatesForCell(masks *solverMasks, x, y int) uint16 {77+ used := masks.row[y] | masks.col[x] | masks.box[boxIndex(x, y)]78+ return allDigitsMask &^ used79+}80+81+func findFirstEmptyCell(g *grid) (x, y int, found bool) {82+ for y := range gridSize {83+ for x := range gridSize {84+ if g[y][x].v == 0 {85+ return x, y, true86+ }87+ }88+ }89+ return 0, 0, false90+}91+92+func fillGridRec(g *grid, rng *rand.Rand, masks *solverMasks) bool {93+ x, y, found := findFirstEmptyCell(g)94+ if !found {95+ return true96+ }97+98+ order := rng.Perm(gridSize)99+ for _, i := range order {100+ val := i + 1101+ if !canPlace(masks, val, x, y) {102+ continue103+ }104+105+ placeValue(g, masks, val, x, y)106+ if fillGridRec(g, rng, masks) {107+ return true108+ }109+ clearValue(g, masks, val, x, y)110+ }111+112+ return false113+}114+115+func countCandidateBits(mask uint16) int {116+ count := 0117+ for val := 1; val <= gridSize; val++ {118+ if mask&valueBit(val) != 0 {119+ count++120+ }121+ }122+ return count123+}124+125+func findMostConstrainedEmptyCell(g *grid, masks *solverMasks) (x, y int, candidates uint16, found bool) {126+ bestCount := gridSize + 1127+ bestX, bestY := 0, 0128+ for y := range gridSize {129+ for x := range gridSize {130+ if g[y][x].v != 0 {131+ continue132+ }133+134+ mask := candidatesForCell(masks, x, y)135+ candidateCount := countCandidateBits(mask)136+ if candidateCount == 0 {137+ return x, y, 0, true138+ }139+140+ if !found || candidateCount < bestCount {141+ bestCount = candidateCount142+ candidates = mask143+ bestX, bestY = x, y144+ found = true145+ }146+ if bestCount == 1 {147+ return bestX, bestY, candidates, true148+ }149+ }150+ }151+152+ return bestX, bestY, candidates, found153+}154+10155 // isValid checks whether placing val at (x, y) in the grid is valid.11156 func isValid(g *grid, val, x, y int) bool {12157 for i := range gridSize {
@@ -35,61 +180,56 @@ func fillGrid(g *grid) bool {
35180 }36181 37182 func fillGridSeeded(g *grid, rng *rand.Rand) bool {38- for y := range gridSize {39- for x := range gridSize {40- if g[y][x].v == 0 {41- order := rng.Perm(gridSize)42- for _, i := range order {43- val := i + 144- if isValid(g, val, x, y) {45- g[y][x].v = val46- if fillGridSeeded(g, rng) {47- return true48- }49- g[y][x].v = 050- }51- }52- return false53- }54- }183+ masks, ok := buildMasks(g)184+ if !ok {185+ return false55186 }56- return true187+ return fillGridRec(g, rng, &masks)57188 }58189 59190 // countSolutions counts solutions of the grid up to limit using backtracking.60191 // Returns 0 immediately if the grid already contains conflicting values.61192 func countSolutions(g *grid, limit int) int {62- for y := range gridSize {63- for x := range gridSize {64- if g[y][x].v != 0 && !isValid(g, g[y][x].v, x, y) {65- return 066- }67- }193+ if limit <= 0 {194+ return 0195+ }196+197+ masks, ok := buildMasks(g)198+ if !ok {199+ return 068200 }69- return countSolutionsRec(g, limit)201+ return countSolutionsRec(g, limit, &masks)70202 }71203 72204 // countSolutionsRec is the recursive backtracking helper for countSolutions.73-func countSolutionsRec(g *grid, limit int) int {74- for y := range gridSize {75- for x := range gridSize {76- if g[y][x].v == 0 {77- count := 078- for val := 1; val <= gridSize; val++ {79- if isValid(g, val, x, y) {80- g[y][x].v = val81- count += countSolutionsRec(g, limit-count)82- g[y][x].v = 083- if count >= limit {84- return count85- }86- }87- }88- return count89- }205+func countSolutionsRec(g *grid, limit int, masks *solverMasks) int {206+ if limit <= 0 {207+ return 0208+ }209+210+ x, y, candidateMask, found := findMostConstrainedEmptyCell(g, masks)211+ if !found {212+ return 1213+ }214+ if candidateMask == 0 {215+ return 0216+ }217+218+ count := 0219+ for val := 1; val <= gridSize; val++ {220+ bit := valueBit(val)221+ if candidateMask&bit == 0 {222+ continue223+ }224+225+ placeValue(g, masks, val, x, y)226+ count += countSolutionsRec(g, limit-count, masks)227+ clearValue(g, masks, val, x, y)228+ if count >= limit {229+ return count90230 }91231 }92- return 1232+ return count93233 }94234 95235 // GenerateProvidedCells generates a random sudoku puzzle with the number of
@@ -98,12 +238,49 @@ func GenerateProvidedCells(m SudokuMode) []cell {
98238 return GenerateProvidedCellsSeeded(m, newRNG())99239 }100240 241+type pos struct{ x, y int }242+243+func clueRemovalScore(g *grid, x, y int) int {244+ score := 0245+ for i := range gridSize {246+ if i != x && g[y][i].v == 0 {247+ score++248+ }249+ if i != y && g[i][x].v == 0 {250+ score++251+ }252+ }253+254+ boxX, boxY := (x/3)*3, (y/3)*3255+ for by := boxY; by < boxY+3; by++ {256+ for bx := boxX; bx < boxX+3; bx++ {257+ if (bx != x || by != y) && g[by][bx].v == 0 {258+ score++259+ }260+ }261+ }262+263+ return score264+}265+266+func pickRemovalCandidateIndex(g *grid, positions []pos, rng *rand.Rand) int {267+ bestIndex := 0268+ bestScore := clueRemovalScore(g, positions[0].x, positions[0].y)269+ for i := 1; i < len(positions); i++ {270+ score := clueRemovalScore(g, positions[i].x, positions[i].y)271+ if score < bestScore || (score == bestScore && rng.IntN(2) == 0) {272+ bestScore = score273+ bestIndex = i274+ }275+ }276+ return bestIndex277+}278+101279 func GenerateProvidedCellsSeeded(m SudokuMode, rng *rand.Rand) []cell {102280 g := newGrid(nil)103281 fillGridSeeded(&g, rng)104282 105283 // Build shuffled list of all positions106- type pos struct{ x, y int }107284 positions := make([]pos, 0, gridSize*gridSize)108285 for y := range gridSize {109286 for x := range gridSize {
@@ -114,12 +291,14 @@ func GenerateProvidedCellsSeeded(m SudokuMode, rng *rand.Rand) []cell {
114291 positions[i], positions[j] = positions[j], positions[i]115292 })116293 117- // Iteratively remove cells, ensuring unique solution294+ // Iteratively remove cells in low-impact-first order while preserving uniqueness.118295 filled := gridSize * gridSize119- for _, p := range positions {120- if filled <= m.ProvidedCount {121- break122- }296+ for len(positions) > 0 && filled > m.ProvidedCount {297+ idx := pickRemovalCandidateIndex(&g, positions, rng)298+ p := positions[idx]299+ positions[idx] = positions[len(positions)-1]300+ positions = positions[:len(positions)-1]301+123302 saved := g[p.y][p.x].v124303 g[p.y][p.x].v = 0125304 if countSolutions(&g, 2) != 1 {
A sudoku/uniqueness_test.go +26 -0