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.
40use std::array::from_fn;
41
42use crate::util::parse::*;
43
44pub struct Square {
45 size: usize,
46 bytes: Vec<u8>,
47}
48
49pub fn parse(input: &str) -> Square {
50 let size = input.lines().next().unwrap().len();
51 let bytes = input.bytes().filter(u8::is_ascii_digit).map(u8::to_decimal).collect();
52 Square { size, bytes }
53}
54
55/// Search the regular size grid.
56pub fn part1(input: &Square) -> usize {
57 astar(input, build_estimates(input))
58}
59
60/// Create an expanded grid then search.
61pub fn part2(input: &Square) -> usize {
62 let Square { size, bytes } = input;
63
64 let mut expanded = Square { size: 5 * size, bytes: vec![0; 25 * size * size] };
65
66 for (i, &b) in bytes.iter().enumerate() {
67 let x1 = i % size;
68 let y1 = i / size;
69 let base = b as usize;
70
71 for x2 in 0..5 {
72 for y2 in 0..5 {
73 let index = (5 * size) * (y2 * size + y1) + (x2 * size + x1);
74 expanded.bytes[index] = (1 + (base - 1 + x2 + y2) % 9) as u8;
75 }
76 }
77 }
78
79 astar(&expanded, build_estimates(&expanded))
80}
81
82// Create a table of heuristics for use in an A* search. This is admissible (never overestimates)
83// but not consistent (the diagonal clamping can sometimes change a node's estimated score by more
84// than its risk, necessitating the search queue to jump back a bucket).
85fn build_estimates(square: &Square) -> Vec<u32> {
86 let Square { size, bytes } = square;
87 let edge = size - 1;
88 let end = size * size - 1;
89
90 let mut estimate = vec![0_u32; size * size];
91 // Produce a coordinate in estimate, or 0 if x or y out of bounds. Since the origin does
92 // not contribute to the overall risk level, we use it instead to hold an effective infinity
93 // to make processing easier at the ends of diagonals.
94 let coord = |col: usize, row: usize| -> usize {
95 if col > edge || row > edge { 0 } else { row * size + col }
96 };
97 estimate[0] = u32::MAX; // Larger than any possible other estimate.
98
99 // Give the target its own risk level.
100 estimate[end] = bytes[end] as u32;
101
102 // Visit the grid by diagonals, starting closest to the target.
103 for diag in (1..edge * 2).rev() {
104 let mut best_diag = u32::MAX - 18;
105 let start = diag.saturating_sub(edge);
106 let end = edge.min(diag) + 1;
107
108 // For each tile crawling up and right, select the minimum between its lower neighbor,
109 // its right neighbor, or the minimum Manhattan distance to any earlier node on the
110 // 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 & 0x1f].push(0);
161 grid_data[0] = 0;
162
163 loop {
164 while let Some(current) = todo[i & 0x1f].pop() {
165 let current = current as usize;
166 let risk = (grid_data[current] & 0xffff) as usize;
167 if current == end {
168 return risk;
169 }
170
171 let mut check = |next: usize| {
172 let next_cost = risk + bytes[next] as usize;
173 if next_cost < grid_data[next] as usize & 0xffff {
174 let next_f = risk + (grid_data[next] >> 16) as usize;
175 // Cope if this resulted in the rare backward jump.
176 i = i.min(next_f);
177 todo[next_f & 0x1f].push(next as u32);
178 grid_data[next] = (grid_data[next] & !0xffff) | next_cost as u32;
179 }
180 };
181 let x = current % size;
182 let y = current / size;
183
184 if x > 0 {
185 check(current - 1);
186 }
187 if x < edge {
188 check(current + 1);
189 }
190 if y > 0 {
191 check(current - size);
192 }
193 if y < edge {
194 check(current + size);
195 }
196 }
197
198 i += 1;
199 }
200}