InternBootcamp/internbootcamp/libs/calcudoku/calcudoku_solver.py
2025-05-23 15:27:15 +08:00

489 lines
No EOL
22 KiB
Python
Executable file

#!/usr/bin/env python3
"""
Calcudoku (a variant sometimes known under the trademark KenKen) solver.
Given a puzzle "spec" (a grid of strings, one row per line, each cell
describing its group and/or an initial digit), this code will parse
the puzzle, then solve it via backtracking (DFS).
Usage example (puzzle spec is the example puzzle from the prompt):
puzzle_spec = [
"J+12 F/4 5 2 E*3 E",
"J F H+7 H I*60 I",
"J D+12 G+28 G G I",
"3 D K-1 K G G",
"4 D D B+11 G A*2",
"C+10 C C B 4 A"
]
solver = CalcudokuSolver(puzzle_spec)
solver.solve()
solver.print_solution()
"""
import re
from collections import defaultdict
class CalcudokuSolver:
def __init__(self, puzzle_spec):
"""
Initialize solver with a puzzle specification.
puzzle_spec is a list of strings, each representing a row.
Each row has N cells (for N x N puzzle), separated by spaces.
Example (6x6):
[
"J+12 F/4 5 2 E*3 E",
"J F H+7 H I*60 I",
...
]
"""
# Store puzzle size by length of the puzzle_spec
self.size = len(puzzle_spec)
# We'll parse each cell to figure out:
# 1) which group it belongs to (group label)
# 2) if that cell includes an operation + target
# 3) any initial digit given for that cell
# Then we'll unify group information (operation, target, set of cells).
self.groups = {} # group_label -> {"op": "+/-/*//", "target": int, "cells": [(r,c), ...]}
self.grid = [[None for _ in range(self.size)] for _ in range(self.size)]
# Each cell in self.grid will hold an integer once solved (1..size), or 0 if not assigned.
# We'll also store partial group definitions if a cell has e.g. J+12
# Later, all cells sharing group "J" must share the same op/target.
# partial_groups[group_label] => {'op': str, 'target': int}
self.partial_groups = defaultdict(lambda: {"op": None, "target": None})
# Parse the puzzle spec line by line
for r, line in enumerate(puzzle_spec):
# Split on whitespace, respecting that each row has exactly self.size cells
cells = line.strip().split()
if len(cells) != self.size:
raise ValueError(f"Row {r} does not have {self.size} cells: {cells}")
for c, cell_str in enumerate(cells):
# cell_str might look like "J+12", "F/4", "5", "2", "E*3", "E", or e.g. "3"
# We can have:
# - a leading digit that is the initial value (e.g. "5")
# - a group label + optional operation + optional target (e.g. "J+12", "F/4", "E*3", or just "J")
# Sometimes they are combined (e.g. "3" alone might be *just* an initial digit, or it's an initial digit
# that also happens to be a group label if it doesn't match a group pattern.
# We'll handle the standard patterns using a regex.
# Attempt to parse out the initial digit
# We'll consider the possibility that there's an integer alone or at the start,
# vs. a group label plus operation plus target.
# A robust approach is to use a regex that looks for patterns like:
# group_label (one or more letters)
# optional: operation symbol [+|\-|*|/]
# optional: digits for target
# or just a single integer
# We'll store an integer 0 if cell is not pre-filled.
initial_digit = 0
group_label = None
op = None
target = None
# Check if the entire cell is just an integer
if re.fullmatch(r"\d+", cell_str):
# It's purely an integer => initial digit
initial_digit = int(cell_str)
else:
# Look for an integer at the start
# e.g. "3" might appear, or "4" might appear, or "5"
match_int_first = re.match(r"^(\d+)$", cell_str)
if match_int_first:
# purely digit
initial_digit = int(match_int_first.group(1))
else:
# Not purely digit, so let's see if there's an embedded digit or group + operation
# We'll attempt a more general pattern
# group label can be letters (one or more)
# optionally, there's an operation symbol and a target
# or the cell might be just letters (i.e. no operation or target).
match_group = re.match(r"^([A-Za-z]+)([+\-\*/])?(\d+)?$", cell_str)
if match_group:
g_label = match_group.group(1)
g_op = match_group.group(2)
g_target = match_group.group(3)
group_label = g_label
if g_op:
op = g_op
if g_target:
target = int(g_target)
else:
# Another possibility is that there's a leading digit plus a group pattern, e.g. "5J+12"
# But the example puzzle doesn't seem to do that. If that were needed, we'd parse accordingly.
# We'll just handle the example puzzle format.
# If we get here, it might be a single digit or something else. Let's see if it's purely digit:
# We already covered that. So we'll raise an error if it doesn't match expected format.
# But let's check if there's a leading digit plus group pattern.
match_leading_digit = re.match(r"^(\d+)([A-Za-z]+)([+\-\*/])?(\d+)?$", cell_str)
if match_leading_digit:
d_val = match_leading_digit.group(1)
g_label = match_leading_digit.group(2)
g_op = match_leading_digit.group(3)
g_target = match_leading_digit.group(4)
initial_digit = int(d_val)
group_label = g_label
if g_op:
op = g_op
if g_target:
target = int(g_target)
else:
# If none of these patterns match, let's see if it's purely a digit followed by nothing
# or if there's a stray trailing digit...
# We'll just raise an error because we don't expect that format in the example puzzle.
raise ValueError(f"Cell '{cell_str}' doesn't match known puzzle spec formats.")
# If it's still possible there's a group label+op+target in the same cell as a digit,
# we'd handle that above. In the example puzzle, the given digits (mostly "3" or "4" etc.)
# appear in separate cells or after an operation. We have covered those patterns.
# If we haven't got group_label/op/target from the above, maybe there's another pattern:
# purely a digit, or purely a group label. Let's see if there's a leftover.
if group_label is None:
# Maybe it's just letters
match_letters = re.match(r"^[A-Za-z]+$", cell_str)
if match_letters:
group_label = cell_str
else:
# If not, we already set initial_digit if it was purely a digit.
# so possibly we are done.
pass
# Store initial_digit in self.grid
self.grid[r][c] = initial_digit
if group_label: # We have a group for this cell
# Add this cell to that group in groups dictionary
if group_label not in self.groups:
self.groups[group_label] = {
"op": None,
"target": None,
"cells": []
}
self.groups[group_label]["cells"].append((r, c))
# If this cell indicated an operation & target, note that
if op and target:
# If the group was never assigned an operation or target, do so
if self.groups[group_label]["op"] is None and self.groups[group_label]["target"] is None:
self.groups[group_label]["op"] = op
self.groups[group_label]["target"] = target
else:
# If there's already a mismatch, that means puzzle spec is conflicting
if (self.groups[group_label]["op"] != op or
self.groups[group_label]["target"] != target):
raise ValueError(
f"Conflict in group {group_label}: multiple ops/targets found."
)
# It's also possible the group operation+target is found in another cell of the same group
# so we won't do anything if this cell has no op/target. We'll unify them at the end.
# else: the cell has no group label => For Calcudoku, typically every cell belongs to a group.
# Possibly the puzzle spec is incomplete or the puzzle is malformed. We'll ignore that for now.
# Now we unify groups (some might have zero/None op/target because it was specified in a different cell).
# It's typical that only one cell of each group has the operation/target. Or in the case of a group of size 1,
# it might be labeled, e.g. "K-1" or something. We assume each group must have exactly one op/target overall.
# Some puzzle formats only show that op/target in one cell. We'll assume each group is valid for the example.
for g_label, info in self.groups.items():
if info["op"] is None or info["target"] is None:
# In a well-formed puzzle, presumably one of the group's cells had the clue "g_label op target"
# If truly missing, that might be an error or might be a group of size 1 with no operation (?),
# but that wouldn't be standard Calcudoku. We'll raise an error if missing.
raise ValueError(f"Group '{g_label}' is missing an op or a target: {info}")
# Data structures to help with row/column constraints while solving:
# We'll keep track of used_in_row[r][val] = True if val is used in row r
# We'll keep track of used_in_col[c][val] = True if val is used in col c
self.used_in_row = [[False]*(self.size+1) for _ in range(self.size)]
self.used_in_col = [[False]*(self.size+1) for _ in range(self.size)]
# Fill these in with any initial digits
for r in range(self.size):
for c in range(self.size):
val = self.grid[r][c]
if val != 0:
if self.used_in_row[r][val]:
raise ValueError(f"Duplicate initial value {val} in row {r}")
if self.used_in_col[c][val]:
raise ValueError(f"Duplicate initial value {val} in col {c}")
self.used_in_row[r][val] = True
self.used_in_col[c][val] = True
# We'll convert self.groups to a list for convenience:
# group_list = [(group_label, op, target, [(r,c), ...])]
self.group_list = []
for g_label, info in self.groups.items():
self.group_list.append((g_label, info["op"], info["target"], info["cells"]))
# Sort group_list by size or something if we want (optional).
# We'll just keep it as is. The solver doesn't particularly require an order.
self.solved = False
self.history = []
def solve(self):
"""
Solve the puzzle via backtracking DFS.
"""
# print(self.grid)
# print(self.used_in_row)
# print(self.group_list)
self.dfs_solve(0, 0)
if not self.solved:
return False, None
else:
return self.grid, self.history
def dfs_solve(self, row, col):
"""
Recursive backtracking approach.
row, col: current cell to fill.
"""
if row == self.size:
# We've assigned all rows successfully
self.history.append(f"Assign all rows successfully. Find the solution!")
self.solved = True
return True
# Compute next position to go after we fill this cell
next_col = col + 1
next_row = row
if next_col == self.size:
next_row = row + 1
next_col = 0
self.history.append(f"Select position ({col},{row}).")
# If cell is already assigned (from initial clues), skip
if self.grid[row][col] != 0:
self.history[-1] = self.history[-1] + f"This position already has number {self.grid[row][col]}. Skip this position."
return self.dfs_solve(next_row, next_col)
self.history[-1] = self.history[-1] + f"This position is empty now."
# Try each candidate from 1..size
for val in range(1, self.size+1):
# Check if it's valid to place val here
self.history.append(f"Select position ({col},{row}). Try to fill it with number {val}.")
if self.used_in_row[row][val] or self.used_in_col[col][val]:
self.history[-1] = self.history[-1] + f"Number {val} is already used in the current row/column. Retry."
continue
self.history[-1] = self.history[-1] + f"Number {val} has not been used in the current row and column."
# Place val
self.grid[row][col] = val
self.used_in_row[row][val] = True
self.used_in_col[col][val] = True
# Check if group constraint remains valid
# We'll do a partial check if possible. For + and * there's partial pruning:
# For - and / there's not straightforward partial pruning, so we only check if the group is fully assigned.
self.history.append(f"Check if position ({col},{row}) with number {val} keep group constraint valid.")
is_group_valid, reason = self.group_is_valid(row, col)
if is_group_valid:
self.history[-1] = self.history[-1] + f"Position ({col},{row}) with number {val} keep group constraint valid."
if self.dfs_solve(next_row, next_col):
return True
else:
self.history.append(f"Backtrack to Position ({col},{row}).")
else:
self.history[-1] = self.history[-1] + f"Due to {reason}. Position ({col},{row}) with number {val} can't keep group constraint valid. Retry."
# Revert
self.grid[row][col] = 0
self.used_in_row[row][val] = False
self.used_in_col[col][val] = False
self.history.append(f"All possible values at position ({col},{row}) have been tried and no value satisfies the condition. So clear the value of position ({col},{row}) and backtrack to the previous node and retry.")
return False
def group_is_valid(self, row, col):
"""
Check if the group containing (row, col) is still valid with the current partial assignment.
We'll look up which group (row,col) belongs to. In Calcudoku there's exactly one group per cell,
so let's find that. We'll then check partial constraints:
- If the group's operation is '+', we ensure that the sum of assigned cells does not exceed the target,
and if all cells are assigned, that the sum equals the target.
- If '*', we ensure that the product of assigned cells does not exceed the target,
and if all cells are assigned, the product equals the target.
- If '-' or '/', we only check if all cells in group are assigned; if yes, test if there's a permutation of
those values that yields the target via difference or ratio. If not all assigned, we can't prune easily.
Returns:
(bool, str): A tuple of (is_valid, reason_if_invalid).
If is_valid == False, reason_if_invalid is a string describing why.
If is_valid == True, reason_if_invalid will be None.
"""
# Find which group this cell belongs to
group_label = None
for g_label, op, target, cells in self.group_list:
if (row, col) in cells:
group_label = g_label
break
if group_label is None:
# No group? That would be odd. We'll just say it's valid and provide no reason.
# (In well-formed puzzles, every cell belongs to exactly one group.)
return True, None
# Grab group details
op, target, group_cells = None, None, None
for g_label, g_op, g_target, g_cells in self.group_list:
if g_label == group_label:
op, target, group_cells = g_op, g_target, g_cells
break
# Gather assigned values in this group
assigned_values = []
unassigned_count = 0
for (r, c) in group_cells:
val = self.grid[r][c]
if val == 0:
unassigned_count += 1
else:
assigned_values.append(val)
# --------------------------------------------------------
# Partial checks for sum or product groups (if not fully assigned)
# --------------------------------------------------------
if unassigned_count > 0:
if op == '+':
current_sum = sum(assigned_values)
if current_sum > target:
return False, f"Partial sum {current_sum} exceeds target {target}"
elif op == '*':
prod = 1
for v in assigned_values:
prod *= v
if prod > target:
return False, f"Partial product {prod} exceeds target {target}"
# For '-' or '/', partial checks are trickier, so no pruning here.
return True, None
# --------------------------------------------------------
# All cells in this group have been assigned, so do a final check.
# --------------------------------------------------------
if op == '+':
if sum(assigned_values) != target:
return False, f"Sum of group {sum(assigned_values)} != target {target}"
return True, None
elif op == '*':
prod = 1
for v in assigned_values:
prod *= v
if prod != target:
return False, f"Product of group {prod} != target {target}"
return True, None
elif op == '-':
# Check if there's a permutation of assigned_values such that
# one value minus the others (in sequence) == target
from itertools import permutations
for perm in permutations(assigned_values):
result = perm[0]
for v in perm[1:]:
result -= v
if result == target:
return True, None
return False, f"No permutation of values {assigned_values} produces difference {target}"
elif op == '/':
# Check if there's a permutation of assigned_values such that
# one value divided by the others (in sequence) == target
from itertools import permutations
for perm in permutations(assigned_values):
result = float(perm[0])
for v in perm[1:]:
result /= float(v) # safe, since puzzle values won't be 0
if abs(result - target) < 1e-9:
return True, None
return False, f"No permutation of values {assigned_values} produces ratio {target}"
else:
# Unknown operation
return False, f"Unknown operation '{op}'"
def print_solution(self):
"""
Print the solved grid (if solved).
"""
if not self.solved:
print("No solution found.")
return
print("Solution:")
for r in range(self.size):
row_str = " ".join(str(self.grid[r][c]) for c in range(self.size))
print(row_str)
#print("\n".join(self.history))
def example_usage():
"""
Example usage with the puzzle from the prompt.
The puzzle spec is the one given in the prompt.
(This is the 6x6 puzzle with solution.)
"""
puzzle_spec = [
"J+12 F/4 5 2 E*3 E",
"J F H+7 H I*60 I",
"J D+12 G+28 G G I",
"3 D K-1 K G G",
"4 D D B+11 G A*2",
"C+10 C C B Z+4 A"
]
# puzzle_spec = [
# "A-1 B+6 C-2 C",
# "A B D+5 D",
# "E+7 B D F",
# "E E F F+9",
# ]
puzzle_spec = [ "P+4 O+1 T+5 G-2 E+3 R+5",
"A-2 Q/3 Q G F+5 R",
"A A H+2 N+8 N K+4",
"L-1 B*48 B N C+6 S+1",
"L J+10 B I*10 I I",
"J J D+8 D D M+6",
]
solver = CalcudokuSolver(puzzle_spec)
solution, history = solver.solve()
if solution:
solver.print_solution()
else:
print("No solution found.")
if __name__ == "__main__":
# Uncomment the following line to run the example puzzle:
example_usage()
# Or you can import this module elsewhere and call CalcudokuSolver with your own puzzle spec.
#pass