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 in 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//! Using A* instead of [Dijkstra](https://en.wikipedia.org/wiki/Dijkstra%27s_algorithm) results
12//! in a 6x speedup. This is because Dijkstra explores the grid evenly in both axes, so if the
13//! target is 700 deep, then we will explore an area roughly 700 x 700 in size. In contrast A*
14//! prefers reducing the distance to the target, exploring a more narrow area
15//! approximately 130 x 700 in size. The state is a tuple of `(location, tool)` in order to track
16//! the time per tool separately.
17//!
18//! To speed things up even further we use a trick. Classic A* uses a generic priority queue that
19//! can be implemented in Rust using a [`BinaryHeap`]. However the total cost follows a strictly
20//! increasing order in a constrained range of values, so we can use a much faster
21//! [bucket queue](https://en.wikipedia.org/wiki/Bucket_queue). The range of the increase is from
22//! 0 (moving towards the target and not changing tools) to 7 (staying put and changing tools)
23//! requiring 8 buckets total.
24//!
25//! [`BinaryHeap`]: std::collections::BinaryHeap
26use crate::util::grid::*;
27use crate::util::iter::*;
28use crate::util::parse::*;
29use crate::util::point::*;
30use std::iter::repeat_with;
31
32/// The index of each tool is that tool that *cannot* be used in that region, for example
33/// Rocky => 0 => Neither, Wet => 1 => Torch and Narrow => 2 => Climbing Gear.
34const TORCH: usize = 1;
35const BUCKETS: usize = 8;
36
37type Input = [i32; 3];
38
39#[derive(Clone, Copy)]
40struct Region {
41    erosion: i32,
42    minutes: [i32; 3],
43}
44
45impl Region {
46    fn update(&mut self, geologic: i32) -> i32 {
47        let erosion = geologic % 20183;
48        self.erosion = erosion;
49        // Subtle trick here. By setting the time to zero for the tool that cannot be used,
50        // we implicitly disallow it during the A* search as the time to reach the square will
51        // always be greater than zero.
52        self.minutes[(erosion % 3) as usize] = 0;
53        erosion
54    }
55}
56
57pub fn parse(input: &str) -> Input {
58    input.iter_signed::<i32>().chunk::<3>().next().unwrap()
59}
60
61/// Build the minimum grid to the target then calculate the risk level.
62pub fn part1(input: &Input) -> i32 {
63    let [_, height, width] = *input;
64    let cave = scan_cave(input, width + 1, height + 1);
65    cave.bytes.iter().map(|r| r.erosion % 3).sum()
66}
67
68/// A* search for the shortest path to the target.
69pub fn part2(input: &Input) -> i32 {
70    // Swap width and height.
71    let [_, height, width] = *input;
72    let target = Point::new(width, height);
73
74    // Initialise bucket queue with pre-allocated capacity to reduce reallocations needed.
75    let mut base = 0;
76    let mut todo = repeat_with(|| Vec::with_capacity(1000)).take(BUCKETS).collect::<Vec<_>>();
77
78    // Add extra width and height so the search does not exceed the bounds of the grid.
79    let mut cave = scan_cave(input, width + 10, height + 140);
80
81    // Start at origin with the torch equipped.
82    todo[0].push((ORIGIN, TORCH));
83    cave[ORIGIN].minutes[TORCH] = 0;
84
85    loop {
86        // All items in the same bucket have the same priority.
87        while let Some((point, tool)) = todo[base % BUCKETS].pop() {
88            let time = cave[point].minutes[tool];
89
90            // Check for completion.
91            if point == target && tool == TORCH {
92                return time;
93            }
94
95            // Move to adjacent region with the same tool.
96            for next in ORTHOGONAL.map(|o| point + o) {
97                // We don't need an additional check that the tool cannot be used in the
98                // destination region, as the time check will take care of that.
99                if next.x >= 0 && next.y >= 0 && time + 1 < cave[next].minutes[tool] {
100                    let heuristic = next.manhattan(target);
101                    let index = (time + 1 + heuristic) as usize;
102
103                    cave[next].minutes[tool] = time + 1;
104                    todo[index % BUCKETS].push((next, tool));
105                }
106            }
107
108            // Stay put and change to the other possible tool.
109            for other in 0..3 {
110                if time + 7 < cave[point].minutes[other] {
111                    let heuristic = point.manhattan(target);
112                    let index = (time + 7 + heuristic) as usize;
113
114                    cave[point].minutes[other] = time + 7;
115                    todo[index % BUCKETS].push((point, other));
116                }
117            }
118        }
119
120        base += 1;
121    }
122}
123
124/// Calculate the erosion level for each region. We swap width and height for a small speed boost
125/// without affecting the outcome of the shortest path.
126fn scan_cave(input: &Input, width: i32, height: i32) -> Grid<Region> {
127    let [depth, target_y, target_x] = *input;
128    let target = Point::new(target_x, target_y);
129
130    let region = Region { erosion: 0, minutes: [i32::MAX; 3] };
131    let mut grid = Grid::new(width, height, region);
132
133    grid[Point::new(0, 0)].update(depth);
134
135    for x in 1..width {
136        grid[Point::new(x, 0)].update(48271 * x + depth);
137    }
138
139    for y in 1..height {
140        let mut prev = grid[Point::new(0, y)].update(16807 * y + depth);
141
142        for x in 1..width {
143            let point = Point::new(x, y);
144            if point == target {
145                grid[point].update(depth);
146            } else {
147                let up = grid[point + UP].erosion;
148                prev = grid[point].update(prev * up + depth);
149            }
150        }
151    }
152
153    grid
154}