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.
40use std::array::from_fn;
41
42use crate::util::parse::*;
43
44pub struct Square {
45    size: usize,
46    bytes: Vec<u8>,
47}
48
49pub fn parse(input: &str) -> Square {
50    let size = input.lines().next().unwrap().len();
51    let bytes = input.bytes().filter(u8::is_ascii_digit).map(u8::to_decimal).collect();
52    Square { size, bytes }
53}
54
55/// Search the regular size grid.
56pub fn part1(input: &Square) -> usize {
57    astar(input, build_estimates(input))
58}
59
60/// Create an expanded grid then search.
61pub fn part2(input: &Square) -> usize {
62    let Square { size, bytes } = input;
63
64    let mut expanded = Square { size: 5 * size, bytes: vec![0; 25 * size * size] };
65
66    for (i, &b) in bytes.iter().enumerate() {
67        let x1 = i % size;
68        let y1 = i / size;
69        let base = b as usize;
70
71        for x2 in 0..5 {
72            for y2 in 0..5 {
73                let index = (5 * size) * (y2 * size + y1) + (x2 * size + x1);
74                expanded.bytes[index] = (1 + (base - 1 + x2 + y2) % 9) as u8;
75            }
76        }
77    }
78
79    astar(&expanded, build_estimates(&expanded))
80}
81
82// Create a table of heuristics for use in an A* search. This is admissible (never overestimates)
83// but not consistent (the diagonal clamping can sometimes change a node's estimated score by more
84// than its risk, necessitating the search queue to jump back a bucket).
85fn build_estimates(square: &Square) -> Vec<u32> {
86    let Square { size, bytes } = square;
87    let edge = size - 1;
88    let end = size * size - 1;
89
90    let mut estimate = vec![0_u32; size * size];
91    // Produce a coordinate in estimate, or 0 if x or y out of bounds. Since the origin does
92    // not contribute to the overall risk level, we use it instead to hold an effective infinity
93    // to make processing easier at the ends of diagonals.
94    let coord = |col: usize, row: usize| -> usize {
95        if col > edge || row > edge { 0 } else { row * size + col }
96    };
97    estimate[0] = u32::MAX; // Larger than any possible other estimate.
98
99    // Give the target its own risk level.
100    estimate[end] = bytes[end] as u32;
101
102    // Visit the grid by diagonals, starting closest to the target.
103    for diag in (1..edge * 2).rev() {
104        let mut best_diag = u32::MAX - 18;
105        let start = diag.saturating_sub(edge);
106        let end = edge.min(diag) + 1;
107
108        // For each tile crawling up and right, select the minimum between its lower neighbor,
109        // its right neighbor, or the minimum Manhattan distance to any earlier node on the
110        // 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 & 0x1f].push(0);
161    grid_data[0] = 0;
162
163    loop {
164        while let Some(current) = todo[i & 0x1f].pop() {
165            let current = current as usize;
166            let risk = (grid_data[current] & 0xffff) as usize;
167            if current == end {
168                return risk;
169            }
170
171            let mut check = |next: usize| {
172                let next_cost = risk + bytes[next] as usize;
173                if next_cost < grid_data[next] as usize & 0xffff {
174                    let next_f = risk + (grid_data[next] >> 16) as usize;
175                    // Cope if this resulted in the rare backward jump.
176                    i = i.min(next_f);
177                    todo[next_f & 0x1f].push(next as u32);
178                    grid_data[next] = (grid_data[next] & !0xffff) | next_cost as u32;
179                }
180            };
181            let x = current % size;
182            let y = current / size;
183
184            if x > 0 {
185                check(current - 1);
186            }
187            if x < edge {
188                check(current + 1);
189            }
190            if y > 0 {
191                check(current - size);
192            }
193            if y < edge {
194                check(current + size);
195            }
196        }
197
198        i += 1;
199    }
200}