Skip to main content

aoc/year2021/
day15.rs

1//! # Chiton
2//!
3//! Traversing a graph with different non-negative edge weights is a job for the classic
4//! [A* algorithm](https://www.redblobgames.com/pathfinding/a-star/introduction.html),
5//! explained really well in the linked blog post.
6//!
7//! The simplest possible heuristic in A* is to make no estimate at all, making the search
8//! behave the same as Dijkstra's algorithm. With that heuristic, the search frontier visits
9//! nearly every tile, since only a few tiles near the target might have a total risk higher
10//! than the target itself. Slightly better is a heuristic of the Manhattan distance to the
11//! target, although this still underestimates and prunes no more than 5% of the total search
12//! space. Logically this makes sense: Manhattan distance only changes by 1 per tile, but with risks
13//! between 1-9, the average risk is closer to 5, and our best path averages closer to 3 risk per
14//! tile. But we can do much better with a heuristic that prunes about 75% of the search space,
15//! by assigning a value within 2% of the actual cost for each node. In the long run, it is
16//! faster to visit all 250,000 to set up a fairly close estimate which lets us prune the search
17//! space to under 60,000 nodes, than it is to skip the heuristic but have a search space near
18//! 250,000 nodes, since the effort of searching is less predictable than the effort to compute
19//! the heuristic.
20//!
21//! The heuristic we use builds up an estimate for the minimum cost incurred from each node to
22//! the destination. The target itself starts with its risk level. Then for every diagonal line of
23//! tiles, starting next to the target and ending next to the origin, a given tile's estimate is
24//! chosen to be its own risk level plus the minimum of the tile below, the tile to the right,
25//! or the minimum Manhattan distance to reach any other tile on the same diagonal with a better
26//! estimate. Building this up requires traveling each diagonal twice (the first pass captures
27//! any better tiles below and left, the second pass captures any tiles above and right). Allowing
28//! other tiles on the same diagonal to influence the current tile's estimate covers the case where
29//! the optimal path moves up or left around an obstacle. Failure to consider the ability to
30//! reach other tiles on the same diagonal via an unseen path that loops around a wall would result
31//! in a heuristic that is not admissible in A*. At the same time, without actually verifying
32//! whether same-diagonal tiles can actually be reached in the estimated Manhattan distance, the
33//! heuristic is no longer perfectly consistent, which means in practice we can sometimes see a
34//! neighbor point inserted into the work queue with a priority one less than the current tile.
35//!
36//! With our heuristic, the maximum possible increase in risk is 9, compounded with the maximum
37//! jump in the estimate table of another 9. A circular array of 32 buckets (for bitwise math
38//! windowing) can handle our empirical range of -1 to 18 in the set of active buckets, and we
39//! avoid having to allocate memory as the search gradually shifts the active window of buckets.
40//!
41//! [`BinaryHeap`]: std::collections::BinaryHeap
42use crate::util::parse::*;
43use std::array::from_fn;
44
45pub struct Square {
46    size: usize,
47    bytes: Vec<u8>,
48}
49
50pub fn parse(input: &str) -> Square {
51    let size = input.lines().next().unwrap().len();
52    let bytes = input.bytes().filter(u8::is_ascii_digit).map(u8::to_decimal).collect();
53    Square { size, bytes }
54}
55
56/// Search the regular size grid.
57pub fn part1(input: &Square) -> usize {
58    astar(input, build_estimates(input))
59}
60
61/// Create an expanded grid then search.
62pub fn part2(input: &Square) -> usize {
63    let Square { size, bytes } = input;
64
65    let mut expanded = Square { size: 5 * size, bytes: vec![0; 25 * size * size] };
66
67    for (i, &b) in bytes.iter().enumerate() {
68        let x1 = i % size;
69        let y1 = i / size;
70        let base = b as usize;
71
72        for x2 in 0..5 {
73            for y2 in 0..5 {
74                let index = (5 * size) * (y2 * size + y1) + (x2 * size + x1);
75                expanded.bytes[index] = (1 + (base - 1 + x2 + y2) % 9) as u8;
76            }
77        }
78    }
79
80    astar(&expanded, build_estimates(&expanded))
81}
82
83// Create a table of heuristics for use in an A* search. This is admissible (never overestimates)
84// but not consistent (the diagonal clamping can sometimes change a node's estimated score by more
85// than its risk, necessitating the search queue to jump back a bucket).
86fn build_estimates(square: &Square) -> Vec<u32> {
87    let Square { size, bytes } = square;
88    let edge = size - 1;
89    let end = size * size - 1;
90
91    let mut estimate = vec![0_u32; size * size];
92    // Produce a coordinate in estimate, or 0 if x or y out of bounds. Since the origin does
93    // not contribute to the overall risk level, we use it instead to hold an effective infinity
94    // to make processing easier at the ends of diagonals.
95    let coord = |col: usize, row: usize| -> usize {
96        if col > edge || row > edge { 0 } else { row * size + col }
97    };
98    estimate[0] = u32::MAX; // Larger than any possible other estimate.
99
100    // Give the target its own risk level.
101    estimate[end] = bytes[end] as u32;
102
103    // Visit the grid by diagonals, starting closest to the target.
104    for diag in (1..edge * 2).rev() {
105        let mut best_diag = u32::MAX - 18;
106        let start = diag.saturating_sub(edge);
107        let end = edge.min(diag) + 1;
108
109        // For each tile crawling up and right, select the minimum between its lower neighbor,
110        // its right neighbor, or the minimum Manhattan distance to any earlier node on the diagonal.
111        for col in start..end {
112            let row = diag - col;
113            let value =
114                estimate[coord(col + 1, row)].min(estimate[coord(col, row + 1)]).min(best_diag + 2)
115                    + bytes[coord(col, row)] as u32;
116            estimate[coord(col, row)] = value;
117            best_diag = (best_diag + 2).min(value);
118        }
119
120        // For each tile crawling down and left, also check for the minimum Manhattan distance from
121        // any better node earlier on the diagonal.
122        best_diag = u32::MAX - 18;
123        for col in (start..end).rev() {
124            let row = diag - col;
125            let value =
126                estimate[coord(col, row)].min(best_diag + 2 + bytes[coord(col, row)] as u32);
127            estimate[coord(col, row)] = value;
128            best_diag = (best_diag + 2).min(value);
129        }
130    }
131
132    // The final estimate for the origin is about 2% shy of the actual risk level.
133    estimate[0] = estimate[1].min(estimate[*size]);
134    estimate
135}
136
137/// Implementation of [A* algorithm](https://en.wikipedia.org/wiki/A*_search_algorithm)
138/// without using the decrease-key functionality.
139fn astar(square: &Square, mut grid_data: Vec<u32>) -> usize {
140    let Square { size, bytes } = square;
141    let edge = size - 1;
142    let end = size * size - 1;
143
144    // Initialize our specialized priority queue with 32 vecs. Chosen to be large enough
145    // to cover the largest gap (actual risk increasing by 9 on the same step that the
146    // heuristic jumps by 9), but also safe against the infrequent backwards jump by 1.
147    let mut todo: [Vec<u32>; 32] = from_fn(|_| Vec::with_capacity(1_000));
148
149    // On entry, grid_data contains estimates in the low 16 bits. As long as all estimates
150    // are transformed uniformly by a constant, the sequence of nodes visited will be identical.
151    // For best memory use, we prefer operating with the estimates in the high 16 bits,
152    // and the cost to reach a node in the low 16 bits, with the initial cost estimate of
153    // u16::MAX as a sentinel that the node has not been visited yet.
154    for cell in &mut grid_data {
155        *cell = (*cell << 16) - 1;
156    }
157
158    // Start location and risk are both zero.
159    let mut i = (grid_data[0] >> 16) as usize;
160    todo[i & 31].push(0);
161    grid_data[0] = 0;
162
163    loop {
164        while todo[i & 31].is_empty() {
165            i += 1;
166        }
167
168        if let Some(current) = todo[i & 31].pop() {
169            let current = current as usize;
170            let risk = (grid_data[current] & 0xffff) as usize;
171            if current == end {
172                return risk;
173            }
174
175            let mut check = |next: usize| {
176                let next_cost = risk + bytes[next] as usize;
177                if next_cost < grid_data[next] as usize & 0xffff {
178                    let next_f = risk + (grid_data[next] >> 16) as usize;
179                    // Cope if this resulted in the rare backward jump.
180                    i = i.min(next_f);
181                    todo[next_f & 31].push(next as u32);
182                    grid_data[next] = (grid_data[next] & !0xffff) | next_cost as u32;
183                }
184            };
185            let x = current % size;
186            let y = current / size;
187
188            if x > 0 {
189                check(current - 1);
190            }
191            if x < edge {
192                check(current + 1);
193            }
194            if y > 0 {
195                check(current - size);
196            }
197            if y < edge {
198                check(current + size);
199            }
200        }
201    }
202}