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,207 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This file is a simple helper that parses the images from https://www.chiark.greenend.org.uk/~sgtatham/puzzles/js/inertia.html and converts them to a json file.
|
|
3
|
+
Look at the ./input_output/ directory for examples of input images and output json files.
|
|
4
|
+
The output json is used in the test_solve.py file to test the solver.
|
|
5
|
+
"""
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import numpy as np
|
|
8
|
+
cv = None
|
|
9
|
+
Image = None
|
|
10
|
+
|
|
11
|
+
def load_cell_templates(p: Path) -> dict[str, dict]:
|
|
12
|
+
# img = Image.open(p)
|
|
13
|
+
src = cv.imread(p, cv.IMREAD_COLOR)
|
|
14
|
+
# rgb = np.asarray(img).astype(np.float32) / 255.0
|
|
15
|
+
if len(src.shape) != 2:
|
|
16
|
+
gray = cv.cvtColor(src, cv.COLOR_BGR2GRAY)
|
|
17
|
+
else:
|
|
18
|
+
gray = src
|
|
19
|
+
gray = cv.bitwise_not(gray)
|
|
20
|
+
# bw = cv.adaptiveThreshold(gray.copy(), 255, cv.ADAPTIVE_THRESH_MEAN_C, \
|
|
21
|
+
# cv.THRESH_BINARY, 15, -2)
|
|
22
|
+
return {"gray": gray}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _grad_mag(img: np.ndarray) -> np.ndarray:
|
|
27
|
+
"""Fast gradient magnitude (no dependencies)."""
|
|
28
|
+
img = img.astype(np.float32)
|
|
29
|
+
# forward diffs with clamped prepend to keep shape
|
|
30
|
+
gx = np.diff(img, axis=1, prepend=img[:, :1])
|
|
31
|
+
gy = np.diff(img, axis=0, prepend=img[:1, :])
|
|
32
|
+
return np.hypot(gx, gy)
|
|
33
|
+
|
|
34
|
+
def get_distance_robust(cell: np.ndarray, template: np.ndarray, max_shift: int = 2, use_edges: bool = True) -> float:
|
|
35
|
+
"""
|
|
36
|
+
Distance robust to small translations & brightness/contrast changes.
|
|
37
|
+
- Compares gradient magnitude images (toggle with use_edges).
|
|
38
|
+
- Z-score normalizes each overlap region.
|
|
39
|
+
- Returns the minimum mean squared error across integer shifts
|
|
40
|
+
in [-max_shift, max_shift] for both axes.
|
|
41
|
+
"""
|
|
42
|
+
A = cell.astype(np.float32)
|
|
43
|
+
B = template.astype(np.float32)
|
|
44
|
+
|
|
45
|
+
if use_edges:
|
|
46
|
+
A = _grad_mag(A)
|
|
47
|
+
B = _grad_mag(B)
|
|
48
|
+
|
|
49
|
+
H, W = A.shape
|
|
50
|
+
best = np.inf
|
|
51
|
+
|
|
52
|
+
for dy in range(-max_shift, max_shift + 1):
|
|
53
|
+
for dx in range(-max_shift, max_shift + 1):
|
|
54
|
+
# compute overlapping slices for this shift
|
|
55
|
+
y0a = max(0, dy)
|
|
56
|
+
y1a = H + min(0, dy)
|
|
57
|
+
x0a = max(0, dx)
|
|
58
|
+
x1a = W + min(0, dx)
|
|
59
|
+
y0b = max(0, -dy)
|
|
60
|
+
y1b = H + min(0, -dy)
|
|
61
|
+
x0b = max(0, -dx)
|
|
62
|
+
x1b = W + min(0, -dx)
|
|
63
|
+
|
|
64
|
+
if y1a <= y0a or x1a <= x0a: # no overlap
|
|
65
|
+
continue
|
|
66
|
+
|
|
67
|
+
Aa = A[y0a:y1a, x0a:x1a]
|
|
68
|
+
Bb = B[y0b:y1b, x0b:x1b]
|
|
69
|
+
|
|
70
|
+
# per-overlap z-score to remove brightness/contrast bias
|
|
71
|
+
Aa = (Aa - Aa.mean()) / (Aa.std() + 1e-6)
|
|
72
|
+
Bb = (Bb - Bb.mean()) / (Bb.std() + 1e-6)
|
|
73
|
+
|
|
74
|
+
mse = np.mean((Aa - Bb) ** 2)
|
|
75
|
+
if mse < best:
|
|
76
|
+
best = mse
|
|
77
|
+
return float(best)
|
|
78
|
+
|
|
79
|
+
def distance_to_cell_templates(cell_rgb_image, templates: dict[str, dict]) -> dict[str, float]:
|
|
80
|
+
W, H = 100, 100
|
|
81
|
+
cell = cv.resize(cell_rgb_image, (W, H), interpolation=cv.INTER_LINEAR)
|
|
82
|
+
distances = {}
|
|
83
|
+
for name, rec in templates.items():
|
|
84
|
+
if rec["gray"].shape != (W, H):
|
|
85
|
+
rec["gray"] = cv.resize(rec["gray"], (W, H), interpolation=cv.INTER_LINEAR)
|
|
86
|
+
distances[name] = get_distance_robust(cell, rec["gray"])
|
|
87
|
+
return distances
|
|
88
|
+
|
|
89
|
+
def extract_lines(bw):
|
|
90
|
+
# Create the images that will use to extract the horizontal and vertical lines
|
|
91
|
+
horizontal = np.copy(bw)
|
|
92
|
+
vertical = np.copy(bw)
|
|
93
|
+
|
|
94
|
+
cols = horizontal.shape[1]
|
|
95
|
+
horizontal_size = cols // 5
|
|
96
|
+
# Create structure element for extracting horizontal lines through morphology operations
|
|
97
|
+
horizontalStructure = cv.getStructuringElement(cv.MORPH_RECT, (horizontal_size, 1))
|
|
98
|
+
horizontal = cv.erode(horizontal, horizontalStructure)
|
|
99
|
+
horizontal = cv.dilate(horizontal, horizontalStructure)
|
|
100
|
+
horizontal_means = np.mean(horizontal, axis=1)
|
|
101
|
+
horizontal_cutoff = np.percentile(horizontal_means, 50)
|
|
102
|
+
# location where the horizontal lines are
|
|
103
|
+
horizontal_idx = np.where(horizontal_means > horizontal_cutoff)[0]
|
|
104
|
+
# print(f"horizontal_idx: {horizontal_idx}")
|
|
105
|
+
height = len(horizontal_idx)
|
|
106
|
+
# show_wait_destroy("horizontal", horizontal) # this has the horizontal lines
|
|
107
|
+
|
|
108
|
+
rows = vertical.shape[0]
|
|
109
|
+
verticalsize = rows // 5
|
|
110
|
+
# Create structure element for extracting vertical lines through morphology operations
|
|
111
|
+
verticalStructure = cv.getStructuringElement(cv.MORPH_RECT, (1, verticalsize))
|
|
112
|
+
vertical = cv.erode(vertical, verticalStructure)
|
|
113
|
+
vertical = cv.dilate(vertical, verticalStructure)
|
|
114
|
+
vertical_means = np.mean(vertical, axis=0)
|
|
115
|
+
vertical_cutoff = np.percentile(vertical_means, 50)
|
|
116
|
+
vertical_idx = np.where(vertical_means > vertical_cutoff)[0]
|
|
117
|
+
# print(f"vertical_idx: {vertical_idx}")
|
|
118
|
+
width = len(vertical_idx)
|
|
119
|
+
# print(f"height: {height}, width: {width}")
|
|
120
|
+
# print(f"vertical_means: {vertical_means}")
|
|
121
|
+
# show_wait_destroy("vertical", vertical) # this has the vertical lines
|
|
122
|
+
|
|
123
|
+
vertical = cv.bitwise_not(vertical)
|
|
124
|
+
# show_wait_destroy("vertical_bit", vertical)
|
|
125
|
+
|
|
126
|
+
return (width, height), (horizontal_idx, vertical_idx)
|
|
127
|
+
|
|
128
|
+
def show_wait_destroy(winname, img):
|
|
129
|
+
cv.imshow(winname, img)
|
|
130
|
+
cv.moveWindow(winname, 500, 0)
|
|
131
|
+
cv.waitKey(0)
|
|
132
|
+
cv.destroyWindow(winname)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def main(image):
|
|
137
|
+
global Image
|
|
138
|
+
global cv
|
|
139
|
+
from PIL import Image as Image_module
|
|
140
|
+
import cv2 as cv_module
|
|
141
|
+
Image = Image_module
|
|
142
|
+
cv = cv_module
|
|
143
|
+
CELL_BLANK = load_cell_templates(Path(__file__).parent / 'cells' / 'cell_blank.png')
|
|
144
|
+
CELL_WALL = load_cell_templates(Path(__file__).parent / 'cells' / 'cell_wall.png')
|
|
145
|
+
CELL_GEM = load_cell_templates(Path(__file__).parent / 'cells' / 'cell_gem.png')
|
|
146
|
+
CELL_MINE = load_cell_templates(Path(__file__).parent / 'cells' / 'cell_mine.png')
|
|
147
|
+
CELL_STOP = load_cell_templates(Path(__file__).parent / 'cells' / 'cell_stop.png')
|
|
148
|
+
CELL_START = load_cell_templates(Path(__file__).parent / 'cells' / 'cell_start.png')
|
|
149
|
+
TEMPLATES = {
|
|
150
|
+
"blank": CELL_BLANK,
|
|
151
|
+
"gem": CELL_GEM,
|
|
152
|
+
"mine": CELL_MINE,
|
|
153
|
+
"stop": CELL_STOP,
|
|
154
|
+
"start": CELL_START,
|
|
155
|
+
"wall": CELL_WALL,
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
image_path = Path(image)
|
|
160
|
+
output_path = image_path.parent / (image_path.stem + '.json')
|
|
161
|
+
src = cv.imread(image, cv.IMREAD_COLOR)
|
|
162
|
+
assert src is not None, f'Error opening image: {image}'
|
|
163
|
+
if len(src.shape) != 2:
|
|
164
|
+
gray = cv.cvtColor(src, cv.COLOR_BGR2GRAY)
|
|
165
|
+
else:
|
|
166
|
+
gray = src
|
|
167
|
+
# now the image is in grayscale
|
|
168
|
+
|
|
169
|
+
# Apply adaptiveThreshold at the bitwise_not of gray, notice the ~ symbol
|
|
170
|
+
gray = cv.bitwise_not(gray)
|
|
171
|
+
bw = cv.adaptiveThreshold(gray.copy(), 255, cv.ADAPTIVE_THRESH_MEAN_C, \
|
|
172
|
+
cv.THRESH_BINARY, 15, -2)
|
|
173
|
+
# show_wait_destroy("binary", bw)
|
|
174
|
+
|
|
175
|
+
# show_wait_destroy("src", src)
|
|
176
|
+
(width, height), (horizontal_idx, vertical_idx) = extract_lines(bw)
|
|
177
|
+
print(f"width: {width}, height: {height}")
|
|
178
|
+
print(f"horizontal_idx: {horizontal_idx}")
|
|
179
|
+
print(f"vertical_idx: {vertical_idx}")
|
|
180
|
+
output = np.zeros((height - 1, width - 1), dtype=object)
|
|
181
|
+
output_map = {'blank': ' ', 'gem': 'G', 'mine': 'M', 'stop': 'O', 'start': 'B', 'wall': 'W'}
|
|
182
|
+
for j in range(height - 1):
|
|
183
|
+
for i in range(width - 1):
|
|
184
|
+
hidx1, hidx2 = horizontal_idx[j], horizontal_idx[j+1]
|
|
185
|
+
vidx1, vidx2 = vertical_idx[i], vertical_idx[i+1]
|
|
186
|
+
cell = gray[hidx1:hidx2, vidx1:vidx2]
|
|
187
|
+
# show_wait_destroy(f"cell_{i}_{j}", cell)
|
|
188
|
+
distances = distance_to_cell_templates(cell, TEMPLATES)
|
|
189
|
+
# print(f"distances: {distances}")
|
|
190
|
+
best_match = min(distances, key=distances.get)
|
|
191
|
+
# print(f"best_match: {best_match}")
|
|
192
|
+
# cv.imwrite(f"i_{i}_j_{j}.png", cell)
|
|
193
|
+
output[j, i] = output_map[best_match]
|
|
194
|
+
|
|
195
|
+
with open(output_path, 'w') as f:
|
|
196
|
+
f.write('[\n')
|
|
197
|
+
for i, row in enumerate(output):
|
|
198
|
+
f.write(' ' + str(row.tolist()).replace("'", '"'))
|
|
199
|
+
if i != len(output) - 1:
|
|
200
|
+
f.write(',')
|
|
201
|
+
f.write('\n')
|
|
202
|
+
f.write(']')
|
|
203
|
+
|
|
204
|
+
if __name__ == '__main__':
|
|
205
|
+
main(Path(__file__).parent / 'input_output' / 'inertia.html#15x12%23919933974949365.png')
|
|
206
|
+
main(Path(__file__).parent / 'input_output' / 'inertia.html#15x12%23518193627142459.png')
|
|
207
|
+
main(Path(__file__).parent / 'input_output' / 'inertia.html#20x16%23200992952951435.png')
|
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
from collections import defaultdict, deque
|
|
2
|
+
from typing import Dict, List, Tuple, Set, Any, Optional
|
|
3
|
+
import random
|
|
4
|
+
|
|
5
|
+
from ortools.constraint_solver import pywrapcp, routing_enums_pb2
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
Pos = Any # Hashable node id
|
|
9
|
+
|
|
10
|
+
def solve_optimal_walk(
|
|
11
|
+
start_pos: Pos,
|
|
12
|
+
edges: Set[Tuple[Pos, Pos]],
|
|
13
|
+
gems_to_edges: "defaultdict[Pos, List[Tuple[Pos, Pos]]]",
|
|
14
|
+
*,
|
|
15
|
+
restarts: int, # try more for harder instances (e.g., 48–128)
|
|
16
|
+
time_limit_ms: int, # per restart
|
|
17
|
+
seed: int,
|
|
18
|
+
verbose: bool
|
|
19
|
+
) -> List[Tuple[Pos, Pos]]:
|
|
20
|
+
"""
|
|
21
|
+
Directed edges. For each gem (key in gems_to_edges), traverse >=1 of its directed edges.
|
|
22
|
+
Returns the actual directed walk (edge-by-edge) from start_pos.
|
|
23
|
+
Uses multi-start Noon–Bean + OR-Tools and post-optimizes the representative order.
|
|
24
|
+
|
|
25
|
+
I significantly used AI for the implementation of this function which is why it is a bit messy with useless comments.
|
|
26
|
+
"""
|
|
27
|
+
# ---- Multi-start Noon–Bean + metaheuristic sweeps ----
|
|
28
|
+
meta_list = [
|
|
29
|
+
# routing_enums_pb2.LocalSearchMetaheuristic.GUIDED_LOCAL_SEARCH,
|
|
30
|
+
# routing_enums_pb2.LocalSearchMetaheuristic.SIMULATED_ANNEALING,
|
|
31
|
+
routing_enums_pb2.LocalSearchMetaheuristic.TABU_SEARCH,
|
|
32
|
+
]
|
|
33
|
+
expected_runtime = time_limit_ms * restarts * len(meta_list)
|
|
34
|
+
if verbose:
|
|
35
|
+
print(f'minimum runtime: {expected_runtime/1000:.1f} seconds')
|
|
36
|
+
rng = random.Random(seed)
|
|
37
|
+
|
|
38
|
+
assert start_pos is not None, 'start_pos is required'
|
|
39
|
+
assert edges, 'edges must be non-empty'
|
|
40
|
+
assert gems_to_edges, 'gems_to_edges must be non-empty'
|
|
41
|
+
assert all(all(e in edges for e in elist) for elist in gems_to_edges.values()), \
|
|
42
|
+
'all gem edges must be in edges'
|
|
43
|
+
|
|
44
|
+
nodes = set(u for (u, v) in edges) | set(v for (u, v) in edges)
|
|
45
|
+
assert start_pos in nodes, 'start_pos must be in edges'
|
|
46
|
+
assert all(u in nodes and v in nodes for (u, v) in edges)
|
|
47
|
+
|
|
48
|
+
# ---------- Directed adjacency ----------
|
|
49
|
+
adj: Dict[Pos, List[Pos]] = {u: [] for u in nodes}
|
|
50
|
+
for (u, v) in edges:
|
|
51
|
+
adj[u].append(v)
|
|
52
|
+
|
|
53
|
+
# ---------- States: ONLY the given directed gem edges ----------
|
|
54
|
+
states: List[Tuple[Pos, Pos]] = [] # index -> (tail, head)
|
|
55
|
+
state_group: List[Pos] = [] # index -> gem_id
|
|
56
|
+
group_to_state_indices_original: Dict[Pos, List[int]] = defaultdict(list)
|
|
57
|
+
|
|
58
|
+
for gem_id, elist in gems_to_edges.items():
|
|
59
|
+
for (u, v) in elist:
|
|
60
|
+
idx = len(states)
|
|
61
|
+
states.append((u, v))
|
|
62
|
+
state_group.append(gem_id)
|
|
63
|
+
group_to_state_indices_original[gem_id].append(idx)
|
|
64
|
+
|
|
65
|
+
# Depot node
|
|
66
|
+
DEPOT = len(states)
|
|
67
|
+
states.append((None, None))
|
|
68
|
+
state_group.append("__DEPOT__")
|
|
69
|
+
|
|
70
|
+
N_no_depot = DEPOT
|
|
71
|
+
N = len(states)
|
|
72
|
+
gem_groups = list(group_to_state_indices_original.keys())
|
|
73
|
+
all_gems: Set[Pos] = set(gem_groups)
|
|
74
|
+
|
|
75
|
+
# ---------- Directed shortest paths among relevant nodes ----------
|
|
76
|
+
relevant: Set[Pos] = {start_pos}
|
|
77
|
+
for (tail, head) in states[:N_no_depot]:
|
|
78
|
+
relevant.add(tail)
|
|
79
|
+
relevant.add(head)
|
|
80
|
+
|
|
81
|
+
def bfs_dir(src: Pos) -> Tuple[Dict[Pos, int], Dict[Pos, Optional[Pos]]]:
|
|
82
|
+
dist = {n: float('inf') for n in nodes}
|
|
83
|
+
prev = {n: None for n in nodes}
|
|
84
|
+
if src not in adj:
|
|
85
|
+
return dist, prev
|
|
86
|
+
q = deque([src])
|
|
87
|
+
dist[src] = 0
|
|
88
|
+
while q:
|
|
89
|
+
u = q.popleft()
|
|
90
|
+
for w in adj[u]:
|
|
91
|
+
if dist[w] == float('inf'):
|
|
92
|
+
dist[w] = dist[u] + 1
|
|
93
|
+
prev[w] = u
|
|
94
|
+
q.append(w)
|
|
95
|
+
return dist, prev
|
|
96
|
+
|
|
97
|
+
sp_dist: Dict[Pos, Dict[Pos, int]] = {}
|
|
98
|
+
sp_prev: Dict[Pos, Dict[Pos, Optional[Pos]]] = {}
|
|
99
|
+
for s in relevant:
|
|
100
|
+
d, p = bfs_dir(s)
|
|
101
|
+
sp_dist[s], sp_prev[s] = d, p
|
|
102
|
+
|
|
103
|
+
def reconstruct_path(a: Pos, b: Pos) -> List[Pos]:
|
|
104
|
+
if a == b:
|
|
105
|
+
return [a]
|
|
106
|
+
if sp_dist[a][b] == float('inf'):
|
|
107
|
+
raise ValueError(f"No directed path {a} -> {b}.")
|
|
108
|
+
path = [b]
|
|
109
|
+
cur = b
|
|
110
|
+
prev_map = sp_prev[a]
|
|
111
|
+
while cur != a:
|
|
112
|
+
cur = prev_map[cur]
|
|
113
|
+
if cur is None:
|
|
114
|
+
raise RuntimeError("Predecessor chain broken.")
|
|
115
|
+
path.append(cur)
|
|
116
|
+
path.reverse()
|
|
117
|
+
return path
|
|
118
|
+
|
|
119
|
+
BIG = 10**9
|
|
120
|
+
|
|
121
|
+
def build_base_cost_matrix() -> Tuple[List[List[int]], int]:
|
|
122
|
+
# dist(i->j) = sp(head_i, tail_j) + 1
|
|
123
|
+
# dist(DEPOT->j) = sp(start_pos, tail_j) + 1
|
|
124
|
+
# dist(i->DEPOT) = 0 (end anywhere)
|
|
125
|
+
C = [[0]*N for _ in range(N)]
|
|
126
|
+
max_base = 0
|
|
127
|
+
for i in range(N):
|
|
128
|
+
for j in range(N):
|
|
129
|
+
if i == j:
|
|
130
|
+
c = 0
|
|
131
|
+
elif i == DEPOT and j == DEPOT:
|
|
132
|
+
c = 0
|
|
133
|
+
elif i == DEPOT:
|
|
134
|
+
tail_j, _ = states[j]
|
|
135
|
+
d = 0 if start_pos == tail_j else sp_dist[start_pos][tail_j]
|
|
136
|
+
c = BIG if d == float('inf') else d + 1
|
|
137
|
+
elif j == DEPOT:
|
|
138
|
+
c = 0
|
|
139
|
+
else:
|
|
140
|
+
_, head_i = states[i]
|
|
141
|
+
tail_j, _ = states[j]
|
|
142
|
+
d = 0 if head_i == tail_j else sp_dist[head_i][tail_j]
|
|
143
|
+
c = BIG if d == float('inf') else d + 1
|
|
144
|
+
C[i][j] = c
|
|
145
|
+
if i != j and i != DEPOT and j != DEPOT and c < BIG:
|
|
146
|
+
if c > max_base:
|
|
147
|
+
max_base = c
|
|
148
|
+
return C, max_base
|
|
149
|
+
|
|
150
|
+
C_base, max_base = build_base_cost_matrix()
|
|
151
|
+
|
|
152
|
+
edge_to_gems: Dict[Tuple[Pos, Pos], Set[Pos]] = defaultdict(set)
|
|
153
|
+
for g, elist in gems_to_edges.items():
|
|
154
|
+
for e in elist:
|
|
155
|
+
edge_to_gems[e].add(g)
|
|
156
|
+
|
|
157
|
+
# ---- Coverage-aware stitching cost for a sequence of representatives ----
|
|
158
|
+
def build_walk_from_reps(rep_seq: List[int]) -> Tuple[List[Pos], Set[Pos]]:
|
|
159
|
+
"""Return (walk_nodes, covered_gems) for given representative state indices."""
|
|
160
|
+
covered: Set[Pos] = set()
|
|
161
|
+
walk_nodes: List[Pos] = [start_pos]
|
|
162
|
+
cur = start_pos
|
|
163
|
+
# map states idx -> (tail, head)
|
|
164
|
+
for st in rep_seq:
|
|
165
|
+
tail, head = states[st]
|
|
166
|
+
# skip if gem already covered
|
|
167
|
+
g = state_group[st]
|
|
168
|
+
if g in covered:
|
|
169
|
+
continue
|
|
170
|
+
# connector
|
|
171
|
+
if cur != tail:
|
|
172
|
+
path = reconstruct_path(cur, tail)
|
|
173
|
+
# mark gems on connector
|
|
174
|
+
for i in range(len(path)-1):
|
|
175
|
+
e = (path[i], path[i+1])
|
|
176
|
+
if e in edge_to_gems:
|
|
177
|
+
covered.update(edge_to_gems[e])
|
|
178
|
+
walk_nodes.extend(path[1:])
|
|
179
|
+
cur = tail
|
|
180
|
+
if g in covered:
|
|
181
|
+
continue
|
|
182
|
+
# traverse rep edge
|
|
183
|
+
walk_nodes.append(head)
|
|
184
|
+
cur = head
|
|
185
|
+
if (tail, head) in edge_to_gems:
|
|
186
|
+
covered.update(edge_to_gems[(tail, head)])
|
|
187
|
+
return walk_nodes, covered
|
|
188
|
+
|
|
189
|
+
def walk_edges(nodes_seq: List[Pos]) -> List[Tuple[Pos, Pos]]:
|
|
190
|
+
return [(nodes_seq[i], nodes_seq[i+1]) for i in range(len(nodes_seq)-1)]
|
|
191
|
+
|
|
192
|
+
def simplify_ping_pongs(nodes_seq: List[Pos]) -> List[Pos]:
|
|
193
|
+
"""Remove u->v->u pairs if they don't lose coverage."""
|
|
194
|
+
ns = list(nodes_seq)
|
|
195
|
+
changed = True
|
|
196
|
+
while changed:
|
|
197
|
+
changed = False
|
|
198
|
+
i = 0
|
|
199
|
+
while i + 3 < len(ns):
|
|
200
|
+
u, v, w = ns[i], ns[i+1], ns[i+2]
|
|
201
|
+
if w == u: # u->v, v->u
|
|
202
|
+
before_edges = walk_edges(ns[:i+1])
|
|
203
|
+
removed_edges = [(u, v), (v, u)]
|
|
204
|
+
after_edges = walk_edges([u] + ns[i+3:])
|
|
205
|
+
covered_before = set()
|
|
206
|
+
for e in before_edges:
|
|
207
|
+
if e in edge_to_gems:
|
|
208
|
+
covered_before.update(edge_to_gems[e])
|
|
209
|
+
covered_removed = set()
|
|
210
|
+
for e in removed_edges:
|
|
211
|
+
if e in edge_to_gems:
|
|
212
|
+
covered_removed.update(edge_to_gems[e])
|
|
213
|
+
covered_after = set()
|
|
214
|
+
for e in after_edges:
|
|
215
|
+
if e in edge_to_gems:
|
|
216
|
+
covered_after.update(edge_to_gems[e])
|
|
217
|
+
if all_gems.issubset(covered_before | covered_after):
|
|
218
|
+
del ns[i+1:i+3] # drop v,u
|
|
219
|
+
changed = True
|
|
220
|
+
continue
|
|
221
|
+
i += 1
|
|
222
|
+
return ns
|
|
223
|
+
|
|
224
|
+
def true_walk_cost(nodes_seq: List[Pos]) -> int:
|
|
225
|
+
# Number of edges (unit cost)
|
|
226
|
+
return len(nodes_seq) - 1
|
|
227
|
+
|
|
228
|
+
# ---- Noon–Bean + OR-Tools single run (with given cluster ring orders and metaheuristic) ----
|
|
229
|
+
def solve_once(cluster_orders: Dict[Pos, List[int]], metaheuristic):
|
|
230
|
+
# Build Noon–Bean cost matrix from C_base
|
|
231
|
+
M = (max_base + 1) * (N + 5)
|
|
232
|
+
D = [row[:] for row in C_base] # copy
|
|
233
|
+
|
|
234
|
+
# add M to inter-cluster (excluding depot)
|
|
235
|
+
for i in range(N_no_depot):
|
|
236
|
+
gi = state_group[i]
|
|
237
|
+
for j in range(N_no_depot):
|
|
238
|
+
if i == j:
|
|
239
|
+
continue
|
|
240
|
+
gj = state_group[j]
|
|
241
|
+
if gi != gj:
|
|
242
|
+
D[i][j] += M
|
|
243
|
+
|
|
244
|
+
# ring + shift
|
|
245
|
+
INF = 10**12
|
|
246
|
+
succ_in_cluster: Dict[int, int] = {}
|
|
247
|
+
for order in cluster_orders.values():
|
|
248
|
+
k = len(order)
|
|
249
|
+
if k == 0:
|
|
250
|
+
continue
|
|
251
|
+
pred = {}
|
|
252
|
+
for idx, v in enumerate(order):
|
|
253
|
+
pred[v] = order[(idx - 1) % k]
|
|
254
|
+
succ_in_cluster[v] = order[(idx + 1) % k]
|
|
255
|
+
# block intra except ring
|
|
256
|
+
for a in order:
|
|
257
|
+
for b in order:
|
|
258
|
+
if a != b:
|
|
259
|
+
D[a][b] = INF
|
|
260
|
+
# ring arcs
|
|
261
|
+
for a in order:
|
|
262
|
+
D[a][succ_in_cluster[a]] = 0
|
|
263
|
+
# shift outgoing to pred
|
|
264
|
+
for v in order:
|
|
265
|
+
pv = pred[v]
|
|
266
|
+
for t in range(N):
|
|
267
|
+
if t in order or v == t or v == DEPOT:
|
|
268
|
+
continue
|
|
269
|
+
if state_group[t] == "__DEPOT__":
|
|
270
|
+
if D[v][t] < INF:
|
|
271
|
+
D[pv][t] = min(D[pv][t], D[v][t])
|
|
272
|
+
D[v][t] = INF
|
|
273
|
+
else:
|
|
274
|
+
if D[v][t] < INF:
|
|
275
|
+
D[pv][t] = min(D[pv][t], D[v][t])
|
|
276
|
+
D[v][t] = INF
|
|
277
|
+
|
|
278
|
+
# OR-Tools
|
|
279
|
+
manager = pywrapcp.RoutingIndexManager(N, 1, DEPOT)
|
|
280
|
+
routing = pywrapcp.RoutingModel(manager)
|
|
281
|
+
|
|
282
|
+
def transit_cb(from_index, to_index):
|
|
283
|
+
i = manager.IndexToNode(from_index)
|
|
284
|
+
j = manager.IndexToNode(to_index)
|
|
285
|
+
return int(D[i][j])
|
|
286
|
+
|
|
287
|
+
transit_cb_index = routing.RegisterTransitCallback(transit_cb)
|
|
288
|
+
routing.SetArcCostEvaluatorOfAllVehicles(transit_cb_index)
|
|
289
|
+
|
|
290
|
+
params = pywrapcp.DefaultRoutingSearchParameters()
|
|
291
|
+
params.first_solution_strategy = routing_enums_pb2.FirstSolutionStrategy.PATH_CHEAPEST_ARC
|
|
292
|
+
params.local_search_metaheuristic = metaheuristic
|
|
293
|
+
params.time_limit.FromMilliseconds(max(0.01, time_limit_ms))
|
|
294
|
+
params.log_search = False
|
|
295
|
+
|
|
296
|
+
solution = routing.SolveWithParameters(params)
|
|
297
|
+
if solution is None:
|
|
298
|
+
return None, None, None, None
|
|
299
|
+
|
|
300
|
+
# decode tour
|
|
301
|
+
route: List[int] = []
|
|
302
|
+
idx = routing.Start(0)
|
|
303
|
+
while not routing.IsEnd(idx):
|
|
304
|
+
route.append(manager.IndexToNode(idx))
|
|
305
|
+
idx = solution.Value(routing.NextVar(idx))
|
|
306
|
+
route.append(manager.IndexToNode(idx)) # DEPOT
|
|
307
|
+
|
|
308
|
+
# representatives via leaving events (succ(p))
|
|
309
|
+
rep_idxs: List[int] = []
|
|
310
|
+
seen_gems: Set[Pos] = set()
|
|
311
|
+
for a, b in zip(route, route[1:]):
|
|
312
|
+
ga, gb = state_group[a], state_group[b]
|
|
313
|
+
if ga == "__DEPOT__" or ga == gb:
|
|
314
|
+
continue
|
|
315
|
+
if ga in seen_gems:
|
|
316
|
+
continue
|
|
317
|
+
# chosen representative is succ(a)
|
|
318
|
+
if a in cluster_orders.get(ga, []):
|
|
319
|
+
# find succ
|
|
320
|
+
order = cluster_orders[ga]
|
|
321
|
+
ai = order.index(a)
|
|
322
|
+
rep = order[(ai + 1) % len(order)]
|
|
323
|
+
rep_idxs.append(rep)
|
|
324
|
+
seen_gems.add(ga)
|
|
325
|
+
|
|
326
|
+
return rep_idxs, succ_in_cluster, D, route
|
|
327
|
+
|
|
328
|
+
best_nodes = None
|
|
329
|
+
best_cost = float('inf')
|
|
330
|
+
|
|
331
|
+
# initial deterministic order as a baseline
|
|
332
|
+
def shuffled_cluster_orders():
|
|
333
|
+
orders = {}
|
|
334
|
+
for g, idxs in group_to_state_indices_original.items():
|
|
335
|
+
order = idxs[:] # copy existing indexing order
|
|
336
|
+
rng.shuffle(order) # randomize ring to mitigate Noon–Bean bias
|
|
337
|
+
orders[g] = order
|
|
338
|
+
return orders
|
|
339
|
+
|
|
340
|
+
attempts = max(1, restarts)
|
|
341
|
+
for _ in range(attempts):
|
|
342
|
+
cluster_orders = shuffled_cluster_orders()
|
|
343
|
+
for meta in meta_list:
|
|
344
|
+
# print('solve once')
|
|
345
|
+
rep_idxs, _, _, _ = solve_once(cluster_orders, meta)
|
|
346
|
+
# print('solve once done')
|
|
347
|
+
if rep_idxs is None:
|
|
348
|
+
continue
|
|
349
|
+
|
|
350
|
+
# -------- Local 2-opt on representative order (under true walk cost) --------
|
|
351
|
+
# Start from the order returned by the solver
|
|
352
|
+
reps = rep_idxs[:]
|
|
353
|
+
|
|
354
|
+
def reps_to_nodes_and_cost(rep_seq: List[int]) -> Tuple[List[Pos], int]:
|
|
355
|
+
nodes_seq, covered = build_walk_from_reps(rep_seq)
|
|
356
|
+
# ensure full coverage; otherwise penalize
|
|
357
|
+
if not all_gems.issubset(covered):
|
|
358
|
+
return nodes_seq, len(nodes_seq) - 1 + 10**6
|
|
359
|
+
nodes_seq = simplify_ping_pongs(nodes_seq)
|
|
360
|
+
return nodes_seq, true_walk_cost(nodes_seq)
|
|
361
|
+
|
|
362
|
+
improved = True
|
|
363
|
+
nodes_seq, cost = reps_to_nodes_and_cost(reps)
|
|
364
|
+
while improved:
|
|
365
|
+
improved = False
|
|
366
|
+
n = len(reps)
|
|
367
|
+
# classic 2-opt swap on the order of representatives
|
|
368
|
+
for i in range(n):
|
|
369
|
+
for j in range(i+1, n):
|
|
370
|
+
new_reps = reps[:i] + reps[i:j+1][::-1] + reps[j+1:]
|
|
371
|
+
new_nodes, new_cost = reps_to_nodes_and_cost(new_reps)
|
|
372
|
+
if new_cost < cost:
|
|
373
|
+
reps = new_reps
|
|
374
|
+
# print('2-opt improved cost from', cost, 'to', new_cost)
|
|
375
|
+
nodes_seq, cost = new_nodes, new_cost
|
|
376
|
+
improved = True
|
|
377
|
+
break
|
|
378
|
+
if improved:
|
|
379
|
+
break
|
|
380
|
+
|
|
381
|
+
if cost < best_cost:
|
|
382
|
+
best_cost = cost
|
|
383
|
+
best_nodes = nodes_seq
|
|
384
|
+
|
|
385
|
+
if best_nodes is None:
|
|
386
|
+
raise RuntimeError("No solution found.")
|
|
387
|
+
# print('final check')
|
|
388
|
+
# Final checks and edge list
|
|
389
|
+
edge_walk: List[Tuple[Pos, Pos]] = [(best_nodes[i], best_nodes[i+1]) for i in range(len(best_nodes)-1)]
|
|
390
|
+
assert all(e in edges for e in edge_walk), "Output contains an edge not in the input directed edges."
|
|
391
|
+
|
|
392
|
+
# Ensure all gems covered
|
|
393
|
+
covered_final: Set[Pos] = set()
|
|
394
|
+
for e in edge_walk:
|
|
395
|
+
if e in edge_to_gems:
|
|
396
|
+
covered_final.update(edge_to_gems[e])
|
|
397
|
+
missing = all_gems - covered_final
|
|
398
|
+
assert not missing, f"Walk lost coverage for gems: {missing}"
|
|
399
|
+
|
|
400
|
+
return edge_walk
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from ortools.sat.python import cp_model
|
|
3
|
+
|
|
4
|
+
from puzzle_solver.core.utils import Pos, get_all_pos, get_pos
|
|
5
|
+
from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
|
|
6
|
+
from puzzle_solver.core.utils_visualizer import combined_function
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Board:
|
|
10
|
+
def __init__(self, side: np.array, bottom: np.array):
|
|
11
|
+
assert side.ndim == 1, f'side must be 1d, got {side.ndim}'
|
|
12
|
+
assert bottom.ndim == 1, f'bottom must be 1d, got {bottom.ndim}'
|
|
13
|
+
self.V = side.shape[0]
|
|
14
|
+
self.H = bottom.shape[0]
|
|
15
|
+
self.side = side
|
|
16
|
+
self.bottom = bottom
|
|
17
|
+
self.model = cp_model.CpModel()
|
|
18
|
+
self.model_vars: dict[Pos, 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
|
+
self.model_vars[pos] = self.model.NewBoolVar(f'{pos}')
|
|
25
|
+
|
|
26
|
+
def add_all_constraints(self):
|
|
27
|
+
for row in range(self.V):
|
|
28
|
+
self.model.Add(sum([self.model_vars[get_pos(x=col, y=row)] * (col + 1) for col in range(self.H)]) == self.side[row])
|
|
29
|
+
for col in range(self.H):
|
|
30
|
+
self.model.Add(sum([self.model_vars[get_pos(x=col, y=row)] * (row + 1) for row in range(self.V)]) == self.bottom[col])
|
|
31
|
+
|
|
32
|
+
def solve_and_print(self, verbose: bool = True):
|
|
33
|
+
def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
|
|
34
|
+
return SingleSolution(assignment={pos: solver.Value(var) for pos, var in board.model_vars.items()})
|
|
35
|
+
def callback(single_res: SingleSolution):
|
|
36
|
+
print("Solution found")
|
|
37
|
+
print(combined_function(self.V, self.H, is_shaded=lambda r, c: single_res.assignment[get_pos(x=c, y=r)]))
|
|
38
|
+
return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
|