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: Vec<_> = line
90            .split(|c: char| !c.is_ascii_uppercase() && !c.is_ascii_digit())
91            .filter(|s| !s.is_empty())
92            .collect();
93        let name = tokens[1];
94        let flow = tokens[2].unsigned();
95        tokens.drain(..3);
96        Valve { name, flow, edges: tokens }
97    }
98
99    /// Order valves in descending order of flow then ascending alphabetical order of names.
100    /// This places all non-zero valves at the start followed immediately by valve `AA`.
101    fn cmp(&self, other: &Valve<'_>) -> Ordering {
102        other.flow.cmp(&self.flow).then(self.name.cmp(other.name))
103    }
104}
105
106pub fn parse(input: &str) -> Input {
107    // Sort valves so that non-zero valves are at the start.
108    let mut valves: Vec<_> = input.lines().map(Valve::parse).collect();
109    valves.sort_unstable_by(Valve::cmp);
110
111    // We only care about non-zero valves with the exception of `AA`.
112    let size = valves.iter().filter(|v| v.flow > 0).count() + 1;
113    let mut distance = vec![u32::MAX; size * size];
114
115    // Eliminate zero valves. Assumes that zero valves are "tunnels" with each linking 2 other
116    // valves. For all non-zero valves follows the tunnels to find the distance to each
117    // immediate neighbor.
118    let indices: FastMap<_, _> = valves.iter().enumerate().map(|(i, v)| (v.name, i)).collect();
119
120    for (from, valve) in valves.iter().enumerate().take(size) {
121        // Distance to ourself is zero.
122        distance[from * size + from] = 0;
123
124        // Follow "tunnels" of zero valves to our non-zero neighbors.
125        for edge in &valve.edges {
126            let mut prev = valve.name;
127            let mut cur = edge;
128            let mut to = indices[cur];
129            let mut total = 1;
130
131            while to >= size {
132                let next = valves[to].edges.iter().find(|&&e| e != prev).unwrap();
133                prev = cur;
134                cur = next;
135                to = indices[cur];
136                total += 1;
137            }
138
139            distance[from * size + to] = total;
140        }
141    }
142
143    // Floyd-Warshall algorithm to find the pairwise distance between any two valves.
144    for k in 0..size {
145        for i in 0..size {
146            for j in 0..size {
147                let candidate = distance[i * size + k].saturating_add(distance[k * size + j]);
148                if candidate < distance[i * size + j] {
149                    distance[i * size + j] = candidate;
150                }
151            }
152        }
153    }
154
155    // Add 1 minute to each distance to include the time needed to open a valve.
156    distance.iter_mut().for_each(|d| *d += 1);
157    // Index of AA is one less than size.
158    let aa = size - 1;
159    // Binary mask of all initial unopened valves not including AA.
160    let all_valves = (1 << aa) - 1;
161    // Extract flow information.
162    let flow: Vec<_> = valves.iter().take(size).map(|v| v.flow).collect();
163    // Closest neighbor to each valve.
164    let closest: Vec<_> = distance
165        .chunks_exact(size)
166        .map(|chunk| *chunk.iter().filter(|&&d| d > 1).min().unwrap())
167        .collect();
168
169    // Compact representation of tunnels and valves.
170    Input { size, aa, all_valves, flow, distance, closest }
171}
172
173/// Explore the tunnels, finding the highest possible score for a single entity.
174pub fn part1(input: &Input) -> u32 {
175    let mut score = 0;
176    // Return the current high score for the heuristic.
177    let mut high_score = |_, pressure: u32| {
178        score = score.max(pressure);
179        score
180    };
181
182    let start = State { todo: input.all_valves, from: input.aa, time: 30, pressure: 0 };
183    explore(input, &start, &mut high_score);
184
185    score
186}
187
188/// Return the maximum possible score from two entities exploring the tunnels simultaneously.
189pub fn part2(input: &Input) -> u32 {
190    // Step 1
191    // Find both the highest possible score and the remaining unopened valves from you
192    // exploring the tunnels.
193    let mut you = 0;
194    let mut remaining = 0;
195    // Keep track of the unopened valves associated with the high score.
196    let mut high_score = |todo: usize, pressure: u32| {
197        if pressure > you {
198            you = pressure;
199            remaining = todo;
200        }
201        you
202    };
203
204    let first = State { todo: input.all_valves, from: input.aa, time: 26, pressure: 0 };
205    explore(input, &first, &mut high_score);
206
207    // Step 2
208    // Find the highest possible score when only allowing the unopened valves from the
209    // previous run. This will set a minimum baseline score for the heuristic.
210    let mut elephant = 0;
211    let mut high_score = |_, pressure: u32| {
212        elephant = elephant.max(pressure);
213        elephant
214    };
215
216    let second = State { todo: remaining, from: input.aa, time: 26, pressure: 0 };
217    explore(input, &second, &mut high_score);
218
219    // Step 3
220    // Explore a third time allowing only scores that are higher than the previous minimum.
221    // Instead of a single score, store the high score for each of the `2ⁱ` possible combinations
222    // of valves. The index of the score is the bitmask of the *opened* valves.
223    let mut score = vec![0; input.all_valves + 1];
224    let mut high_score = |todo: usize, pressure: u32| {
225        let done = input.all_valves ^ todo;
226        score[done] = score[done].max(pressure);
227        // Always return the elephant value from step 2 for the heuristic.
228        elephant
229    };
230
231    let third = State { todo: input.all_valves, from: input.aa, time: 26, pressure: 0 };
232    explore(input, &third, &mut high_score);
233
234    // Combine the score using the disjoint sets approach. As no valve can be opened twice
235    // only consider scores where there is no overlap by using a bitwise AND.
236    let mut result = you + elephant;
237
238    // Find valid non-zero results then sort in order to check combinations faster.
239    let mut candidates: Vec<_> = score.into_iter().enumerate().filter(|&(_, s)| s > 0).collect();
240    candidates.sort_unstable_by_key(|t| t.1);
241
242    for i in (1..candidates.len()).rev() {
243        let (mask1, you) = candidates[i];
244
245        // Since results are sorted, all subsequent scores are lower than this one.
246        // If the maximum possible sum from remaining scores is lower than the current result
247        // then we're done.
248        if you * 2 <= result {
249            break;
250        }
251
252        for j in (0..i).rev() {
253            let (mask2, elephant) = candidates[j];
254
255            // Find the best result where the two sets of valves are disjoint.
256            if mask1 & mask2 == 0 {
257                result = result.max(you + elephant);
258                break;
259            }
260        }
261    }
262
263    result
264}
265
266fn explore(input: &Input, state: &State, high_score: &mut impl FnMut(usize, u32) -> u32) {
267    let State { todo, from, time, pressure } = *state;
268    let score = high_score(todo, pressure);
269
270    // Stores the set of unopened valves in a single integer as a bit mask with a 1
271    // for each unopened valve. This code iterates over each valve by finding the lowest
272    // 1 bit then removing it from the set.
273    for to in todo.biterator() {
274        // Check if there's enough time to reach the valve.
275        let needed = input.distance[from * input.size + to];
276        if needed >= time {
277            continue;
278        }
279
280        // Calculate the total pressure released by a valve up front.
281        let todo = todo ^ (1 << to);
282        let time = time - needed;
283        let pressure = pressure + time * input.flow[to];
284
285        // Pretend that we could visit each remaining unopened valve in descending order
286        // of flow taking only the minimum possible time to reach each valve. As this is always
287        // better than the actual graph if we can't beat the high score then we can prune
288        // this branch right away.
289        let heuristic = {
290            let mut valves = todo;
291            let mut time = time;
292            let mut pressure = pressure;
293
294            // Assume that all valves have a distance of 3 or more.
295            while valves > 0 && time > 3 {
296                let to = valves.trailing_zeros() as usize;
297                valves ^= 1 << to;
298                time -= input.closest[to];
299                pressure += time * input.flow[to];
300            }
301
302            pressure
303        };
304
305        // Only explore further if it's possible to beat the high score.
306        if heuristic > score {
307            let next = State { todo, from: to, time, pressure };
308            explore(input, &next, high_score);
309        }
310    }
311}