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//! The heuristic is the [Manhattan distance](https://en.wikipedia.org/wiki/Taxicab_geometry)
8//! to the bottom right corner. This will never overestimate the actual distance which is an
9//! essential characteristic in the heuristic.
10//!
11//! A crucial insight speeds things up. We only need to store `(position, direction)` pairs in
12//! the map of previously seen costs and do not also need to store the number of steps.
13//! The reason is that each time we generate new states from the current state we loop over all
14//! possible forward states. This implicitly means that every new state will always make a left or
15//! right turn, alternating between horizontal and vertical movements.
16//!
17//! It's a little more subtle but we also don't need to store 4 directions but only 2, horizontal
18//! and vertical. The reason is similar to not encoding the number of steps. As we are always
19//! implicitly going to make a left or right turn immediately, entering a square from the opposite
20//! direction is equivalent. This reduces the storage space and time by half.
21//!
22//! To speed things up even further we use a trick. Classic A* uses a generic priority queue that
23//! can be implemented in Rust using a [`BinaryHeap`]. However, the total cost follows a strictly
24//! increasing order in a constrained range of values, so we can use a much faster
25//! [bucket queue](https://en.wikipedia.org/wiki/Bucket_queue). The maximum possible increase in
26//! heuristic is 10 × 9 from heat plus 10 for the distance change for a total of 100 buckets.
27//!
28//! [`BinaryHeap`]: std::collections::BinaryHeap
29use std::iter::repeat_with;
30
31use crate::util::grid::*;
32use crate::util::parse::*;
33
34/// Parse the input into a 2D grid of `u8` then convert to `u32` for convenience.
35pub fn parse(input: &str) -> Grid<i32> {
36 let Grid { width, height, bytes } = Grid::parse(input);
37 let bytes = bytes.into_iter().map(u8::to_decimal).collect();
38 Grid { width, height, bytes }
39}
40
41/// Search with a maximum of 3 steps in any direction.
42pub fn part1(grid: &Grid<i32>) -> i32 {
43 astar::<1, 3>(grid)
44}
45
46/// Search with a minimum of 4 and maximum of 10 steps in any direction. Using const generics
47/// to specify the limits allows the compiler to optimize and unroll loops, speeding things
48/// up by about 25%, versus specifying the loop limits as regular parameters.
49pub fn part2(grid: &Grid<i32>) -> i32 {
50 astar::<4, 10>(grid)
51}
52
53/// Optimized A* search.
54fn astar<const L: i32, const U: i32>(grid: &Grid<i32>) -> i32 {
55 let size = grid.width;
56 let stride = size as usize;
57 let heat = &grid.bytes;
58
59 let mut index = 0;
60 let mut todo: Vec<_> = repeat_with(|| Vec::with_capacity(1_000)).take(100).collect();
61 let mut cost = vec![[i32::MAX; 2]; heat.len()];
62
63 // Start from the top left corner checking both vertical and horizontal directions.
64 todo[0].push((0, 0, 0));
65 todo[0].push((0, 0, 1));
66
67 cost[0][0] = 0;
68 cost[0][1] = 0;
69
70 loop {
71 // All items in the same bucket have the same priority.
72 while let Some((x, y, direction)) = todo[index % 100].pop() {
73 // Retrieve cost for our current location and direction.
74 let index = (size * y + x) as usize;
75 let steps = cost[index][direction];
76
77 // The heuristic is used as an index into the bucket priority queue.
78 // Prefer heading toward the bottom right corner, except if we're in the top left
79 // quadrant where all directions are considered equally. This prevents a pathological
80 // dual frontier on some inputs that takes twice the time.
81 let heuristic = |x: i32, y: i32, cost: i32| {
82 let priority = (2 * size - x - y).min(size + size / 2);
83 ((cost + priority) % 100) as usize
84 };
85
86 // Check if we've reached the end.
87 if x == size - 1 && y == size - 1 {
88 return steps;
89 }
90
91 // Alternate directions each turn. We arbitrarily pick `0` to mean vertical and `1` to
92 // mean horizontal. These constants are used as offsets into the `cost` array.
93 if direction == 0 {
94 // We just moved vertically so now check both left and right directions.
95
96 // Each direction loop is the same:
97 // * Check to see if we've gone out of bounds
98 // * Increase the cost by the "heat" of the square we've just moved into.
99 // * Check if we've already been to this location with a lower cost.
100 // * Add new state to priority queue.
101
102 // Right
103 let mut next = index;
104 let mut extra = steps;
105
106 for i in 1..U + 1 {
107 if x + i >= size {
108 break;
109 }
110
111 next += 1;
112 extra += heat[next];
113
114 if i >= L && extra < cost[next][1] {
115 todo[heuristic(x + i, y, extra)].push((x + i, y, 1));
116 cost[next][1] = extra;
117 }
118 }
119
120 // Left
121 let mut next = index;
122 let mut extra = steps;
123
124 for i in 1..U + 1 {
125 if i > x {
126 break;
127 }
128
129 next -= 1;
130 extra += heat[next];
131
132 if i >= L && extra < cost[next][1] {
133 todo[heuristic(x - i, y, extra)].push((x - i, y, 1));
134 cost[next][1] = extra;
135 }
136 }
137 } else {
138 // We just moved horizontally so now check both up and down directions.
139
140 // Down
141 let mut next = index;
142 let mut extra = steps;
143
144 for i in 1..U + 1 {
145 if y + i >= size {
146 break;
147 }
148
149 next += stride;
150 extra += heat[next];
151
152 if i >= L && extra < cost[next][0] {
153 todo[heuristic(x, y + i, extra)].push((x, y + i, 0));
154 cost[next][0] = extra;
155 }
156 }
157
158 // Up
159 let mut next = index;
160 let mut extra = steps;
161
162 for i in 1..U + 1 {
163 if i > y {
164 break;
165 }
166
167 next -= stride;
168 extra += heat[next];
169
170 if i >= L && extra < cost[next][0] {
171 todo[heuristic(x, y - i, extra)].push((x, y - i, 0));
172 cost[next][0] = extra;
173 }
174 }
175 }
176 }
177
178 // Bump priority by one to check the next bucket.
179 index += 1;
180 }
181}