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}