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,128 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
from ortools.sat.python import cp_model
|
|
6
|
+
|
|
7
|
+
from puzzle_solver.core.utils import Pos, get_all_pos, in_bounds, set_char, get_char, Direction, get_next_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
|
+
def factor_pairs(N: int, upper_limit_i: int, upper_limit_j: int):
|
|
13
|
+
"""Return all unique pairs (a, b) such that a * b == N, with a, b <= upper_limit."""
|
|
14
|
+
if N <= 0 or upper_limit_i <= 0 or upper_limit_j <= 0:
|
|
15
|
+
return []
|
|
16
|
+
|
|
17
|
+
pairs = []
|
|
18
|
+
i = 1
|
|
19
|
+
while i * i <= N:
|
|
20
|
+
if N % i == 0:
|
|
21
|
+
j = N // i
|
|
22
|
+
if i <= upper_limit_i and j <= upper_limit_j:
|
|
23
|
+
pairs.append((i, j))
|
|
24
|
+
if i != j and j <= upper_limit_i and i <= upper_limit_j:
|
|
25
|
+
pairs.append((j, i))
|
|
26
|
+
i += 1
|
|
27
|
+
return pairs
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class Rectangle:
|
|
32
|
+
active: cp_model.IntVar
|
|
33
|
+
N: int
|
|
34
|
+
clue_id: int
|
|
35
|
+
width: int
|
|
36
|
+
height: int
|
|
37
|
+
body: set[Pos]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Board:
|
|
41
|
+
def __init__(self, board: np.array):
|
|
42
|
+
assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
|
|
43
|
+
assert all((c.item() == ' ') or str(c.item()).isdecimal() for c in np.nditer(board)), 'board must contain only space or digits'
|
|
44
|
+
self.board = board
|
|
45
|
+
self.V, self.H = board.shape
|
|
46
|
+
self.clue_pos: list[Pos] = [pos for pos in get_all_pos(self.V, self.H) if str(get_char(self.board, pos)).isdecimal()]
|
|
47
|
+
self.clue_pos_to_id: dict[Pos, int] = {pos: i for i, pos in enumerate(self.clue_pos)}
|
|
48
|
+
self.clue_pos_to_value: dict[Pos, int] = {pos: int(get_char(self.board, pos)) for pos in self.clue_pos}
|
|
49
|
+
|
|
50
|
+
self.model = cp_model.CpModel()
|
|
51
|
+
self.model_vars: dict[tuple[Pos, Pos], cp_model.IntVar] = {}
|
|
52
|
+
self.rectangles: list[Rectangle] = []
|
|
53
|
+
|
|
54
|
+
self.create_vars()
|
|
55
|
+
self.add_all_constraints()
|
|
56
|
+
|
|
57
|
+
def create_vars(self):
|
|
58
|
+
self.init_rectangles()
|
|
59
|
+
# for each position it belongs to exactly 1 clue
|
|
60
|
+
# instead of iterating over all clues, we only look at the clues that are possible for this position (by looking at the rectangles that contain this position)
|
|
61
|
+
for pos in get_all_pos(self.V, self.H):
|
|
62
|
+
possible_clue_here = {rectangle.clue_id for rectangle in self.rectangles if pos in rectangle.body} # get the clue position for any rectangle that contains this position
|
|
63
|
+
for possible_clue in possible_clue_here:
|
|
64
|
+
self.model_vars[(pos, possible_clue)] = self.model.NewBoolVar(f'{pos}:{possible_clue}')
|
|
65
|
+
|
|
66
|
+
def init_rectangles(self) -> list[Rectangle]:
|
|
67
|
+
self.fixed_pos: set[Pos] = set(self.clue_pos)
|
|
68
|
+
for pos in self.clue_pos: # for each clue on the board
|
|
69
|
+
clue_id = self.clue_pos_to_id[pos]
|
|
70
|
+
clue_num = self.clue_pos_to_value[pos]
|
|
71
|
+
other_fixed_pos = self.fixed_pos - {pos}
|
|
72
|
+
for width, height in factor_pairs(clue_num, self.V, self.H): # for each possible width x height rectangle that can fit the clue
|
|
73
|
+
# if the digit is at pos and we have a width x height rectangle then we can translate the rectangle "0 to width" to the left and "0 to height" to the top
|
|
74
|
+
for dx in range(width):
|
|
75
|
+
for dy in range(height):
|
|
76
|
+
body = {Pos(x=pos.x - dx + i, y=pos.y - dy + j) for i in range(width) for j in range(height)}
|
|
77
|
+
if any(not in_bounds(p, self.V, self.H) for p in body): # a rectangle cannot be out of bounds
|
|
78
|
+
continue
|
|
79
|
+
if any(p in other_fixed_pos for p in body): # a rectangle cannot contain a different clue; each clue is 1 rectangle only
|
|
80
|
+
continue
|
|
81
|
+
rectangle = Rectangle(active=self.model.NewBoolVar(f'{clue_id}'), N=clue_num, clue_id=clue_id, width=width, height=height, body=body)
|
|
82
|
+
self.rectangles.append(rectangle)
|
|
83
|
+
|
|
84
|
+
def add_all_constraints(self):
|
|
85
|
+
# each pos has only 1 rectangle active
|
|
86
|
+
for pos in get_all_pos(self.V, self.H):
|
|
87
|
+
self.model.AddExactlyOne(rectangle.active for rectangle in self.rectangles if pos in rectangle.body)
|
|
88
|
+
# each pos has only 1 clue active
|
|
89
|
+
for pos in get_all_pos(self.V, self.H):
|
|
90
|
+
self.model.AddExactlyOne(self.model_vars[(pos, clue_id)] for clue_id in self.clue_pos_to_id.values() if (pos, clue_id) in self.model_vars)
|
|
91
|
+
# a rectangle being active means all its body ponts to the clue
|
|
92
|
+
for rectangle in self.rectangles:
|
|
93
|
+
is_active = rectangle.active
|
|
94
|
+
for pos in rectangle.body:
|
|
95
|
+
self.model.Add(self.model_vars[(pos, rectangle.clue_id)] == 1).OnlyEnforceIf(is_active)
|
|
96
|
+
|
|
97
|
+
def solve_and_print(self, verbose: bool = True):
|
|
98
|
+
def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
|
|
99
|
+
assignment: dict[Pos, int] = {}
|
|
100
|
+
for rectangle in self.rectangles:
|
|
101
|
+
if solver.Value(rectangle.active) == 1:
|
|
102
|
+
for pos in rectangle.body:
|
|
103
|
+
assignment[pos] = f'id{rectangle.clue_id}:N={rectangle.N}:{rectangle.height}x{rectangle.width}'
|
|
104
|
+
return SingleSolution(assignment=assignment)
|
|
105
|
+
def callback(single_res: SingleSolution):
|
|
106
|
+
print("Solution found")
|
|
107
|
+
res = np.full((self.V, self.H), '', dtype=object)
|
|
108
|
+
id_board = np.full((self.V, self.H), '', dtype=object)
|
|
109
|
+
for pos in get_all_pos(self.V, self.H):
|
|
110
|
+
cur = single_res.assignment[pos]
|
|
111
|
+
set_char(id_board, pos, cur)
|
|
112
|
+
left_pos = get_next_pos(pos, Direction.LEFT)
|
|
113
|
+
right_pos = get_next_pos(pos, Direction.RIGHT)
|
|
114
|
+
top_pos = get_next_pos(pos, Direction.UP)
|
|
115
|
+
bottom_pos = get_next_pos(pos, Direction.DOWN)
|
|
116
|
+
if left_pos not in single_res.assignment or single_res.assignment[left_pos] != cur:
|
|
117
|
+
set_char(res, pos, get_char(res, pos) + 'L')
|
|
118
|
+
if right_pos not in single_res.assignment or single_res.assignment[right_pos] != cur:
|
|
119
|
+
set_char(res, pos, get_char(res, pos) + 'R')
|
|
120
|
+
if top_pos not in single_res.assignment or single_res.assignment[top_pos] != cur:
|
|
121
|
+
set_char(res, pos, get_char(res, pos) + 'U')
|
|
122
|
+
if bottom_pos not in single_res.assignment or single_res.assignment[bottom_pos] != cur:
|
|
123
|
+
set_char(res, pos, get_char(res, pos) + 'D')
|
|
124
|
+
print(combined_function(self.V, self.H,
|
|
125
|
+
cell_flags=lambda r, c: res[r, c],
|
|
126
|
+
center_char=lambda r, c: self.board[r, c] if self.board[r, c] != ' ' else ' '))
|
|
127
|
+
|
|
128
|
+
return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
|
|
@@ -0,0 +1,83 @@
|
|
|
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_pos, Direction, get_next_pos, in_bounds
|
|
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
|
+
def get_orthogonals_with_dist(pos: Pos, V: int, H: int) -> list[tuple[Pos, int]]:
|
|
12
|
+
out = []
|
|
13
|
+
for direction in Direction:
|
|
14
|
+
current_pos = pos
|
|
15
|
+
current_dist = 0
|
|
16
|
+
while True:
|
|
17
|
+
current_pos = get_next_pos(current_pos, direction)
|
|
18
|
+
current_dist += 1
|
|
19
|
+
if not in_bounds(current_pos, V, H):
|
|
20
|
+
break
|
|
21
|
+
out.append((current_pos, current_dist))
|
|
22
|
+
return out
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Board:
|
|
26
|
+
def __init__(self, board: np.array, id_board: np.array):
|
|
27
|
+
assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
|
|
28
|
+
assert id_board.shape == board.shape, f'id_board and board must have the same shape, got {id_board.shape} and {board.shape}'
|
|
29
|
+
assert all((c.item() == ' ') or str(c.item()).isdecimal() for c in np.nditer(board)), 'board must contain only space or digits'
|
|
30
|
+
assert all(str(c.item()).isdecimal() for c in np.nditer(id_board)), 'id_board must contain only digits'
|
|
31
|
+
self.board = board
|
|
32
|
+
self.id_board = id_board
|
|
33
|
+
self.V, self.H = board.shape
|
|
34
|
+
self.id_to_pos: dict[int, set[Pos]] = defaultdict(set)
|
|
35
|
+
for pos in get_all_pos(self.V, self.H):
|
|
36
|
+
self.id_to_pos[int(get_char(self.id_board, pos))].add(pos)
|
|
37
|
+
self.id_to_max_val: dict[int, int] = {id_: len(self.id_to_pos[id_]) for id_ in self.id_to_pos}
|
|
38
|
+
self.model = cp_model.CpModel()
|
|
39
|
+
self.model_vars: dict[tuple[Pos, int], cp_model.IntVar] = {}
|
|
40
|
+
self.pos_vars: dict[Pos, set[cp_model.IntVar]] = defaultdict(set)
|
|
41
|
+
self.create_vars()
|
|
42
|
+
self.add_all_constraints()
|
|
43
|
+
|
|
44
|
+
def create_vars(self):
|
|
45
|
+
for pos in get_all_pos(self.V, self.H):
|
|
46
|
+
id_ = int(get_char(self.id_board, pos))
|
|
47
|
+
max_val = self.id_to_max_val[id_]
|
|
48
|
+
for n in range(1, max_val + 1):
|
|
49
|
+
self.model_vars[(pos, n)] = self.model.NewBoolVar(f'{pos}:{n}')
|
|
50
|
+
self.pos_vars[pos].add(self.model_vars[(pos, n)])
|
|
51
|
+
|
|
52
|
+
def add_all_constraints(self):
|
|
53
|
+
for pos in get_all_pos(self.V, self.H):
|
|
54
|
+
self.model.AddExactlyOne(self.pos_vars[pos]) # each position has exactly one number
|
|
55
|
+
c = get_char(self.board, pos).strip() # force clues
|
|
56
|
+
if c != '':
|
|
57
|
+
self.model.Add(self.model_vars[(pos, int(c))] == 1)
|
|
58
|
+
for id_ in self.id_to_pos: # each group has at most one number
|
|
59
|
+
max_val = self.id_to_max_val[id_]
|
|
60
|
+
for n in range(1, max_val + 1):
|
|
61
|
+
self.model.AddExactlyOne([self.model_vars[(pos, n)] for pos in self.id_to_pos[id_]])
|
|
62
|
+
for pos in get_all_pos(self.V, self.H):
|
|
63
|
+
# if pos is X then neighbors within X cant be X
|
|
64
|
+
orthogonals = get_orthogonals_with_dist(pos, self.V, self.H)
|
|
65
|
+
for neighbor, dist in orthogonals:
|
|
66
|
+
cur_n = dist
|
|
67
|
+
while True:
|
|
68
|
+
if (pos, cur_n) not in self.model_vars: # current position cant be as high as "cur_n"
|
|
69
|
+
break
|
|
70
|
+
if (neighbor, cur_n) not in self.model_vars: # neighbor position cant be as high as "cur_n"
|
|
71
|
+
break
|
|
72
|
+
self.model.Add(self.model_vars[(neighbor, cur_n)] == 0).OnlyEnforceIf(self.model_vars[(pos, cur_n)])
|
|
73
|
+
cur_n += 1
|
|
74
|
+
|
|
75
|
+
def solve_and_print(self, verbose: bool = True):
|
|
76
|
+
def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
|
|
77
|
+
return SingleSolution(assignment={pos: n for (pos, n), var in board.model_vars.items() if solver.Value(var) == 1})
|
|
78
|
+
def callback(single_res: SingleSolution):
|
|
79
|
+
print("Solution found")
|
|
80
|
+
print(combined_function(self.V, self.H,
|
|
81
|
+
cell_flags=lambda r, c: id_board_to_wall_fn(self.id_board)(r, c),
|
|
82
|
+
center_char=lambda r, c: str(single_res.assignment[get_pos(x=c, y=r)])))
|
|
83
|
+
return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
|
|
@@ -0,0 +1,75 @@
|
|
|
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, Direction, get_next_pos, get_opposite_direction, get_pos, get_ray, in_bounds
|
|
7
|
+
from puzzle_solver.core.utils_ortools import and_constraint, generic_solve_all, SingleSolution, force_connected_component
|
|
8
|
+
from puzzle_solver.core.utils_visualizer import combined_function
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Board:
|
|
12
|
+
def __init__(self, board: np.array):
|
|
13
|
+
assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
|
|
14
|
+
assert all((c.item() == ' ') or str(c.item()).isdecimal() for c in np.nditer(board)), 'board must contain only space or digits'
|
|
15
|
+
self.board = board
|
|
16
|
+
self.V, self.H = board.shape
|
|
17
|
+
self.model = cp_model.CpModel()
|
|
18
|
+
self.model_vars: dict[tuple[Pos, Direction], cp_model.IntVar] = {}
|
|
19
|
+
self.create_vars()
|
|
20
|
+
self.add_all_constraints()
|
|
21
|
+
|
|
22
|
+
def create_vars(self):
|
|
23
|
+
for pos in get_all_pos(self.V, self.H):
|
|
24
|
+
for direction in Direction:
|
|
25
|
+
next_pos = get_next_pos(pos, direction)
|
|
26
|
+
opposite_direction = get_opposite_direction(direction)
|
|
27
|
+
if (next_pos, opposite_direction) in self.model_vars:
|
|
28
|
+
self.model_vars[(pos, direction)] = self.model_vars[(next_pos, opposite_direction)]
|
|
29
|
+
elif not in_bounds(next_pos, self.V, self.H):
|
|
30
|
+
self.model_vars[(pos, direction)] = self.model.NewConstant(1)
|
|
31
|
+
else:
|
|
32
|
+
self.model_vars[(pos, direction)] = self.model.NewBoolVar(f'{pos}:{direction}')
|
|
33
|
+
|
|
34
|
+
def add_all_constraints(self):
|
|
35
|
+
for pos in get_all_pos(self.V, self.H):
|
|
36
|
+
c = get_char(self.board, pos)
|
|
37
|
+
if not str(c).isdecimal():
|
|
38
|
+
continue
|
|
39
|
+
self.range_clue(pos, int(c))
|
|
40
|
+
def is_neighbor(pd1: tuple[Pos, Direction], pd2: tuple[Pos, Direction]) -> bool:
|
|
41
|
+
p1, d1 = pd1
|
|
42
|
+
p2, d2 = pd2
|
|
43
|
+
if d1 is None or d2 is None: # cell center, only neighbor to its own walls
|
|
44
|
+
return p1 == p2
|
|
45
|
+
if p1 == p2 and d1 != d2: # same position, different direction, is neighbor
|
|
46
|
+
return True
|
|
47
|
+
if get_next_pos(p1, d1) == p2 and d2 == get_opposite_direction(d1):
|
|
48
|
+
return True
|
|
49
|
+
return False
|
|
50
|
+
not_walls = {k: v.Not() for k, v in self.model_vars.items()}
|
|
51
|
+
cell_centers = {(k, None): self.model.NewConstant(1) for k in get_all_pos(self.V, self.H)}
|
|
52
|
+
force_connected_component(self.model, {**not_walls, **cell_centers}, is_neighbor=is_neighbor)
|
|
53
|
+
|
|
54
|
+
def range_clue(self, pos: Pos, k: int):
|
|
55
|
+
vis_vars: list[cp_model.IntVar] = []
|
|
56
|
+
for direction in Direction: # Build visibility chains in four direction
|
|
57
|
+
ray = get_ray(pos, direction, self.V, self.H, include_self=True) # cells outward
|
|
58
|
+
for idx in range(len(ray)):
|
|
59
|
+
v = self.model.NewBoolVar(f"vis[{pos}]->({direction.name})[{idx}]")
|
|
60
|
+
and_constraint(self.model, target=v, cs=[self.model_vars[(p, direction)].Not() for p in ray[:idx+1]])
|
|
61
|
+
vis_vars.append(v)
|
|
62
|
+
self.model.Add(sum(vis_vars) == int(k)) # Sum of visible whites = 1 (itself) + sum(chains) == k
|
|
63
|
+
|
|
64
|
+
def solve_and_print(self, verbose: bool = True):
|
|
65
|
+
def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
|
|
66
|
+
assignment: dict[Pos, str] = defaultdict(str)
|
|
67
|
+
for pos in get_all_pos(self.V, self.H):
|
|
68
|
+
for direction in Direction:
|
|
69
|
+
if (pos, direction) in board.model_vars and solver.Value(board.model_vars[(pos, direction)]) == 1:
|
|
70
|
+
assignment[pos] += direction.name[0]
|
|
71
|
+
return SingleSolution(assignment=assignment)
|
|
72
|
+
def callback(single_res: SingleSolution):
|
|
73
|
+
print("Solution found")
|
|
74
|
+
print(combined_function(self.V, self.H, cell_flags=lambda r, c: single_res.assignment.get(get_pos(x=c, y=r), ''), center_char=lambda r, c: str(self.board[r, c]).strip()))
|
|
75
|
+
return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose, max_solutions=4)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
import json
|
|
3
|
+
|
|
4
|
+
from ortools.sat.python import cp_model
|
|
5
|
+
|
|
6
|
+
from puzzle_solver.core.utils_ortools import generic_solve_all
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class SingleSolution:
|
|
11
|
+
assignment: dict[int, str]
|
|
12
|
+
|
|
13
|
+
def get_hashable_solution(self) -> str:
|
|
14
|
+
return json.dumps(self.assignment, sort_keys=True)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def all_pairs(lst: list[int]) -> list[tuple[int, int]]:
|
|
18
|
+
for i, ni in enumerate(lst):
|
|
19
|
+
for _j, nj in enumerate(lst[i:]):
|
|
20
|
+
yield ni, nj
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SchurNumbers:
|
|
24
|
+
def __init__(self, colors: list[str], n: int):
|
|
25
|
+
self.N = n
|
|
26
|
+
self.num_colors = len(colors)
|
|
27
|
+
self.int_to_color: dict[int, str] = {i+1: c for i, c in enumerate(colors)}
|
|
28
|
+
|
|
29
|
+
self.model = cp_model.CpModel()
|
|
30
|
+
self.model_vars: dict[int, cp_model.IntVar] = {}
|
|
31
|
+
self.eq_vars: dict[tuple[int, int], cp_model.BoolVar] = {}
|
|
32
|
+
self.create_vars()
|
|
33
|
+
self.add_all_constraints()
|
|
34
|
+
|
|
35
|
+
def create_vars(self):
|
|
36
|
+
for number in range(1, self.N + 1):
|
|
37
|
+
self.model_vars[number] = self.model.NewIntVar(1, self.num_colors, f'{number}:color')
|
|
38
|
+
for other_number in range(number + 1, self.N + 1):
|
|
39
|
+
self.eq_vars[(number, other_number)] = self.model.NewBoolVar(f'{number} == {other_number}')
|
|
40
|
+
|
|
41
|
+
def add_all_constraints(self):
|
|
42
|
+
numbers = list(self.model_vars.keys())
|
|
43
|
+
for (number, other_number) in self.eq_vars.keys(): # enforce auxiliary variables
|
|
44
|
+
v = self.eq_vars[(number, other_number)]
|
|
45
|
+
self.model.Add(self.model_vars[number] == self.model_vars[other_number]).OnlyEnforceIf(v)
|
|
46
|
+
self.model.Add(self.model_vars[number] != self.model_vars[other_number]).OnlyEnforceIf(v.Not())
|
|
47
|
+
|
|
48
|
+
for ni, nj in all_pairs(numbers):
|
|
49
|
+
if ni + nj not in numbers:
|
|
50
|
+
continue
|
|
51
|
+
nk = ni + nj
|
|
52
|
+
self.model.AddBoolOr([self.eq_vars[(ni, nk)].Not(), self.eq_vars[(nj, nk)].Not()])
|
|
53
|
+
|
|
54
|
+
def count_num_ways(self) -> int:
|
|
55
|
+
def board_to_solution(board: SchurNumbers, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
|
|
56
|
+
return SingleSolution(assignment={number: board.int_to_color[solver.Value(var)] for number, var in board.model_vars.items()})
|
|
57
|
+
solutions = generic_solve_all(self, board_to_solution, callback=None, verbose=False)
|
|
58
|
+
return len(solutions), solutions
|
|
59
|
+
|
|
60
|
+
def is_feasible(self) -> bool:
|
|
61
|
+
solver = cp_model.CpSolver()
|
|
62
|
+
solver.solve(self.model)
|
|
63
|
+
return solver.StatusName() in ['OPTIMAL', 'FEASIBLE']
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def find_max_n(colors: list[str], n=1) -> int:
|
|
67
|
+
while True:
|
|
68
|
+
print(f'checking n = {n}')
|
|
69
|
+
solver = SchurNumbers(colors=colors, n=n)
|
|
70
|
+
if not solver.is_feasible():
|
|
71
|
+
return n
|
|
72
|
+
n += 1
|
|
73
|
+
return n
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
from collections import defaultdict
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from enum import Enum
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from ortools.sat.python import cp_model
|
|
7
|
+
|
|
8
|
+
from puzzle_solver.core.utils import Pos, get_all_pos, set_char, get_char, get_neighbors4, in_bounds
|
|
9
|
+
from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
|
|
10
|
+
from puzzle_solver.core.utils_visualizer import render_bw_tiles_split
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
TPos = tuple[int, int]
|
|
14
|
+
|
|
15
|
+
class State(Enum):
|
|
16
|
+
WHITE = 'W'
|
|
17
|
+
BLACK = 'B'
|
|
18
|
+
TOP_LEFT = 'TL'
|
|
19
|
+
TOP_RIGHT = 'TR'
|
|
20
|
+
BOTTOM_LEFT = 'BL'
|
|
21
|
+
BOTTOM_RIGHT = 'BR'
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class Rectangle:
|
|
25
|
+
is_rotated: bool
|
|
26
|
+
width: int
|
|
27
|
+
height: int
|
|
28
|
+
body: frozenset[tuple[TPos, State]]
|
|
29
|
+
disallow_white: frozenset[tuple[TPos]]
|
|
30
|
+
max_x: int
|
|
31
|
+
max_y: int
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class RectangleOnBoard:
|
|
35
|
+
is_active: cp_model.IntVar
|
|
36
|
+
rectangle: Rectangle
|
|
37
|
+
body: frozenset[tuple[Pos, State]]
|
|
38
|
+
body_positions: frozenset[Pos]
|
|
39
|
+
disallow_white: frozenset[Pos]
|
|
40
|
+
translate: Pos
|
|
41
|
+
width: int
|
|
42
|
+
height: int
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def init_rectangles(V: int, H: int) -> list[Rectangle]:
|
|
46
|
+
"""Returns all possible upright and 45 degree rotated rectangles on a VxH board that are NOT translated (i.e. both min_x and min_y are always 0)"""
|
|
47
|
+
rectangles = []
|
|
48
|
+
# up right rectangles
|
|
49
|
+
for height in range(1, V+1):
|
|
50
|
+
for width in range(1, H+1):
|
|
51
|
+
body = {(x, y) for x in range(width) for y in range(height)}
|
|
52
|
+
# disallow any orthogonal adjacent white positions
|
|
53
|
+
disallow_white = set((p[0] + dxdy[0], p[1] + dxdy[1]) for p in body for dxdy in ((1,0),(-1,0),(0,1),(0,-1)))
|
|
54
|
+
disallow_white -= body
|
|
55
|
+
rectangles.append(Rectangle(
|
|
56
|
+
is_rotated=False,
|
|
57
|
+
width=width,
|
|
58
|
+
height=height,
|
|
59
|
+
body={(p, State.WHITE) for p in body},
|
|
60
|
+
disallow_white=disallow_white,
|
|
61
|
+
max_x=width-1,
|
|
62
|
+
max_y=height-1,
|
|
63
|
+
))
|
|
64
|
+
# now imagine rectangles rotated clockwise by 45 degrees
|
|
65
|
+
for height in range(1, V+1):
|
|
66
|
+
for width in range(1, H+1):
|
|
67
|
+
if width + height > V or width + height > H: # this rotated rectangle won't fit
|
|
68
|
+
continue
|
|
69
|
+
body = {}
|
|
70
|
+
tl_body = {(i, height-1-i) for i in range(height)} # top left edge
|
|
71
|
+
tr_body = {(height+i, i) for i in range(width)} # top right edge
|
|
72
|
+
br_body = {(width+height-i-1, width+i) for i in range(height)} # bottom right edge
|
|
73
|
+
bl_body = {(width-i-1, width+height-i-1) for i in range(width)} # bottom left edge
|
|
74
|
+
inner_body = set() # inner body is anything to the right of L and to the left of R
|
|
75
|
+
for y in range(width+height):
|
|
76
|
+
row_is_active = False
|
|
77
|
+
for x in range(width+height):
|
|
78
|
+
if (x, y) in tl_body or (x, y) in bl_body:
|
|
79
|
+
row_is_active = True
|
|
80
|
+
continue
|
|
81
|
+
if (x, y) in tr_body or (x, y) in br_body:
|
|
82
|
+
break
|
|
83
|
+
if row_is_active:
|
|
84
|
+
inner_body.add((x, y))
|
|
85
|
+
tl_body = {(p, State.TOP_LEFT) for p in tl_body}
|
|
86
|
+
tr_body = {(p, State.TOP_RIGHT) for p in tr_body}
|
|
87
|
+
br_body = {(p, State.BOTTOM_RIGHT) for p in br_body}
|
|
88
|
+
bl_body = {(p, State.BOTTOM_LEFT) for p in bl_body}
|
|
89
|
+
inner_body = {(p, State.WHITE) for p in inner_body}
|
|
90
|
+
rectangles.append(Rectangle(
|
|
91
|
+
is_rotated=True, width=width, height=height, body=tl_body | tr_body | br_body | bl_body | inner_body, disallow_white=set(),
|
|
92
|
+
# clear from vizualization, both width and height contribute to both dimensions since it is rotated
|
|
93
|
+
max_x=width + height - 1,
|
|
94
|
+
max_y=width + height - 1,
|
|
95
|
+
))
|
|
96
|
+
return rectangles
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class Board:
|
|
100
|
+
def __init__(self, board: np.array):
|
|
101
|
+
assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
|
|
102
|
+
assert all((c.item() in [' ', 'B', '0', '1', '2', '3', '4']) for c in np.nditer(board)), 'board must contain only space, B, 0, 1, 2, 3, 4'
|
|
103
|
+
self.board = board
|
|
104
|
+
self.V, self.H = board.shape
|
|
105
|
+
self.black_positions: set[Pos] = {pos for pos in get_all_pos(self.V, self.H) if get_char(self.board, pos).strip() != ''}
|
|
106
|
+
self.black_positions_tuple: set[TPos] = {(p.x, p.y) for p in self.black_positions}
|
|
107
|
+
self.pos_to_rectangle_on_board: dict[Pos, list[RectangleOnBoard]] = defaultdict(list)
|
|
108
|
+
self.model = cp_model.CpModel()
|
|
109
|
+
self.B: dict[Pos, cp_model.IntVar] = {}
|
|
110
|
+
self.W: dict[Pos, cp_model.IntVar] = {}
|
|
111
|
+
self.rectangles_on_board: list[RectangleOnBoard] = []
|
|
112
|
+
self.init_rectangles_on_board()
|
|
113
|
+
self.create_vars()
|
|
114
|
+
self.add_all_constraints()
|
|
115
|
+
|
|
116
|
+
def init_rectangles_on_board(self):
|
|
117
|
+
rectangles = init_rectangles(self.V, self.H)
|
|
118
|
+
for rectangle in rectangles:
|
|
119
|
+
# translate
|
|
120
|
+
for dx in range(self.H - rectangle.max_x):
|
|
121
|
+
for dy in range(self.V - rectangle.max_y):
|
|
122
|
+
body: list[tuple[Pos, State]] = [None] * len(rectangle.body)
|
|
123
|
+
for i, (p, s) in enumerate(rectangle.body):
|
|
124
|
+
pp = (p[0] + dx, p[1] + dy)
|
|
125
|
+
body[i] = (pp, s)
|
|
126
|
+
if pp in self.black_positions_tuple:
|
|
127
|
+
body = None
|
|
128
|
+
break
|
|
129
|
+
if body is None:
|
|
130
|
+
continue
|
|
131
|
+
disallow_white = {Pos(x=p[0] + dx, y=p[1] + dy) for p in rectangle.disallow_white}
|
|
132
|
+
body_positions = set((Pos(x=p[0], y=p[1])) for p, _ in body)
|
|
133
|
+
rectangle_on_board = RectangleOnBoard(
|
|
134
|
+
is_active=self.model.NewBoolVar(f'{rectangle.is_rotated}:{rectangle.width}x{rectangle.height}:{dx}:{dy}:is_active'),
|
|
135
|
+
rectangle=rectangle,
|
|
136
|
+
body=set((Pos(x=p[0], y=p[1]), s) for p, s in body),
|
|
137
|
+
body_positions=body_positions,
|
|
138
|
+
disallow_white=disallow_white,
|
|
139
|
+
translate=Pos(x=dx, y=dy),
|
|
140
|
+
width=rectangle.width,
|
|
141
|
+
height=rectangle.height,
|
|
142
|
+
)
|
|
143
|
+
self.rectangles_on_board.append(rectangle_on_board)
|
|
144
|
+
for p in body_positions:
|
|
145
|
+
self.pos_to_rectangle_on_board[p].append(rectangle_on_board)
|
|
146
|
+
|
|
147
|
+
def create_vars(self):
|
|
148
|
+
for pos in get_all_pos(self.V, self.H):
|
|
149
|
+
self.B[pos] = self.model.NewBoolVar(f'B:{pos}')
|
|
150
|
+
self.W[pos] = self.B[pos].Not()
|
|
151
|
+
if pos in self.black_positions:
|
|
152
|
+
self.model.Add(self.B[pos] == 1)
|
|
153
|
+
|
|
154
|
+
def add_all_constraints(self):
|
|
155
|
+
# every position not fixed must be part of exactly one rectangle
|
|
156
|
+
for pos in get_all_pos(self.V, self.H):
|
|
157
|
+
if pos in self.black_positions:
|
|
158
|
+
continue
|
|
159
|
+
self.model.AddExactlyOne([r.is_active for r in self.pos_to_rectangle_on_board[pos]])
|
|
160
|
+
# if a rectangle is active then all its body is black and all its disallow_white is white
|
|
161
|
+
for rectangle_on_board in self.rectangles_on_board:
|
|
162
|
+
for pos, state in rectangle_on_board.body:
|
|
163
|
+
if state == State.WHITE:
|
|
164
|
+
self.model.Add(self.W[pos] == 1).OnlyEnforceIf(rectangle_on_board.is_active)
|
|
165
|
+
else:
|
|
166
|
+
self.model.Add(self.B[pos] == 1).OnlyEnforceIf(rectangle_on_board.is_active)
|
|
167
|
+
for pos in rectangle_on_board.disallow_white:
|
|
168
|
+
if not in_bounds(pos, self.V, self.H):
|
|
169
|
+
continue
|
|
170
|
+
self.model.Add(self.B[pos] == 1).OnlyEnforceIf(rectangle_on_board.is_active)
|
|
171
|
+
# if a position has a clue, enforce it
|
|
172
|
+
for pos in get_all_pos(self.V, self.H):
|
|
173
|
+
c = get_char(self.board, pos)
|
|
174
|
+
if c.strip() != '' and c.strip().isdecimal():
|
|
175
|
+
clue = int(c.strip())
|
|
176
|
+
neighbors = [self.B[p] for p in get_neighbors4(pos, self.V, self.H) if p not in self.black_positions]
|
|
177
|
+
self.model.Add(sum(neighbors) == clue)
|
|
178
|
+
|
|
179
|
+
def solve_and_print(self, verbose: bool = True):
|
|
180
|
+
def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
|
|
181
|
+
assignment: dict[Pos, int] = {}
|
|
182
|
+
for rectangle_on_board in board.rectangles_on_board:
|
|
183
|
+
if solver.Value(rectangle_on_board.is_active) == 1:
|
|
184
|
+
for p, s in rectangle_on_board.body:
|
|
185
|
+
assignment[p] = s.value
|
|
186
|
+
return SingleSolution(assignment=assignment)
|
|
187
|
+
def callback(single_res: SingleSolution):
|
|
188
|
+
print("Solution found")
|
|
189
|
+
res = np.full((self.V, self.H), 'W', dtype=object)
|
|
190
|
+
text = np.full((self.V, self.H), '', dtype=object)
|
|
191
|
+
for pos in get_all_pos(self.V, self.H):
|
|
192
|
+
if pos in single_res.assignment:
|
|
193
|
+
val = single_res.assignment[pos]
|
|
194
|
+
else:
|
|
195
|
+
c = get_char(self.board, pos)
|
|
196
|
+
if c.strip() != '':
|
|
197
|
+
val = 'B'
|
|
198
|
+
text[pos.y][pos.x] = c if c.strip() != 'B' else '.'
|
|
199
|
+
set_char(res, pos, val)
|
|
200
|
+
print(render_bw_tiles_split(res, cell_w=6, cell_h=3, borders=True, mode="text", cell_text=lambda r, c: text[r][c]))
|
|
201
|
+
return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
|