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}