Skip to main content

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}