11"""Everyone Codes Day 15."""
22import collections
3- import itertools
4- import logging
53import 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
131131TEST_DATA = [
@@ -152,5 +152,4 @@ def get_them_all():
152152TESTS = [
153153 (1 , TEST_DATA [0 ], 26 ),
154154 (2 , TEST_DATA [1 ], 38 ),
155- # (3, TEST_DATA[2], None),
156155]
0 commit comments