|
| 1 | +from __future__ import annotations |
| 2 | +""" |
| 3 | +Lightweight human strategies: |
| 4 | + - candidates() snapshot |
| 5 | + - naked_single |
| 6 | + - hidden_single (row/col/box) |
| 7 | + - locked candidates (pointing) |
| 8 | +Deterministic scan order for reproducible explanations. |
| 9 | +""" |
| 10 | +from typing import List, Dict, Set, Tuple, Optional |
| 11 | + |
| 12 | +Grid = List[List[int]] |
| 13 | +Cand = List[List[Set[int]]] |
| 14 | + |
| 15 | +def _box_id(r: int, c: int) -> int: |
| 16 | + return (r // 3) * 3 + (c // 3) |
| 17 | + |
| 18 | +def _unit_cells_row(r: int) -> List[Tuple[int,int]]: |
| 19 | + return [(r, c) for c in range(9)] |
| 20 | + |
| 21 | +def _unit_cells_col(c: int) -> List[Tuple[int,int]]: |
| 22 | + return [(r, c) for r in range(9)] |
| 23 | + |
| 24 | +def _unit_cells_box(b: int) -> List[Tuple[int,int]]: |
| 25 | + br, bc = (b // 3) * 3, (b % 3) * 3 |
| 26 | + return [(br + dr, bc + dc) for dr in range(3) for dc in range(3)] |
| 27 | + |
| 28 | +def candidates(grid: Grid) -> Cand: |
| 29 | + """Compute candidate sets for each empty cell (deterministic).""" |
| 30 | + rows = [set() for _ in range(9)] |
| 31 | + cols = [set() for _ in range(9)] |
| 32 | + boxes = [set() for _ in range(9)] |
| 33 | + for r in range(9): |
| 34 | + for c in range(9): |
| 35 | + v = grid[r][c] |
| 36 | + if v: |
| 37 | + rows[r].add(v); cols[c].add(v); boxes[_box_id(r,c)].add(v) |
| 38 | + out: Cand = [[set() for _ in range(9)] for _ in range(9)] |
| 39 | + allv = set(range(1,10)) |
| 40 | + for r in range(9): |
| 41 | + for c in range(9): |
| 42 | + if grid[r][c] == 0: |
| 43 | + out[r][c] = allv - rows[r] - cols[c] - boxes[_box_id(r,c)] |
| 44 | + return out |
| 45 | + |
| 46 | +# ----- Moves ------------------------------------------------------------------------------------ |
| 47 | + |
| 48 | +def apply_naked_single(grid: Grid, cand: Cand) -> Optional[Dict]: |
| 49 | + """If any cell has exactly one candidate, place it.""" |
| 50 | + for r in range(9): |
| 51 | + for c in range(9): |
| 52 | + if grid[r][c] == 0 and len(cand[r][c]) == 1: |
| 53 | + v = next(iter(cand[r][c])) |
| 54 | + grid[r][c] = v |
| 55 | + return {"type": "place", "strategy": "naked_single", "r": r, "c": c, "v": v} |
| 56 | + return None |
| 57 | + |
| 58 | +def _hidden_single_in_unit(grid: Grid, cand: Cand, cells: List[Tuple[int,int]], unit_kind: str, unit_idx: int) -> Optional[Dict]: |
| 59 | + # map digit -> cells where it can go |
| 60 | + places: Dict[int, List[Tuple[int,int]]] = {d: [] for d in range(1,10)} |
| 61 | + for (r,c) in cells: |
| 62 | + if grid[r][c] != 0: |
| 63 | + continue |
| 64 | + for d in cand[r][c]: |
| 65 | + places[d].append((r,c)) |
| 66 | + for d in range(1,10): |
| 67 | + locs = places[d] |
| 68 | + if len(locs) == 1: |
| 69 | + r, c = locs[0] |
| 70 | + grid[r][c] = d |
| 71 | + return {"type": "place", "strategy": "hidden_single", "unit": unit_kind, "unit_index": unit_idx, "r": r, "c": c, "v": d} |
| 72 | + return None |
| 73 | + |
| 74 | +def apply_hidden_single(grid: Grid, cand: Cand) -> Optional[Dict]: |
| 75 | + # rows |
| 76 | + for r in range(9): |
| 77 | + move = _hidden_single_in_unit(grid, cand, _unit_cells_row(r), "row", r) |
| 78 | + if move: return move |
| 79 | + # cols |
| 80 | + for c in range(9): |
| 81 | + move = _hidden_single_in_unit(grid, cand, _unit_cells_col(c), "col", c) |
| 82 | + if move: return move |
| 83 | + # boxes |
| 84 | + for b in range(9): |
| 85 | + move = _hidden_single_in_unit(grid, cand, _unit_cells_box(b), "box", b) |
| 86 | + if move: return move |
| 87 | + return None |
| 88 | + |
| 89 | +def apply_locked_candidates_pointing(grid: Grid, cand: Cand) -> Optional[Dict]: |
| 90 | + """ |
| 91 | + Pointing: in a 3x3 box, if all candidates for digit d lie in the same row (or same col), |
| 92 | + eliminate d from that row (or col) outside the box. |
| 93 | + Produces one elimination at a time for deterministic playback. |
| 94 | + """ |
| 95 | + # For each box and digit, gather positions |
| 96 | + for b in range(9): |
| 97 | + cells = _unit_cells_box(b) |
| 98 | + for d in range(1,10): |
| 99 | + locs = [(r,c) for (r,c) in cells if grid[r][c] == 0 and d in cand[r][c]] |
| 100 | + if len(locs) < 2: |
| 101 | + continue |
| 102 | + rows = {r for (r,_) in locs} |
| 103 | + cols = {c for (_,c) in locs} |
| 104 | + if len(rows) == 1: |
| 105 | + r = next(iter(rows)) |
| 106 | + # eliminate from row r, columns not in this box |
| 107 | + box_cols = {c for (_,c) in cells} |
| 108 | + for c in range(9): |
| 109 | + if (r,c) not in locs and c not in box_cols and grid[r][c] == 0 and d in cand[r][c]: |
| 110 | + cand[r][c].remove(d) |
| 111 | + return {"type": "eliminate", "strategy": "locked_pointing_row", "box": b, "r": r, "c": c, "v": d} |
| 112 | + if len(cols) == 1: |
| 113 | + c = next(iter(cols)) |
| 114 | + box_rows = {r for (r,_) in cells} |
| 115 | + for r in range(9): |
| 116 | + if (r,c) not in locs and r not in box_rows and grid[r][c] == 0 and d in cand[r][c]: |
| 117 | + cand[r][c].remove(d) |
| 118 | + return {"type": "eliminate", "strategy": "locked_pointing_col", "box": b, "r": r, "c": c, "v": d} |
| 119 | + return None |
| 120 | + |
| 121 | +def step_once(grid: Grid) -> Optional[Dict]: |
| 122 | + """ |
| 123 | + Apply exactly one logical step (prioritized order). Returns a move dict or None. |
| 124 | + Priority: naked_single > hidden_single > locked_pointing (elimination) |
| 125 | + """ |
| 126 | + cand = candidates(grid) |
| 127 | + m = apply_naked_single(grid, cand) |
| 128 | + if m: return m |
| 129 | + m = apply_hidden_single(grid, cand) |
| 130 | + if m: return m |
| 131 | + m = apply_locked_candidates_pointing(grid, cand) |
| 132 | + return m |
| 133 | + |
| 134 | +__all__ = [ |
| 135 | + "candidates", |
| 136 | + "apply_naked_single", |
| 137 | + "apply_hidden_single", |
| 138 | + "apply_locked_candidates_pointing", |
| 139 | + "step_once", |
| 140 | +] |
0 commit comments