aoc/year2022/
day16.rs

1//! # Proboscidea Volcanium
2//!
3//! ## Parsing
4//!
5//! First we simplify the graph formed by the valves. With the exception of `AA` there's no need to
6//! stop at any zero valve, so we're only interested in the distance between non-zero valves.
7//! This significantly reduces the complexity of the solution space as there are only around
8//! 15 non-zero valves versus around 60 valves total.
9//!
10//! For each valve we find the distance to its immediate non-zero neighbors. Then we use the
11//! [Floyd Warshall algorithm](https://en.wikipedia.org/wiki/Floyd%E2%80%93Warshall_algorithm) to
12//! find the distance between any two non-zero valves, storing this information in an
13//! [adjacency matrix](https://en.wikipedia.org/wiki/Adjacency_matrix) for fast lookup.
14//!
15//! ## Part One
16//!
17//! The approach is [branch and bound](https://en.wikipedia.org/wiki/Branch_and_bound) enumerating
18//! every possible combination combined with a heuristic to prune those combinations in order to
19//! achieve a reasonable running time.
20//!
21//! The heuristic assumes that we can visit all remaining valves in descending order of flow,
22//! taking only the minimum possible time to reach each valve. As this will always be better
23//! than the actual maximum possible we can immediately prune any branch that would still be less
24//! than the current high score.
25//!
26//! ## Part Two
27//!
28//! Part two uses an ingenious approach from [@korreman](https://github.com/korreman/aoc2022).
29//!
30//! First calculate the maximum value for any possible combination of valves reachable in
31//! 26 minutes by a single entity. Then calculate a second score from the remaining unopened
32//! valves.
33//!
34//! The neat part is using this second score as the heuristic threshold for a search over all
35//! possible valve combinations. This works as the sum of the first two searches provides a
36//! minimum baseline. If a branch can't do better then it can be pruned.
37//!
38//! Then we check every possible pair formed by those values, considering only the pairs
39//! where the sets of valves are [disjoint](https://en.wikipedia.org/wiki/Disjoint_sets),
40//! which is when you and the elephant have visited different sets of valves.
41use crate::util::bitset::*;
42use crate::util::hash::*;
43use crate::util::parse::*;
44use std::cmp::Ordering;
45
46/// Simplified graph of valves. Valves are stored in descending order of flow so the valve at
47/// index 0 has the highest flow, valve at index 1 the second highest and so on.
48/// This descending order is used by the heuristic to prune branches.
49///
50/// * `size` Number of non-zero valves plus 1 for `AA`
51/// * `todo` Bitmask with a `1` for each initial unopened non-zero valve. For example if there
52///   are 5 valves this would be binary `11111`.
53/// * `flow` Stores the flow for each valve
54/// * `distance` Adjacency matrix of distances between each pair of valves.
55pub struct Input {
56    size: usize,
57    aa: usize,
58    all_valves: usize,
59    flow: Vec<u32>,
60    distance: Vec<u32>,
61    closest: Vec<u32>,
62}
63
64/// State of a single exploration path through the valves.
65///
66/// * `todo` Binary mask of unopened valves. For example if there are 3 unopened valves left this
67///   could look like `11100`.
68/// * `from` Index of current valve.
69/// * `time` The *remaining* time left.
70/// * `pressure` Total pressure released from all opened valves including future extrapolated flow.
71struct State {
72    todo: usize,
73    from: usize,
74    time: u32,
75    pressure: u32,
76}
77
78/// Intermediate struct for parsing only.
79struct Valve<'a> {
80    name: &'a str,
81    flow: u32,
82    edges: Vec<&'a str>,
83}
84
85impl Valve<'_> {
86    /// We're only interested in uppercase valve names and digits for the flow.
87    fn parse(line: &str) -> Valve<'_> {
88        let mut tokens: Vec<_> = line
89            .split(|c: char| !c.is_ascii_uppercase() && !c.is_ascii_digit())
90            .filter(|s| !s.is_empty())
91            .collect();
92        let name = tokens[1];
93        let flow = tokens[2].unsigned();
94        tokens.drain(..3);
95        Valve { name, flow, edges: tokens }
96    }
97
98    /// Order valves is descending order of flow then ascending alpabetical order of names.
99    /// This places all non-zero valves at the start followed immediately by valve `AA`.
100    fn cmp(&self, other: &Valve<'_>) -> Ordering {
101        other.flow.cmp(&self.flow).then(self.name.cmp(other.name))
102    }
103}
104
105pub fn parse(input: &str) -> Input {
106    // Sort valves so that non-zero valves are at the start
107    let mut valves: Vec<_> = input.lines().map(Valve::parse).collect();
108    valves.sort_unstable_by(Valve::cmp);
109
110    // We only care about non-zero valves with the exception of `AA`.
111    let size = valves.iter().filter(|v| v.flow > 0).count() + 1;
112    let mut distance = vec![u32::MAX; size * size];
113
114    // Eliminate zero valves. Assumes that zero valves are "tunnels" with each linking 2 other
115    // valves. For all non-zero valves follows the tunnels to find the distance to each
116    // immediate neighbor.
117    let indices: FastMap<_, _> = valves.iter().enumerate().map(|(i, v)| (v.name, i)).collect();
118
119    for (from, valve) in valves.iter().enumerate().take(size) {
120        // Distance to ourself is zero.
121        distance[from * size + from] = 0;
122
123        // Follow "tunnels" of zero valves to our non-zero neighbors.
124        for edge in &valve.edges {
125            let mut prev = valve.name;
126            let mut cur = edge;
127            let mut to = indices[cur];
128            let mut total = 1;
129
130            while to >= size {
131                let next = valves[to].edges.iter().find(|&&e| e != prev).unwrap();
132                prev = cur;
133                cur = next;
134                to = indices[cur];
135                total += 1;
136            }
137
138            distance[from * size + to] = total;
139        }
140    }
141
142    // Floyd-Warshall algorithm to find the pairwise distance between any two valves.
143    for k in 0..size {
144        for i in 0..size {
145            for j in 0..size {
146                let candidate = distance[i * size + k].saturating_add(distance[k * size + j]);
147                if candidate < distance[i * size + j] {
148                    distance[i * size + j] = candidate;
149                }
150            }
151        }
152    }
153
154    // Add 1 minute to each distance to include the time needed to open a valve.
155    distance.iter_mut().for_each(|d| *d += 1);
156    // Index of AA is one less than size
157    let aa = size - 1;
158    // Binary mask of all initial unopened valves not including AA.
159    let all_valves = (1 << aa) - 1;
160    // Extract flow information.
161    let flow: Vec<_> = valves.iter().take(size).map(|v| v.flow).collect();
162    // Closest neighbor to each valve
163    let closest: Vec<_> = distance
164        .chunks_exact(size)
165        .map(|chunk| *chunk.iter().filter(|&&d| d > 1).min().unwrap())
166        .collect();
167
168    // Compact representation of tunnels and valves.
169    Input { size, aa, all_valves, flow, distance, closest }
170}
171
172/// Explore the tunnels, finding the highest possible score for a single entity.
173pub fn part1(input: &Input) -> u32 {
174    let mut score = 0;
175    // Return the current high score for the heuristic.
176    let mut high_score = |_, pressure: u32| {
177        score = score.max(pressure);
178        score
179    };
180
181    let start = State { todo: input.all_valves, from: input.aa, time: 30, pressure: 0 };
182    explore(input, &start, &mut high_score);
183
184    score
185}
186
187/// Return the maximum possible score from two entities exploring the tunnels simultaneously.
188pub fn part2(input: &Input) -> u32 {
189    // Step 1
190    // Find both the highest possible score and the remaining unopened valves from you
191    // exploring the tunnels.
192    let mut you = 0;
193    let mut remaining = 0;
194    // Keep track of the unopened valves associated with the high score.
195    let mut high_score = |todo: usize, pressure: u32| {
196        if pressure > you {
197            you = pressure;
198            remaining = todo;
199        }
200        you
201    };
202
203    let first = State { todo: input.all_valves, from: input.aa, time: 26, pressure: 0 };
204    explore(input, &first, &mut high_score);
205
206    // Step 2
207    // Find the highest possible score when only allowing the unopened valves from the
208    // previous run. This will set a minimum baseline score for the heuristic.
209    let mut elephant = 0;
210    let mut high_score = |_, pressure: u32| {
211        elephant = elephant.max(pressure);
212        elephant
213    };
214
215    let second = State { todo: remaining, from: input.aa, time: 26, pressure: 0 };
216    explore(input, &second, &mut high_score);
217
218    // Step 3
219    // Explore a third time allowing only scores that are higher than the previous minimum.
220    // Instead of a single score, store the high score for each possible `2ⁱ` combinations
221    // of valves. The index of the score is the bitmask of the *opened* valves.
222    let mut score = vec![0; input.all_valves + 1];
223    let mut high_score = |todo: usize, pressure: u32| {
224        let done = input.all_valves ^ todo;
225        score[done] = score[done].max(pressure);
226        // Always return the elephant value from step 2 for the heuristic.
227        elephant
228    };
229
230    let third = State { todo: input.all_valves, from: input.aa, time: 26, pressure: 0 };
231    explore(input, &third, &mut high_score);
232
233    // Combine the score using the disjoint sets approach. As no valve can be opened twice
234    // only consider scores where there is no overlap by using a bitwise AND.
235    let mut result = you + elephant;
236
237    // Find valid non-zero results then sort in order to check combinations faster.
238    let mut candidates: Vec<_> = score.into_iter().enumerate().filter(|(_, s)| *s > 0).collect();
239    candidates.sort_unstable_by_key(|t| t.1);
240
241    for i in (1..candidates.len()).rev() {
242        let (mask1, you) = candidates[i];
243
244        // Since results are sorted, all subsequent scores are lower than this one.
245        // If the maximum possible sum from remaining scores is lower than the current result
246        // then we're done.
247        if you * 2 <= result {
248            break;
249        }
250
251        for j in (0..i).rev() {
252            let (mask2, elephant) = candidates[j];
253
254            // Find the best result where the two sets of valves are disjoint.
255            if mask1 & mask2 == 0 {
256                result = result.max(you + elephant);
257                break;
258            }
259        }
260    }
261
262    result
263}
264
265fn explore(input: &Input, state: &State, high_score: &mut impl FnMut(usize, u32) -> u32) {
266    let State { todo, from, time, pressure } = *state;
267    let score = high_score(todo, pressure);
268
269    // Stores the set of unopened valves in a single integer as a bit mask with a 1
270    // for each unopened valve. This code iterates over each valve by finding the lowest
271    // 1 bit then removing it from the set.
272    for to in todo.biterator() {
273        // Check if there's enough time to reach the valve.
274        let needed = input.distance[from * input.size + to];
275        if needed >= time {
276            continue;
277        }
278
279        // Calculate the total pressure released by a valve up front.
280        let todo = todo ^ (1 << to);
281        let time = time - needed;
282        let pressure = pressure + time * input.flow[to];
283
284        // Pretend that we could visit each remaining unopened valve in descending order
285        // of flow taking only the minimum possible time to reach each valve. As this is always
286        // better than the actual graph if we can't beat the high score then we can prune
287        // this branch right away.
288        let heuristic = {
289            let mut valves = todo;
290            let mut time = time;
291            let mut pressure = pressure;
292
293            // Assume that all valves have a distance of 3 or more.
294            while valves > 0 && time > 3 {
295                let to = valves.trailing_zeros() as usize;
296                valves ^= 1 << to;
297                time -= input.closest[to];
298                pressure += time * input.flow[to];
299            }
300
301            pressure
302        };
303
304        // Only explore further if it's possible to beat the high score.
305        if heuristic > score {
306            let next = State { todo, from: to, time, pressure };
307            explore(input, &next, high_score);
308        }
309    }
310}