1
1
"""Everyone Codes Day 15."""
2
2
import collections
3
- import itertools
4
- import logging
5
3
import queue
6
4
7
- DIRECTIONS = [ complex ( 0 , 1 ), complex ( 0 , - 1 ), complex ( 1 , 0 ), complex ( - 1 , 0 ) ]
5
+ Point = tuple [ int , int ]
8
6
9
7
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."""
35
10
36
- def neighbors (pos ):
11
+ def neighbors (pos : Point ) -> list [Point ]:
12
+ """Return all the neighboring accessible map positions."""
37
13
x , y = pos
38
14
n = [(x + 1 , y + 0 ), (x - 1 , y + 0 ), (x + 0 , y + 1 ), (x + 0 , y - 1 )]
39
15
return [i for i in n if i in spaces ]
40
16
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."""
42
19
bfs = collections .deque ([(0 , starting )])
43
20
found = 0
44
21
want = len (points_of_interest )
@@ -50,82 +27,105 @@ def get_dist(starting):
50
27
distances [pos ] = steps
51
28
found += 1
52
29
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 )
57
34
return distances
58
35
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 }
62
38
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
76
39
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 ())
79
65
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.
80
68
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 )
82
70
for pos in points_of_interest
83
71
}
84
- logging .info ("Nearest graph built." )
85
72
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 )
124
125
if d not in seen :
125
126
q .put (d )
126
127
seen .add (d )
127
-
128
- return get_them_all ()
128
+ raise RuntimeError ("No solution found." )
129
129
130
130
131
131
TEST_DATA = [
@@ -152,5 +152,4 @@ def get_them_all():
152
152
TESTS = [
153
153
(1 , TEST_DATA [0 ], 26 ),
154
154
(2 , TEST_DATA [1 ], 38 ),
155
- # (3, TEST_DATA[2], None),
156
155
]
0 commit comments