multi-puzzle-solver 1.1.8__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- multi_puzzle_solver-1.1.8.dist-info/METADATA +4326 -0
- multi_puzzle_solver-1.1.8.dist-info/RECORD +106 -0
- multi_puzzle_solver-1.1.8.dist-info/WHEEL +5 -0
- multi_puzzle_solver-1.1.8.dist-info/top_level.txt +1 -0
- puzzle_solver/__init__.py +184 -0
- puzzle_solver/core/utils.py +298 -0
- puzzle_solver/core/utils_ortools.py +333 -0
- puzzle_solver/core/utils_visualizer.py +575 -0
- puzzle_solver/puzzles/abc_view/abc_view.py +75 -0
- puzzle_solver/puzzles/aquarium/aquarium.py +97 -0
- puzzle_solver/puzzles/area_51/area_51.py +159 -0
- puzzle_solver/puzzles/battleships/battleships.py +139 -0
- puzzle_solver/puzzles/binairo/binairo.py +98 -0
- puzzle_solver/puzzles/binairo/binairo_plus.py +7 -0
- puzzle_solver/puzzles/black_box/black_box.py +243 -0
- puzzle_solver/puzzles/branches/branches.py +64 -0
- puzzle_solver/puzzles/bridges/bridges.py +104 -0
- puzzle_solver/puzzles/chess_range/chess_melee.py +6 -0
- puzzle_solver/puzzles/chess_range/chess_range.py +406 -0
- puzzle_solver/puzzles/chess_range/chess_solo.py +9 -0
- puzzle_solver/puzzles/chess_sequence/chess_sequence.py +262 -0
- puzzle_solver/puzzles/circle_9/circle_9.py +44 -0
- puzzle_solver/puzzles/clouds/clouds.py +81 -0
- puzzle_solver/puzzles/connect_the_dots/connect_the_dots.py +50 -0
- puzzle_solver/puzzles/cow_and_cactus/cow_and_cactus.py +66 -0
- puzzle_solver/puzzles/dominosa/dominosa.py +67 -0
- puzzle_solver/puzzles/filling/filling.py +94 -0
- puzzle_solver/puzzles/flip/flip.py +64 -0
- puzzle_solver/puzzles/flood_it/flood_it.py +174 -0
- puzzle_solver/puzzles/flood_it/parse_map/parse_map.py +197 -0
- puzzle_solver/puzzles/galaxies/galaxies.py +110 -0
- puzzle_solver/puzzles/galaxies/parse_map/parse_map.py +216 -0
- puzzle_solver/puzzles/guess/guess.py +232 -0
- puzzle_solver/puzzles/heyawake/heyawake.py +152 -0
- puzzle_solver/puzzles/hidden_stars/hidden_stars.py +52 -0
- puzzle_solver/puzzles/hidoku/hidoku.py +59 -0
- puzzle_solver/puzzles/inertia/inertia.py +121 -0
- puzzle_solver/puzzles/inertia/parse_map/parse_map.py +207 -0
- puzzle_solver/puzzles/inertia/tsp.py +400 -0
- puzzle_solver/puzzles/kakurasu/kakurasu.py +38 -0
- puzzle_solver/puzzles/kakuro/kakuro.py +81 -0
- puzzle_solver/puzzles/kakuro/krypto_kakuro.py +95 -0
- puzzle_solver/puzzles/keen/keen.py +76 -0
- puzzle_solver/puzzles/kropki/kropki.py +94 -0
- puzzle_solver/puzzles/light_up/light_up.py +58 -0
- puzzle_solver/puzzles/linesweeper/linesweeper.py +71 -0
- puzzle_solver/puzzles/link_a_pix/link_a_pix.py +91 -0
- puzzle_solver/puzzles/lits/lits.py +138 -0
- puzzle_solver/puzzles/magnets/magnets.py +96 -0
- puzzle_solver/puzzles/map/map.py +56 -0
- puzzle_solver/puzzles/mathema_grids/mathema_grids.py +119 -0
- puzzle_solver/puzzles/mathrax/mathrax.py +93 -0
- puzzle_solver/puzzles/minesweeper/minesweeper.py +123 -0
- puzzle_solver/puzzles/mosaic/mosaic.py +38 -0
- puzzle_solver/puzzles/n_queens/n_queens.py +71 -0
- puzzle_solver/puzzles/nonograms/nonograms.py +121 -0
- puzzle_solver/puzzles/nonograms/nonograms_colored.py +220 -0
- puzzle_solver/puzzles/norinori/norinori.py +96 -0
- puzzle_solver/puzzles/number_path/number_path.py +76 -0
- puzzle_solver/puzzles/numbermaze/numbermaze.py +97 -0
- puzzle_solver/puzzles/nurikabe/nurikabe.py +130 -0
- puzzle_solver/puzzles/palisade/palisade.py +91 -0
- puzzle_solver/puzzles/pearl/pearl.py +107 -0
- puzzle_solver/puzzles/pipes/pipes.py +82 -0
- puzzle_solver/puzzles/range/range.py +59 -0
- puzzle_solver/puzzles/rectangles/rectangles.py +128 -0
- puzzle_solver/puzzles/ripple_effect/ripple_effect.py +83 -0
- puzzle_solver/puzzles/rooms/rooms.py +75 -0
- puzzle_solver/puzzles/schurs_numbers/schurs_numbers.py +73 -0
- puzzle_solver/puzzles/shakashaka/shakashaka.py +201 -0
- puzzle_solver/puzzles/shingoki/shingoki.py +116 -0
- puzzle_solver/puzzles/signpost/signpost.py +93 -0
- puzzle_solver/puzzles/singles/singles.py +53 -0
- puzzle_solver/puzzles/slant/parse_map/parse_map.py +135 -0
- puzzle_solver/puzzles/slant/slant.py +111 -0
- puzzle_solver/puzzles/slitherlink/slitherlink.py +130 -0
- puzzle_solver/puzzles/snail/snail.py +97 -0
- puzzle_solver/puzzles/split_ends/split_ends.py +93 -0
- puzzle_solver/puzzles/star_battle/star_battle.py +75 -0
- puzzle_solver/puzzles/star_battle/star_battle_shapeless.py +7 -0
- puzzle_solver/puzzles/stitches/parse_map/parse_map.py +267 -0
- puzzle_solver/puzzles/stitches/stitches.py +96 -0
- puzzle_solver/puzzles/sudoku/sudoku.py +267 -0
- puzzle_solver/puzzles/suguru/suguru.py +55 -0
- puzzle_solver/puzzles/suko/suko.py +54 -0
- puzzle_solver/puzzles/tapa/tapa.py +97 -0
- puzzle_solver/puzzles/tatami/tatami.py +64 -0
- puzzle_solver/puzzles/tents/tents.py +80 -0
- puzzle_solver/puzzles/thermometers/thermometers.py +82 -0
- puzzle_solver/puzzles/towers/towers.py +89 -0
- puzzle_solver/puzzles/tracks/tracks.py +88 -0
- puzzle_solver/puzzles/trees_logic/trees_logic.py +48 -0
- puzzle_solver/puzzles/troix/dumplings.py +7 -0
- puzzle_solver/puzzles/troix/troix.py +75 -0
- puzzle_solver/puzzles/twiddle/twiddle.py +112 -0
- puzzle_solver/puzzles/undead/undead.py +130 -0
- puzzle_solver/puzzles/unequal/unequal.py +128 -0
- puzzle_solver/puzzles/unruly/unruly.py +54 -0
- puzzle_solver/puzzles/vectors/vectors.py +94 -0
- puzzle_solver/puzzles/vermicelli/vermicelli.py +74 -0
- puzzle_solver/puzzles/walls/walls.py +52 -0
- puzzle_solver/puzzles/yajilin/yajilin.py +87 -0
- puzzle_solver/puzzles/yin_yang/parse_map/parse_map.py +172 -0
- puzzle_solver/puzzles/yin_yang/yin_yang.py +103 -0
- puzzle_solver/utils/etc/parser/board_color_digit.py +497 -0
- puzzle_solver/utils/visualizer.py +155 -0
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
from typing import Union, Optional
|
|
2
|
+
from collections import defaultdict
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
from ortools.sat.python import cp_model
|
|
6
|
+
|
|
7
|
+
from puzzle_solver.core.utils import Pos, get_pos, get_all_pos, get_char, set_char, get_row_pos, get_col_pos
|
|
8
|
+
from puzzle_solver.core.utils_ortools import and_constraint, generic_solve_all, or_constraint, SingleSolution
|
|
9
|
+
from puzzle_solver.core.utils_visualizer import combined_function
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def get_value(board: np.array, pos: Pos) -> Union[int, str]:
|
|
13
|
+
c = get_char(board, pos).lower()
|
|
14
|
+
if c == ' ':
|
|
15
|
+
return c
|
|
16
|
+
if str(c).isdecimal():
|
|
17
|
+
return int(c)
|
|
18
|
+
# a,b,... maps to 10,11,...
|
|
19
|
+
return ord(c) - ord('a') + 10
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def set_value(board: np.array, pos: Pos, value: Union[int, str]):
|
|
23
|
+
if value == ' ':
|
|
24
|
+
value = ' '
|
|
25
|
+
elif value < 10:
|
|
26
|
+
value = str(value)
|
|
27
|
+
else:
|
|
28
|
+
value = chr(value - 10 + ord('a'))
|
|
29
|
+
set_char(board, pos, value)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def get_block_pos(i: int, Bv: int, Bh: int) -> list[Pos]:
|
|
33
|
+
# Think: Bv=3 and Bh=4 while the board has 4 vertical blocks and 3 horizontal blocks
|
|
34
|
+
top_left_x = (i%Bv)*Bh
|
|
35
|
+
top_left_y = (i//Bv)*Bv
|
|
36
|
+
return [get_pos(x=top_left_x + x, y=top_left_y + y) for x in range(Bh) for y in range(Bv)]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Board:
|
|
40
|
+
def __init__(self,
|
|
41
|
+
board: np.array,
|
|
42
|
+
constrain_blocks: bool = True,
|
|
43
|
+
block_size: Optional[tuple[int, int]] = None,
|
|
44
|
+
sandwich: Optional[dict[str, list[int]]] = None,
|
|
45
|
+
unique_diagonal: bool = False,
|
|
46
|
+
jigsaw: Optional[np.array] = None,
|
|
47
|
+
killer: Optional[tuple[np.array, dict[str, int]]] = None,
|
|
48
|
+
):
|
|
49
|
+
"""
|
|
50
|
+
board: 2d array of characters
|
|
51
|
+
constrain_blocks: whether to constrain the blocks. If True, each block must contain all numbers from 1 to 9 exactly once.
|
|
52
|
+
block_size: tuple of block size (vertical, horizontal). If not provided, the block size is the square root of the board size.
|
|
53
|
+
sandwich: dictionary of sandwich clues (side, bottom). If provided, the sum of the values between 1 and 9 for each row and column is equal to the clue.
|
|
54
|
+
unique_diagonal: whether to constrain the 2 diagonals to be unique. If True, each diagonal must contain all numbers from 1 to 9 exactly once.
|
|
55
|
+
killer: tuple of (killer board, killer clues). If provided, the killer board must be a 2d array of ids of the killer blocks. The killer clues must be a dictionary of killer block ids to clues.
|
|
56
|
+
Each numbers in a killer block must be unique and sum to the clue.
|
|
57
|
+
"""
|
|
58
|
+
assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
|
|
59
|
+
assert all(isinstance(i.item(), str) and len(i.item()) == 1 and (i.item().isalnum() or i.item() == ' ') for i in np.nditer(board)), 'board must contain only alphanumeric characters or space'
|
|
60
|
+
self.board = board
|
|
61
|
+
self.V, self.H = board.shape
|
|
62
|
+
self.L = max(self.V, self.H)
|
|
63
|
+
self.constrain_blocks = constrain_blocks
|
|
64
|
+
self.unique_diagonal = unique_diagonal
|
|
65
|
+
self.sandwich = None
|
|
66
|
+
self.jigsaw_id_to_pos = None
|
|
67
|
+
self.killer = None
|
|
68
|
+
|
|
69
|
+
if self.constrain_blocks:
|
|
70
|
+
if block_size is None:
|
|
71
|
+
B = np.sqrt(self.V) # block size
|
|
72
|
+
assert B.is_integer(), 'board size must be a perfect square or provide block_size'
|
|
73
|
+
Bv, Bh = int(B), int(B)
|
|
74
|
+
else:
|
|
75
|
+
Bv, Bh = block_size
|
|
76
|
+
assert Bv * Bh == self.V, 'block size must be a factor of board size'
|
|
77
|
+
# can be different in 4x3 for example
|
|
78
|
+
self.Bv = Bv
|
|
79
|
+
self.Bh = Bh
|
|
80
|
+
self.B = Bv * Bh # block count
|
|
81
|
+
else:
|
|
82
|
+
assert block_size is None, 'cannot set block size if blocks are not constrained'
|
|
83
|
+
|
|
84
|
+
if jigsaw is not None:
|
|
85
|
+
if self.constrain_blocks:
|
|
86
|
+
print('Warning: jigsaw and blocks are both constrained, are you sure you want to do this?')
|
|
87
|
+
assert jigsaw.ndim == 2, f'jigsaw must be 2d, got {jigsaw.ndim}'
|
|
88
|
+
assert jigsaw.shape[0] == self.V and jigsaw.shape[1] == self.H, 'jigsaw must be the same size as the board'
|
|
89
|
+
assert all(isinstance(i.item(), str) and i.item().isdecimal() for i in np.nditer(jigsaw)), 'jigsaw must contain only digits or space'
|
|
90
|
+
self.jigsaw_id_to_pos: dict[int, list[Pos]] = defaultdict(list)
|
|
91
|
+
for pos in get_all_pos(self.V, self.H):
|
|
92
|
+
v = get_char(jigsaw, pos)
|
|
93
|
+
if v.isdecimal():
|
|
94
|
+
self.jigsaw_id_to_pos[int(v)].append(pos)
|
|
95
|
+
assert all(len(pos_list) <= self.L for pos_list in self.jigsaw_id_to_pos.values()), 'jigsaw areas cannot be larger than the number of digits'
|
|
96
|
+
|
|
97
|
+
if sandwich is not None:
|
|
98
|
+
assert set(sandwich.keys()) == set(['side', 'bottom']), 'sandwich must contain only side and bottom'
|
|
99
|
+
assert len(sandwich['side']) == self.H, 'side must be equal to board width'
|
|
100
|
+
assert len(sandwich['bottom']) == self.V, 'bottom must be equal to board height'
|
|
101
|
+
self.sandwich = sandwich
|
|
102
|
+
|
|
103
|
+
if killer is not None:
|
|
104
|
+
assert killer[0].ndim == 2, f'killer board must be 2d, got {killer[0].ndim}'
|
|
105
|
+
assert killer[0].shape[0] == self.V and killer[0].shape[1] == self.H, 'killer board must be the same size as the board'
|
|
106
|
+
assert all(isinstance(i.item(), str) and i.item().isdecimal() for i in np.nditer(killer[0])), 'killer board must contain only digits or space'
|
|
107
|
+
assert set(killer[1].keys()).issubset(set(killer[0].flatten())), f'killer clues must contain all killer block ids, {set(killer[0].flatten()) - set(killer[1].keys())}'
|
|
108
|
+
self.killer = killer
|
|
109
|
+
|
|
110
|
+
self.model = cp_model.CpModel()
|
|
111
|
+
self.model_vars: dict[Pos, cp_model.IntVar] = {}
|
|
112
|
+
|
|
113
|
+
self.create_vars()
|
|
114
|
+
self.add_all_constraints()
|
|
115
|
+
|
|
116
|
+
def create_vars(self):
|
|
117
|
+
for pos in get_all_pos(self.V, self.H):
|
|
118
|
+
self.model_vars[pos] = self.model.NewIntVar(1, self.L, f'{pos}')
|
|
119
|
+
|
|
120
|
+
def add_all_constraints(self):
|
|
121
|
+
# some squares are already filled
|
|
122
|
+
for pos in get_all_pos(self.V, self.H):
|
|
123
|
+
c = get_value(self.board, pos)
|
|
124
|
+
if c != ' ':
|
|
125
|
+
self.model.Add(self.model_vars[pos] == c)
|
|
126
|
+
# every number appears exactly once in each row, each column and each block
|
|
127
|
+
# each row
|
|
128
|
+
for row in range(self.V):
|
|
129
|
+
row_vars = [self.model_vars[pos] for pos in get_row_pos(row, H=self.H)]
|
|
130
|
+
self.model.AddAllDifferent(row_vars)
|
|
131
|
+
# each column
|
|
132
|
+
for col in range(self.H):
|
|
133
|
+
col_vars = [self.model_vars[pos] for pos in get_col_pos(col, V=self.V)]
|
|
134
|
+
self.model.AddAllDifferent(col_vars)
|
|
135
|
+
if self.constrain_blocks: # each block must contain all numbers from 1 to 9 exactly once
|
|
136
|
+
for block_i in range(self.B):
|
|
137
|
+
block_vars = [self.model_vars[p] for p in get_block_pos(block_i, Bv=self.Bv, Bh=self.Bh)]
|
|
138
|
+
self.model.AddAllDifferent(block_vars)
|
|
139
|
+
if self.sandwich is not None:
|
|
140
|
+
self.add_sandwich_constraints()
|
|
141
|
+
if self.unique_diagonal:
|
|
142
|
+
self.add_unique_diagonal_constraints()
|
|
143
|
+
if self.jigsaw_id_to_pos is not None:
|
|
144
|
+
self.add_jigsaw_constraints()
|
|
145
|
+
if self.killer is not None:
|
|
146
|
+
self.add_killer_constraints()
|
|
147
|
+
|
|
148
|
+
def add_sandwich_constraints(self):
|
|
149
|
+
"""Sandwich constraints, enforce that the sum of the values between 1 and 9 for each row and column is equal to the clue."""
|
|
150
|
+
for c, clue in enumerate(self.sandwich['bottom']):
|
|
151
|
+
if clue is None or int(clue) < 0:
|
|
152
|
+
continue
|
|
153
|
+
col_vars = [self.model_vars[p] for p in get_col_pos(c, V=self.V)]
|
|
154
|
+
add_single_sandwich(col_vars, int(clue), self.model, name=f"sand_side_{c}")
|
|
155
|
+
for r, clue in enumerate(self.sandwich['side']):
|
|
156
|
+
if clue is None or int(clue) < 0:
|
|
157
|
+
continue
|
|
158
|
+
row_vars = [self.model_vars[p] for p in get_row_pos(r, H=self.H)]
|
|
159
|
+
add_single_sandwich(row_vars, int(clue), self.model, name=f"sand_bottom_{r}")
|
|
160
|
+
|
|
161
|
+
def add_unique_diagonal_constraints(self):
|
|
162
|
+
main_diagonal_vars = [self.model_vars[get_pos(x=i, y=i)] for i in range(min(self.V, self.H))]
|
|
163
|
+
self.model.AddAllDifferent(main_diagonal_vars)
|
|
164
|
+
anti_diagonal_vars = [self.model_vars[get_pos(x=i, y=self.V-i-1)] for i in range(min(self.V, self.H))]
|
|
165
|
+
self.model.AddAllDifferent(anti_diagonal_vars)
|
|
166
|
+
|
|
167
|
+
def add_jigsaw_constraints(self):
|
|
168
|
+
"""All digits in one jigsaw area must be unique."""
|
|
169
|
+
for pos_list in self.jigsaw_id_to_pos.values():
|
|
170
|
+
self.model.AddAllDifferent([self.model_vars[p] for p in pos_list])
|
|
171
|
+
|
|
172
|
+
def add_killer_constraints(self):
|
|
173
|
+
"""Killer constraints, enforce that the sum of the values in each killer block is equal to the clue and all numbers in a block are unique."""
|
|
174
|
+
killer_board, killer_clues = self.killer
|
|
175
|
+
# change clue keys to ints
|
|
176
|
+
killer_clues = {int(k): v for k, v in killer_clues.items()}
|
|
177
|
+
killer_id_to_pos = defaultdict(list)
|
|
178
|
+
for pos in get_all_pos(self.V, self.H):
|
|
179
|
+
v = get_char(killer_board, pos)
|
|
180
|
+
if v.isdecimal():
|
|
181
|
+
killer_id_to_pos[int(v)].append(pos)
|
|
182
|
+
for killer_id, pos_list in killer_id_to_pos.items():
|
|
183
|
+
self.model.AddAllDifferent([self.model_vars[p] for p in pos_list])
|
|
184
|
+
clue = killer_clues[killer_id]
|
|
185
|
+
self.model.Add(sum([self.model_vars[p] for p in pos_list]) == clue)
|
|
186
|
+
|
|
187
|
+
def solve_and_print(self, verbose: bool = True):
|
|
188
|
+
def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
|
|
189
|
+
return SingleSolution(assignment={pos: solver.value(var) for pos, var in board.model_vars.items()})
|
|
190
|
+
def callback(single_res: SingleSolution):
|
|
191
|
+
print("Solution found")
|
|
192
|
+
val_arr = np.array([[single_res.assignment[get_pos(x=c, y=r)] for c in range(self.H)] for r in range(self.V)])
|
|
193
|
+
print(combined_function(self.V, self.H, center_char=lambda r, c: val_arr[r, c] if val_arr[r, c] < 10 else chr(val_arr[r, c] - 10 + ord('a'))))
|
|
194
|
+
return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def add_single_sandwich(vars_line: list[cp_model.IntVar], clue: int, model: cp_model.CpModel, name: str):
|
|
199
|
+
# VAR count:
|
|
200
|
+
# is_min: L
|
|
201
|
+
# is_max: L
|
|
202
|
+
# pos_min/max/lt: 1+1+1
|
|
203
|
+
# between: L
|
|
204
|
+
# a1/a2/case_a: L+L+L
|
|
205
|
+
# b1/b2/case_b: L+L+L
|
|
206
|
+
# take: L
|
|
207
|
+
# 10L+3 per 1 call of the function (i.e. per 1 line)
|
|
208
|
+
# entire board will have 2L lines (rows and columns)
|
|
209
|
+
# in total: 20L^2+6L
|
|
210
|
+
|
|
211
|
+
L = len(vars_line)
|
|
212
|
+
is_min = [model.NewBoolVar(f"{name}_ismin_{i}") for i in range(L)]
|
|
213
|
+
is_max = [model.NewBoolVar(f"{name}_ismax_{i}") for i in range(L)]
|
|
214
|
+
for i, v in enumerate(vars_line):
|
|
215
|
+
model.Add(v == 1).OnlyEnforceIf(is_min[i])
|
|
216
|
+
model.Add(v != 1).OnlyEnforceIf(is_min[i].Not())
|
|
217
|
+
model.Add(v == L).OnlyEnforceIf(is_max[i])
|
|
218
|
+
model.Add(v != L).OnlyEnforceIf(is_max[i].Not())
|
|
219
|
+
|
|
220
|
+
# index of the minimum and maximum values (sum of the values inbetween must = clue)
|
|
221
|
+
pos_min = model.NewIntVar(0, L - 1, f"{name}_pos_min")
|
|
222
|
+
pos_max = model.NewIntVar(0, L - 1, f"{name}_pos_max")
|
|
223
|
+
model.Add(pos_min == sum(i * is_min[i] for i in range(L)))
|
|
224
|
+
model.Add(pos_max == sum(i * is_max[i] for i in range(L)))
|
|
225
|
+
|
|
226
|
+
# used later to handle both cases (A. pos_min < pos_max and B. pos_max < pos_min)
|
|
227
|
+
lt = model.NewBoolVar(f"{name}_lt") # pos_min < pos_max ?
|
|
228
|
+
model.Add(pos_min < pos_max).OnlyEnforceIf(lt)
|
|
229
|
+
model.Add(pos_min >= pos_max).OnlyEnforceIf(lt.Not())
|
|
230
|
+
|
|
231
|
+
between = [model.NewBoolVar(f"{name}_between_{i}") for i in range(L)]
|
|
232
|
+
for i in range(L):
|
|
233
|
+
# Case A: pos_min < i < pos_max (AND lt is true)
|
|
234
|
+
a1 = model.NewBoolVar(f"{name}_a1_{i}") # pos_min < i
|
|
235
|
+
a2 = model.NewBoolVar(f"{name}_a2_{i}") # i < pos_max
|
|
236
|
+
|
|
237
|
+
model.Add(pos_min < i).OnlyEnforceIf(a1)
|
|
238
|
+
model.Add(pos_min >= i).OnlyEnforceIf(a1.Not())
|
|
239
|
+
model.Add(i < pos_max).OnlyEnforceIf(a2)
|
|
240
|
+
model.Add(i >= pos_max).OnlyEnforceIf(a2.Not())
|
|
241
|
+
|
|
242
|
+
case_a = model.NewBoolVar(f"{name}_caseA_{i}")
|
|
243
|
+
and_constraint(model, case_a, [lt, a1, a2])
|
|
244
|
+
|
|
245
|
+
# Case B: pos_max < i < pos_min (AND lt is false)
|
|
246
|
+
b1 = model.NewBoolVar(f"{name}_b1_{i}") # pos_max < i
|
|
247
|
+
b2 = model.NewBoolVar(f"{name}_b2_{i}") # i < pos_min
|
|
248
|
+
|
|
249
|
+
model.Add(pos_max < i).OnlyEnforceIf(b1)
|
|
250
|
+
model.Add(pos_max >= i).OnlyEnforceIf(b1.Not())
|
|
251
|
+
model.Add(i < pos_min).OnlyEnforceIf(b2)
|
|
252
|
+
model.Add(i >= pos_min).OnlyEnforceIf(b2.Not())
|
|
253
|
+
|
|
254
|
+
case_b = model.NewBoolVar(f"{name}_caseB_{i}")
|
|
255
|
+
and_constraint(model, case_b, [lt.Not(), b1, b2])
|
|
256
|
+
|
|
257
|
+
# between[i] is true if we're in case A or case B
|
|
258
|
+
or_constraint(model, between[i], [case_a, case_b])
|
|
259
|
+
|
|
260
|
+
# sum values at indices that are "between"
|
|
261
|
+
take = [model.NewIntVar(0, L, f"{name}_take_{i}") for i in range(L)]
|
|
262
|
+
for i, v in enumerate(vars_line):
|
|
263
|
+
# take[i] = v if between[i] else 0
|
|
264
|
+
model.Add(take[i] == v).OnlyEnforceIf(between[i])
|
|
265
|
+
model.Add(take[i] == 0).OnlyEnforceIf(between[i].Not())
|
|
266
|
+
|
|
267
|
+
model.Add(sum(take) == clue)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from collections import defaultdict
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
from ortools.sat.python import cp_model
|
|
5
|
+
|
|
6
|
+
from puzzle_solver.core.utils import Pos, get_all_pos, get_char, get_neighbors8, get_pos
|
|
7
|
+
from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
|
|
8
|
+
from puzzle_solver.core.utils_visualizer import combined_function, id_board_to_wall_fn
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Board:
|
|
12
|
+
def __init__(self, id_board: np.array, num_board: np.array):
|
|
13
|
+
assert id_board.ndim == 2, f'id_board must be 2d, got {id_board.ndim}'
|
|
14
|
+
assert all(str(c.item()).isdecimal() for c in np.nditer(id_board)), 'id_board must contain only digits'
|
|
15
|
+
assert num_board.ndim == 2, f'num_board must be 2d, got {num_board.ndim}'
|
|
16
|
+
assert all(str(c.item()).strip() == '' or str(c.item()).isdecimal() for c in np.nditer(num_board)), 'num_board must contain only space or digits'
|
|
17
|
+
assert id_board.shape == num_board.shape, f'id_board and num_board must have the same shape, got {id_board.shape} and {num_board.shape}'
|
|
18
|
+
self.id_board = id_board
|
|
19
|
+
self.num_board = num_board
|
|
20
|
+
self.V, self.H = id_board.shape
|
|
21
|
+
self.ids = {int(c.item()) for c in np.nditer(id_board)}
|
|
22
|
+
self.id_to_pos: dict[int, set[Pos]] = defaultdict(set)
|
|
23
|
+
for pos in get_all_pos(self.V, self.H):
|
|
24
|
+
self.id_to_pos[int(get_char(self.id_board, pos))].add(pos)
|
|
25
|
+
self.N = max([len(v) for v in self.id_to_pos.values()])
|
|
26
|
+
|
|
27
|
+
self.model = cp_model.CpModel()
|
|
28
|
+
self.model_vars: dict[Pos, cp_model.IntVar] = {}
|
|
29
|
+
self.create_vars()
|
|
30
|
+
self.add_all_constraints()
|
|
31
|
+
|
|
32
|
+
def create_vars(self):
|
|
33
|
+
for pos in get_all_pos(self.V, self.H):
|
|
34
|
+
self.model_vars[pos] = self.model.NewIntVar(1, self.N, f'{pos}')
|
|
35
|
+
|
|
36
|
+
def add_all_constraints(self):
|
|
37
|
+
for cage in self.ids:
|
|
38
|
+
cage_vars = [self.model_vars[pos] for pos in self.id_to_pos[cage]]
|
|
39
|
+
self.model.AddAllDifferent(cage_vars)
|
|
40
|
+
for pos in self.id_to_pos[cage]:
|
|
41
|
+
self.model.Add(self.model_vars[pos] <= len(cage_vars))
|
|
42
|
+
for pos in get_all_pos(self.V, self.H):
|
|
43
|
+
c = get_char(self.num_board, pos).strip()
|
|
44
|
+
if c: # force clues
|
|
45
|
+
self.model.Add(self.model_vars[pos] == int(c))
|
|
46
|
+
for n in get_neighbors8(pos, self.V, self.H):
|
|
47
|
+
self.model.Add(self.model_vars[pos] != self.model_vars[n])
|
|
48
|
+
|
|
49
|
+
def solve_and_print(self, verbose: bool = True):
|
|
50
|
+
def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
|
|
51
|
+
return SingleSolution(assignment={pos: solver.Value(var) for pos, var in board.model_vars.items()})
|
|
52
|
+
def callback(single_res: SingleSolution):
|
|
53
|
+
print("Solution found")
|
|
54
|
+
print(combined_function(self.V, self.H, center_char=lambda r, c: str(single_res.assignment[get_pos(x=c, y=r)]), cell_flags=id_board_to_wall_fn(self.id_board)))
|
|
55
|
+
return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from ortools.sat.python import cp_model
|
|
3
|
+
from ortools.sat.python.cp_model import LinearExpr as lxp
|
|
4
|
+
|
|
5
|
+
from puzzle_solver.core.utils import Direction, Pos, get_all_pos, get_char, get_next_pos, get_pos
|
|
6
|
+
from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
|
|
7
|
+
from puzzle_solver.core.utils_visualizer import combined_function
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Board:
|
|
11
|
+
def __init__(self, board: np.array, quadrant: np.array, color_sums: dict[str, int]):
|
|
12
|
+
assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
|
|
13
|
+
assert all(c.item().strip() != '' for c in np.nditer(board)), 'board must contain only non-empty strings'
|
|
14
|
+
self.V, self.H = board.shape
|
|
15
|
+
self.colors = {c.item().strip() for c in np.nditer(board)}
|
|
16
|
+
assert quadrant.shape == (self.V-1, self.H-1), f'quadrant must be {(self.V-1, self.H-1)}, got {quadrant.shape}'
|
|
17
|
+
assert all(str(c.item()).isdecimal() for c in np.nditer(quadrant)), 'quadrant must contain only digits'
|
|
18
|
+
assert set(color_sums.keys()) == self.colors, f'color_sums must contain all colors, missing {self.colors - set(color_sums.keys())} and extra {set(color_sums.keys()) - self.colors}'
|
|
19
|
+
self.N = self.V * self.H
|
|
20
|
+
self.board = board
|
|
21
|
+
self.quadrant = quadrant
|
|
22
|
+
self.color_sums = color_sums
|
|
23
|
+
self.model = cp_model.CpModel()
|
|
24
|
+
self.model_vars: dict[Pos, cp_model.IntVar] = {}
|
|
25
|
+
|
|
26
|
+
self.create_vars()
|
|
27
|
+
self.add_all_constraints()
|
|
28
|
+
|
|
29
|
+
def create_vars(self):
|
|
30
|
+
for pos in get_all_pos(self.V, self.H):
|
|
31
|
+
self.model_vars[pos] = self.model.NewIntVar(1, self.N, f'{pos}')
|
|
32
|
+
|
|
33
|
+
def add_all_constraints(self):
|
|
34
|
+
self.model.AddAllDifferent(list(self.model_vars.values())) # all numbers are unique
|
|
35
|
+
for color in self.colors: # enforce color sums
|
|
36
|
+
color_sum = self.color_sums[color]
|
|
37
|
+
color_vars = [self.model_vars[pos] for pos in get_all_pos(self.V, self.H) if get_char(self.board, pos) == color]
|
|
38
|
+
self.model.Add(lxp.Sum(color_vars) == color_sum)
|
|
39
|
+
for pos in get_all_pos(self.V - 1, self.H - 1): # enforce the 2x2 sums
|
|
40
|
+
quadrant_sum = int(get_char(self.quadrant, pos))
|
|
41
|
+
tl = pos
|
|
42
|
+
tr = get_next_pos(tl, Direction.RIGHT)
|
|
43
|
+
bl = get_next_pos(tl, Direction.DOWN)
|
|
44
|
+
br = get_next_pos(bl, Direction.RIGHT)
|
|
45
|
+
quadrant = np.array([self.model_vars[tl], self.model_vars[tr], self.model_vars[bl], self.model_vars[br]])
|
|
46
|
+
self.model.Add(lxp.Sum(quadrant) == quadrant_sum)
|
|
47
|
+
|
|
48
|
+
def solve_and_print(self, verbose: bool = True):
|
|
49
|
+
def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
|
|
50
|
+
return SingleSolution(assignment={pos: solver.Value(var) for pos, var in board.model_vars.items()})
|
|
51
|
+
def callback(single_res: SingleSolution):
|
|
52
|
+
print("Solution found")
|
|
53
|
+
print(combined_function(self.V, self.H, center_char=lambda r, c: single_res.assignment[get_pos(x=c, y=r)]))
|
|
54
|
+
return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
from typing import Union
|
|
2
|
+
from itertools import combinations
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
from ortools.sat.python import cp_model
|
|
6
|
+
|
|
7
|
+
from puzzle_solver.core.utils import Direction8, Pos, get_all_pos, get_char, in_bounds, Direction, get_next_pos, get_pos, get_neighbors4
|
|
8
|
+
from puzzle_solver.core.utils_ortools import SingleSolution, force_connected_component_using_demand, generic_unique_projections
|
|
9
|
+
from puzzle_solver.core.utils_visualizer import combined_function
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def rotated_assignments_N_nums(Xs: tuple[int, ...], target_length: int = 8) -> set[tuple[bool, ...]]:
|
|
13
|
+
""" Given Xs = [X1, X2, ..., Xm] (each Xi >= 1), build all unique length-`target_length`
|
|
14
|
+
boolean lists of the form: [ True*X1, False*N1, True*X2, False*N2, ..., True*Xm, False*Nm ]
|
|
15
|
+
where each Ni >= 1 and sum(Xs) + sum(Ni) = target_length,
|
|
16
|
+
including all `target_length` wrap-around rotations, de-duplicated.
|
|
17
|
+
"""
|
|
18
|
+
assert len(Xs) >= 1 and all(x >= 1 for x in Xs), "Xs must have at least one block length and all Xi must be >= 1."
|
|
19
|
+
assert sum(Xs) + len(Xs) <= target_length, f"sum(Xs) + len(Xs) <= target_length required; got {sum(Xs)} + {len(Xs)} > {target_length}"
|
|
20
|
+
num_zero_blocks = len(Xs)
|
|
21
|
+
total_zeros = target_length - sum(Xs)
|
|
22
|
+
seen: set[tuple[bool, ...]] = set()
|
|
23
|
+
for cut_positions in combinations(range(1, total_zeros), num_zero_blocks - 1):
|
|
24
|
+
cut_positions = (*cut_positions, total_zeros)
|
|
25
|
+
Ns = [cut_positions[0]] # length of zero blocks
|
|
26
|
+
for i in range(1, len(cut_positions)):
|
|
27
|
+
Ns.append(cut_positions[i] - cut_positions[i - 1])
|
|
28
|
+
base: list[bool] = []
|
|
29
|
+
for x, n in zip(Xs, Ns):
|
|
30
|
+
base.extend([True] * x)
|
|
31
|
+
base.extend([False] * n)
|
|
32
|
+
for dx in range(target_length): # all rotations (wrap-around)
|
|
33
|
+
rot = tuple(base[dx:] + base[:dx])
|
|
34
|
+
seen.add(rot)
|
|
35
|
+
return seen
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Board:
|
|
39
|
+
def __init__(self, board: np.array, separator: str = '/'):
|
|
40
|
+
assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
|
|
41
|
+
assert all(all(str(c).strip().isdecimal() or str(c).strip() == '' for c in cell.item().split(separator)) for cell in np.nditer(board)), 'board must contain only digits and separator'
|
|
42
|
+
self.V, self.H = board.shape
|
|
43
|
+
self.board = board
|
|
44
|
+
self.separator = separator
|
|
45
|
+
self.model = cp_model.CpModel()
|
|
46
|
+
self.model_vars: dict[Pos, cp_model.IntVar] = {}
|
|
47
|
+
self.create_vars()
|
|
48
|
+
self.add_all_constraints()
|
|
49
|
+
|
|
50
|
+
def create_vars(self):
|
|
51
|
+
for pos in get_all_pos(self.V, self.H):
|
|
52
|
+
self.model_vars[pos] = self.model.NewBoolVar(f'{pos}')
|
|
53
|
+
|
|
54
|
+
def add_all_constraints(self):
|
|
55
|
+
# 2x2 blacks are not allowed
|
|
56
|
+
for pos in get_all_pos(self.V, self.H):
|
|
57
|
+
tl = pos
|
|
58
|
+
tr = get_next_pos(pos, Direction.RIGHT)
|
|
59
|
+
bl = get_next_pos(pos, Direction.DOWN)
|
|
60
|
+
br = get_next_pos(bl, Direction.RIGHT)
|
|
61
|
+
if any(not in_bounds(p, self.V, self.H) for p in [tl, tr, bl, br]):
|
|
62
|
+
continue
|
|
63
|
+
self.model.AddBoolOr([self.model_vars[tl].Not(), self.model_vars[tr].Not(), self.model_vars[bl].Not(), self.model_vars[br].Not()])
|
|
64
|
+
for pos in get_all_pos(self.V, self.H):
|
|
65
|
+
c = get_char(self.board, pos)
|
|
66
|
+
if c.strip() == '':
|
|
67
|
+
continue
|
|
68
|
+
clue = tuple(int(x.strip()) for x in c.split(self.separator))
|
|
69
|
+
self.model.Add(self.model_vars[pos] == 0) # clue cannot be black
|
|
70
|
+
self.enforce_clue(pos, clue) # each clue must be satisfied
|
|
71
|
+
# all blacks are connected
|
|
72
|
+
force_connected_component_using_demand(self.model, self.model_vars)
|
|
73
|
+
for pos in get_all_pos(self.V, self.H):
|
|
74
|
+
self.model.Add(sum([self.model_vars[n] for n in get_neighbors4(pos, self.V, self.H)]) > 0).OnlyEnforceIf(self.model_vars[pos])
|
|
75
|
+
|
|
76
|
+
def enforce_clue(self, pos: Pos, clue: Union[int, tuple[int, int]]):
|
|
77
|
+
neighbors = []
|
|
78
|
+
for direction in [Direction8.UP, Direction8.UP_RIGHT, Direction8.RIGHT, Direction8.DOWN_RIGHT, Direction8.DOWN, Direction8.DOWN_LEFT, Direction8.LEFT, Direction8.UP_LEFT]:
|
|
79
|
+
n = get_next_pos(pos, direction)
|
|
80
|
+
neighbors.append(self.model_vars[n] if in_bounds(n, self.V, self.H) else self.model.NewConstant(False))
|
|
81
|
+
valid_assignments = rotated_assignments_N_nums(Xs=clue)
|
|
82
|
+
self.model.AddAllowedAssignments(neighbors, valid_assignments)
|
|
83
|
+
|
|
84
|
+
def solve_and_print(self, verbose: bool = True):
|
|
85
|
+
def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
|
|
86
|
+
return SingleSolution(assignment={pos: solver.Value(var) for pos, var in board.model_vars.items()})
|
|
87
|
+
def callback(single_res: SingleSolution):
|
|
88
|
+
print("Solution found")
|
|
89
|
+
def get_c(c: str) -> str:
|
|
90
|
+
return (' ' * (2 - len(c)) + c) if len(c) <= 3 else '...'
|
|
91
|
+
print(combined_function(self.V, self.H,
|
|
92
|
+
is_shaded=lambda r, c: single_res.assignment[get_pos(x=c, y=r)] == 1,
|
|
93
|
+
center_char=lambda r, c: get_c(self.board[r, c]),
|
|
94
|
+
text_on_shaded_cells=False
|
|
95
|
+
))
|
|
96
|
+
project_vars = list(self.model_vars.values())
|
|
97
|
+
return generic_unique_projections(self, project_vars, board_to_solution, callback=callback if verbose else None, verbose=verbose)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
from collections import defaultdict
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
from ortools.sat.python import cp_model
|
|
5
|
+
from ortools.sat.python.cp_model import LinearExpr as lxp
|
|
6
|
+
|
|
7
|
+
from puzzle_solver.core.utils import Pos, get_all_pos, get_char, get_pos, get_row_pos, get_col_pos, get_next_pos, Direction, in_bounds
|
|
8
|
+
from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
|
|
9
|
+
from puzzle_solver.core.utils_visualizer import combined_function, id_board_to_wall_fn
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Board:
|
|
13
|
+
def __init__(self, board: np.array, id_board: np.array):
|
|
14
|
+
assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
|
|
15
|
+
assert all((c.item() == ' ') or str(c.item()).isdecimal() for c in np.nditer(board)), 'board must contain only space or digits'
|
|
16
|
+
self.board = board
|
|
17
|
+
self.id_board = id_board
|
|
18
|
+
self.V, self.H = board.shape
|
|
19
|
+
self.pos_to_id: dict[Pos, int] = {pos: int(get_char(id_board, pos)) for pos in get_all_pos(self.V, self.H)}
|
|
20
|
+
self.id_to_pos: dict[int, set[Pos]] = defaultdict(set)
|
|
21
|
+
for pos in get_all_pos(self.V, self.H):
|
|
22
|
+
self.id_to_pos[self.pos_to_id[pos]].add(pos)
|
|
23
|
+
id_lens = set([len(v) for v in self.id_to_pos.values()])
|
|
24
|
+
assert len(id_lens) == 1, f'all tatamis must have the same size, got {id_lens}'
|
|
25
|
+
self.N = list(id_lens)[0]
|
|
26
|
+
|
|
27
|
+
self.model = cp_model.CpModel()
|
|
28
|
+
self.model_vars: dict[tuple[Pos, int], cp_model.IntVar] = {}
|
|
29
|
+
self.create_vars()
|
|
30
|
+
self.add_all_constraints()
|
|
31
|
+
|
|
32
|
+
def create_vars(self):
|
|
33
|
+
for pos in get_all_pos(self.V, self.H):
|
|
34
|
+
for n in range(1, self.N + 1):
|
|
35
|
+
self.model_vars[(pos, n)] = self.model.NewBoolVar(f'{pos}:{n}')
|
|
36
|
+
|
|
37
|
+
def add_all_constraints(self):
|
|
38
|
+
for pos in get_all_pos(self.V, self.H): # every pos has exactly one number
|
|
39
|
+
self.model.AddExactlyOne([self.model_vars[(pos, n)] for n in range(1, self.N + 1)])
|
|
40
|
+
c = get_char(self.board, pos).strip()
|
|
41
|
+
if c: # force clues
|
|
42
|
+
self.model.Add(self.model_vars[(pos, int(c))] == 1)
|
|
43
|
+
for v in range(1, self.N + 1):
|
|
44
|
+
for (_, pos_set) in self.id_to_pos.items(): # a tatami cannot have a repeating number
|
|
45
|
+
self.model.AddExactlyOne([self.model_vars[(pos, v)] for pos in pos_set])
|
|
46
|
+
for row in range(self.V): # numbers repeat X times horizontally
|
|
47
|
+
self.model.Add(lxp.Sum([self.model_vars[(pos, v)] for pos in get_row_pos(row, self.H)]) == int(np.ceil(self.V / self.N)))
|
|
48
|
+
for col in range(self.H): # numbers repeat X times vertically
|
|
49
|
+
self.model.Add(lxp.Sum([self.model_vars[(pos, v)] for pos in get_col_pos(col, self.V)]) == int(np.ceil(self.H / self.N)))
|
|
50
|
+
for pos in get_all_pos(self.V, self.H): # numbers cannot touch horizontally or vertically
|
|
51
|
+
right_pos = get_next_pos(pos, Direction.RIGHT)
|
|
52
|
+
down_pos = get_next_pos(pos, Direction.DOWN)
|
|
53
|
+
if in_bounds(right_pos, self.V, self.H):
|
|
54
|
+
self.model.Add(self.model_vars[(right_pos, v)] == 0).OnlyEnforceIf(self.model_vars[(pos, v)])
|
|
55
|
+
if in_bounds(down_pos, self.V, self.H):
|
|
56
|
+
self.model.Add(self.model_vars[(down_pos, v)] == 0).OnlyEnforceIf(self.model_vars[(pos, v)])
|
|
57
|
+
|
|
58
|
+
def solve_and_print(self, verbose: bool = True):
|
|
59
|
+
def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
|
|
60
|
+
return SingleSolution(assignment={pos: v for (pos, v), var in board.model_vars.items() if solver.Value(var)})
|
|
61
|
+
def callback(single_res: SingleSolution):
|
|
62
|
+
print("Solution found")
|
|
63
|
+
print(combined_function(self.V, self.H, cell_flags=id_board_to_wall_fn(self.id_board), center_char=lambda r, c: str(single_res.assignment.get(get_pos(x=c, y=r), ''))))
|
|
64
|
+
return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from collections import defaultdict
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
from ortools.sat.python import cp_model
|
|
5
|
+
from ortools.sat.python.cp_model import LinearExpr as lxp
|
|
6
|
+
|
|
7
|
+
from puzzle_solver.core.utils import Direction, Pos, get_all_pos, get_char, get_neighbors8, get_next_pos, get_row_pos, get_col_pos, get_opposite_direction, get_pos
|
|
8
|
+
from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
|
|
9
|
+
from puzzle_solver.core.utils_visualizer import combined_function
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Board:
|
|
13
|
+
def __init__(self, board: np.array, side: np.array, top: np.array):
|
|
14
|
+
assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
|
|
15
|
+
assert side.ndim == 1 and side.shape[0] == board.shape[0], 'side must be 1d and equal to board size'
|
|
16
|
+
assert top.ndim == 1 and top.shape[0] == board.shape[1], 'top must be 1d and equal to board size'
|
|
17
|
+
assert all(c.item() in [' ', 'T'] for c in np.nditer(board)), 'board must contain only space or T'
|
|
18
|
+
self.board = board
|
|
19
|
+
self.V, self.H = board.shape
|
|
20
|
+
self.side = side
|
|
21
|
+
self.top = top
|
|
22
|
+
self.non_tree_positions: set[Pos] = {pos for pos in get_all_pos(self.V, self.H) if get_char(self.board, pos) == ' '}
|
|
23
|
+
self.tree_positions: set[Pos] = {pos for pos in get_all_pos(self.V, self.H) if get_char(self.board, pos) == 'T'}
|
|
24
|
+
|
|
25
|
+
self.model = cp_model.CpModel()
|
|
26
|
+
self.is_tent: dict[Pos, cp_model.IntVar] = defaultdict(int)
|
|
27
|
+
self.tent_direction: dict[tuple[Pos, Direction], cp_model.IntVar] = defaultdict(int)
|
|
28
|
+
self.create_vars()
|
|
29
|
+
self.add_all_constraints()
|
|
30
|
+
|
|
31
|
+
def create_vars(self):
|
|
32
|
+
for pos in self.non_tree_positions:
|
|
33
|
+
self.is_tent[pos] = self.model.NewBoolVar(f'{pos}:is_tent')
|
|
34
|
+
for pos in self.tree_positions:
|
|
35
|
+
for direction in Direction:
|
|
36
|
+
tent_pos = get_next_pos(pos, direction)
|
|
37
|
+
if tent_pos not in self.is_tent:
|
|
38
|
+
continue
|
|
39
|
+
opposite_direction = get_opposite_direction(direction)
|
|
40
|
+
tent_direction = self.model.NewBoolVar(f'{pos}:{direction}')
|
|
41
|
+
self.model.Add(tent_direction == 0).OnlyEnforceIf(self.is_tent[tent_pos].Not())
|
|
42
|
+
self.tent_direction[(pos, direction)] = tent_direction
|
|
43
|
+
self.tent_direction[(tent_pos, opposite_direction)] = tent_direction
|
|
44
|
+
|
|
45
|
+
def add_all_constraints(self):
|
|
46
|
+
# - There are exactly as many tents as trees.
|
|
47
|
+
self.model.Add(lxp.sum([self.is_tent[pos] for pos in self.non_tree_positions]) == len(self.tree_positions))
|
|
48
|
+
# - no two tents are adjacent horizontally, vertically or diagonally
|
|
49
|
+
for pos in self.non_tree_positions:
|
|
50
|
+
for neighbor in get_neighbors8(pos, V=self.V, H=self.H, include_self=False):
|
|
51
|
+
self.model.Add(self.is_tent[neighbor] == 0).OnlyEnforceIf(self.is_tent[pos])
|
|
52
|
+
# - the number of tents in each row and column matches the numbers around the edge of the grid
|
|
53
|
+
for row in range(self.V):
|
|
54
|
+
if self.side[row] == -1:
|
|
55
|
+
continue
|
|
56
|
+
row_vars = [self.is_tent[pos] for pos in get_row_pos(row, H=self.H)]
|
|
57
|
+
self.model.Add(lxp.sum(row_vars) == self.side[row])
|
|
58
|
+
for col in range(self.H):
|
|
59
|
+
if self.top[col] == -1:
|
|
60
|
+
continue
|
|
61
|
+
col_vars = [self.is_tent[pos] for pos in get_col_pos(col, V=self.V)]
|
|
62
|
+
self.model.Add(lxp.sum(col_vars) == self.top[col])
|
|
63
|
+
# - it is possible to match tents to trees so that each tree is orthogonally adjacent to its own tent (but may also be adjacent to other tents).
|
|
64
|
+
# each tent is pointing exactly once at a tree
|
|
65
|
+
for pos in self.non_tree_positions:
|
|
66
|
+
var_list = [self.tent_direction[(pos, direction)] for direction in Direction]
|
|
67
|
+
self.model.Add(lxp.sum(var_list) == 1).OnlyEnforceIf(self.is_tent[pos])
|
|
68
|
+
self.model.Add(lxp.sum(var_list) == 0).OnlyEnforceIf(self.is_tent[pos].Not())
|
|
69
|
+
# each tree is pointed at by exactly one tent
|
|
70
|
+
for pos in self.tree_positions:
|
|
71
|
+
var_list = [self.tent_direction[(pos, direction)] for direction in Direction]
|
|
72
|
+
self.model.Add(lxp.sum(var_list) == 1)
|
|
73
|
+
|
|
74
|
+
def solve_and_print(self, verbose: bool = True):
|
|
75
|
+
def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
|
|
76
|
+
return SingleSolution(assignment={pos: solver.value(var) for pos, var in board.is_tent.items() if not isinstance(var, int)})
|
|
77
|
+
def callback(single_res: SingleSolution):
|
|
78
|
+
print("Solution found")
|
|
79
|
+
print(combined_function(self.V, self.H, center_char=lambda r, c: ('|' if self.board[r][c].strip() else ('▲' if single_res.assignment[get_pos(c, r)] == 1 else ' '))))
|
|
80
|
+
return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose, max_solutions=5)
|