Skip to content

Commit 2fdb930

Browse files
committed
Everybody Codes: tidy py/2024/15
1 parent a6f236f commit 2fdb930

File tree

1 file changed

+94
-95
lines changed

1 file changed

+94
-95
lines changed

everybody_codes/quest_15.py

Lines changed: 94 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,21 @@
11
"""Everyone Codes Day 15."""
22
import collections
3-
import itertools
4-
import logging
53
import queue
64

7-
DIRECTIONS = [complex(0, 1), complex(0, -1), complex(1, 0), complex(-1, 0)]
5+
Point = tuple[int, int]
86

97

10-
def solve(part: int, data: str) -> int:
11-
"""Solve the parts."""
12-
# Parse
13-
starts = set()
14-
spaces = set()
15-
plants = collections.defaultdict(set)
16-
lines = data.splitlines()
17-
for y, line in enumerate(lines):
18-
for x, char in enumerate(line):
19-
if char in "#~":
20-
continue
21-
pos = (x, y)
22-
if char.isalpha():
23-
plants[char].add(pos)
24-
spaces.add(pos)
25-
if y == 0:
26-
starts.add(pos)
27-
assert len(starts) == 1
28-
plantmap = {pos: plant for plant, positions in plants.items() for pos in positions}
29-
start = starts.copy().pop()
30-
all_plant_types = frozenset(plants)
31-
all_want = frozenset(all_plant_types | {"start"})
32-
all_plant_pos = set().union(*plants.values())
33-
points_of_interest = starts | all_plant_pos
34-
logging.info(f"Points: {len(points_of_interest)}. Plant types: {len(plants)}")
8+
def get_distances(points_of_interest: set[Point], spaces: set[Point]) -> dict[Point, dict[Point, int]]:
9+
"""Return distances between points of interest."""
3510

36-
def neighbors(pos):
11+
def neighbors(pos: Point) -> list[Point]:
12+
"""Return all the neighboring accessible map positions."""
3713
x, y = pos
3814
n = [(x + 1, y + 0), (x - 1, y + 0), (x + 0, y + 1), (x + 0, y - 1)]
3915
return [i for i in n if i in spaces]
4016

41-
def get_dist(starting):
17+
def get_dist(starting: Point) -> dict[Point, int]:
18+
"""Return the distance from a starting position to all other points of interest. Dijkstra."""
4219
bfs = collections.deque([(0, starting)])
4320
found = 0
4421
want = len(points_of_interest)
@@ -50,82 +27,105 @@ def get_dist(starting):
5027
distances[pos] = steps
5128
found += 1
5229
steps += 1
53-
for nd in neighbors(pos):
54-
if nd not in seen:
55-
bfs.append((steps, nd))
56-
seen.add(nd)
30+
for neighbor in neighbors(pos):
31+
if neighbor not in seen:
32+
bfs.append((steps, neighbor))
33+
seen.add(neighbor)
5734
return distances
5835

59-
# 3. Part two, second attempt.
60-
# logging.info({t: len(u) for t, u in plants.items()})
61-
dist = {pos: get_dist(pos) for pos in points_of_interest}
36+
# Build a map of the distance from every point of interest to every other point of interest (POI).
37+
return {pos: get_dist(pos) for pos in points_of_interest}
6238

63-
if part < 3:
64-
shortest = None
65-
for ordering in itertools.permutations(plants):
66-
s = min(
67-
sum(dist[a][b] for a, b in zip(path, list(path)[1:]))
68-
for path in itertools.product(starts, *[plants[i] for i in ordering], starts)
69-
)
70-
if shortest is None or s < shortest:
71-
logging.info(f"{ordering} is shorter than {shortest} at {s}")
72-
shortest = s
73-
else:
74-
logging.info(f"{ordering} is longer than {shortest} at {s}")
75-
return shortest
7639

77-
# logging.info("Part three")
78-
logging.info("Distance graph built.")
40+
def solve(part: int, data: str) -> int:
41+
"""Solve the parts."""
42+
del part
43+
# Parse the input, building various structures.
44+
starts = set()
45+
spaces = set()
46+
plants = collections.defaultdict(set)
47+
lines = data.splitlines()
48+
for y, line in enumerate(lines):
49+
for x, char in enumerate(line):
50+
if char in "#~":
51+
continue
52+
pos = (x, y)
53+
if char.isalpha():
54+
plants[char].add(pos)
55+
spaces.add(pos)
56+
if y == 0:
57+
starts.add(pos)
58+
59+
start = starts.copy().pop()
60+
# All the plant types to collect.
61+
all_plant_types = frozenset(plants)
62+
# All the nodes in the path -- one of each plant type plus return to the start.
63+
all_want = frozenset(all_plant_types | {"start"})
64+
points_of_interest = starts.union(*plants.values())
7965

66+
distances = get_distances(points_of_interest, spaces)
67+
# For every point of interest, get the nearest plant of every type. Distance, position, plant type.
8068
nearest = {
81-
pos: sorted((*min((dist[pos][p], p) for p in plants[t]), t) for t in all_plant_types)
69+
pos: sorted((*min((distances[pos][p], p) for p in plants[t]), t) for t in all_plant_types)
8270
for pos in points_of_interest
8371
}
84-
logging.info("Nearest graph built.")
8572

