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 needs 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//! implictly 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 crate::util::grid::*;
30use crate::util::parse::*;
31use std::iter::repeat_with;
32
33/// Parse the input into a 2D grid of `u8` then convert to `u32` for convenience.
34pub fn parse(input: &str) -> Grid<i32> {
35 let Grid { width, height, bytes } = Grid::parse(input);
36 let bytes = bytes.iter().map(|b| b.to_decimal() as i32).collect();
37 Grid { width, height, bytes }
38}
39
40/// Search with a maximum of 3 steps in any direction.
41pub fn part1(grid: &Grid<i32>) -> i32 {
42 astar::<1, 3>(grid)
43}
44
45/// Search with a minimum of 4 and maximum of 10 steps in any direction. Using const generics
46/// to specify the limits allows the compiler to optimize and unroll loops, speeding things
47/// up by about 25%, versus specifying the loop limits as regular parameters.
48pub fn part2(grid: &Grid<i32>) -> i32 {
49 astar::<4, 10>(grid)
50}
51
52/// Optimized A* search.
53fn astar<const L: i32, const U: i32>(grid: &Grid<i32>) -> i32 {
54 let size = grid.width;
55 let stride = size as usize;
56 let heat = &grid.bytes;
57
58 let mut index = 0;
59 let mut todo = repeat_with(|| Vec::with_capacity(1000)).take(100).collect::<Vec<_>>();
60 let mut cost = vec![[i32::MAX; 2]; heat.len()];
61
62 // Start from the top left corner checking both vertical and horizontal directions.
63 todo[0].push((0, 0, 0));
64 todo[0].push((0, 0, 1));
65
66 cost[0][0] = 0;
67 cost[0][1] = 0;
68
69 loop {
70 // All items in the same bucket have the same priority.
71 while let Some((x, y, direction)) = todo[index % 100].pop() {
72 // Retrieve cost for our current location and direction.
73 let index = (size * y + x) as usize;
74 let steps = cost[index][direction];
75
76 // The heuristic is used as an index into the bucket priority queue.
77 // Prefer heading towards the bottom right corner, except if we're in the top left
78 // quadrant where all directions are considered equally. This prevents a pathological
79 // dual frontier on some inputs that takes twice the time.
80 let heuristic = |x: i32, y: i32, cost: i32| {
81 let priority = (2 * size - x - y).min(size + size / 2);
82 ((cost + priority) % 100) as usize
83 };
84
85 // Check if we've reached the end.
86 if x == size - 1 && y == size - 1 {
87 return steps;
88 }
89
90 // Alternate directions each turn. We arbitrarily pick `0` to mean vertical and `1` to
91 // mean horizontal. These constants are used as offsets into the `cost` array.
92 if direction == 0 {
93 // We just moved vertically so now check both left and right directions.
94
95 // Each direction loop is the same:
96 // * Check to see if we gone out of bounds
97 // * Increase the cost by the "heat" of the square we've just moved into.
98 // * Check if we've already been to this location with a lower cost.
99 // * Add new state to priority queue.
100
101 // Right
102 let mut next = index;
103 let mut extra = steps;
104
105 for i in 1..=U {
106 if x + i >= size {
107 break;
108 }
109
110 next += 1;
111 extra += heat[next];
112
113 if i >= L && extra < cost[next][1] {
114 todo[heuristic(x + i, y, extra)].push((x + i, y, 1));
115 cost[next][1] = extra;
116 }
117 }
118
119 // Left
120 let mut next = index;
121 let mut extra = steps;
122
123 for i in 1..=U {
124 if i > x {
125 break;
126 }
127
128 next -= 1;
129 extra += heat[next];
130
131 if i >= L && extra < cost[next][1] {
132 todo[heuristic(x - i, y, extra)].push((x - i, y, 1));
133 cost[next][1] = extra;
134 }
135 }
136 } else {
137 // We just moved horizontally so now check both up and down directions.
138
139 // Down
140 let mut next = index;
141 let mut extra = steps;
142
143 for i in 1..=U {
144 if y + i >= size {
145 break;
146 }
147
148 next += stride;
149 extra += heat[next];
150
151 if i >= L && extra < cost[next][0] {
152 todo[heuristic(x, y + i, extra)].push((x, y + i, 0));
153 cost[next][0] = extra;
154 }
155 }
156
157 // Up
158 let mut next = index;
159 let mut extra = steps;
160
161 for i in 1..=U {
162 if i > y {
163 break;
164 }
165
166 next -= stride;
167 extra += heat[next];
168
169 if i >= L && extra < cost[next][0] {
170 todo[heuristic(x, y - i, extra)].push((x, y - i, 0));
171 cost[next][0] = extra;
172 }
173 }
174 }
175 }
176
177 // Bump priority by one to check the next bucket.
178 index += 1;
179 }
180}