aoc/year2018/day22.rs
1//! # Mode Maze
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. The heuristic is the
6//! [Manhattan distance](https://en.wikipedia.org/wiki/Taxicab_geometry) to the target. This will
7//! never overestimate the actual distance, which is an essential characteristic of the heuristic.
8//! Interestingly, benchmarking showed that adding the time to switch tools if we don't have the
9//! torch to the heuristic slowed things down.
10//!
11//! The various input files all have a target with a first coordinate less than 20, and a
12//! second coordinate between 700 and 800. It is slightly faster to swap the coordinate system
13//! to treat the first coordinate as the number of rows (height), and the second as the number of
14//! columns (width), since memory is more efficient with row-major iteration and cross-row motion
15//! when rows are short (the rest of this file generally avoids the terms `x` and `y` to minimize
16//! confusion in relation to the puzzle statement).
17//!
18//! Part two needs a larger grid than part one, but pre-populating the larger grid during the
19//! parse avoids repeated work for the portion of the grid used by both parts.
20//!
21//! Using A* instead of [Dijkstra](https://en.wikipedia.org/wiki/Dijkstra%27s_algorithm) results
22//! in a 6x speedup on an unconstrained grid. This is because Dijkstra explores the grid evenly
23//! in both axes, so if the target is 700 deep, then we will explore an area roughly 700 x 700
24//! in size. In contrast, A* prefers reducing the distance to the target, exploring a narrower
25//! area approximately 130 x 700 in size. On the other hand, use of an unconstrained grid does
26//! unnecessary work, since all known input files can be solved with a grid of width 80. The smaller
27//! grid benefits Dijkstra more than A*, although A* remains faster. The state is a tuple of
28//! `(location, tool)` in order to track the time per tool separately.
29//!
30//! To speed things up even further we use a trick. Classic A* uses a generic priority queue that
31//! can be implemented in Rust using a [`BinaryHeap`]. However, the total cost follows a strictly
32//! increasing order in a constrained range of values, so we can use a much faster
33//! [bucket queue](https://en.wikipedia.org/wiki/Bucket_queue). The range of the increase is from
34//! 0 (moving toward the target and not changing tools) to 7 (staying put and changing tools),
35//! requiring 8 buckets total.
36//!
37//! [`BinaryHeap`]: std::collections::BinaryHeap
38use std::array::from_fn;
39
40use crate::util::grid::*;
41use crate::util::iter::*;
42use crate::util::parse::*;
43use crate::util::point::*;
44
45/// The index of each tool is the tool that *cannot* be used in that region, for example
46/// Rocky => 0 => Neither, Wet => 1 => Torch, and Narrow => 2 => Climbing Gear.
47const TORCH: usize = 1;
48const BUCKETS: usize = 8;
49
50/// Amount of slop beyond the target to include in the grid. Too little, and this will miss
51/// paths that can take a detour to avoid a tool swap. Too large, and this will waste time
52/// exploring additional points in the frontier that end up not affecting the shortest path. The
53/// values picked here match empirical testing against multiple known input files, although
54/// it is conceivable that an alternative cave depth may need a larger height.
55const SLOP_WIDTH: i32 = 3;
56const SLOP_HEIGHT: i32 = 65;
57
58pub struct Input {
59 cave: Grid<u8>, // region types for grid, (x + SLOP_HEIGHT) by (y + SLOP_WIDTH)
60 height: i32, // x coordinate of the target, < 20
61 width: i32, // y coordinate of the target, > 700
62}
63
64pub fn parse(input: &str) -> Input {
65 // The puzzle describes the input as X,Y, but it is more efficient to use the numbers as
66 // row,column, rearranged to have row-major iteration.
67 let [depth, target_row, target_col] = input.iter_signed::<i32>().chunk::<3>().next().unwrap();
68
69 let target = Point::new(target_col, target_row);
70
71 let mut row = vec![0; (target_col + SLOP_WIDTH) as usize];
72 let mut grid = Grid::new(target_col + SLOP_WIDTH, target_row + SLOP_HEIGHT, 0_u8);
73
74 // Erosion levels in the first row (when puzzle X is zero) are set to a scaled geologic index.
75 for c in 0..row.len() {
76 row[c] = (48271 * c as i32 + depth) % 20183;
77 grid[Point::new(c as i32, 0)] = (row[c] % 3) as u8;
78 }
79
80 // Remaining rows have the first column (when puzzle Y is zero) set to a scaled geologic
81 // index, and other columns set by the product of two neighboring erosion levels, except
82 // for the target point having a hard-coded geologic index of 0.
83 for r in 1..target_row + SLOP_HEIGHT {
84 let mut prev = (16807 * r + depth) % 20183;
85 row[0] = prev;
86 grid[Point::new(0, r)] = (row[0] % 3) as u8;
87
88 for c in 1..target_col + SLOP_WIDTH {
89 let point = Point::new(c, r);
90 let c = c as usize;
91 let geologic = if point == target { 0 } else { prev * row[c] };
92 row[c] = (geologic + depth) % 20183;
93 prev = row[c];
94 grid[point] = (row[c] % 3) as u8;
95 }
96 }
97
98 Input { cave: grid, height: target_row, width: target_col }
99}
100
101/// Calculate the risk level of the relevant subset of the overall cave grid.
102pub fn part1(input: &Input) -> i32 {
103 let Input { cave, height, width } = input;
104 cave.bytes
105 .chunks(cave.width as usize)
106 .take(*height as usize + 1)
107 .map(|row| row.iter().take(*width as usize + 1).map(|point| *point as i32).sum::<i32>())
108 .sum()
109}
110
111/// A* search for the shortest path to the target.
112pub fn part2(input: &Input) -> i32 {
113 let erosion = &input.cave;
114 let target = Point::new(input.width, input.height);
115
116 // Initialize bucket queue with pre-allocated capacity to reduce reallocations needed.
117 let mut base = 0;
118 let mut todo: [_; BUCKETS] = from_fn(|_| Vec::with_capacity(1_000));
119
120 // Populate times for the cave, which already has extra width and height so the search does
121 // not exceed the bounds of the grid.
122 // Subtle trick here. By setting the time to zero for the tool that cannot be used,
123 // we implicitly disallow it during the A* search as the time to reach the square will
124 // always be greater than zero.
125 let mut cave = Grid::new(erosion.width, erosion.height, [i32::MAX; 3]);
126 for (i, &level) in erosion.bytes.iter().enumerate() {
127 cave.bytes[i][level as usize] = 0;
128 }
129
130 // Start at origin with the torch equipped.
131 todo[0].push((ORIGIN, TORCH));
132 cave[ORIGIN][TORCH] = 0;
133
134 loop {
135 // All items in the same bucket have the same priority.
136 while let Some((point, tool)) = todo[base % BUCKETS].pop() {
137 let time = cave[point][tool];
138
139 // Check for completion.
140 if point == target && tool == TORCH {
141 return time;
142 }
143
144 // Move to adjacent region with the same tool.
145 for next in ORTHOGONAL.map(|o| point + o) {
146 // We don't need an additional check that the tool cannot be used in the
147 // destination region, as the time check will take care of that.
148 if cave.contains(next) && time + 1 < cave[next][tool] {
149 let heuristic = next.manhattan(target);
150 let index = (time + 1 + heuristic) as usize;
151
152 cave[next][tool] = time + 1;
153 todo[index % BUCKETS].push((next, tool));
154 }
155 }
156
157 // Stay put and change to the other possible tool.
158 for (other, elapsed) in cave[point].iter_mut().enumerate() {
159 if time + 7 < *elapsed {
160 let heuristic = point.manhattan(target);
161 let index = (time + 7 + heuristic) as usize;
162
163 *elapsed = time + 7;
164 todo[index % BUCKETS].push((point, other));
165 }
166 }
167 }
168
169 base += 1;
170 }
171}