86-
def get_them_all():
87-
most_found = 0
88-
q = queue.PriorityQueue()
89-
q.put((0, set(), start))
90-
counts = collections.defaultdict(int)
91-
seen = set()
92-
while not q.empty():
93-
steps, found, pos = q.get()
94-
counts[frozenset(found)] += 1
95-
if len(found) > most_found:
96-
logging.info(f"Found {len(found)} in {steps=} at qlen {q.qsize()}")
97-
most_found = len(found)
98-
# logging.info(dict(counts))
99-
if found == all_want:
100-
return steps
101-
elif found == all_plant_types:
102-
logging.info(f"Found a solution: {steps + dist[pos][start]}")
103-
q.put((steps + dist[pos][start], all_want, start))
104-
else:
105-
next_candidates = [(d, p, t) for d, p, t in nearest[pos] if t not in found][:3]
106-
assert next_candidates
107-
candidates = []
108-
for c_dist, c_pos, c_type in next_candidates:
109-
c_found = found | {c_type}
110-
if c_found == all_plant_types:
111-
next_next_candidates = starts
112-
else:
113-
next_next_candidates = [p for d, p, t in nearest[c_pos] if t not in c_found][:3]
114-
assert next_next_candidates
115-
for next_next_pos in next_next_candidates:
116-
consider = min((dist[pos][next_pos] + dist[next_pos][next_next_pos], next_pos) for next_pos in plants[c_type])[1]
117-
candidates.append((consider, c_type))
118-
119-
assert candidates
120-
for next_pos, next_type in candidates:
121-
next_have = frozenset(found | {next_type})
122-
assert len(next_have) > len(found), f"{found=}, {next_type=}, {next_have=}"
123-
d = (steps + dist[pos][next_pos], next_have, next_pos)
73+
# Approach, tailored to the specifics of the puzzle input.
74+
# Plants are grouped in rooms; all plants of a given type are only found in one room.
75+
# Two optimizations.
76+
# 1. Rather than considering all uncollected plants as candidates for the next step,
77+
# only consider the closest three types.
78+
# 2. Rather than considering the path through every plant in a room, only track the path through a few plants.
79+
# The first optimization can be done using the `nearest` which maps the nearest plant of each type to any given POI.
80+
# The second optimization builds on the first.
81+
# * Given a position, figure out the nearest three plant types we want to consider.
82+
# * For each candidate plant type, find the closest position with that plant (candidate position).
83+
# * For each candidate position, find the next-next three closest plant types and positions.
84+
# * For each next plant type and its associated three next-next plant positions,
85+
# pick positions for the next plant type which minimizes the distance to the next-next plant positions.
86+
#
87+
# Assume we're currently at S and considering plants A, B, C.
88+
# From A we can next visit A1, A2, A3.
89+
# Select the three plants of type A which minimizes the distance from S through A to A1, A2, A3.
90+
# Repeat for B, C.
91+
q: queue.PriorityQueue[tuple[int, frozenset[str], Point]] = queue.PriorityQueue()
92+
# Steps, items collected, position.
93+
q.put((0, frozenset(), start))
94+
seen = set()
95+
while not q.empty():
96+
steps, found, pos = q.get()
97+
# If we collected everything, this is the solition.
98+
if found == all_want:
99+
return steps
100+
# Nearly done! Return to start.
101+
if found == all_plant_types:
102+
q.put((steps + distances[pos][start], all_want, start))
103+
else:
104+
# Pick the next three closest plant types as candidates.
105+
next_candidates = [(d, p, t) for d, p, t in nearest[pos] if t not in found][:3]
106+
# Ugly hack to prune candidates based on distance. Be a bit greedy.
107+
if len(next_candidates) > 2 and next_candidates[-1][0] > next_candidates[0][0] * 2:
108+
next_candidates.pop()
109+
# For each candidate plant type, pick three positions which minimize distance
110+
# through that plant type to the next-next plant type.
111+
for _, c_pos, next_type in next_candidates:
112+
next_have = frozenset(found | {next_type})
113+
# Once we have all the plant types, the next-next type is the start.
114+
if next_have == all_plant_types:
115+
next_next_candidates = list(starts)
116+
else:
117+
next_next_candidates = [p for d, p, t in nearest[c_pos] if t not in next_have][:3]
118+
for next_next_pos in next_next_candidates:
119+
# Compute the min distance for all next_type plants.
120+
next_pos = min(
121+
(distances[pos][next_pos] + distances[next_pos][next_next_pos], next_pos)
122+
for next_pos in plants[next_type]
123+
)[1]
124+
d = (steps + distances[pos][next_pos], next_have, next_pos)
124125
if d not in seen:
125126
q.put(d)
126127
seen.add(d)
127-
128-
return get_them_all()
128+
raise RuntimeError("No solution found.")
129129

130130

131131
TEST_DATA = [
@@ -152,5 +152,4 @@ def get_them_all():
152152
TESTS = [
153153
(1, TEST_DATA[0], 26),
154154
(2, TEST_DATA[1], 38),
155-
# (3, TEST_DATA[2], None),
156155
]

0 commit comments

Comments
 (0)