- commit
- 6bfdc4240ffbfc049c3aa296ef16ce38c959b27c
- parent
- 95a2c2a6b1b746130b365a9637b69bd4a3da30b9
- parent
- 9a748b706438333e65abd31b8b9517641aa57cce
- author
- Dami <dami.etoile@gmail.com>
| M | nonogram/ | 294 | +++++++++++++++++++++++------- |
| M | nonogram/ | 147 | +++++++++++++++ |
| M | nurikabe/ | 161 | ++++++++++------- |
| M | nurikabe/ | 98 | ++++++++++ |
| M | nurikabe/ | 281 | ++++++++++++++++++++-------- |
@@ -2,8 +2,8 @@ package nonogram
22 33 import (44 "context"5- "fmt"65 "math/rand/v2"6+ "strconv"77 "strings"88 "sync"99 "time"@@ -11,16 +11,49 @@ import (
1111 1212 const (1313 maxAttempts = 10014- maxPossibilities = 1000014+ maxPossibilities = 2000015+ maxCacheEntries = 1638416+ maxCacheCells = 500_000_0001517 )1618 19+type lineCacheKey struct {20+ length int21+ hint string22+}23+24+type possibilitiesCacheEntry struct {25+ possibilities [][]cellState26+ cellCost int27+}28+1729 var (18- possibilitiesCache = make(map[string][][]cellState)19- possibilitiesMu sync.RWMutex30+ possibilitiesCache = make(map[lineCacheKey]*possibilitiesCacheEntry)31+ possibilitiesOrder = make([]lineCacheKey, 0, maxCacheEntries)32+ possibilitiesOrderHead = 033+ possibilitiesCacheSize int34+ possibilitiesMu sync.Mutex2035 )2136 22-func cacheKey(length int, hint []int) string {23- return fmt.Sprintf("%d:%v", length, hint)37+func cacheKey(length int, hint []int) lineCacheKey {38+ return lineCacheKey{39+ length: length,40+ hint: encodeHint(hint),41+ }42+}43+44+func encodeHint(hint []int) string {45+ if len(hint) == 0 {46+ return ""47+ }48+ var b strings.Builder49+ b.Grow(len(hint) * 3)50+ for i, v := range hint {51+ if i > 0 {52+ b.WriteByte(',')53+ }54+ b.WriteString(strconv.Itoa(v))55+ }56+ return b.String()2457 }2558 2659 func GenerateRandomTomography(mode NonogramMode) Hints {@@ -114,6 +147,15 @@ type solutionGrid struct {
114147 w, h int115148 }116149 150+type cellChange struct {151+ x, y int152+ prev cellState153+}154+155+type undoStack struct {156+ changes []cellChange157+}158+117159 func newSolutionGrid(w, h int) solutionGrid {118160 cells := make([][]cellState, h)119161 for y := range h {@@ -125,23 +167,6 @@ func newSolutionGrid(w, h int) solutionGrid {
125167 return solutionGrid{cells: cells, w: w, h: h}126168 }127169 128-func (g *solutionGrid) clone() solutionGrid {129- cells := make([][]cellState, g.h)130- for y := range g.h {131- cells[y] = make([]cellState, g.w)132- copy(cells[y], g.cells[y])133- }134- return solutionGrid{cells: cells, w: g.w, h: g.h}135-}136-137-func (g *solutionGrid) set(x, y int, state cellState) bool {138- if g.cells[y][x] != cellUnknown && g.cells[y][x] != state {139- return false140- }141- g.cells[y][x] = state142- return true143-}144-145170 func (g *solutionGrid) isComplete() bool {146171 for y := range g.h {147172 for x := range g.w {@@ -155,65 +180,79 @@ func (g *solutionGrid) isComplete() bool {
155180 156181 func countSolutions(hints Hints, w, h, limit int, ctx context.Context) int {157182 g := newSolutionGrid(w, h)158- return countSolutionsRecursive(g, hints, limit, ctx)183+ stack := undoStack{184+ changes: make([]cellChange, 0, w*h),185+ }186+ return countSolutionsRecursive(&g, hints, limit, ctx, &stack)159187 }160188 161-func countSolutionsRecursive(g solutionGrid, hints Hints, limit int, ctx context.Context) int {189+func countSolutionsRecursive(190+ g *solutionGrid,191+ hints Hints,192+ limit int,193+ ctx context.Context,194+ stack *undoStack,195+) int {196+ if limit <= 0 {197+ return 0198+ }162199 select {163200 case <-ctx.Done():164201 return -1165202 default:166203 }167204 168- propagated, valid := propagate(g, hints, ctx)169- if !valid {205+ mark := stack.mark()206+ if !propagateInPlace(g, hints, ctx, stack) {207+ stack.revert(g, mark)170208 return 0171209 }172210 173- if propagated.isComplete() {211+ if g.isComplete() {212+ stack.revert(g, mark)174213 return 1175214 }176215 177- for y := range propagated.h {178- for x := range propagated.w {179- if propagated.cells[y][x] == cellUnknown {180- count := 0181-182- if propagated.set(x, y, cellFilled) {183- g1 := propagated.clone()184- propagated.cells[y][x] = cellUnknown185- n := countSolutionsRecursive(g1, hints, limit-count, ctx)186- if n < 0 {187- return n188- }189- count += n190- if count >= limit {191- return count192- }193- }194-195- if propagated.set(x, y, cellEmpty) {196- g2 := propagated.clone()197- propagated.cells[y][x] = cellUnknown198- n := countSolutionsRecursive(g2, hints, limit-count, ctx)199- if n < 0 {200- return n201- }202- count += n203- if count >= limit {204- return count205- }206- }216+ x, y, ok := pickMostConstrainedCell(*g, hints)217+ if !ok {218+ stack.revert(g, mark)219+ return 0220+ }207221 222+ count := 0223+ branchOrder := [2]cellState{cellFilled, cellEmpty}224+ for _, value := range branchOrder {225+ branchMark := stack.mark()226+ if stack.set(g, x, y, value) {227+ n := countSolutionsRecursive(g, hints, limit-count, ctx, stack)228+ if n < 0 {229+ stack.revert(g, mark)230+ return n231+ }232+ count += n233+ if count >= limit {234+ stack.revert(g, mark)208235 return count209236 }210237 }238+ stack.revert(g, branchMark)211239 }212240 213- return 0241+ stack.revert(g, mark)242+ return count214243 }215244 216245 func propagate(g solutionGrid, hints Hints, ctx context.Context) (solutionGrid, bool) {246+ stack := undoStack{247+ changes: make([]cellChange, 0, g.w*g.h),248+ }249+ if !propagateInPlace(&g, hints, ctx, &stack) {250+ return g, false251+ }252+ return g, true253+}254+255+func propagateInPlace(g *solutionGrid, hints Hints, ctx context.Context, stack *undoStack) bool {217256 rowBuf := make([]cellState, g.w)218257 colBuf := make([]cellState, g.h)219258 @@ -221,7 +260,7 @@ func propagate(g solutionGrid, hints Hints, ctx context.Context) (solutionGrid,
221260 for changed {222261 select {223262 case <-ctx.Done():224- return g, false263+ return false225264 default:226265 }227266 changed = false@@ -233,12 +272,14 @@ func propagate(g solutionGrid, hints Hints, ctx context.Context) (solutionGrid,
233272 234273 newRow, valid := propagateLine(rowBuf[:g.w], hints.rows[y])235274 if !valid {236- return g, false275+ return false237276 }238277 239278 for x := range g.w {240279 if g.cells[y][x] == cellUnknown && newRow[x] != cellUnknown {241- g.cells[y][x] = newRow[x]280+ if !stack.set(g, x, y, newRow[x]) {281+ return false282+ }242283 changed = true243284 }244285 }@@ -251,19 +292,94 @@ func propagate(g solutionGrid, hints Hints, ctx context.Context) (solutionGrid,
251292 252293 newCol, valid := propagateLine(colBuf[:g.h], hints.cols[x])253294 if !valid {254- return g, false295+ return false255296 }256297 257298 for y := range g.h {258299 if g.cells[y][x] == cellUnknown && newCol[y] != cellUnknown {259- g.cells[y][x] = newCol[y]300+ if !stack.set(g, x, y, newCol[y]) {301+ return false302+ }260303 changed = true261304 }262305 }263306 }264307 }265308 266- return g, true309+ return true310+}311+312+func (s *undoStack) mark() int {313+ return len(s.changes)314+}315+316+func (s *undoStack) set(g *solutionGrid, x, y int, state cellState) bool {317+ current := g.cells[y][x]318+ if current == state {319+ return true320+ }321+ if current != cellUnknown {322+ return false323+ }324+ s.changes = append(s.changes, cellChange{x: x, y: y, prev: current})325+ g.cells[y][x] = state326+ return true327+}328+329+func (s *undoStack) revert(g *solutionGrid, mark int) {330+ for i := len(s.changes) - 1; i >= mark; i-- {331+ change := s.changes[i]332+ g.cells[change.y][change.x] = change.prev333+ }334+ s.changes = s.changes[:mark]335+}336+337+func pickMostConstrainedCell(g solutionGrid, hints Hints) (x, y int, ok bool) {338+ firstX, firstY := -1, -1339+ bestNeighborScore := -1340+ bestHintWeight := -1341+342+ for yy := range g.h {343+ for xx := range g.w {344+ if g.cells[yy][xx] != cellUnknown {345+ continue346+ }347+ if firstX < 0 {348+ firstX, firstY = xx, yy349+ }350+351+ neighborScore := 0352+ if yy > 0 && g.cells[yy-1][xx] != cellUnknown {353+ neighborScore++354+ }355+ if yy+1 < g.h && g.cells[yy+1][xx] != cellUnknown {356+ neighborScore++357+ }358+ if xx > 0 && g.cells[yy][xx-1] != cellUnknown {359+ neighborScore++360+ }361+ if xx+1 < g.w && g.cells[yy][xx+1] != cellUnknown {362+ neighborScore++363+ }364+365+ hintWeight := len(hints.rows[yy]) + len(hints.cols[xx])366+ if neighborScore > bestNeighborScore ||367+ (neighborScore == bestNeighborScore && hintWeight > bestHintWeight) {368+ bestNeighborScore = neighborScore369+ bestHintWeight = hintWeight370+ x, y = xx, yy371+ ok = true372+ }373+ }374+ }375+376+ if firstX < 0 {377+ return 0, 0, false378+ }379+ if bestNeighborScore <= 0 {380+ return firstX, firstY, true381+ }382+ return x, y, ok267383 }268384 269385 func propagateLine(line []cellState, hint []int) ([]cellState, bool) {@@ -312,26 +428,60 @@ func propagateLine(line []cellState, hint []int) ([]cellState, bool) {
312428 func getCachedPossibilities(length int, hint []int) [][]cellState {313429 key := cacheKey(length, hint)314430 315- possibilitiesMu.RLock()316- if cached, ok := possibilitiesCache[key]; ok {317- possibilitiesMu.RUnlock()431+ possibilitiesMu.Lock()432+ if entry, ok := possibilitiesCache[key]; ok {433+ cached := entry.possibilities434+ possibilitiesMu.Unlock()318435 return cached319436 }320- possibilitiesMu.RUnlock()437+ possibilitiesMu.Unlock()321438 322439 possibilities := generateLinePossibilities(length, hint)440+ cellCost := len(possibilities) * length441+ if cellCost <= 0 || cellCost > maxCacheCells {442+ return possibilities443+ }323444 324445 possibilitiesMu.Lock()325- if cached, ok := possibilitiesCache[key]; ok {446+ if entry, ok := possibilitiesCache[key]; ok {326447 possibilitiesMu.Unlock()327- return cached448+ return entry.possibilities449+ }450+ possibilitiesCache[key] = &possibilitiesCacheEntry{451+ possibilities: possibilities,452+ cellCost: cellCost,328453 }329- possibilitiesCache[key] = possibilities454+ possibilitiesOrder = append(possibilitiesOrder, key)455+ possibilitiesCacheSize += cellCost456+ trimPossibilitiesCacheLocked()330457 possibilitiesMu.Unlock()331458 332459 return possibilities333460 }334461 462+func trimPossibilitiesCacheLocked() {463+ for len(possibilitiesCache) > maxCacheEntries || possibilitiesCacheSize > maxCacheCells {464+ if possibilitiesOrderHead >= len(possibilitiesOrder) {465+ return466+ }467+ key := possibilitiesOrder[possibilitiesOrderHead]468+ possibilitiesOrderHead++469+ entry, ok := possibilitiesCache[key]470+ if !ok {471+ continue472+ }473+ possibilitiesCacheSize -= entry.cellCost474+ delete(possibilitiesCache, key)475+ }476+477+ if possibilitiesOrderHead > 1024 && possibilitiesOrderHead*2 > len(possibilitiesOrder) {478+ compacted := make([]lineCacheKey, len(possibilitiesOrder)-possibilitiesOrderHead)479+ copy(compacted, possibilitiesOrder[possibilitiesOrderHead:])480+ possibilitiesOrder = compacted481+ possibilitiesOrderHead = 0482+ }483+}484+335485 func generateLinePossibilities(length int, hint []int) [][]cellState {336486 if len(hint) == 0 || (len(hint) == 1 && hint[0] == 0) {337487 result := make([]cellState, length)@@ -2,6 +2,7 @@ package nonogram
22 33 import (44 "context"5+ "math/rand/v2"56 "reflect"67 "strings"78 "testing"@@ -894,6 +895,80 @@ func TestGenerateRandomTomography_5x5_VerifyUnique(t *testing.T) {
894895 }895896 }896897 898+func TestGenerateRandomTomography_LargeModes_Unique(t *testing.T) {899+ if testing.Short() {900+ t.Skip("skipping large-mode uniqueness regression in short mode")901+ }902+903+ cases := []struct {904+ name string905+ mode NonogramMode906+ seedA uint64907+ seedB uint64908+ }{909+ {name: "Epic", mode: NewMode("Epic", "test", 20, 20, 0.71), seedA: 3101, seedB: 8101},910+ {name: "Massive", mode: NewMode("Massive", "test", 20, 20, 0.56), seedA: 4703, seedB: 9109},911+ }912+913+ for _, tc := range cases {914+ tc := tc915+ t.Run(tc.name, func(t *testing.T) {916+ rng := rand.New(rand.NewPCG(tc.seedA, tc.seedB))917+ hints := GenerateRandomTomographySeeded(tc.mode, rng)918+ if len(hints.rows) != tc.mode.Height || len(hints.cols) != tc.mode.Width {919+ t.Fatalf(920+ "invalid hints dimensions rows=%d cols=%d want rows=%d cols=%d",921+ len(hints.rows),922+ len(hints.cols),923+ tc.mode.Height,924+ tc.mode.Width,925+ )926+ }927+928+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)929+ defer cancel()930+ count := countSolutions(hints, tc.mode.Width, tc.mode.Height, 2, ctx)931+ if count != 1 {932+ t.Fatalf("expected unique solution for %s, got %d", tc.name, count)933+ }934+ })935+ }936+}937+938+func TestGenerateRandomTomography_LargeModes_PerfBudget(t *testing.T) {939+ if testing.Short() {940+ t.Skip("skipping large-mode perf regression in short mode")941+ }942+943+ const maxDuration = 30 * time.Second944+ cases := []struct {945+ name string946+ mode NonogramMode947+ seedA uint64948+ seedB uint64949+ }{950+ {name: "Epic", mode: NewMode("Epic", "test", 20, 20, 0.71), seedA: 5209, seedB: 8219},951+ {name: "Massive", mode: NewMode("Massive", "test", 20, 20, 0.56), seedA: 5303, seedB: 12203},952+ }953+954+ for _, tc := range cases {955+ tc := tc956+ t.Run(tc.name, func(t *testing.T) {957+ start := time.Now()958+ rng := rand.New(rand.NewPCG(tc.seedA, tc.seedB))959+ hints := GenerateRandomTomographySeeded(tc.mode, rng)960+ elapsed := time.Since(start)961+962+ if len(hints.rows) == 0 || len(hints.cols) == 0 {963+ t.Fatalf("GenerateRandomTomographySeeded returned empty hints for %s", tc.name)964+ }965+ if elapsed > maxDuration {966+ t.Fatalf("%s generation exceeded %s: %s", tc.name, maxDuration, elapsed)967+ }968+ })969+ }970+}971+897972 var _ game.Gamer = Model{}898973 899974 // --- Spawn Performance Benchmark (P2) ---@@ -1180,3 +1255,75 @@ func TestHelpToggleInvalidatesOriginCache(t *testing.T) {
11801255 t.Fatal("expected origin cache to be invalidated")11811256 }11821257 }1258+1259+// --- Benchmarks (P2) ---1260+1261+func BenchmarkGenerateRandomTomographyModes(b *testing.B) {1262+ modeConfigs := []struct {1263+ name string1264+ mode NonogramMode1265+ seedA uint641266+ }{1267+ {name: "5x5-Mini", mode: NewMode("Mini", "bench", 5, 5, 0.65), seedA: 100000},1268+ {name: "10x10-Classic", mode: NewMode("Classic", "bench", 10, 10, 0.52), seedA: 200000},1269+ {name: "15x15-Grand", mode: NewMode("Grand", "bench", 15, 15, 0.54), seedA: 300000},1270+ {name: "20x20-Epic", mode: NewMode("Epic", "bench", 20, 20, 0.71), seedA: 350000},1271+ {name: "20x20-Massive", mode: NewMode("Massive", "bench", 20, 20, 0.56), seedA: 400000},1272+ }1273+1274+ for _, cfg := range modeConfigs {1275+ cfg := cfg1276+ b.Run(cfg.name, func(b *testing.B) {1277+ b.ReportAllocs()1278+ for n := 0; n < b.N; n++ {1279+ seedA := cfg.seedA1280+ seedB := uint64((n + 1) * 1777)1281+ rng := rand.New(rand.NewPCG(seedA, seedB))1282+ hints := GenerateRandomTomographySeeded(cfg.mode, rng)1283+ if len(hints.rows) != cfg.mode.Height || len(hints.cols) != cfg.mode.Width {1284+ b.Fatalf(1285+ "invalid hint dimensions rows=%d cols=%d want rows=%d cols=%d",1286+ len(hints.rows),1287+ len(hints.cols),1288+ cfg.mode.Height,1289+ cfg.mode.Width,1290+ )1291+ }1292+ }1293+ })1294+ }1295+}1296+1297+func BenchmarkCountSolutionsLarge(b *testing.B) {1298+ cases := []struct {1299+ name string1300+ mode NonogramMode1301+ seedA uint641302+ seedB uint641303+ }{1304+ {name: "15x15-Grand", mode: NewMode("Grand", "bench", 15, 15, 0.54), seedA: 7301, seedB: 8101},1305+ {name: "20x20-Massive", mode: NewMode("Massive", "bench", 20, 20, 0.56), seedA: 9103, seedB: 10429},1306+ }1307+1308+ for _, tc := range cases {1309+ tc := tc1310+ b.Run(tc.name, func(b *testing.B) {1311+ rng := rand.New(rand.NewPCG(tc.seedA, tc.seedB))1312+ hints := GenerateRandomTomographySeeded(tc.mode, rng)1313+ if len(hints.rows) == 0 || len(hints.cols) == 0 {1314+ b.Fatal("GenerateRandomTomographySeeded returned empty hints")1315+ }1316+1317+ b.ReportAllocs()1318+ b.ResetTimer()1319+ for n := 0; n < b.N; n++ {1320+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)1321+ count := countSolutions(hints, tc.mode.Width, tc.mode.Height, 2, ctx)1322+ cancel()1323+ if count < 1 {1324+ b.Fatalf("expected at least one solution, got %d", count)1325+ }1326+ }1327+ })1328+ }1329+}@@ -83,8 +83,13 @@ type attemptOutcome struct {
8383 err error8484 }8585 86+type uniquenessJob struct {87+ clueKey string88+ puzzle Puzzle89+}90+8691 type uniquenessOutcome struct {87- attemptID int92+ clueKey string8893 count int8994 err error9095 }@@ -133,13 +138,14 @@ func GenerateSeededWithContext(ctx context.Context, mode NurikabeMode, rng *rand
133138 profile := modeIslandProfile(mode)134139 budget := modeGenerationBudget(mode)135140 workerCount := candidateWorkerCount(mode)141+ uniquenessWorkers := uniquenessWorkerCount(mode)136142 137143 masterA := rng.Uint64()138144 masterB := rng.Uint64()139145 140146 attemptCh := make(chan attemptSeed)141147 candidateCh := make(chan attemptOutcome, workerCount*2)142- uniqueCh := make(chan candidateResult, budget.uniquenessProbeLimit)148+ uniqueCh := make(chan uniquenessJob, budget.uniquenessProbeLimit)143149 uniqueResultCh := make(chan uniquenessOutcome, budget.uniquenessProbeLimit)144150 145151 var candidateWG sync.WaitGroup@@ -152,11 +158,13 @@ func GenerateSeededWithContext(ctx context.Context, mode NurikabeMode, rng *rand
152158 }153159 154160 var uniqueWG sync.WaitGroup155- uniqueWG.Add(1)156- go func() {157- defer uniqueWG.Done()158- uniquenessWorker(timeboxedCtx, budget, uniqueCh, uniqueResultCh)159- }()161+ for range uniquenessWorkers {162+ uniqueWG.Add(1)163+ go func() {164+ defer uniqueWG.Done()165+ uniquenessWorker(timeboxedCtx, budget, uniqueCh, uniqueResultCh)166+ }()167+ }160168 go func() {161169 uniqueWG.Wait()162170 close(uniqueResultCh)@@ -167,8 +175,29 @@ func GenerateSeededWithContext(ctx context.Context, mode NurikabeMode, rng *rand
167175 bestUnique := candidateResult{}168176 haveBestUnique := false169177 170- uniquenessQueued := 0171- pendingUnique := map[int]candidateResult{}178+ pendingUniqueByKey := map[string]candidateResult{}179+ knownUniqueByKey := map[string]uniquenessOutcome{}180+ uniquenessScheduled := 0181+182+ handleUniqueResult := func(result uniquenessOutcome) {183+ knownUniqueByKey[result.clueKey] = result184+ candidate, ok := pendingUniqueByKey[result.clueKey]185+ delete(pendingUniqueByKey, result.clueKey)186+ if !ok {187+ return188+ }189+ if result.err != nil {190+ if errors.Is(result.err, context.Canceled) ||191+ errors.Is(result.err, context.DeadlineExceeded) {192+ return193+ }194+ return195+ }196+ if result.count == 1 && (!haveBestUnique || betterCandidate(candidate, bestUnique)) {197+ bestUnique = candidate198+ haveBestUnique = true199+ }200+ }172201 173202 drainUnique := func() {174203 for {@@ -177,22 +206,7 @@ func GenerateSeededWithContext(ctx context.Context, mode NurikabeMode, rng *rand
177206 if !ok {178207 return179208 }180- candidate, ok := pendingUnique[result.attemptID]181- delete(pendingUnique, result.attemptID)182- if !ok {183- continue184- }185- if result.err != nil {186- if errors.Is(result.err, context.Canceled) ||187- errors.Is(result.err, context.DeadlineExceeded) {188- continue189- }190- continue191- }192- if result.count == 1 && (!haveBestUnique || betterCandidate(candidate, bestUnique)) {193- bestUnique = candidate194- haveBestUnique = true195- }209+ handleUniqueResult(result)196210 default:197211 return198212 }@@ -205,6 +219,7 @@ outer:
205219 if err := timeboxedCtx.Err(); err != nil {206220 break207221 }222+ drainUnique()208223 209224 roundSize := min(workerCount, budget.attempts-attemptID)210225 dispatched := 0@@ -224,19 +239,6 @@ outer:
224239 select {225240 case out := <-candidateCh:226241 outcomes = append(outcomes, out)227- case result, ok := <-uniqueResultCh:228- if !ok {229- continue230- }231- candidate, ok := pendingUnique[result.attemptID]232- delete(pendingUnique, result.attemptID)233- if !ok {234- continue235- }236- if result.err == nil && result.count == 1 && (!haveBestUnique || betterCandidate(candidate, bestUnique)) {237- bestUnique = candidate238- haveBestUnique = true239- }240242 case <-timeboxedCtx.Done():241243 break outer242244 }@@ -256,11 +258,32 @@ outer:
256258 haveBestSolvable = true257259 }258260 259- if shouldProbeUniqueness(candidate, haveBestSolvable, bestSolvable, haveBestUnique, bestUnique, uniquenessQueued, budget, deadline) {260- pendingUnique[candidate.attemptID] = candidate261- uniquenessQueued++261+ if knownResult, ok := knownUniqueByKey[candidate.clueKey]; ok {262+ if knownResult.err == nil &&263+ knownResult.count == 1 &&264+ (!haveBestUnique || betterCandidate(candidate, bestUnique)) {265+ bestUnique = candidate266+ haveBestUnique = true267+ }268+ continue269+ }270+271+ if pendingCandidate, ok := pendingUniqueByKey[candidate.clueKey]; ok {272+ if betterCandidate(candidate, pendingCandidate) {273+ pendingUniqueByKey[candidate.clueKey] = candidate274+ }275+ continue276+ }277+278+ if shouldProbeUniqueness(candidate, haveBestSolvable, bestSolvable, uniquenessScheduled, budget, deadline) {279+ job := uniquenessJob{280+ clueKey: candidate.clueKey,281+ puzzle: candidate.puzzle,282+ }283+ pendingUniqueByKey[candidate.clueKey] = candidate262284 select {263- case uniqueCh <- candidate:285+ case uniqueCh <- job:286+ uniquenessScheduled++264287 case <-timeboxedCtx.Done():265288 break outer266289 }@@ -275,15 +298,7 @@ outer:
275298 candidateWG.Wait()276299 close(uniqueCh)277300 for result := range uniqueResultCh {278- candidate, ok := pendingUnique[result.attemptID]279- delete(pendingUnique, result.attemptID)280- if !ok {281- continue282- }283- if result.err == nil && result.count == 1 && (!haveBestUnique || betterCandidate(candidate, bestUnique)) {284- bestUnique = candidate285- haveBestUnique = true286- }301+ handleUniqueResult(result)287302 }288303 289304 if haveBestUnique {@@ -340,20 +355,20 @@ func candidateWorker(
340355 func uniquenessWorker(341356 ctx context.Context,342357 budget generationBudget,343- uniqueCh <-chan candidateResult,358+ uniqueCh <-chan uniquenessJob,344359 uniqueResultCh chan<- uniquenessOutcome,345360 ) {346361 for {347362 select {348363 case <-ctx.Done():349364 return350- case candidate, ok := <-uniqueCh:365+ case job, ok := <-uniqueCh:351366 if !ok {352367 return353368 }354369 355- count, _, err := CountSolutionsContext(ctx, candidate.puzzle, 2, budget.uniquenessNodes)356- result := uniquenessOutcome{attemptID: candidate.attemptID, count: count, err: err}370+ count, _, err := CountSolutionsContext(ctx, job.puzzle, 2, budget.uniquenessNodes)371+ result := uniquenessOutcome{clueKey: job.clueKey, count: count, err: err}357372 select {358373 case uniqueResultCh <- result:359374 case <-ctx.Done():@@ -902,24 +917,19 @@ func shouldProbeUniqueness(
902917 candidate candidateResult,903918 haveBestSolvable bool,904919 bestSolvable candidateResult,905- haveBestUnique bool,906- bestUnique candidateResult,907- queued int,920+ scheduled int,908921 budget generationBudget,909922 deadline time.Time,910923 ) bool {911- if queued >= budget.uniquenessProbeLimit {924+ if scheduled >= budget.uniquenessProbeLimit {912925 return false913926 }914927 if time.Until(deadline) < uniquenessMinRemainingBudget {915928 return false916929 }917- if queued < 3 {930+ if scheduled < 3 {918931 return true919932 }920- if haveBestUnique {921- return candidate.score > bestUnique.score-0.45922- }923933 if !haveBestSolvable {924934 return true925935 }@@ -969,9 +979,26 @@ func candidateWorkerCount(mode NurikabeMode) int {
969979 case "Medium":970980 return 2971981 case "Hard":972- return 3982+ return 4983+ case "Expert":984+ return 4985+ default:986+ return 2987+ }988+}989+990+func uniquenessWorkerCount(mode NurikabeMode) int {991+ switch mode.Title() {992+ case "Mini":993+ return 1994+ case "Easy":995+ return 1996+ case "Medium":997+ return 2998+ case "Hard":999+ return 49731000 case "Expert":974- return 31001+ return 59751002 default:9761003 return 29771004 }@@ -1057,9 +1084,9 @@ func modeGenerationBudget(mode NurikabeMode) generationBudget {
10571084 case "Medium":10581085 return generationBudget{attempts: 500, solvabilityNodes: 70000, uniquenessNodes: 140000, uniquenessProbeLimit: 10}10591086 case "Hard":1060- return generationBudget{attempts: 650, solvabilityNodes: 95000, uniquenessNodes: 180000, uniquenessProbeLimit: 12}1087+ return generationBudget{attempts: 420, solvabilityNodes: 70000, uniquenessNodes: 120000, uniquenessProbeLimit: 8}10611088 case "Expert":1062- return generationBudget{attempts: 800, solvabilityNodes: 120000, uniquenessNodes: 220000, uniquenessProbeLimit: 14}1089+ return generationBudget{attempts: 320, solvabilityNodes: 70000, uniquenessNodes: 100000, uniquenessProbeLimit: 6}10631090 default:10641091 return generationBudget{attempts: 400, solvabilityNodes: 45000, uniquenessNodes: 100000, uniquenessProbeLimit: 8}10651092 }@@ -728,6 +728,26 @@ func TestGenerateSeededCancellationFast(t *testing.T) {
728728 }729729 }730730 731+func TestGenerateSeededDeadlineExceededFast(t *testing.T) {732+ mode := Modes[4].(NurikabeMode) // Expert keeps deadline path realistic.733+ ctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond)734+ defer cancel()735+ time.Sleep(1 * time.Millisecond)736+737+ start := time.Now()738+ rng := rand.New(rand.NewPCG(44, 88))739+ _, err := GenerateSeededWithContext(ctx, mode, rng)740+ if err == nil {741+ t.Fatal("expected deadline exceeded error, got nil")742+ }743+ if !errors.Is(err, context.DeadlineExceeded) {744+ t.Fatalf("expected context.DeadlineExceeded, got %v", err)745+ }746+ if elapsed := time.Since(start); elapsed > 200*time.Millisecond {747+ t.Fatalf("deadline cancellation should return quickly, took %s", elapsed)748+ }749+}750+731751 func TestNoGoroutineLeakOnCancel(t *testing.T) {732752 mode := Modes[4].(NurikabeMode)733753 before := runtime.NumGoroutine()@@ -745,3 +765,81 @@ func TestNoGoroutineLeakOnCancel(t *testing.T) {
745765 t.Fatalf("possible goroutine leak: before=%d after=%d delta=%d", before, after, delta)746766 }747767 }768+769+func TestNoGoroutineLeakOnDeadlineTimeout(t *testing.T) {770+ mode := Modes[4].(NurikabeMode)771+ before := runtime.NumGoroutine()772+773+ for i := 0; i < 8; i++ {774+ ctx, cancel := context.WithTimeout(context.Background(), 1*time.Millisecond)775+ rng := rand.New(rand.NewPCG(uint64(1400+i), uint64(1900+i)))776+ _, _ = GenerateSeededWithContext(ctx, mode, rng)777+ cancel()778+ }779+780+ time.Sleep(120 * time.Millisecond)781+ after := runtime.NumGoroutine()782+ if delta := after - before; delta > 8 {783+ t.Fatalf("possible goroutine leak on deadline timeout: before=%d after=%d delta=%d", before, after, delta)784+ }785+}786+787+// --- Benchmarks (P2) ---788+789+func BenchmarkGenerateSeededModes(b *testing.B) {790+ for i, item := range Modes {791+ mode := item.(NurikabeMode)792+ modeIdx := i793+ b.Run(mode.Title(), func(b *testing.B) {794+ b.ReportAllocs()795+ for n := 0; n < b.N; n++ {796+ seedA := uint64(100 + modeIdx)797+ seedB := uint64(1000 + modeIdx)798+ rng := rand.New(rand.NewPCG(seedA, seedB))799+ if _, err := GenerateSeeded(mode, rng); err != nil {800+ b.Fatalf("GenerateSeeded error: %v", err)801+ }802+ }803+ })804+ }805+}806+807+func BenchmarkCountSolutions(b *testing.B) {808+ easyMode := Modes[1].(NurikabeMode)809+ easyRNG := rand.New(rand.NewPCG(7001, 9001))810+ easyPuzzle, err := GenerateSeeded(easyMode, easyRNG)811+ if err != nil {812+ b.Fatalf("GenerateSeeded setup error: %v", err)813+ }814+815+ cases := []struct {816+ name string817+ puzzle Puzzle818+ nodeLimit int819+ }{820+ {821+ name: "Fixture5x5",822+ puzzle: uniquePuzzleFixture(),823+ nodeLimit: 120000,824+ },825+ {826+ name: "GeneratedEasy",827+ puzzle: easyPuzzle,828+ nodeLimit: generationNodeLimit(easyMode) * 4,829+ },830+ }831+832+ for _, tc := range cases {833+ tc := tc834+ b.Run(tc.name, func(b *testing.B) {835+ b.ReportAllocs()836+ b.ResetTimer()837+ for n := 0; n < b.N; n++ {838+ _, _, solveErr := CountSolutionsContext(context.Background(), tc.puzzle, 2, tc.nodeLimit)839+ if solveErr != nil && !errors.Is(solveErr, errNodeLimit) {840+ b.Fatalf("CountSolutionsContext error: %v", solveErr)841+ }842+ }843+ })844+ }845+}@@ -27,6 +27,57 @@ type islandComponent struct {
2727 2828 var dirs = [4]point{{0, -1}, {0, 1}, {-1, 0}, {1, 0}}2929 30+type solverScratch struct {31+ width int32+ height int33+34+ compIdx [][]int35+ seen [][]int36+ seenMark int37+ workQueue []point38+}39+40+func newSolverScratch(width, height int) *solverScratch {41+ s := &solverScratch{42+ width: width,43+ height: height,44+ }45+ if width <= 0 || height <= 0 {46+ return s47+ }48+49+ s.compIdx = make([][]int, height)50+ s.seen = make([][]int, height)51+ for y := range height {52+ s.compIdx[y] = make([]int, width)53+ s.seen[y] = make([]int, width)54+ }55+ return s56+}57+58+func (s *solverScratch) resetComponentIndex() [][]int {59+ for y := range s.height {60+ for x := range s.width {61+ s.compIdx[y][x] = -162+ }63+ }64+ return s.compIdx65+}66+67+func (s *solverScratch) nextSeenMark() int {68+ s.seenMark++69+ if s.seenMark > 0 {70+ return s.seenMark71+ }72+ for y := range s.height {73+ for x := range s.width {74+ s.seen[y][x] = 075+ }76+ }77+ s.seenMark = 178+ return s.seenMark79+}80+3081 func CountSolutions(puzzle Puzzle, limit, nodeLimit int) (int, SolveStats, error) {3182 return CountSolutionsContext(context.Background(), puzzle, limit, nodeLimit)3283 }@@ -56,6 +107,7 @@ func CountSolutionsContext(ctx context.Context, puzzle Puzzle, limit, nodeLimit
56107 57108 stats := SolveStats{}58109 solutions := 0110+ scratch := newSolverScratch(puzzle.Width, puzzle.Height)59111 var dfs func(grid, int) error60112 dfs = func(g grid, depth int) error {61113 if err := ctx.Err(); err != nil {@@ -69,16 +121,16 @@ func CountSolutionsContext(ctx context.Context, puzzle Puzzle, limit, nodeLimit
69121 stats.MaxDepth = depth70122 }71123 72- if err := propagate(g, puzzle.Clues); err != nil {124+ if err := propagateWithScratch(g, puzzle.Clues, scratch); err != nil {73125 return nil74126 }75- if err := validatePartial(g, puzzle.Clues); err != nil {127+ if err := validatePartialWithScratch(g, puzzle.Clues, scratch); err != nil {76128 return nil77129 }78130 79131 x, y, ok := pickUnknown(g, puzzle.Clues)80132 if !ok {81- if isSolvedGrid(g, puzzle.Clues) {133+ if isSolvedGridWithScratch(g, puzzle.Clues, scratch) {82134 solutions++83135 }84136 return nil@@ -118,6 +170,14 @@ func CountSolutionsContext(ctx context.Context, puzzle Puzzle, limit, nodeLimit
118170 }119171 120172 func propagate(g grid, clues clueGrid) error {173+ if len(g) == 0 || len(g[0]) == 0 {174+ return nil175+ }176+ scratch := newSolverScratch(len(g[0]), len(g))177+ return propagateWithScratch(g, clues, scratch)178+}179+180+func propagateWithScratch(g grid, clues clueGrid, scratch *solverScratch) error {121181 height := len(g)122182 if height == 0 {123183 return nil@@ -126,7 +186,7 @@ func propagate(g grid, clues clueGrid) error {
126186 127187 for {128188 changed := false129- comps, compIdx := islandComponents(g, clues)189+ comps, compIdx := islandComponentsWithScratch(g, clues, scratch)130190 131191 for _, comp := range comps {132192 if comp.clueCount == 1 && len(comp.cells) == comp.clueValue {@@ -150,7 +210,8 @@ func propagate(g grid, clues clueGrid) error {
150210 if g[y][x] != unknownCell {151211 continue152212 }153- seen := map[point]bool{}213+ firstClueComp := -1214+ clashes := false154215 for _, d := range dirs {155216 nx, ny := x+d.x, y+d.y156217 if nx < 0 || nx >= width || ny < 0 || ny >= height {@@ -165,17 +226,24 @@ func propagate(g grid, clues clueGrid) error {
165226 }166227 comp := comps[idx]167228 if comp.clueCount == 1 {168- seen[comp.cluePos] = true229+ if firstClueComp == -1 {230+ firstClueComp = idx231+ continue232+ }233+ if firstClueComp != idx {234+ clashes = true235+ break236+ }169237 }170238 }171- if len(seen) >= 2 {239+ if clashes {172240 g[y][x] = seaCell173241 changed = true174242 }175243 }176244 }177245 178- if err := validatePartial(g, clues); err != nil {246+ if err := validatePartialWithScratch(g, clues, scratch); err != nil {179247 return err180248 }181249 if !changed {@@ -185,14 +253,22 @@ func propagate(g grid, clues clueGrid) error {
185253 }186254 187255 func validatePartial(g grid, clues clueGrid) error {256+ if len(g) == 0 || len(g[0]) == 0 {257+ return nil258+ }259+ scratch := newSolverScratch(len(g[0]), len(g))260+ return validatePartialWithScratch(g, clues, scratch)261+}262+263+func validatePartialWithScratch(g grid, clues clueGrid, scratch *solverScratch) error {188264 if hasSeaSquare(g) {189265 return fmt.Errorf("contains 2x2 sea block")190266 }191- if !seaCanRemainConnected(g) {267+ if !seaCanRemainConnectedWithScratch(g, scratch) {192268 return fmt.Errorf("sea components cannot be connected")193269 }194270 195- comps, _ := islandComponents(g, clues)271+ comps, _ := islandComponentsWithScratch(g, clues, scratch)196272 for _, comp := range comps {197273 size := len(comp.cells)198274 switch {@@ -200,9 +276,9 @@ func validatePartial(g grid, clues clueGrid) error {
200276 return fmt.Errorf("island has multiple clues")201277 case comp.clueCount == 1 && size > comp.clueValue:202278 return fmt.Errorf("island exceeds clue size")203- case comp.clueCount == 0 && !componentCanReachClue(g, clues, comp):279+ case comp.clueCount == 0 && !componentCanReachClueWithScratch(g, clues, comp, scratch):204280 return fmt.Errorf("orphan island component")205- case comp.clueCount == 1 && maxReachableForComponent(g, clues, comp) < comp.clueValue:281+ case comp.clueCount == 1 && maxReachableForComponentWithScratch(g, clues, comp, scratch) < comp.clueValue:206282 return fmt.Errorf("clue cannot reach required size")207283 }208284 }@@ -214,6 +290,14 @@ func isSolvedGrid(g grid, clues clueGrid) bool {
214290 if len(g) == 0 || len(g[0]) == 0 {215291 return false216292 }293+ scratch := newSolverScratch(len(g[0]), len(g))294+ return isSolvedGridWithScratch(g, clues, scratch)295+}296+297+func isSolvedGridWithScratch(g grid, clues clueGrid, scratch *solverScratch) bool {298+ if len(g) == 0 || len(g[0]) == 0 {299+ return false300+ }217301 218302 height := len(g)219303 width := len(g[0])@@ -231,11 +315,11 @@ func isSolvedGrid(g grid, clues clueGrid) bool {
231315 if hasSeaSquare(g) {232316 return false233317 }234- if !isSeaConnected(g) {318+ if !isSeaConnectedWithScratch(g, scratch) {235319 return false236320 }237321 238- comps, _ := islandComponents(g, clues)322+ comps, _ := islandComponentsWithScratch(g, clues, scratch)239323 seenClues := 0240324 for _, comp := range comps {241325 if comp.clueCount != 1 {@@ -306,6 +390,14 @@ func candidateOrder(g grid, clues clueGrid, x, y int) []cellState {
306390 }307391 308392 func seaCanRemainConnected(g grid) bool {393+ if len(g) == 0 || len(g[0]) == 0 {394+ return true395+ }396+ scratch := newSolverScratch(len(g[0]), len(g))397+ return seaCanRemainConnectedWithScratch(g, scratch)398+}399+400+func seaCanRemainConnectedWithScratch(g grid, scratch *solverScratch) bool {309401 height := len(g)310402 if height == 0 {311403 return true@@ -328,32 +420,30 @@ func seaCanRemainConnected(g grid) bool {
328420 return true329421 }330422 331- visited := make([][]bool, height)332- for y := range height {333- visited[y] = make([]bool, width)334- }335- queue := []point{start}336- visited[start.y][start.x] = true423+ seenMark := scratch.nextSeenMark()424+ scratch.workQueue = scratch.workQueue[:0]425+ scratch.workQueue = append(scratch.workQueue, start)426+ scratch.seen[start.y][start.x] = seenMark337427 338- for len(queue) > 0 {339- p := queue[0]340- queue = queue[1:]428+ for len(scratch.workQueue) > 0 {429+ p := scratch.workQueue[0]430+ scratch.workQueue = scratch.workQueue[1:]341431 for _, d := range dirs {342432 nx, ny := p.x+d.x, p.y+d.y343433 if nx < 0 || ny < 0 || nx >= width || ny >= height {344434 continue345435 }346- if visited[ny][nx] || g[ny][nx] == islandCell {436+ if scratch.seen[ny][nx] == seenMark || g[ny][nx] == islandCell {347437 continue348438 }349- visited[ny][nx] = true350- queue = append(queue, point{nx, ny})439+ scratch.seen[ny][nx] = seenMark440+ scratch.workQueue = append(scratch.workQueue, point{nx, ny})351441 }352442 }353443 354444 for y := range height {355445 for x := range width {356- if g[y][x] == seaCell && !visited[y][x] {446+ if g[y][x] == seaCell && scratch.seen[y][x] != seenMark {357447 return false358448 }359449 }@@ -362,6 +452,14 @@ func seaCanRemainConnected(g grid) bool {
362452 }363453 364454 func isSeaConnected(g grid) bool {455+ if len(g) == 0 || len(g[0]) == 0 {456+ return false457+ }458+ scratch := newSolverScratch(len(g[0]), len(g))459+ return isSeaConnectedWithScratch(g, scratch)460+}461+462+func isSeaConnectedWithScratch(g grid, scratch *solverScratch) bool {365463 height := len(g)366464 if height == 0 {367465 return false@@ -384,29 +482,26 @@ func isSeaConnected(g grid) bool {
384482 return false385483 }386484 387- visited := make([][]bool, height)388- for y := range height {389- visited[y] = make([]bool, width)390- }391-392- queue := []point{start}393- visited[start.y][start.x] = true485+ seenMark := scratch.nextSeenMark()486+ scratch.workQueue = scratch.workQueue[:0]487+ scratch.workQueue = append(scratch.workQueue, start)488+ scratch.seen[start.y][start.x] = seenMark394489 seen := 1395490 396- for len(queue) > 0 {397- p := queue[0]398- queue = queue[1:]491+ for len(scratch.workQueue) > 0 {492+ p := scratch.workQueue[0]493+ scratch.workQueue = scratch.workQueue[1:]399494 for _, d := range dirs {400495 nx, ny := p.x+d.x, p.y+d.y401496 if nx < 0 || ny < 0 || nx >= width || ny >= height {402497 continue403498 }404- if visited[ny][nx] || g[ny][nx] != seaCell {499+ if scratch.seen[ny][nx] == seenMark || g[ny][nx] != seaCell {405500 continue406501 }407- visited[ny][nx] = true502+ scratch.seen[ny][nx] = seenMark408503 seen++409- queue = append(queue, point{nx, ny})504+ scratch.workQueue = append(scratch.workQueue, point{nx, ny})410505 }411506 }412507 @@ -430,15 +525,17 @@ func hasSeaSquare(g grid) bool {
430525 }431526 432527 func islandComponents(g grid, clues clueGrid) ([]islandComponent, [][]int) {528+ if len(g) == 0 || len(g[0]) == 0 {529+ return nil, nil530+ }531+ scratch := newSolverScratch(len(g[0]), len(g))532+ return islandComponentsWithScratch(g, clues, scratch)533+}534+535+func islandComponentsWithScratch(g grid, clues clueGrid, scratch *solverScratch) ([]islandComponent, [][]int) {433536 height := len(g)434537 width := len(g[0])435- idx := make([][]int, height)436- for y := range height {437- idx[y] = make([]int, width)438- for x := range width {439- idx[y][x] = -1440- }441- }538+ idx := scratch.resetComponentIndex()442539 443540 components := make([]islandComponent, 0)444541 for y := range height {@@ -448,13 +545,17 @@ func islandComponents(g grid, clues clueGrid) ([]islandComponent, [][]int) {
448545 }449546 450547 compIndex := len(components)451- comp := islandComponent{cluePos: point{-1, -1}}452- queue := []point{{x, y}}548+ comp := islandComponent{549+ cluePos: point{-1, -1},550+ cells: make([]point, 0, 16),551+ }552+ scratch.workQueue = scratch.workQueue[:0]553+ scratch.workQueue = append(scratch.workQueue, point{x, y})453554 idx[y][x] = compIndex454555 455- for len(queue) > 0 {456- p := queue[0]457- queue = queue[1:]556+ for len(scratch.workQueue) > 0 {557+ p := scratch.workQueue[0]558+ scratch.workQueue = scratch.workQueue[1:]458559 comp.cells = append(comp.cells, p)459560 if clues[p.y][p.x] > 0 {460561 comp.clueCount++@@ -471,7 +572,7 @@ func islandComponents(g grid, clues clueGrid) ([]islandComponent, [][]int) {
471572 continue472573 }473574 idx[ny][nx] = compIndex474- queue = append(queue, point{nx, ny})575+ scratch.workQueue = append(scratch.workQueue, point{nx, ny})475576 }476577 }477578 @@ -483,22 +584,31 @@ func islandComponents(g grid, clues clueGrid) ([]islandComponent, [][]int) {
483584 }484585 485586 func componentCanReachClue(g grid, clues clueGrid, comp islandComponent) bool {486- height := len(g)487- width := len(g[0])488- seen := make([][]bool, height)489- for y := range height {490- seen[y] = make([]bool, width)587+ if len(g) == 0 || len(g[0]) == 0 {588+ return false491589 }590+ scratch := newSolverScratch(len(g[0]), len(g))591+ return componentCanReachClueWithScratch(g, clues, comp, scratch)592+}492593 493- queue := make([]point, len(comp.cells))494- copy(queue, comp.cells)594+func componentCanReachClueWithScratch(595+ g grid,596+ clues clueGrid,597+ comp islandComponent,598+ scratch *solverScratch,599+) bool {600+ height := len(g)601+ width := len(g[0])602+ seenMark := scratch.nextSeenMark()603+ scratch.workQueue = scratch.workQueue[:0]495604 for _, c := range comp.cells {496- seen[c.y][c.x] = true605+ scratch.seen[c.y][c.x] = seenMark606+ scratch.workQueue = append(scratch.workQueue, c)497607 }498608 499- for len(queue) > 0 {500- p := queue[0]501- queue = queue[1:]609+ for len(scratch.workQueue) > 0 {610+ p := scratch.workQueue[0]611+ scratch.workQueue = scratch.workQueue[1:]502612 if clues[p.y][p.x] > 0 {503613 return true504614 }@@ -507,11 +617,11 @@ func componentCanReachClue(g grid, clues clueGrid, comp islandComponent) bool {
507617 if nx < 0 || ny < 0 || nx >= width || ny >= height {508618 continue509619 }510- if seen[ny][nx] || g[ny][nx] == seaCell {620+ if scratch.seen[ny][nx] == seenMark || g[ny][nx] == seaCell {511621 continue512622 }513- seen[ny][nx] = true514- queue = append(queue, point{nx, ny})623+ scratch.seen[ny][nx] = seenMark624+ scratch.workQueue = append(scratch.workQueue, point{nx, ny})515625 }516626 }517627 @@ -519,41 +629,50 @@ func componentCanReachClue(g grid, clues clueGrid, comp islandComponent) bool {
519629 }520630 521631 func maxReachableForComponent(g grid, clues clueGrid, comp islandComponent) int {632+ if len(g) == 0 || len(g[0]) == 0 {633+ return 0634+ }635+ scratch := newSolverScratch(len(g[0]), len(g))636+ return maxReachableForComponentWithScratch(g, clues, comp, scratch)637+}638+639+func maxReachableForComponentWithScratch(640+ g grid,641+ clues clueGrid,642+ comp islandComponent,643+ scratch *solverScratch,644+) int {522645 if comp.clueCount != 1 {523646 return 0524647 }525648 526649 height := len(g)527650 width := len(g[0])528- seen := make([][]bool, height)529- for y := range height {530- seen[y] = make([]bool, width)531- }532-533- queue := make([]point, len(comp.cells))534- copy(queue, comp.cells)651+ seenMark := scratch.nextSeenMark()652+ scratch.workQueue = scratch.workQueue[:0]535653 for _, c := range comp.cells {536- seen[c.y][c.x] = true654+ scratch.seen[c.y][c.x] = seenMark655+ scratch.workQueue = append(scratch.workQueue, c)537656 }538657 539658 count := len(comp.cells)540- for len(queue) > 0 {541- p := queue[0]542- queue = queue[1:]659+ for len(scratch.workQueue) > 0 {660+ p := scratch.workQueue[0]661+ scratch.workQueue = scratch.workQueue[1:]543662 for _, d := range dirs {544663 nx, ny := p.x+d.x, p.y+d.y545664 if nx < 0 || ny < 0 || nx >= width || ny >= height {546665 continue547666 }548- if seen[ny][nx] || g[ny][nx] == seaCell {667+ if scratch.seen[ny][nx] == seenMark || g[ny][nx] == seaCell {549668 continue550669 }551670 if clues[ny][nx] > 0 && (nx != comp.cluePos.x || ny != comp.cluePos.y) {552671 continue553672 }554- seen[ny][nx] = true673+ scratch.seen[ny][nx] = seenMark555674 count++556- queue = append(queue, point{nx, ny})675+ scratch.workQueue = append(scratch.workQueue, point{nx, ny})557676 }558677 }559678