aoc/year2021/day15.rs
1//! # Chiton
2//!
3//! Traversing a graph with different non-negative edge weights is a job for the classic
4//! [A* algorithm](https://www.redblobgames.com/pathfinding/a-star/introduction.html),
5//! explained really well in the linked blog post.
6//!
7//! The simplest possible heuristic in A* is to make no estimate at all, making the search
8//! behave the same as Dijkstra's algorithm. With that heuristic, the search frontier visits
9//! nearly every tile, since only a few tiles near the target might have a total risk higher
10//! than the target itself. Slightly better is a heuristic of the Manhattan distance to the
11//! target, although this still underestimates and prunes no more than 5% of the total search
12//! space. Logically this makes sense: Manhattan distance only changes by 1 per tile, but with risks
13//! between 1-9, the average risk is closer to 5, and our best path averages closer to 3 risk per
14//! tile. But we can do much better with a heuristic that prunes about 75% of the search space,
15//! by assigning a value within 2% of the actual cost for each node. In the long run, it is
16//! faster to visit all 250,000 to set up a fairly close estimate which lets us prune the search
17//! space to under 60,000 nodes, than it is to skip the heuristic but have a search space near
18//! 250,000 nodes, since the effort of searching is less predictable than the effort to compute
19//! the heuristic.
20//!
21//! The heuristic we use builds up an estimate for the minimum cost incurred from each node to
22//! the destination. The target itself starts with its risk level. Then for every diagonal line of
23//! tiles, starting next to the target and ending next to the origin, a given tile's estimate is
24//! chosen to be its own risk level plus the minimum of the tile below, the tile to the right,
25//! or the minimum Manhattan distance to reach any other tile on the same diagonal with a better
26//! estimate. Building this up requires traveling each diagonal twice (the first pass captures
27//! any better tiles below and left, the second pass captures any tiles above and right). Allowing
28//! other tiles on the same diagonal to influence the current tile's estimate covers the case where
29//! the optimal path moves up or left around an obstacle. Failure to consider the ability to
30//! reach other tiles on the same diagonal via an unseen path that loops around a wall would result
31//! in a heuristic that is not admissible in A*. At the same time, without actually verifying
32//! whether same-diagonal tiles can actually be reached in the estimated Manhattan distance, the
33//! heuristic is no longer perfectly consistent, which means in practice we can sometimes see a
34//! neighbor point inserted into the work queue with a priority one less than the current tile.
35//!
36//! With our heuristic, the maximum possible increase in risk is 9, compounded with the maximum
37//! jump in the estimate table of another 9. A circular array of 32 buckets (for bitwise math
38//! windowing) can handle our empirical range of -1 to 18 in the set of active buckets, and we
39//! avoid having to allocate memory as the search gradually shifts the active window of buckets.
40//!
41//! [`BinaryHeap`]: std::collections::BinaryHeap
42use crate::util::parse::*;
43use std::array::from_fn;
44
45pub struct Square {
46 size: usize,
47 bytes: Vec<u8>,
48}
49
50pub fn parse(input: &str) -> Square {
51 let size = input.lines().next().unwrap().len();
52 let bytes = input.bytes().filter(u8::is_ascii_digit).map(u8::to_decimal).collect();
53 Square { size, bytes }
54}
55
56/// Search the regular size grid.
57pub fn part1(input: &Square) -> usize {
58 astar(input, build_estimates(input))
59}
60
61/// Create an expanded grid then search.
62pub fn part2(input: &Square) -> usize {
63 let Square { size, bytes } = input;
64
65 let mut expanded = Square { size: 5 * size, bytes: vec![0; 25 * size * size] };
66
67 for (i, &b) in bytes.iter().enumerate() {
68 let x1 = i % size;
69 let y1 = i / size;
70 let base = b as usize;
71
72 for x2 in 0..5 {
73 for y2 in 0..5 {
74 let index = (5 * size) * (y2 * size + y1) + (x2 * size + x1);
75 expanded.bytes[index] = (1 + (base - 1 + x2 + y2) % 9) as u8;
76 }
77 }
78 }
79
80 astar(&expanded, build_estimates(&expanded))
81}
82
83// Create a table of heuristics for use in an A* search. This is admissible (never overestimates)
84// but not consistent (the diagonal clamping can sometimes change a node's estimated score by more
85// than its risk, necessitating the search queue to jump back a bucket).
86fn build_estimates(square: &Square) -> Vec<u32> {
87 let Square { size, bytes } = square;
88 let edge = size - 1;
89 let end = size * size - 1;
90
91 let mut estimate = vec![0_u32; size * size];
92 // Produce a coordinate in estimate, or 0 if x or y out of bounds. Since the origin does
93 // not contribute to the overall risk level, we use it instead to hold an effective infinity
94 // to make processing easier at the ends of diagonals.
95 let coord = |col: usize, row: usize| -> usize {
96 if col > edge || row > edge { 0 } else { row * size + col }
97 };
98 estimate[0] = u32::MAX; // Larger than any possible other estimate.
99
100 // Give the target its own risk level.
101 estimate[end] = bytes[end] as u32;
102
103 // Visit the grid by diagonals, starting closest to the target.
104 for diag in (1..edge * 2).rev() {
105 let mut best_diag = u32::MAX - 18;
106 let start = diag.saturating_sub(edge);
107 let end = edge.min(diag) + 1;
108
109 // For each tile crawling up and right, select the minimum between its lower neighbor,
110 // its right neighbor, or the minimum Manhattan distance to any earlier node on the diagonal.
111 for col in start..end {
112 let row = diag - col;
113 let value =
114 estimate[coord(col + 1, row)].min(estimate[coord(col, row + 1)]).min(best_diag + 2)
115 + bytes[coord(col, row)] as u32;
116 estimate[coord(col, row)] = value;
117 best_diag = (best_diag + 2).min(value);
118 }
119
120 // For each tile crawling down and left, also check for the minimum Manhattan distance from
121 // any better node earlier on the diagonal.
122 best_diag = u32::MAX - 18;
123 for col in (start..end).rev() {
124 let row = diag - col;
125 let value =
126 estimate[coord(col, row)].min(best_diag + 2 + bytes[coord(col, row)] as u32);
127 estimate[coord(col, row)] = value;
128 best_diag = (best_diag + 2).min(value);
129 }
130 }
131
132 // The final estimate for the origin is about 2% shy of the actual risk level.
133 estimate[0] = estimate[1].min(estimate[*size]);
134 estimate
135}
136
137/// Implementation of [A* algorithm](https://en.wikipedia.org/wiki/A*_search_algorithm)
138/// without using the decrease-key functionality.
139fn astar(square: &Square, mut grid_data: Vec<u32>) -> usize {
140 let Square { size, bytes } = square;
141 let edge = size - 1;
142 let end = size * size - 1;
143
144 // Initialize our specialized priority queue with 32 vecs. Chosen to be large enough
145 // to cover the largest gap (actual risk increasing by 9 on the same step that the
146 // heuristic jumps by 9), but also safe against the infrequent backwards jump by 1.
147 let mut todo: [Vec<u32>; 32] = from_fn(|_| Vec::with_capacity(1_000));
148
149 // On entry, grid_data contains estimates in the low 16 bits. As long as all estimates
150 // are transformed uniformly by a constant, the sequence of nodes visited will be identical.
151 // For best memory use, we prefer operating with the estimates in the high 16 bits,
152 // and the cost to reach a node in the low 16 bits, with the initial cost estimate of
153 // u16::MAX as a sentinel that the node has not been visited yet.
154 for cell in &mut grid_data {
155 *cell = (*cell << 16) - 1;
156 }
157
158 // Start location and risk are both zero.
159 let mut i = (grid_data[0] >> 16) as usize;
160 todo[i & 31].push(0);
161 grid_data[0] = 0;
162
163 loop {
164 while todo[i & 31].is_empty() {
165 i += 1;
166 }
167
168 if let Some(current) = todo[i & 31].pop() {
169 let current = current as usize;
170 let risk = (grid_data[current] & 0xffff) as usize;
171 if current == end {
172 return risk;
173 }
174
175 let mut check = |next: usize| {
176 let next_cost = risk + bytes[next] as usize;
177 if next_cost < grid_data[next] as usize & 0xffff {
178 let next_f = risk + (grid_data[next] >> 16) as usize;
179 // Cope if this resulted in the rare backward jump.
180 i = i.min(next_f);
181 todo[next_f & 31].push(next as u32);
182 grid_data[next] = (grid_data[next] & !0xffff) | next_cost as u32;
183 }
184 };
185 let x = current % size;
186 let y = current / size;
187
188 if x > 0 {
189 check(current - 1);
190 }
191 if x < edge {
192 check(current + 1);
193 }
194 if y > 0 {
195 check(current - size);
196 }
197 if y < edge {
198 check(current + size);
199 }
200 }
201 }
202}