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