aoc/year2023/day17.rs
1//! # Clumsy Crucible
2//!
3//! Our high-level approach is an [A*](https://en.wikipedia.org/wiki/A*_search_algorithm) search.
4//! This [fantastic blog](https://www.redblobgames.com/pathfinding/a-star/introduction.html)
5//! is a great introduction to this algorithm.
6//!
7//! A crucial insight speeds things up. We only need to store `(position, direction)` pairs in
8//! the map of previously seen costs and do not also need to store the number of steps.
9//! The reason is that each time we generate new states from the current state we loop over all
10//! possible forward states. This implicitly means that every new state will always make a left or
11//! right turn, alternating between horizontal and vertical movements.
12//!
13//! It's a little more subtle but we also don't need to store 4 directions but only 2, horizontal
14//! and vertical. The reason is similar to not encoding the number of steps. As we are always
15//! implicitly going to make a left or right turn immediately, entering a square from the opposite
16//! direction is equivalent. This reduces the storage space and time by half.
17//!
18//! ## Heuristic
19//!
20//! The obvious heuristic is the [Manhattan distance](https://en.wikipedia.org/wiki/Taxicab_geometry)
21//! to the bottom right corner. This never overestimates the actual cost, however it is so weak that
22//! the search ends up visiting almost every state in the grid.
23//!
24//! Instead we spend a little time up front computing a much sharper bound. Relaxing the puzzle by
25//! dropping the straight line rule entirely leaves a plain grid shortest path problem. Any real
26//! crucible route is also a valid route in the relaxed problem, so the relaxed distance from each
27//! square to the bottom right corner can never exceed the true remaining cost. The relaxed
28//! distances are computed once during parsing with a backwards [Dijkstra](https://en.wikipedia.org/wiki/Dijkstra's_algorithm)
29//! from the bottom right corner then shared with both parts.
30//!
31//! ## Implementation
32//!
33//! Classic A* uses a generic priority queue that can be implemented in Rust using a [`BinaryHeap`].
34//! However the total cost follows a strictly increasing order in a constrained range of values, so
35//! we can use a much faster [bucket queue](https://en.wikipedia.org/wiki/Bucket_queue).
36//!
37//! As the buckets are drained in increasing cost order, an entry is stale if its cost no longer
38//! agrees with the bucket it was found in. Checking this skips roughly half the states in part two.
39//!
40//! Finally the grid is surrounded by a border of zero cost squares. A square is only worth visiting
41//! if it improves on the previous best cost, and nothing improves on zero, so the search can move
42//! blindly in a straight line without a single bounds check.
43//!
44//! [`BinaryHeap`]: std::collections::BinaryHeap
45use std::array::from_fn;
46
47use crate::util::grid::*;
48use crate::util::parse::*;
49
50/// Border is the size of the longest possible straight line.
51const BORDER: usize = 10;
52
53pub struct Input {
54 size: usize,
55 stride: usize,
56 start: usize,
57 end: usize,
58 heat: Vec<u8>,
59 heuristic: Vec<u16>,
60}
61
62/// Parse the input into a bordered grid then precompute the heuristic shared by both parts.
63pub fn parse(input: &str) -> Input {
64 let grid = Grid::parse(input);
65 let size = grid.width as usize;
66 let stride = size + 2 * BORDER;
67 let start = stride * BORDER + BORDER;
68 let end = stride * stride - start - 1;
69
70 let mut heat = vec![0; stride * stride];
71 let mut heuristic = vec![0; stride * stride];
72
73 for y in 0..size {
74 for x in 0..size {
75 heat[start + y * stride + x] = grid.bytes[y * size + x].to_decimal();
76 heuristic[start + y * stride + x] = u16::MAX;
77 }
78 }
79
80 let mut input = Input { size, stride, start, end, heat, heuristic };
81 dijkstra(&mut input);
82 input
83}
84
85/// Search with a maximum of 3 steps in any direction.
86pub fn part1(input: &Input) -> u16 {
87 astar::<1, 3>(input)
88}
89
90/// Search with a minimum of 4 and maximum of 10 steps in any direction. Using const generics
91/// to specify the limits allows the compiler to optimize and unroll loops, speeding things
92/// up by about 5%, versus specifying the loop limits as regular parameters.
93pub fn part2(input: &Input) -> u16 {
94 astar::<4, 10>(input)
95}
96
97/// Cost to each square from the bottom right corner if the crucible could turn freely.
98///
99/// Entering a square always costs that square's heat, so the reverse edge from a square to each
100/// of its neighbours has the same weight in every direction.
101fn dijkstra(input: &mut Input) {
102 let Input { size, stride, end, .. } = *input;
103 let Input { heat, heuristic, .. } = input;
104
105 let mut loss = 0;
106 let mut remaining = size * size;
107 let mut todo: [_; 10] = from_fn(|_| Vec::with_capacity(100));
108
109 heuristic[end] = 0;
110 todo[0].push(end);
111
112 while remaining > 0 {
113 while let Some(position) = todo[loss % 10].pop() {
114 // Skip stale entries.
115 if heuristic[position] as usize == loss {
116 remaining -= 1;
117
118 let cost = loss as u16 + heat[position] as u16;
119 let bucket = cost as usize % 10;
120
121 for next in [position - 1, position + 1, position - stride, position + stride] {
122 if cost < heuristic[next] {
123 heuristic[next] = cost;
124 todo[bucket].push(next);
125 }
126 }
127 }
128 }
129
130 loss += 1;
131 }
132}
133
134/// Optimized A* search.
135fn astar<const L: usize, const U: usize>(input: &Input) -> u16 {
136 let Input { size, stride, start, end, .. } = *input;
137 let Input { heat, heuristic, .. } = input;
138
139 // The border remains zero so that squares outside the grid are never visited.
140 let mut cost = vec![[0; 2]; heat.len()];
141
142 for y in 0..size {
143 let from = start + y * stride;
144 cost[from..from + size].fill([u16::MAX; 2]);
145 }
146
147 // Total cost of both starting states is the heuristic alone.
148 let mut loss = heuristic[start] as usize;
149 let mut todo: [_; 100] = from_fn(|_| Vec::with_capacity(1_000));
150
151 // We arbitrarily pick `0` to mean vertical and `1` to mean horizontal, stored in the lowest
152 // bit of the state alongside the position.
153 todo[loss % 100].push(start << 1);
154 todo[loss % 100].push((start << 1) | 1);
155 cost[start] = [0; 2];
156
157 loop {
158 // All items in the same bucket have the same priority.
159 while let Some(state) = todo[loss % 100].pop() {
160 let position = state >> 1;
161 let direction = state & 1;
162 let steps = cost[position][direction];
163
164 // A cheaper route to this state was found after it was added to the queue.
165 if steps as usize + heuristic[position] as usize != loss {
166 continue;
167 }
168
169 // Check if we've reached the end.
170 if position == end {
171 return steps;
172 }
173
174 // Alternate directions each turn, so a vertical arrival leaves horizontally
175 // and vice-versa.
176 let turn = 1 - direction;
177 let delta = if direction == 0 { 1 } else { stride };
178
179 // Both directions along the new axis are the same:
180 // * Increase the cost by the "heat" of the square we've just moved into.
181 // * Check if we've already been to this square with a lower cost.
182 // * Add new state to priority queue.
183 for delta in [delta, delta.wrapping_neg()] {
184 let mut next = position;
185 let mut extra = steps;
186
187 for i in 1..=U {
188 next = next.wrapping_add(delta);
189 extra += heat[next] as u16;
190
191 if i >= L && extra < cost[next][turn] {
192 cost[next][turn] = extra;
193 let bucket = (extra as usize + heuristic[next] as usize) % 100;
194 todo[bucket].push((next << 1) | turn);
195 }
196 }
197 }
198 }
199
200 // Bump priority by one to check the next bucket.
201 loss += 1;
202 }
203}