init-commit

This commit is contained in:
lilinyang 2025-05-23 15:27:15 +08:00
commit 18a552597a
3461 changed files with 1150579 additions and 0 deletions

View file

@ -0,0 +1,370 @@
import random
import itertools
from collections import deque
class CalcudokuGenerator:
def __init__(
self,
n=6,
group_size_range=(1, 5),
seed=None
):
"""
n: the puzzle dimension (n x n).
group_size_range: tuple (min_group_size, max_group_size) or any range-like object
describing how large or small each group can be.
seed: optional random seed for reproducibility.
"""
self.n = n
self.group_size_range = group_size_range
if seed is not None:
random.seed(seed)
# solution_grid will hold the fully-specified solution
self.solution_grid = [[0]*n for _ in range(n)]
# Each cell's group label (for puzzle spec)
self.cell_group_label = [[None]*n for _ in range(n)]
# Each group will have an (operation, target_value, cells)
self.groups_info = []
def generate_puzzle(self):
"""
Generate the puzzle in several steps:
1) Generate random valid n×n grid with each row & column a permutation of [1..n].
2) Partition cells into contiguous groups of size in group_size_range.
3) Assign operation & target for each group.
4) Build puzzle spec.
"""
# Step 1: Generate a random solution grid (Latin square).
self._generate_random_solution()
# Step 2: Partition into random contiguous groups
self._create_random_groups()
# Step 3: Assign an operation and compute target for each group
self._assign_operations_and_targets()
# Step 4: Build puzzle spec lines
puzzle_spec = self._build_puzzle_spec()
return puzzle_spec
def _generate_random_solution(self):
"""
Create an n×n grid that satisfies "all numbers 1..n in each row & column exactly once."
This is effectively generating a random Latin square.
"""
# A naive approach:
# For row i, randomly shuffle [1..n], but if we ever fail to place
# them uniquely in columns, re-try.
# For larger n, you might want a more robust approach.
# Start with a random row permutation for row 0
available_cols = [list(range(1, self.n+1)) for _ in range(self.n)]
# for each row, we'll choose a permutation that doesn't clash with above rows
for r in range(self.n):
placed = False
attempts = 0
while not placed:
attempts += 1
if attempts > 10000:
# fallback: if this happens a lot, it's a sign we are stuck; re-start
raise RuntimeError("Stuck generating random solution. Try a different approach or seed.")
row_perm = random.sample(range(1, self.n+1), self.n)
# Check if it fits uniqueness constraints vs. columns
valid = True
for c in range(self.n):
val = row_perm[c]
# check column constraint
for rr in range(r):
if self.solution_grid[rr][c] == val:
valid = False
break
if not valid:
break
if valid:
# Place in solution
for c in range(self.n):
self.solution_grid[r][c] = row_perm[c]
placed = True
def _create_random_groups(self):
"""
Randomly partition the grid into contiguous groups.
We'll do a BFS/DFS-based approach:
1) Start from any unvisited cell.
2) Decide how big this group should be (random size within group_size_range, but not exceeding leftover cells).
3) Grow that group by exploring neighbors until we reach desired size or can't expand further.
4) Label those cells as belonging to the new group.
Continue until all cells are assigned to some group.
For group labeling, we'll just use letters in sequence, then 'AA', etc. for bigger boards if needed.
"""
label_list = self._generate_group_labels(self.n * self.n)
all_cells = [(r, c) for r in range(self.n) for c in range(self.n)]
random.shuffle(all_cells) # random order to pick seeds
visited = [[False]*self.n for _ in range(self.n)]
label_index = 0
for (start_r, start_c) in all_cells:
if visited[start_r][start_c]:
continue
# Decide group size
desired_size = random.randint(self.group_size_range[0], self.group_size_range[1])
group_cells = []
queue = deque()
queue.append((start_r, start_c))
group_cells.append((start_r, start_c))
visited[start_r][start_c] = True
while queue and len(group_cells) < desired_size:
cur_r, cur_c = queue.popleft()
# Explore neighbors in random order
neighbors = []
if cur_r > 0:
neighbors.append((cur_r-1, cur_c))
if cur_r < self.n-1:
neighbors.append((cur_r+1, cur_c))
if cur_c > 0:
neighbors.append((cur_r, cur_c-1))
if cur_c < self.n-1:
neighbors.append((cur_r, cur_c+1))
random.shuffle(neighbors)
for nr, nc in neighbors:
if not visited[nr][nc]:
visited[nr][nc] = True
group_cells.append((nr, nc))
queue.append((nr, nc))
if len(group_cells) >= desired_size:
break
# Assign these group cells to the label
label = label_list[label_index]
label_index += 1
for (r, c) in group_cells:
self.cell_group_label[r][c] = label
# Collect group defs
group_dict = {}
for r in range(self.n):
for c in range(self.n):
lbl = self.cell_group_label[r][c]
if lbl not in group_dict:
group_dict[lbl] = []
group_dict[lbl].append((r, c))
self.groups_info = [(lbl, group_dict[lbl]) for lbl in group_dict]
def _assign_operations_and_targets(self):
"""
For each group, pick an operation (+, -, *, /) at random, and compute the target.
Requirements:
- For '-' or '/', the result must be an integer > 0.
- If a group has 1 cell, then the operation can be anything, but '+' or '*' makes sense
(or we treat single cells as special, effectively no operation needed).
We'll just do '+' with that single cell's value.
"""
possible_ops = ['+', '-', '*', '/']
updated_groups = []
for (label, cells) in self.groups_info:
# Gather the solution values for these cells
values = [self.solution_grid[r][c] for (r, c) in cells]
# Decide operation
op = None
target_val = None
# If group has only one cell, trivial: use '+' with target = that cell's value
if len(cells) == 1:
op = '+'
target_val = values[0]
updated_groups.append((label, op, target_val, cells))
continue
# Otherwise, keep trying random ops until we find one that works
trial_ops = possible_ops[:]
random.shuffle(trial_ops)
success = False
while trial_ops and not success:
candidate_op = trial_ops.pop()
candidate_target = self._calculate_target(candidate_op, values)
if candidate_target is not None:
op = candidate_op
target_val = candidate_target
success = True
if not success:
# fallback: if none of the 4 ops worked out for integral positive,
# just default to '+' with sum
op = '+'
target_val = sum(values)
updated_groups.append((label, op, target_val, cells))
# Now we have operation & target for each group
# Overwrite self.groups_info with operation included
self.groups_info = updated_groups
def _calculate_target(self, op, values):
"""
Given an operation (op) and a list of integer values, return the target value if it is
valid (positive integer, etc.) for the group, otherwise None.
"""
if op == '+':
return sum(values)
elif op == '*':
prod = 1
for v in values:
prod *= v
return prod
elif op == '-':
# For subtraction, standard KenKen typically only has 2 cells.
# But let's handle any size: we check if there's a permutation p of values
# so that p[0] - p[1] - ... p[n-1] is a positive integer.
# We'll just pick the difference as max(val) - sum(others) for 2-cell groups, or do a check for multi-cell.
if len(values) == 2:
a, b = values[0], values[1]
diff1 = a - b
diff2 = b - a
# Must be positive
if diff1 > 0:
return abs(diff1)
elif diff2 > 0:
return abs(diff2)
else:
return None
else:
# For multi-cell difference, let's see if any permutation yields a positive result
from itertools import permutations
for p in permutations(values):
result = p[0]
for x in p[1:]:
result -= x
if result > 0:
return result
return None
elif op == '/':
# For division, typically 2 cells. If bigger, we check permutations similarly.
# For 2 cells, we just do int division check.
if len(values) == 2:
a, b = values[0], values[1]
# We want either a/b or b/a to be a positive integer
if a >= b and a % b == 0:
return a // b
elif b > a and b % a == 0:
return b // a
else:
return None
else:
# multi-cell division check
from itertools import permutations
for p in permutations(values):
numerator = p[0]
ok = True
for x in p[1:]:
if numerator % x != 0:
ok = False
break
numerator //= x
if ok and numerator > 0:
return numerator
return None
# If we reach here:
return None
def _build_puzzle_spec(self):
"""
Return a list of puzzle_spec lines. Each line is an n-element list (strings) joined by spaces.
Each cell is either:
- <group_label><op><target> if we want to embed the group clue right there, or
- <group_label> if the operation/target is shown on a different cell (like how KenKen typically does).
However, the usual puzzle_spec from the prompt includes exactly one cell in each group having
e.g. "J+12", and the other cells in that group just "J".
We'll do that: pick one cell in each group to display the clue, and the others only show the label.
"""
# Well pick the first cell in each group to carry the operation & target
# The rest only show the group label.
puzzle_spec_grid = [['']*self.n for _ in range(self.n)]
# Make a map group_label -> (op, target, cells)
group_map = {}
for (label, op, target, cells) in self.groups_info:
group_map[label] = (op, target, cells)
# Which cell in each group will carry the “op+target”?
# Well just pick the first cell in cells for that group
label_to_clue_cell = {}
for lbl, (op, tgt, cells) in group_map.items():
# pick first cell
clue_cell = cells[0]
label_to_clue_cell[lbl] = clue_cell
# Now fill puzzle_spec_grid with either
# "<lbl><op><target>" if (r,c) == clue cell
# or simply "<lbl>" otherwise
for r in range(self.n):
for c in range(self.n):
lbl = self.cell_group_label[r][c]
op, tgt, cells = group_map[lbl]
if (r, c) == label_to_clue_cell[lbl]:
# show the op & target
# e.g. "J+12"
# If it's a single-cell group, its likely a +. We'll just do the same format.
puzzle_spec_grid[r][c] = f"{lbl}{op}{tgt}"
else:
# just show the label
puzzle_spec_grid[r][c] = lbl
# Optionally, we can also embed the solution number in one or two cells if we want to provide partial clues,
# but that isn't strictly required for constructing a puzzle spec.
# The puzzle spec from your example sometimes contained cells like "5" or "3" with no label, indicating an initial clue.
# If you want to add givens, you can do that here by randomly choosing a few cells to reveal.
# Build lines as strings
puzzle_spec_lines = []
for r in range(self.n):
row_str = " ".join(puzzle_spec_grid[r])
puzzle_spec_lines.append(row_str)
return puzzle_spec_lines
def _generate_group_labels(self, count):
"""
Returns a list of group labels (strings) of at least 'count' distinct labels.
For n up to 26, you can just use single letters A..Z.
If you might need more, generate double letters, triple letters, etc.
"""
labels = []
# build single letters first
for ch in range(ord('A'), ord('Z')+1):
labels.append(chr(ch))
# then double letters if needed
if len(labels) < count:
for ch1 in range(ord('A'), ord('Z')+1):
for ch2 in range(ord('A'), ord('Z')+1):
labels.append(chr(ch1)+chr(ch2))
if len(labels) >= count:
break
if len(labels) >= count:
break
return labels[:count]
def example_usage():
"""
Generate a puzzle spec for a 6x6 Calcudoku puzzle.
"""
generator = CalcudokuGenerator(n=6, group_size_range=(1,4), seed=1234)
puzzle_spec = generator.generate_puzzle()
print(f"puzzle_spec {puzzle_spec}")
print("Generated puzzle spec:")
for line in puzzle_spec:
print(line)
if __name__ == "__main__":
example_usage()

