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 crate::util::grid::*;
39use crate::util::iter::*;
40use crate::util::parse::*;
41use crate::util::point::*;
42use std::array::from_fn;
43
44/// The index of each tool is the tool that *cannot* be used in that region, for example
45/// Rocky => 0 => Neither, Wet => 1 => Torch, and Narrow => 2 => Climbing Gear.
46const TORCH: usize = 1;
47const BUCKETS: usize = 8;
48
49/// Amount of slop beyond the target to include in the grid. Too little, and this will miss
50/// paths that can take a detour to avoid a tool swap. Too large, and this will waste time
51/// exploring additional points in the frontier that end up not affecting the shortest path. The
52/// values picked here match empirical testing against multiple known input files, although
53/// it is conceivable that an alternative cave depth may need a larger height.
54const SLOP_WIDTH: i32 = 3;
55const SLOP_HEIGHT: i32 = 65;
56
57pub struct Input {
58 cave: Grid<u8>, // region types for grid, (x + SLOP_HEIGHT) by (y + SLOP_WIDTH)
59 height: i32, // x coordinate of the target, < 20
60 width: i32, // y coordinate of the target, > 700
61}
62
63pub fn parse(input: &str) -> Input {
64 // The puzzle describes the input as X,Y, but it is more efficient to use the numbers as
65 // row,column, rearranged to have row-major iteration.
66 let [depth, target_row, target_col] = input.iter_signed::<i32>().chunk::<3>().next().unwrap();
67
68 let target = Point::new(target_col, target_row);
69
70 let mut row = vec![0; (target_col + SLOP_WIDTH) as usize];
71 let mut grid = Grid::new(target_col + SLOP_WIDTH, target_row + SLOP_HEIGHT, 0_u8);
72
73 // Erosion levels in the first row (when puzzle X is zero) are set to a scaled geologic index.
74 for c in 0..row.len() {
75 row[c] = (48271 * c as i32 + depth) % 20183;
76 grid[Point::new(c as i32, 0)] = (row[c] % 3) as u8;
77 }
78
79 // Remaining rows have the first column (when puzzle Y is zero) set to a scaled geologic
80 // index, and other columns set by the product of two neighboring erosion levels, except
81 // for the target point having a hard-coded geologic index of 0.
82 for r in 1..target_row + SLOP_HEIGHT {
83 let mut prev = (16807 * r + depth) % 20183;
84 row[0] = prev;
85 grid[Point::new(0, r)] = (row[0] % 3) as u8;
86
87 for c in 1..target_col + SLOP_WIDTH {
88 let point = Point::new(c, r);
89 let c = c as usize;
90 let geologic = if point == target { 0 } else { prev * row[c] };
91 row[c] = (geologic + depth) % 20183;
92 prev = row[c];
93 grid[point] = (row[c] % 3) as u8;
94 }
95 }
96
97 Input { cave: grid, height: target_row, width: target_col }
98}
99
100/// Calculate the risk level of the relevant subset of the overall cave grid.
101pub fn part1(input: &Input) -> i32 {
102 let Input { cave, height, width } = input;
103 cave.bytes
104 .chunks(cave.width as usize)
105 .take(*height as usize + 1)
106 .map(|row| row.iter().take(*width as usize + 1).map(|point| *point as i32).sum::<i32>())
107 .sum()
108}
109
110/// A* search for the shortest path to the target.
111pub fn part2(input: &Input) -> i32 {
112 let erosion = &input.cave;
113 let target = Point::new(input.width, input.height);
114
115 // Initialize bucket queue with pre-allocated capacity to reduce reallocations needed.
116 let mut base = 0;
117 let mut todo: [_; BUCKETS] = from_fn(|_| Vec::with_capacity(1_000));
118
119 // Populate times for the cave, which already has extra width and height so the search does
120 // not exceed the bounds of the grid.
121 // Subtle trick here. By setting the time to zero for the tool that cannot be used,
122 // we implicitly disallow it during the A* search as the time to reach the square will
123 // always be greater than zero.
124 let mut cave = Grid::new(erosion.width, erosion.height, [i32::MAX; 3]);
125 for (i, &level) in erosion.bytes.iter().enumerate() {
126 cave.bytes[i][level as usize] = 0;
127 }
128
129 // Start at origin with the torch equipped.
130 todo[0].push((ORIGIN, TORCH));
131 cave[ORIGIN][TORCH] = 0;
132
133 loop {
134 // All items in the same bucket have the same priority.
135 while let Some((point, tool)) = todo[base % BUCKETS].pop() {
136 let time = cave[point][tool];
137
138 // Check for completion.
139 if point == target && tool == TORCH {
140 return time;
141 }
142
143 // Move to adjacent region with the same tool.
144 for next in ORTHOGONAL.map(|o| point + o) {
145 // We don't need an additional check that the tool cannot be used in the
146 // destination region, as the time check will take care of that.
147 if cave.contains(next) && time + 1 < cave[next][tool] {
148 let heuristic = next.manhattan(target);
149 let index = (time + 1 + heuristic) as usize;
150
151 cave[next][tool] = time + 1;
152 todo[index % BUCKETS].push((next, tool));
153 }
154 }
155
156 // Stay put and change to the other possible tool.
157 for other in 0..3 {
158 if time + 7 < cave[point][other] {
159 let heuristic = point.manhattan(target);
160 let index = (time + 7 + heuristic) as usize;
161
162 cave[point][other] = time + 7;
163 todo[index % BUCKETS].push((point, other));
164 }
165 }
166 }
167
168 base += 1;
169 }
170}