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}