View file

@ -0,0 +1,489 @@
#!/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

View file

@ -0,0 +1,207 @@
import re
from collections import defaultdict
from itertools import permutations
def validate_calcudoku_puzzle(puzzle_spec, solution):
"""
Given:
puzzle_spec: list of strings, each string has n group labels or clues separated by whitespace.
E.g., "J+12 F/4 5 2 E*3 E".
solution: 2D list of ints (n×n), e.g. [[6,4,5,2,1,3], [5,1,4,3,2,6], ...]
Returns: (bool, str)
- bool indicating whether the solution is fully valid or not.
- str is "" if valid, or an explanation of the first error found.
Steps to validate:
1) Parse puzzle spec to find:
- The puzzle size n
- group_label -> list of cells
- group_label -> (op, target), if any
2) Check solution dimension matches puzzle dimension.
3) Check each row, each column for distinct 1..n.
4) For each group, check if the group's constraint is satisfied:
op "+" => sum of group cells == target
op "*" => product of group cells == target
op "-" => for some permutation p of group cells, p[0] - p[1] - ... = target
op "/" => for some permutation p of group cells, p[0] / p[1] / ... = target (within an epsilon for float)
"""
# ---------------------------------------------------------------------------
# Step 1: parse puzzle spec
# ---------------------------------------------------------------------------
n = len(puzzle_spec)
# basic check: each row in puzzle_spec should have n tokens
puzzle_grid = []
for r, line in enumerate(puzzle_spec):
tokens = line.strip().split()
if len(tokens) != n:
return False, f"Row {r} of puzzle_spec has {len(tokens)} cells, expected {n}"
puzzle_grid.append(tokens)
# group_label -> dict with {"cells": [...], "op": None or str, "target": None or int}
groups = defaultdict(lambda: {"cells": [], "op": None, "target": None})
# We'll read each cell's label or clue.
# If it includes operation+target (like "J+12"), we store that in the group info.
for r in range(n):
for c in range(n):
cell_str = puzzle_grid[r][c]
# We assume the puzzle format:
# either "X" (just a label)
# or "X+12", "X-3", "X*60", "X/4", etc.
# or possibly "XY+12" if label has multiple letters
# We'll parse label, operation, and target.
match = re.match(r"^([A-Za-z]+)([+\-\*/])(\d+)$", cell_str)
if match:
g_label = match.group(1)
op = match.group(2)
tgt = int(match.group(3))
groups[g_label]["op"] = op
groups[g_label]["target"] = tgt
groups[g_label]["cells"].append((r, c))
else:
# just a label
g_label = cell_str
groups[g_label]["cells"].append((r, c))
# gather group data in a simpler structure
group_list = []
for g_label, info in groups.items():
op = info["op"]
target = info["target"]
cells = info["cells"]
group_list.append((g_label, op, target, cells))
# ---------------------------------------------------------------------------
# 2) Check solution dimension
# ---------------------------------------------------------------------------
if len(solution) != n:
return False, f"Solution row count {len(solution)} != puzzle dimension {n}"
for r in range(n):
if len(solution[r]) != n:
return False, f"Solution row {r} has {len(solution[r])} cols, expected {n}"
# ---------------------------------------------------------------------------
# 3) Check row & column distinctness
# Each row/col must contain numbers 1..n exactly once
# ---------------------------------------------------------------------------
# Check row distinctness
for r in range(n):
row_vals = solution[r]
if len(set(row_vals)) != n:
return False, f"Row {r} has repeated values: {row_vals}"
# optionally check they are exactly 1..n
for val in row_vals:
if val < 1 or val > n:
return False, f"Row {r} has out-of-range value {val}"
# Check column distinctness
for c in range(n):
col_vals = [solution[r][c] for r in range(n)]
if len(set(col_vals)) != n:
return False, f"Column {c} has repeated values: {col_vals}"
for val in col_vals:
if val < 1 or val > n:
return False, f"Column {c} has out-of-range value {val}"
# ---------------------------------------------------------------------------
# 4) Check group operation constraints
# ---------------------------------------------------------------------------
for g_label, op, target, cells in group_list:
# Gather the solution values for these cells
vals = [solution[r][c] for (r, c) in cells]
# If the group has no op/target (like single-cell group sometimes?),
# then it's typically the puzzle format that it does have an op & target
# on at least one cell. For a single-cell group, one possibility is "A+5" or similar.
# If we truly have no op, we'll just skip or treat it as passing automatically.
if op is None or target is None:
# If the group is single cell, we can interpret that as the target = that cell's value with op = '+'
# or we can just skip. Here, let's skip. Or we can check that if group has 1 cell, the value is that cell.
if len(cells) == 1:
# It's presumably correct.
# If you need a stricter logic, uncomment:
# if vals[0] != target: return False, f"Single-cell group {g_label} mismatch"
pass
continue
if op == '+':
if sum(vals) != target:
return False, f"Group {g_label} sum {sum(vals)} != target {target}"
elif op == '*':
prod = 1
for v in vals:
prod *= v
if prod != target:
return False, f"Group {g_label} product {prod} != target {target}"
elif op == '-':
# We look for any permutation p of vals s.t. p[0] - p[1] - ... = target
found = False
for perm in permutations(vals):
result = perm[0]
for x in perm[1:]:
result -= x
if result == target:
found = True
break
if not found:
return False, f"Group {g_label} cannot satisfy difference = {target} with values {vals}"
elif op == '/':
# We look for any permutation p of vals s.t. p[0] / p[1] / ... = target
found = False
for perm in permutations(vals):
result = float(perm[0])
ok = True
for x in perm[1:]:
# check dividing by zero not possible here if x>0
# but just in case
if x == 0:
ok = False
break
result /= float(x)
# check if close
if ok and abs(result - target) < 1e-9:
found = True
break
if not found:
return False, f"Group {g_label} cannot satisfy ratio = {target} with values {vals}"
else:
return False, f"Group {g_label} has unsupported op {op}"
# If we reach here, everything passed
return True, ""
if __name__ == "__main__":
# Example puzzle (the same puzzle_spec might be from a generator).
puzzle_spec_example = [
"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"
]
# Suppose n=4 here. A= group 1, B= group 2, etc.
# This puzzle_spec is purely an illustrative example, likely incomplete as a real puzzle.
# And an example solution that we want to check:
solution_example = [
[4, 1, 5, 6, 3, 2],
[1 ,2, 6, 4, 5, 3],
[3, 6, 2, 5, 1, 4],
[5, 4, 3, 2, 6, 1],
[6, 3, 4, 1, 2, 5],
[2, 5, 1, 3, 4, 6],
]
is_valid, reason = validate_calcudoku_puzzle(puzzle_spec_example, solution_example)
if is_valid:
print("Solution is valid!")
else:
print("Solution is invalid:", reason)