|
| 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 betweeen 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 contruct 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 practise |
| 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's 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 | +//! #[email protected]# => [6 0 4] |
| 46 | +//! ######### [2 4 0] |
| 47 | +//! ``` |
| 48 | +
|
| 49 | +// Disable lints with false positives |
| 50 | +#![allow(clippy::needless_range_loop)] |
| 51 | +#![allow(clippy::unnecessary_lazy_evaluations)] |
| 52 | + |
| 53 | +use crate::util::grid::*; |
| 54 | +use crate::util::hash::*; |
| 55 | +use crate::util::heap::*; |
| 56 | +use std::collections::VecDeque; |
| 57 | + |
| 58 | +/// `position` and `remaining` are both bitfields. For example a robot at key `d` that needs |
| 59 | +/// `b` and `c` would be stored as `position = 1000` and `remaining = 110`. |
| 60 | +#[derive(Clone, Copy, Default, PartialEq, Eq, Hash)] |
| 61 | +struct State { |
| 62 | + position: u32, |
| 63 | + remaining: u32, |
| 64 | +} |
| 65 | + |
| 66 | +/// `distance` in the edge weight between nodes. `needed` stores any doors in between as a bitfield. |
| 67 | +#[derive(Clone, Copy)] |
| 68 | +struct Door { |
| 69 | + distance: u32, |
| 70 | + needed: u32, |
| 71 | +} |
| 72 | + |
| 73 | +/// `initial` is the complete set of keys that we need to collect. Will always be binary |
| 74 | +/// `11111111111111111111111111` for the real input but fewer for sample data. |
| 75 | +/// |
| 76 | +/// `maze` is the adjacency of distances and doors between each pair of keys and the robots |
| 77 | +/// starting locations. |
| 78 | +struct Maze { |
| 79 | + initial: State, |
| 80 | + maze: [[Door; 30]; 30], |
| 81 | +} |
| 82 | + |
| 83 | +pub fn parse(input: &str) -> Grid<u8> { |
| 84 | + Grid::parse(input) |
| 85 | +} |
| 86 | + |
| 87 | +pub fn part1(input: &Grid<u8>) -> u32 { |
| 88 | + explore(input.width, &input.bytes) |
| 89 | +} |
| 90 | + |
| 91 | +pub fn part2(input: &Grid<u8>) -> u32 { |
| 92 | + let mut modified = input.bytes.clone(); |
| 93 | + let mut patch = |s: &str, offset: i32| { |
| 94 | + let middle = (input.width * input.height) / 2; |
| 95 | + let index = (middle + offset * input.width - 1) as usize; |
| 96 | + modified[index..index + 3].copy_from_slice(s.as_bytes()); |
| 97 | + }; |
| 98 | + |
| 99 | + patch("@#@", -1); |
| 100 | + patch("###", 0); |
| 101 | + patch("@#@", 1); |
| 102 | + |
| 103 | + explore(input.width, &modified) |
| 104 | +} |
| 105 | + |
| 106 | +fn parse_maze(width: i32, bytes: &[u8]) -> Maze { |
| 107 | + let mut initial = State::default(); |
| 108 | + let mut found = Vec::new(); |
| 109 | + let mut robots = 26; |
| 110 | + |
| 111 | + // Find the location of every key and robot in the maze. |
| 112 | + for (i, &b) in bytes.iter().enumerate() { |
| 113 | + if let Some(key) = is_key(b) { |
| 114 | + initial.remaining |= 1 << key; |
| 115 | + found.push((i, key)); |
| 116 | + } |
| 117 | + if b == b'@' { |
| 118 | + initial.position |= 1 << robots; |
| 119 | + found.push((i, robots)); |
| 120 | + robots += 1; |
| 121 | + } |
| 122 | + } |
| 123 | + |
| 124 | + // Start a BFS from each key and robot's location stopping at the nearest neighbor. |
| 125 | + // As a minor optimization we re-use the same `todo` and `visited` between each search. |
| 126 | + let default = Door { distance: u32::MAX, needed: 0 }; |
| 127 | + let orthogonal = [1, -1, width, -width].map(|i| i as usize); |
| 128 | + |
| 129 | + let mut maze = [[default; 30]; 30]; |
| 130 | + let mut visited = vec![usize::MAX; bytes.len()]; |
| 131 | + let mut todo = VecDeque::new(); |
| 132 | + |
| 133 | + for (start, from) in found { |
| 134 | + todo.push_front((start, 0, 0)); |
| 135 | + visited[start] = from; |
| 136 | + |
| 137 | + while let Some((index, distance, mut needed)) = todo.pop_front() { |
| 138 | + if let Some(door) = is_door(bytes[index]) { |
| 139 | + needed |= 1 << door; |
| 140 | + } |
| 141 | + |
| 142 | + if let Some(to) = is_key(bytes[index]) { |
| 143 | + if distance > 0 { |
| 144 | + // Store the reciprocal edge weight and doors in the adjacency matrix. |
| 145 | + maze[from][to] = Door { distance, needed }; |
| 146 | + maze[to][from] = Door { distance, needed }; |
| 147 | + // Faster to stop here and use Floyd-Warshall later. |
| 148 | + continue; |
| 149 | + } |
| 150 | + } |
| 151 | + |
| 152 | + for delta in orthogonal { |
| 153 | + let next_index = index.wrapping_add(delta); |
| 154 | + if bytes[next_index] != b'#' && visited[next_index] != from { |
| 155 | + todo.push_back((next_index, distance + 1, needed)); |
| 156 | + visited[next_index] = from; |
| 157 | + } |
| 158 | + } |
| 159 | + } |
| 160 | + } |
| 161 | + |
| 162 | + // Fill in the rest of the graph using the Floyd–Warshal algorithm. |
| 163 | + // As a slight twist we also build the list of intervening doors at the same time. |
| 164 | + for i in 0..30 { |
| 165 | + maze[i][i].distance = 0; |
| 166 | + } |
| 167 | + |
| 168 | + for k in 0..30 { |
| 169 | + for i in 0..30 { |
| 170 | + for j in 0..30 { |
| 171 | + let candidate = maze[i][k].distance.saturating_add(maze[k][j].distance); |
| 172 | + if maze[i][j].distance > candidate { |
| 173 | + maze[i][j].distance = candidate; |
| 174 | + // `(1 << k)` is a crucial optimization. By treating intermediate keys like |
| 175 | + // doors we speed things up by a factor of 30. |
| 176 | + maze[i][j].needed = maze[i][k].needed | (1 << k) | maze[k][j].needed; |
| 177 | + } |
| 178 | + } |
| 179 | + } |
| 180 | + } |
| 181 | + |
| 182 | + Maze { initial, maze } |
| 183 | +} |
| 184 | + |
| 185 | +fn explore(width: i32, bytes: &[u8]) -> u32 { |
| 186 | + let mut todo = MinHeap::with_capacity(5_000); |
| 187 | + let mut cache = FastMap::with_capacity(5_000); |
| 188 | + |
| 189 | + let Maze { initial, maze } = parse_maze(width, bytes); |
| 190 | + todo.push(0, initial); |
| 191 | + |
| 192 | + while let Some((total, State { position, remaining })) = todo.pop() { |
| 193 | + // Finish immediately if no keys left. |
| 194 | + // Since we're using Dijkstra this will always be the optimal solution. |
| 195 | + if remaining == 0 { |
| 196 | + return total; |
| 197 | + } |
| 198 | + |
| 199 | + let mut robots = position; |
| 200 | + |
| 201 | + while robots != 0 { |
| 202 | + // The set of robots is stored as bits in a `u32` shifted by the index of the location. |
| 203 | + let from = robots.trailing_zeros() as usize; |
| 204 | + let from_mask = 1 << from; |
| 205 | + robots ^= from_mask; |
| 206 | + |
| 207 | + let mut keys = remaining; |
| 208 | + |
| 209 | + while keys != 0 { |
| 210 | + // The set of keys still needed is also stored as bits in a `u32` similar as robots. |
| 211 | + let to = keys.trailing_zeros() as usize; |
| 212 | + let to_mask = 1 << to; |
| 213 | + keys ^= to_mask; |
| 214 | + |
| 215 | + let Door { distance, needed } = maze[from][to]; |
| 216 | + |
| 217 | + // u32::MAX indicates that two nodes are not connected. Only possible in part two. |
| 218 | + if distance != u32::MAX && remaining & needed == 0 { |
| 219 | + let next_total = total + distance; |
| 220 | + let next_state = State { |
| 221 | + position: position ^ from_mask ^ to_mask, |
| 222 | + remaining: remaining ^ to_mask, |
| 223 | + }; |
| 224 | + |
| 225 | + // Memoize previously seen states to eliminate suboptimal states right away. |
| 226 | + cache |
| 227 | + .entry(next_state) |
| 228 | + .and_modify(|e| { |
| 229 | + if next_total < *e { |
| 230 | + todo.push(next_total, next_state); |
| 231 | + *e = next_total; |
| 232 | + } |
| 233 | + }) |
| 234 | + .or_insert_with(|| { |
| 235 | + todo.push(next_total, next_state); |
| 236 | + next_total |
| 237 | + }); |
| 238 | + } |
| 239 | + } |
| 240 | + } |
| 241 | + } |
| 242 | + |
| 243 | + unreachable!() |
| 244 | +} |
| 245 | + |
| 246 | +// Convenience functions to find keys and robots |
| 247 | +fn is_key(b: u8) -> Option<usize> { |
| 248 | + b.is_ascii_lowercase().then(|| (b - b'a') as usize) |
| 249 | +} |
| 250 | + |
| 251 | +fn is_door(b: u8) -> Option<usize> { |
| 252 | + b.is_ascii_uppercase().then(|| (b - b'A') as usize) |
| 253 | +} |
0 commit comments