Skip to main content

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-Warshall_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 std::cmp::Ordering;
42
43use crate::util::bitset::*;
44use crate::util::hash::*;
45use crate::util::parse::*;
46
47/// Simplified graph of valves. Valves are stored in descending order of flow so the valve at
48/// index 0 has the highest flow, valve at index 1 the second highest and so on.
49/// This descending order is used by the heuristic to prune branches.
50///
51/// * `size` Number of non-zero valves plus 1 for `AA`.
52/// * `todo` Bitmask with a `1` for each initial unopened non-zero valve. For example, if there are
53///   5 valves this would be binary `11111`.
54/// * `flow` Stores the flow for each valve.
55/// * `distance` Adjacency matrix of distances between each pair of valves.
56pub struct Input {
57    size: usize,
58    aa: usize,
59    all_valves: usize,
60    flow: Vec<u32>,
61    distance: Vec<u32>,
62    closest: Vec<u32>,
63}
64
65/// State of a single exploration path through the valves.
66///
67/// * `todo` Binary mask of unopened valves. For example, if there are 3 unopened valves left this
68///   could look like `11100`.
69/// * `from` Index of current valve.
70/// * `time` The *remaining* time left.
71/// * `pressure` Total pressure released from all opened valves including future extrapolated flow.
72struct State {
73    todo: usize,
74    from: usize,
75    time: u32,
76    pressure: u32,
77}
78
79/// Intermediate struct for parsing only.
80struct Valve<'a> {
81    name: &'a str,
82    flow: u32,
83    edges: Vec<&'a str>,
84}
85
86impl Valve<'_> {
87    /// We're only interested in uppercase valve names and digits for the flow.
88    fn parse(line: &str) -> Valve<'_> {
89        let mut tokens = line
90            .split(|c: char| !c.is_ascii_uppercase() && !c.is_ascii_digit())
91            .filter(|s| !s.is_empty())
92            .skip(1);
93        let name = tokens.next().unwrap();
94        let flow = tokens.next().unwrap().unsigned();
95        Valve { name, flow, edges: tokens.collect() }
96    }
97
98    /// Order valves in descending order of flow then ascending alphabetical order of names.
99    /// This places all non-zero valves at the start followed immediately by valve `AA`.
100    fn cmp(&self, other: &Self) -> 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                distance[i * size + j] = distance[i * size + j].min(candidate);
148            }
149        }
150    }
151
152    // Add 1 minute to each distance to include the time needed to open a valve.
153    distance.iter_mut().for_each(|d| *d += 1);
154    // Index of AA is one less than size.
155    let aa = size - 1;
156    // Binary mask of all initial unopened valves not including AA.
157    let all_valves = (1 << aa) - 1;
158    // Extract flow information.
159    let flow = valves.iter().take(size).map(|v| v.flow).collect();
160    // Closest neighbor to each valve.
161    let closest = distance
162        .chunks_exact(size)
163        .map(|chunk| *chunk.iter().filter(|&&d| d > 1).min().unwrap())
164        .collect();
165
166    // Compact representation of tunnels and valves.
167    Input { size, aa, all_valves, flow, distance, closest }
168}
169
170/// Explore the tunnels, finding the highest possible score for a single entity.
171pub fn part1(input: &Input) -> u32 {
172    let mut score = 0;
173    // Return the current high score for the heuristic.
174    let mut high_score = |_, pressure| {
175        score = score.max(pressure);
176        score
177    };
178
179    let start = State { todo: input.all_valves, from: input.aa, time: 30, pressure: 0 };
180    explore(input, &start, &mut high_score);
181
182    score
183}
184
185/// Return the maximum possible score from two entities exploring the tunnels simultaneously.
186pub fn part2(input: &Input) -> u32 {
187    // Step 1
188    // Find both the highest possible score and the remaining unopened valves from you
189    // exploring the tunnels.
190    let mut you = 0;
191    let mut remaining = 0;
192    // Keep track of the unopened valves associated with the high score.
193    let mut high_score = |todo, pressure| {
194        if pressure > you {
195            you = pressure;
196            remaining = todo;
197        }
198        you
199    };
200
201    let first = State { todo: input.all_valves, from: input.aa, time: 26, pressure: 0 };
202    explore(input, &first, &mut high_score);
203
204    // Step 2
205    // Find the highest possible score when only allowing the unopened valves from the
206    // previous run. This will set a minimum baseline score for the heuristic.
207    let mut elephant = 0;
208    let mut high_score = |_, pressure| {
209        elephant = elephant.max(pressure);
210        elephant
211    };
212
213    let second = State { todo: remaining, from: input.aa, time: 26, pressure: 0 };
214    explore(input, &second, &mut high_score);
215
216    // Step 3
217    // Explore a third time allowing only scores that are higher than the previous minimum.
218    // Instead of a single score, store the high score for each of the `2ⁱ` possible combinations
219    // of valves. The index of the score is the bitmask of the *opened* valves.
220    let mut score = vec![0; input.all_valves + 1];
221    let mut high_score = |todo: usize, pressure| {
222        let done = input.all_valves ^ todo;
223        score[done] = score[done].max(pressure);
224        // Always return the elephant value from step 2 for the heuristic.
225        elephant
226    };
227
228    let third = State { todo: input.all_valves, from: input.aa, time: 26, pressure: 0 };
229    explore(input, &third, &mut high_score);
230
231    // Combine the score using the disjoint sets approach. As no valve can be opened twice
232    // only consider scores where there is no overlap by using a bitwise AND.
233    let mut result = you + elephant;
234
235    // Find valid non-zero results then sort in order to check combinations faster.
236    let mut candidates: Vec<_> = score.into_iter().enumerate().filter(|&(_, s)| s > 0).collect();
237    candidates.sort_unstable_by_key(|t| t.1);
238
239    for i in (1..candidates.len()).rev() {
240        let (mask1, you) = candidates[i];
241
242        // Since results are sorted, all subsequent scores are lower than this one.
243        // If the maximum possible sum from remaining scores is lower than the current result
244        // then we're done.
245        if you * 2 <= result {
246            break;
247        }
248
249        // Find the best result where the two sets of valves are disjoint.
250        if let Some((_, elephant)) = candidates[..i].iter().rfind(|(mask2, _)| mask1 & mask2 == 0) {
251            result = result.max(you + elephant);
252        }
253    }
254
255    result
256}
257
258fn explore(input: &Input, state: &State, high_score: &mut impl FnMut(usize, u32) -> u32) {
259    let State { todo, from, time, pressure } = *state;
260    let score = high_score(todo, pressure);
261
262    // Stores the set of unopened valves in a single integer as a bit mask with a 1
263    // for each unopened valve. This code iterates over each valve by finding the lowest
264    // 1 bit then removing it from the set.
265    for to in todo.biterator() {
266        // Check if there's enough time to reach the valve.
267        let needed = input.distance[from * input.size + to];
268        if needed >= time {
269            continue;
270        }
271
272        // Calculate the total pressure released by a valve up front.
273        let todo = todo ^ (1 << to);
274        let time = time - needed;
275        let pressure = pressure + time * input.flow[to];
276
277        // Pretend that we could visit each remaining unopened valve in descending order
278        // of flow taking only the minimum possible time to reach each valve. As this is always
279        // better than the actual graph if we can't beat the high score then we can prune
280        // this branch right away.
281        let heuristic = {
282            let mut valves = todo;
283            let mut time = time;
284            let mut pressure = pressure;
285
286            // Assume that all valves have a distance of 3 or more.
287            while valves > 0 && time > 3 {
288                let to = valves.trailing_zeros() as usize;
289                valves ^= 1 << to;
290                time -= input.closest[to];
291                pressure += time * input.flow[to];
292            }
293
294            pressure
295        };
296
297        // Only explore further if it's possible to beat the high score.
298        if heuristic > score {
299            let next = State { todo, from: to, time, pressure };
300            explore(input, &next, high_score);
301        }
302    }
303}