Skip to main content

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}