aoc/year2023/day25.rs
1//! # Snowverload
2//!
3//! We need to find a [minimum cut](https://en.wikipedia.org/wiki/Minimum_cut) of 3 edges that
4//! divides the graph into 2 parts. Several general purpose algorithms exist:
5//!
6//! * Deterministic [Stoer-Wagner algorithm](https://en.wikipedia.org/wiki/Stoer-Wagner_algorithm)
7//! * Probabilistic [Karger's algorithm](https://en.wikipedia.org/wiki/Karger%27s_algorithm)
8//!
9//! The [max-flow min-cut theorem](https://en.wikipedia.org/wiki/Max-flow_min-cut_theorem) also
10//! allows the minimum cut to be expressed as a [maximum flow problem](https://en.wikipedia.org/wiki/Maximum_flow_problem).
11//! There are several general purpose algorithms:
12//!
13//! * [Ford-Fulkerson algorithm](https://en.wikipedia.org/wiki/Ford-Fulkerson_algorithm)
14//! * [Edmonds-Karp algorithm](https://en.wikipedia.org/wiki/Edmonds-Karp_algorithm)
15//!
16//! We can use a simplified version of the Edmonds-Karp algorithm taking advantage of two pieces of
17//! information and a special property of the input graph structure:
18//!
19//! * The minimum cut size is already known to be 3.
20//! * All edge weights (or flow capacity) are 1.
21//! * The 3 edges to be cut are in the "middle" of the graph, that is the graph looks something
22//! like:
23//! ```none
24//! * * * *
25//! * * * * - * * * *
26//! * * * * * - * * * * *
27//! * * * * - * * * *
28//! * * * *
29//! ```
30//!
31//! Our high-level approach is as follows:
32//! * Pick any arbitrary node.
33//! * Find a start node furthest from it.
34//! * Find an end node furthest from the start node.
35//!
36//! The key insight is that the start and end nodes must be on opposite sides of the cut.
37//! We then BFS 3 times from the start to the end to find 3 different shortest paths.
38//! We keep track of the edges each time and only allow each edge to be used once so that each
39//! path has no common edges.
40//!
41//! This will "saturate" the 3 edges across the middle. Finally, we BFS from the start node
42//! a fourth time. As the middle links are already used, this will only be able to reach the nodes
43//! on start's side of the graph and will find our answer.
44//!
45//! The complexity of each BFS is `O(V + E)` and we perform a total of 6. To speed things up even
46//! further some low-level optimizations are used:
47//!
48//! * Numeric node and edge identifiers to allow `vec` to store previously seen values instead of
49//! `HashMap`.
50//! * Linked list of path from start to end, stored in a `vec` using indices for simplicity and
51//! cache locality. This [blog post series](https://rust-unofficial.github.io/too-many-lists/)
52//! describes the complexity of using actual references.
53use std::collections::VecDeque;
54
55/// Store the graph as an [adjacency list](https://en.wikipedia.org/wiki/Adjacency_list).
56/// Each node has a unique index in the `nodes` vec.
57/// Each directed edge has a unique index in the `edges` vec.
58pub struct Input {
59 edges: Vec<usize>,
60 nodes: Vec<(usize, usize)>,
61}
62
63impl Input {
64 /// Convenience function to return an iterator of `(edge, node)` pairs.
65 #[inline]
66 fn neighbors(&self, node: usize) -> impl Iterator<Item = (usize, usize)> {
67 let (start, end) = self.nodes[node];
68 (start..end).map(|edge| (edge, self.edges[edge]))
69 }
70}
71
72/// Convert the input to use numeric indices instead of string keys for speed.
73/// Each node is assigned a unique index on a first come first served basis.
74/// Then the edges are gathered into a single vec so that each edge also has a unique index.
75///
76/// As both node and edge indices are contiguous this allows us to use a vec to store previously
77/// seen values which is much faster than using a `HashMap`.
78pub fn parse(input: &str) -> Input {
79 let mut lookup = vec![usize::MAX; 26 * 26 * 26];
80 let mut neighbors = Vec::with_capacity(2_000);
81
82 for line in input.lines().map(str::as_bytes) {
83 let first = perfect_minimal_hash(&mut lookup, &mut neighbors, line);
84
85 // The graph is undirected so each link is bidirectional.
86 for chunk in line[5..].chunks(4) {
87 let second = perfect_minimal_hash(&mut lookup, &mut neighbors, chunk);
88 neighbors[first].push(second);
89 neighbors[second].push(first);
90 }
91 }
92
93 // Assign each edge a unique index. Each node then specifies a range into the edges vec.
94 let mut edges = Vec::with_capacity(5_000);
95 let mut nodes = Vec::with_capacity(neighbors.len());
96
97 for list in neighbors {
98 let start = edges.len();
99 edges.extend(list);
100 nodes.push((start, edges.len()));
101 }
102
103 Input { edges, nodes }
104}
105
106pub fn part1(input: &Input) -> usize {
107 // Arbitrarily pick the first node then find the furthest node from it.
108 let start = furthest(input, 0);
109 // Find the furthest node from start. The graph is constructed so that the minimum cut is
110 // in the center of the graph, so start and end will be on opposite sides of the cut.
111 let end = furthest(input, start);
112 // Find the size of the graph still connected to start after the cut.
113 let size = flow(input, start, end);
114 size * (input.nodes.len() - size)
115}
116
117pub fn part2(_input: &Input) -> &'static str {
118 "n/a"
119}
120
121/// Each node's name is exactly 3 lowercase ASCII letters. First we calculate a
122/// [perfect hash](https://en.wikipedia.org/wiki/Perfect_hash_function) by converting to a base 26
123/// number. Then we construct a perfect *minimal* hash by using the first index to lookup a
124/// contiguous index into the nodes vec.
125fn perfect_minimal_hash(lookup: &mut [usize], nodes: &mut Vec<Vec<usize>>, slice: &[u8]) -> usize {
126 // Base 26 index.
127 let hash = slice[..3].iter().fold(0, |acc, b| 26 * acc + usize::from(b - b'a'));
128 let mut index = lookup[hash];
129
130 // First time seeing this key so push a new node and return its index.
131 if index == usize::MAX {
132 index = nodes.len();
133 lookup[hash] = index;
134 nodes.push(Vec::with_capacity(10));
135 }
136
137 index
138}
139
140/// BFS across the graph to find the furthest nodes from start.
141fn furthest(input: &Input, start: usize) -> usize {
142 let mut todo = VecDeque::from([start]);
143
144 // The node indices are also their key so we can use a vec instead of a HashSet for speed.
145 let mut seen = vec![false; input.nodes.len()];
146 seen[start] = true;
147
148 let mut result = start;
149
150 while let Some(current) = todo.pop_front() {
151 // The last node visited will be the furthest.
152 result = current;
153
154 for (_, next) in input.neighbors(current) {
155 if !seen[next] {
156 todo.push_back(next);
157 seen[next] = true;
158 }
159 }
160 }
161
162 result
163}
164
165/// Simplified approach based on Edmonds-Karp algorithm.
166fn flow(input: &Input, start: usize, end: usize) -> usize {
167 let mut todo = VecDeque::new();
168 // The path forms a linked list. During the BFS each path shares most nodes, so it's
169 // more efficient both in space and speed to store the path as a linked list instead
170 // of multiple copies of `vec`s.
171 let mut path = Vec::new();
172 // The capacity of each edge is 1 so only allow each edge to be used once.
173 let mut used = vec![false; input.edges.len()];
174 // The number of nodes from the 4th BFS is the size of one part of the cut graph.
175 let mut result = 0;
176
177 // We know the minimum cut is 3, so the 4th iteration will only be able to reach nodes
178 // on start's side.
179 for _ in 0..4 {
180 todo.push_back((start, usize::MAX));
181 result = 0;
182
183 let mut seen = vec![false; input.nodes.len()];
184 seen[start] = true;
185
186 while let Some((current, head)) = todo.pop_front() {
187 // Count how many nodes we visit.
188 result += 1;
189
190 // If we reached the end then add each edge of the path to `used`
191 // so that it can be used only once.
192 if current == end {
193 let mut index = head;
194
195 // Traverse the linked list.
196 while index != usize::MAX {
197 let (edge, next) = path[index];
198 used[edge] = true;
199 index = next;
200 }
201
202 break;
203 }
204
205 // Find neighboring nodes to explore, only allowing each edge to be used once.
206 for (edge, next) in input.neighbors(current) {
207 if !used[edge] && !seen[next] {
208 seen[next] = true;
209 todo.push_back((next, path.len()));
210 path.push((edge, head));
211 }
212 }
213 }
214
215 // Reuse for each iteration as a minor optimization.
216 todo.clear();
217 path.clear();
218 }
219
220 result
221}