aoc/year2019/day18.rs
1//! # Many-Worlds Interpretation
2//!
3//! Our high-level approach is to simplify the problem into graph path finding. 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
9//! [Dijkstra's algorithm](https://en.wikipedia.org/wiki/Dijkstra%27s_algorithm) to find the
10//! shortest path that visits every node in the graph.
11
12//! The maze is also constructed in such a way to make our life easier:
13//! * There is only ever one possible path to each key. We do not need to consider
14//! paths of different lengths that need different keys.
15//! * As a corollary, if key `b` lies between `a` and `c` then `|ac| = |ab| + |bc|`. This
16//! enables a huge optimization that we only need to consider immediate neighbours.
17//! If we do not possess key `b` then it never makes sense to skip from `a` to `c` since `b` is
18//! along the way. We can model this by treating keys the same as doors. This optimization
19//! sped up my solution by a factor of 30.
20//!
21//! On top of this approach we apply some high-level tricks to go faster:
22//! * We store previously seen pairs of `(location, keys collected)` to `total distance` in a map.
23//! If we are in the same location with the same keys but at a higher cost, then this situation
24//! can never be optimal so the solution can be discarded.
25//! * When finding the distance between every pair of keys, it's faster to first only find the immediate
26//! neighbors of each key using a [Breadth first search](https://en.wikipedia.org/wiki/Breadth-first_search)
27//! then run the [Floyd-Warshall algorithm](https://en.wikipedia.org/wiki/Floyd%E2%80%93Warshall_algorithm)
28//! to construct the rest of the graph. Even though the Floyd-Warshall asymptotic bound of `O(n³)`
29//! is higher than the asymptotic bounds of repeated BFS, this was twice as fast in practice
30//! for my input.
31//!
32//! We also apply some low-level tricks to go even faster:
33//! * The set of remaining keys needed is stored as bits in an `u32`. We can have at most 26 keys
34//! so this will always fit. For example needing `a`, `b` and `e` is represented as `10011`.
35//! * Robot location is also stored the same way. Robots can only ever be in their initial location
36//! or at a key, so this gives a max of 26 + 4 = 30 locations. As a nice bonus this allows
37//! part one and part two to share the same code.
38//! * For fast lookup of distance between keys, the maze is stored as
39//! [adjacency matrix](https://en.wikipedia.org/wiki/Adjacency_matrix). `a` is index 0, `b` is
40//! index 1 and robots' initial positions are from 26 to 29 inclusive.
41//! For example (simplifying by moving robot from index 26 to 2):
42//!
43//! ```none
44//! ######### [0 6 2]
45//! #b.A.@.a# => [6 0 4]
46//! ######### [2 4 0]
47//! ```
48use crate::util::bitset::*;
49use crate::util::grid::*;
50use crate::util::hash::*;
51use crate::util::heap::*;
52use std::collections::VecDeque;
53use std::ops::Range;
54
55const RANGE: Range<usize> = 0..30;
56
57/// `position` and `remaining` are both bitfields. For example a robot at key `d` that needs
58/// `b` and `c` would be stored as `position = 1000` and `remaining = 110`.
59#[derive(Clone, Copy, Default, PartialEq, Eq, Hash)]
60struct State {
61 position: u32,
62 remaining: u32,
63}
64
65/// `distance` in the edge weight between nodes. `needed` stores any doors in between as a bitfield.
66#[derive(Clone, Copy)]
67struct Door {
68 distance: u32,
69 needed: u32,
70}
71
72/// `initial` is the complete set of keys that we need to collect. Will always be binary
73/// `11111111111111111111111111` for the real input but fewer for sample data.
74///
75/// `maze` is the adjacency of distances and doors between each pair of keys and the robots
76/// starting locations.
77struct Maze {
78 initial: State,
79 maze: [[Door; 30]; 30],
80}
81
82pub fn parse(input: &str) -> Grid<u8> {
83 Grid::parse(input)
84}
85
86pub fn part1(input: &Grid<u8>) -> u32 {
87 explore(input.width as usize, &input.bytes)
88}
89
90pub fn part2(input: &Grid<u8>) -> u32 {
91 let mut modified = input.bytes.clone();
92 let mut patch = |s: &str, offset: i32| {
93 let middle = (input.width * input.height) / 2;
94 let index = (middle + offset * input.width - 1) as usize;
95 modified[index..index + 3].copy_from_slice(s.as_bytes());
96 };
97
98 patch("@#@", -1);
99 patch("###", 0);
100 patch("@#@", 1);
101
102 explore(input.width as usize, &modified)
103}
104
105fn parse_maze(width: usize, bytes: &[u8]) -> Maze {
106 let mut initial = State::default();
107 let mut found = Vec::new();
108 let mut robots = 26;
109
110 // Find the location of every key and robot in the maze.
111 for (i, &b) in bytes.iter().enumerate() {
112 if let Some(key) = is_key(b) {
113 initial.remaining |= 1 << key;
114 found.push((i, key));
115 }
116 if b == b'@' {
117 initial.position |= 1 << robots;
118 found.push((i, robots));
119 robots += 1;
120 }
121 }
122
123 // Start a BFS from each key and robot's location stopping at the nearest neighbor.
124 // As a minor optimization we re-use the same `todo` and `visited` between each search.
125 let default = Door { distance: u32::MAX, needed: 0 };
126
127 let mut maze = [[default; 30]; 30];
128 let mut visited = vec![usize::MAX; bytes.len()];
129 let mut todo = VecDeque::new();
130
131 for (start, from) in found {
132 todo.push_front((start, 0, 0));
133 visited[start] = from;
134
135 while let Some((index, distance, mut needed)) = todo.pop_front() {
136 if let Some(door) = is_door(bytes[index]) {
137 needed |= 1 << door;
138 }
139
140 if let Some(to) = is_key(bytes[index])
141 && distance > 0
142 {
143 // Store the reciprocal edge weight and doors in the adjacency matrix.
144 maze[from][to] = Door { distance, needed };
145 maze[to][from] = Door { distance, needed };
146 // Faster to stop here and use Floyd-Warshall later.
147 continue;
148 }
149
150 for next in [index + 1, index - 1, index + width, index - width] {
151 if bytes[next] != b'#' && visited[next] != from {
152 todo.push_back((next, distance + 1, needed));
153 visited[next] = from;
154 }
155 }
156 }
157 }
158
159 // Fill in the rest of the graph using the Floyd–Warshal algorithm.
160 // As a slight twist we also build the list of intervening doors at the same time.
161 for i in RANGE {
162 maze[i][i].distance = 0;
163 }
164
165 for k in RANGE {
166 for i in RANGE {
167 for j in RANGE {
168 let candidate = maze[i][k].distance.saturating_add(maze[k][j].distance);
169 if maze[i][j].distance > candidate {
170 maze[i][j].distance = candidate;
171 // `(1 << k)` is a crucial optimization. By treating intermediate keys like
172 // doors we speed things up by a factor of 30.
173 maze[i][j].needed = maze[i][k].needed | (1 << k) | maze[k][j].needed;
174 }
175 }
176 }
177 }
178
179 Maze { initial, maze }
180}
181
182fn explore(width: usize, bytes: &[u8]) -> u32 {
183 let mut todo = MinHeap::with_capacity(5_000);
184 let mut cache = FastMap::with_capacity(5_000);
185
186 let Maze { initial, maze } = parse_maze(width, bytes);
187 todo.push(0, initial);
188
189 while let Some((total, State { position, remaining })) = todo.pop() {
190 // Finish immediately if no keys left.
191 // Since we're using Dijkstra this will always be the optimal solution.
192 if remaining == 0 {
193 return total;
194 }
195
196 // The set of robots is stored as bits in a `u32` shifted by the index of the location.
197 for from in position.biterator() {
198 // The set of keys still needed is also stored as bits in a `u32` similar as robots.
199 for to in remaining.biterator() {
200 let Door { distance, needed } = maze[from][to];
201
202 // u32::MAX indicates that two nodes are not connected. Only possible in part two.
203 if distance != u32::MAX && remaining & needed == 0 {
204 let next_total = total + distance;
205 let from_mask = 1 << from;
206 let to_mask = 1 << to;
207 let next_state = State {
208 position: position ^ from_mask ^ to_mask,
209 remaining: remaining ^ to_mask,
210 };
211
212 // Memoize previously seen states to eliminate suboptimal states right away.
213 cache
214 .entry(next_state)
215 .and_modify(|e| {
216 if next_total < *e {
217 todo.push(next_total, next_state);
218 *e = next_total;
219 }
220 })
221 .or_insert_with(|| {
222 todo.push(next_total, next_state);
223 next_total
224 });
225 }
226 }
227 }
228 }
229
230 unreachable!()
231}
232
233// Convenience functions to find keys and robots
234fn is_key(b: u8) -> Option<usize> {
235 b.is_ascii_lowercase().then(|| (b - b'a') as usize)
236}
237
238fn is_door(b: u8) -> Option<usize> {
239 b.is_ascii_uppercase().then(|| (b - b'A') as usize)
240}