mirror of
https://github.com/InternLM/InternBootcamp.git
synced 2026-04-19 12:58:04 +00:00
370 lines
No EOL
15 KiB
Python
Executable file
370 lines
No EOL
15 KiB
Python
Executable file
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.
|
||
"""
|
||
# We’ll 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”?
|
||
# We’ll 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, it’s 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() |