/** * Represents the state of a cell in the solver. * -1: Unknown * 0: Empty * 1: Filled */ // Memoized helper for checking if hints fit const memo = new Map(); /** * Solves a single line (row or column) based on hints and current knowledge. * Uses the "Left-Right Overlap" algorithm to find common filled cells. * Also identifies definitely empty cells (reachable by no block). * * @param {number[]} currentLine - Array of -1, 0, 1 * @param {number[]} hints - Array of block lengths * @returns {number[]} - Updated line (or null if contradiction/impossible - though shouldn't happen for valid puzzles) */ export function solveLine(currentLine, hints) { const length = currentLine.length; // If no hints, all must be empty // Clear memo for this line solve memo.clear(); if (hints.length === 0 || (hints.length === 1 && hints[0] === 0)) { return Array(length).fill(0); } // Helper to check if a block can be placed at start index const canPlace = (line, start, blockSize) => { if (start + blockSize > line.length) return false; // Check if any cell in block is 0 (Empty) -> Invalid for (let i = start; i < start + blockSize; i++) { if (line[i] === 0) return false; } // Check boundaries (must be separated by empty or edge) if (start > 0 && line[start - 1] === 1) return false; if (start + blockSize < line.length && line[start + blockSize] === 1) return false; return true; }; // 1. Calculate Left-Most Positions const leftPositions = []; let currentIdx = 0; for (let hIndex = 0; hIndex < hints.length; hIndex++) { const block = hints[hIndex]; // Find first valid position while (currentIdx <= length - block) { if (canPlace(currentLine, currentIdx, block)) { // Verify we can fit remaining blocks if (canFitRest(currentLine, currentIdx + block + 1, hints, hIndex + 1)) { leftPositions.push(currentIdx); currentIdx += block + 1; // Move past this block + 1 space break; } } // Cannot skip a filled cell - if we pass a '1', it becomes uncovered if (currentLine[currentIdx] === 1) return null; currentIdx++; } if (leftPositions.length <= hIndex) return null; // Impossible } // Clear memo for right-side calculation (different line/hints) memo.clear(); // 2. Calculate Right-Most Positions (by reversing line and hints) const reversedLine = [...currentLine].reverse(); const reversedHints = [...hints].reverse(); const rightPositionsReversed = []; currentIdx = 0; for (let hIndex = 0; hIndex < reversedHints.length; hIndex++) { const block = reversedHints[hIndex]; while (currentIdx <= length - block) { if (canPlace(reversedLine, currentIdx, block)) { if (canFitRest(reversedLine, currentIdx + block + 1, reversedHints, hIndex + 1)) { rightPositionsReversed.push(currentIdx); currentIdx += block + 1; break; } } // Cannot skip a filled cell if (reversedLine[currentIdx] === 1) return null; currentIdx++; } if (rightPositionsReversed.length <= hIndex) return null; } // Convert reversed positions to actual indices const rightPositions = rightPositionsReversed.map((rStart, i) => { const block = reversedHints[i]; return length - rStart - block; }).reverse(); // 3. Intersect const newLine = [...currentLine]; // Fill intersection for (let i = 0; i < hints.length; i++) { const l = leftPositions[i]; const r = rightPositions[i]; const block = hints[i]; // If overlap exists: [r, l + block - 1] if (r < l + block) { for (let k = r; k < l + block; k++) { newLine[k] = 1; } } } // Determine Empty cells? // Mask of possible filled cells const possibleFilled = Array(length).fill(false); for (let i = 0; i < hints.length; i++) { for (let k = leftPositions[i]; k < rightPositions[i] + hints[i]; k++) { possibleFilled[k] = true; } } for (let k = 0; k < length; k++) { if (!possibleFilled[k]) { newLine[k] = 0; } } return newLine; } // Memoized helper for checking if hints fit export function canFitRest(line, startIndex, hints, hintIndex) { // Optimization: If hints are empty, we just need to check if remaining line has no '1's if (hintIndex >= hints.length) { for (let i = startIndex; i < line.length; i++) { if (line[i] === 1) return false; } return true; } // Memoization key const key = `${startIndex}-${hintIndex}`; if (memo.has(key)) return memo.get(key); const remainingLen = line.length - startIndex; // Min space needed: sum of hints + (hints.length - 1) spaces let minSpace = 0; for(let i=hintIndex; i 0 && line[i-1] === 1) valid = false; // Boundary after (check implicit in next block placement or end of line, but we need to check i+block cell) if (i + block < line.length && line[i+block] === 1) valid = false; if (valid) { // Recurse if (canFitRest(line, i + block + 1, hints, hintIndex + 1)) { memo.set(key, true); return true; } } } memo.set(key, false); return false; } /** * Main solver function that attempts to solve the puzzle using logic and backtracking (DFS). * Uses an efficient propagation queue and undo stack to avoid deep copying the grid. * * @param {number[][]} rowHints * @param {number[][]} colHints * @param {function} onProgress - Optional callback for progress reporting (percent) * @param {number[][]} initialGrid - Optional initial grid state * @param {boolean} logicOnly - If true, stops after logical propagation (no guessing) * @returns {object} result */ export function solvePuzzle(rowHints, colHints, onProgress, initialGrid = null, logicOnly = false) { const rows = rowHints.length; const cols = colHints.length; const totalCells = rows * cols; // Grid initialization: -1 (Unknown), 0 (Empty), 1 (Filled) // Use initialGrid if provided (deep copy to be safe) const grid = initialGrid ? initialGrid.map(row => [...row]) : Array(rows).fill().map(() => Array(cols).fill(-1)); // Stats let iterations = 0; // Total calls to solve() let maxDepth = 0; // Max recursion depth let backtracks = 0; // Failed guesses let logicSteps = 0; // Cells filled by propagation let lastProgressTime = 0; function reportProgress() { if (!onProgress) return; const now = Date.now(); if (now - lastProgressTime >= 50) { let filled = 0; for(let r=0; r=0; i--) { const {r, c, old} = changes[i]; grid[r][c] = old; } } // Helper: Propagate logic constraints until fixed point or contradiction // Returns list of changes made, or null if contradiction found function propagate() { const changes = []; try { while(queue.size > 0) { reportProgress(); const item = queue.values().next().value; queue.delete(item); const type = item[0]; const idx = parseInt(item.slice(1)); let currentLine, hints; if (type === 'r') { currentLine = grid[idx]; // Reference for row (fast) hints = rowHints[idx]; } else { currentLine = grid.map(row => row[idx]); // Copy for col (slower) hints = colHints[idx]; } const newLine = solveLine(currentLine, hints); if (!newLine) throw 'contradiction'; // Apply changes for(let i=0; i Contradiction if (currentLine[i] !== -1 && currentLine[i] !== newLine[i]) throw 'contradiction'; if (currentLine[i] === -1) { const r = type === 'r' ? idx : i; const c = type === 'r' ? i : idx; // Double check if already set by orthogonal update in same loop? // (Should be handled by -1 check above, as grid is shared) if (grid[r][c] === -1) { grid[r][c] = newLine[i]; changes.push({r, c, old: -1}); logicSteps++; // Add orthogonal line to queue if (type === 'r') queue.add(`c${c}`); else queue.add(`r${r}`); } else if (grid[r][c] !== newLine[i]) { console.log('Contradiction: Orthogonal Conflict at', r, c, 'Grid:', grid[r][c], 'New:', newLine[i]); throw 'contradiction'; } } } } } } catch (e) { // Revert changes from this failed propagation undo(changes); return null; } return changes; } // DFS Solver function solve(depth) { maxDepth = Math.max(maxDepth, depth); iterations++; reportProgress(); // 1. Propagate Logic const changes = propagate(); if (!changes) return false; // Contradiction // 2. Find Best Branch Candidate let bestR = -1, bestC = -1; let minUnknowns = Infinity; let isComplete = true; // Scan for unknowns and pick heuristic // Heuristic: Line with fewest unknowns (most constrained) for(let r=0; r 0) { isComplete = false; if (unknowns < minUnknowns) { minUnknowns = unknowns; bestR = r; bestC = firstUnknownC; } if (minUnknowns === 1) break; // Optimal } } if (isComplete) return true; // Solved! // 3. Branching (Guessing) // Try 1 grid[bestR][bestC] = 1; queue.add(`r${bestR}`); queue.add(`c${bestC}`); if (solve(depth + 1)) return true; // Backtrack from 1 grid[bestR][bestC] = -1; // Undo guess // (Recursive call already undid its propagation changes) // Try 0 grid[bestR][bestC] = 0; queue.add(`r${bestR}`); queue.add(`c${bestC}`); if (solve(depth + 1)) return true; // Backtrack from 0 grid[bestR][bestC] = -1; // Undo guess backtracks++; // Undo propagation from this level undo(changes); return false; } // Start Solving if (logicOnly) { // Just logical propagation without guessing if (initialGrid) { // If resuming, ensure queue has all lines to check consistency queue.clear(); for(let r=0; r r.forEach(c => { if(c !== -1) solvedCount++; })); let percentSolved = (solvedCount / totalCells) * 100; if (onProgress) onProgress(Math.floor(percentSolved)); // Difficulty Calculation // Logic: // - Base: 0-20% based on size/density // - Logic: 0-30% based on iterations needed (depth 0) // - Guessing: 0-50% based on backtracks/depth let difficultyScore = 0; const effectiveSize = Math.sqrt(totalCells); if (percentSolved < 100) { difficultyScore = 100; // Unsolvable (or timed out/too hard) } else { if (maxDepth === 0) { // Pure logic difficultyScore = Math.min(30, effectiveSize); } else { // Required guessing // Simple heuristic: 30 + backtracks * 5 + depth * 2 difficultyScore = 30 + (backtracks * 2) + (maxDepth * 5); difficultyScore = Math.min(100, difficultyScore); } } return { percentSolved, difficultyScore: Math.round(difficultyScore), lookaheadUsed: maxDepth > 0, iterations, maxDepth, backtracks }; }