aoc/year2019/day18.rs
1//! # Many-Worlds Interpretation
2//!
3//! Our high-level approach is to simplify the problem into graph pathfinding. We only
4//! ever need to move directly from key to key, so the maze becomes a graph where the nodes are
5//! keys and the edge weight is the distance between keys. Doors modify which edges
6//! are connected depending on the keys currently possessed.
7//!
8//! We first find the distance between every pair of keys then run the
9//! [A* algorithm](https://en.wikipedia.org/wiki/A*_search_algorithm) to find the
10//! shortest path that visits every node in the graph. One heuristic is a constant-time query
11//! of the sum of the minimum path length out of all remaining keys (updated by subtraction
12//! as a key is visited), which works well for part one when there is only one robot that must
13//! visit every remaining node, but underestimates for part two. Another heuristic is a cacheable
14//! linear-time query of the maximum distance each robot must travel to reach the furthest remaining
15//! key. This latter query is expensive enough that it penalizes part one, but its improved accuracy
16//! prunes more states in part two than the extra time spent on computing the heuristic. Both
17//! heuristics reach zero at the goal, and are consistent, meaning they never overestimate and no
18//! state will lower its score after the initial visit.
19//!
20//! The maze is also constructed in such a way to make our life easier:
21//! * There is only ever one possible path to each key. We do not need to consider paths of
22//! different lengths that need different keys.
23//! * As a corollary, if key `b` lies between `a` and `c` then `|ac| = |ab| + |bc|`. This enables a
24//! huge optimization that we only need to consider immediate neighbors. If we do not possess key
25//! `b` then it never makes sense to skip from `a` to `c` since `b` is along the way. We can model
26//! this by treating keys the same as doors. This optimization sped up my solution by a factor of
27//! 30.
28//!
29//! On top of this approach we apply some high-level tricks to go faster:
30//! * We store previously seen pairs of `(location, keys collected)` to `total distance` in a map.
31//! If we are in the same location with the same keys but at a higher cost, then this situation
32//! can never be optimal so the solution can be discarded.
33//! * When finding the distance between every pair of keys, it's faster to first only find the immediate
34//! neighbors of each key using a [Breadth-first search](https://en.wikipedia.org/wiki/Breadth-first_search)
35//! then run the [Floyd-Warshall algorithm](https://en.wikipedia.org/wiki/Floyd-Warshall_algorithm)
36//! to construct the rest of the graph. Even though the Floyd-Warshall asymptotic bound of `O(n³)`
37//! is higher than the asymptotic bounds of repeated BFS, this was twice as fast in practice
38//! for my input.
39//!
40//! We also apply some low-level tricks to go even faster:
41//! * The set of remaining keys needed is stored as bits in a `u32`. We can have at most 26 keys so
42//! this will always fit. For example, needing `a`, `b` and `e` is represented as `10011`.
43//! * Robot location is also stored the same way. Robots can only ever be in their initial location
44//! or at a key, so this gives a max of 26 + 4 = 30 locations. As a nice bonus this allows part
45//! one and part two to share the same code.
46//! * For fast lookup of distance between keys, the maze is stored as [adjacency matrix](https://en.wikipedia.org/wiki/Adjacency_matrix).
47//! `a` is index 0, `b` is index 1 and robots' initial positions are from 26 to 29 inclusive. For
48//! example (simplifying by moving robot from index 26 to 2):
49//!
50//! ```none
51//! ######### [0 6 2]
52//! #b.A.@.a# => [6 0 4]
53//! ######### [2 4 0]
54//! ```
55use std::collections::VecDeque;
56use std::ops::Range;
57
58use crate::util::bitset::*;
59use crate::util::grid::*;
60use crate::util::hash::*;
61use crate::util::heap::*;
62
63const RANGE: Range<usize> = 0..30;
64
65type Matrix = [[Door; 30]; 30];
66
67/// `position` and `remaining` are both bitfields. For example, a robot at key `d` that needs
68/// `b` and `c` would be stored as `position = 1000` and `remaining = 110`.
69#[derive(Clone, Copy, Default, Eq, Hash, PartialEq)]
70struct State {
71 position: u32,
72 remaining: u32,
73}
74
75/// `distance` is the edge weight between nodes. `needed` stores any doors in between as a bitfield.
76#[derive(Clone, Copy)]
77struct Door {
78 distance: u32,
79 needed: u32,
80}
81
82/// `initial` is the complete set of keys that we need to collect. Will always be binary
83/// `11111111111111111111111111` for the real input but fewer for sample data.
84///
85/// `masks` maps the set of keys in the same quadrant, for prefiltering in part 2.
86/// `minimum` is the smallest distance from a key to any of its neighbors, for the part1 heuristic.
87/// `matrix` is the adjacency of distances and doors between each pair of keys and the robots'
88/// starting locations.
89struct Maze {
90 initial: State,
91 masks: [u32; 30],
92 minimum: [u32; 30],
93 matrix: Matrix,
94}
95
96pub fn parse(input: &str) -> Grid<u8> {
97 Grid::parse(input)
98}
99
100pub fn part1(input: &Grid<u8>) -> u32 {
101 // Select the O(1) A* heuristic, since there is only one robot visiting all keys.
102 explore::<false>(input.width as usize, &input.bytes)
103}
104
105pub fn part2(input: &Grid<u8>) -> u32 {
106 let mut modified = input.bytes.clone();
107 let mut patch = |s: &str, offset: i32| {
108 let middle = (input.width * input.height) / 2;
109 let index = (middle + offset * input.width - 1) as usize;
110 modified[index..index + 3].copy_from_slice(s.as_bytes());
111 };
112
113 patch("@#@", -1);
114 patch("###", 0);
115 patch("@#@", 1);
116
117 // Select the O(n) A* heuristic, since each robot vists about one-fourth of the keys.
118 explore::<true>(input.width as usize, &modified)
119}
120
121fn parse_maze(width: usize, bytes: &[u8]) -> Maze {
122 let mut initial = State::default();
123 let mut found = Vec::new();
124 let mut robots = 26;
125
126 // Find the location of every key and robot in the maze.
127 for (i, &b) in bytes.iter().enumerate() {
128 if let Some(key) = is_key(b) {
129 initial.remaining |= 1 << key;
130 found.push((i, key));
131 }
132 if b == b'@' {
133 initial.position |= 1 << robots;
134 found.push((i, robots));
135 robots += 1;
136 }
137 }
138
139 // Start a BFS from each key and robot's location stopping at the nearest neighbor.
140 // As a minor optimization we reuse the same `todo` and `seen` between each search.
141 let default = Door { distance: u32::MAX, needed: 0 };
142
143 let mut matrix = [[default; 30]; 30];
144 let mut seen = vec![usize::MAX; bytes.len()];
145 let mut todo = VecDeque::new();
146 let mut masks = [0; 30];
147 let mut minimum = [u32::MAX; 30];
148
149 for (start, from) in found {
150 todo.push_front((start, 0, 0));
151 seen[start] = from;
152
153 while let Some((index, distance, mut needed)) = todo.pop_front() {
154 if let Some(door) = is_door(bytes[index]) {
155 needed |= 1 << door;
156 }
157
158 if let Some(to) = is_key(bytes[index])
159 && distance > 0
160 {
161 // Store the reciprocal edge weight and doors in the adjacency matrix.
162 matrix[from][to] = Door { distance, needed };
163 matrix[to][from] = Door { distance, needed };
164 masks[from] |= 1 << to;
165 masks[to] |= 1 << from;
166 minimum[from] = minimum[from].min(distance);
167 minimum[to] = minimum[to].min(distance);
168 // Faster to stop here and use Floyd-Warshall later.
169 continue;
170 }
171
172 for next in [index + 1, index - 1, index + width, index - width] {
173 if bytes[next] != b'#' && seen[next] != from {
174 todo.push_back((next, distance + 1, needed));
175 seen[next] = from;
176 }
177 }
178 }
179 }
180
181 // Fill in the rest of the graph using the Floyd-Warshall algorithm.
182 // As a slight twist we also build the list of intervening doors at the same time.
183 for i in RANGE {
184 matrix[i][i].distance = 0;
185 }
186
187 for k in RANGE {
188 for i in RANGE {
189 for j in RANGE {
190 let candidate = matrix[i][k].distance.saturating_add(matrix[k][j].distance);
191 if matrix[i][j].distance > candidate {
192 matrix[i][j].distance = candidate;
193 // `(1 << k)` is a crucial optimization. By treating intermediate keys like
194 // doors we speed things up by a factor of 30.
195 matrix[i][j].needed = matrix[i][k].needed | (1 << k) | matrix[k][j].needed;
196 masks[i] |= 1 << j;
197 masks[j] |= 1 << i;
198 }
199 }
200 }
201 }
202
203 Maze { initial, masks, minimum, matrix }
204}
205
206// Same algorithm, but specialized on which heuristic to use.
207fn explore<const PART_TWO: bool>(width: usize, bytes: &[u8]) -> u32 {
208 let mut todo = MinHeap::with_capacity(5_000);
209 let mut state_cache = FastMap::with_capacity(5_000);
210 let mut heur_cache = FastMap::with_capacity(5_000);
211
212 let Maze { initial, masks, minimum, matrix } = parse_maze(width, bytes);
213 let heur = if PART_TWO {
214 heuristic(initial, &masks, &matrix, &mut heur_cache)
215 } else {
216 minimum.iter().filter(|&min| *min < u32::MAX).sum()
217 };
218 todo.push(heur, (initial, 0));
219
220 while let Some((guess, (State { position, remaining }, total))) = todo.pop() {
221 // Finish immediately if no keys left.
222 // Since we're using A* with a consistent heuristic this will always be the optimal
223 // solution.
224 if remaining == 0 {
225 return total;
226 }
227
228 // Avoid next-neighbor checks if this state was visited in the meantime by a better path.
229 if let Some(&best) = state_cache.get(&State { position, remaining })
230 && total > best
231 {
232 continue;
233 }
234
235 // The set of robots is stored as bits in a `u32` shifted by the index of the location.
236 for from in position.biterator() {
237 // The set of keys still needed is also stored as bits in a `u32` similarly to robots.
238 // Filter the list of destinations to keys in the same quadrant.
239 for to in (remaining & masks[from]).biterator() {
240 let Door { distance, needed } = matrix[from][to];
241
242 // Don't move to a key that still has unmet dependencies.
243 if remaining & needed == 0 {
244 let next_total = total + distance;
245 let from_mask = 1 << from;
246 let to_mask = 1 << to;
247 let next_state = State {
248 position: position ^ from_mask ^ to_mask,
249 remaining: remaining ^ to_mask,
250 };
251
252 // Memoize previously seen states to eliminate suboptimal states right away.
253 let best = state_cache.entry(next_state).or_insert(u32::MAX);
254 if next_total < *best {
255 *best = next_total;
256 let next_heur = if PART_TWO {
257 heuristic(next_state, &masks, &matrix, &mut heur_cache)
258 } else {
259 guess - total - minimum[to]
260 };
261 let next_guess = next_total + next_heur;
262 todo.push(next_guess, (next_state, next_total));
263 }
264 }
265 }
266 }
267 }
268
269 unreachable!()
270}
271
272// Convenience functions to find keys and robots.
273fn is_key(b: u8) -> Option<usize> {
274 b.is_ascii_lowercase().then(|| (b - b'a') as usize)
275}
276
277fn is_door(b: u8) -> Option<usize> {
278 b.is_ascii_uppercase().then(|| (b - b'A') as usize)
279}
280
281// Compute part two heuristic of the sum of the furthest key remaining per robot. For part one,
282// rely on the faster but weaker O(1) tracking of the sum of all minimum legs.
283fn heuristic(
284 state: State,
285 masks: &[u32],
286 matrix: &Matrix,
287 cache: &mut FastMap<(usize, u32), u32>,
288) -> u32 {
289 let mut heur = 0;
290
291 for bot in state.position.biterator() {
292 let reachable = state.remaining & masks[bot];
293
294 let dist = *cache.entry((bot, reachable)).or_insert_with(|| {
295 reachable.biterator().map(|key| matrix[bot][key].distance).max().unwrap_or(0)
296 });
297
298 heur += dist;
299 }
300 heur
301}