aoc/year2021/day23.rs
1//! # Amphipod
2//!
3//! Our high-level approach is an [A*](https://en.wikipedia.org/wiki/A*_search_algorithm) search
4//! over all possible burrow states. Three techniques are used to speed things up.
5//!
6//! Firstly a good choice of heuristic is crucial. The heuristic used has the following
7//! characteristics:
8//! * Exactly correct for optimal moves.
9//! * Cheap to update on each subsequent move.
10//!
11//! Secondly pruning states to reduce the search space is very beneficial. Two approaches are used:
12//! * A cache of previously seen states. If amphipods are in the same position but with a higher
13//! cost then the current state will never be optimal and can be pruned.
14//! * Detecting deadlocked states where an amphipod in the hallway prevents any possible solution.
15//! Exploring any further is a waste of time.
16//!
17//! Thirdly low-level bit manipulation is used to represent the burrow state compactly in only 16
18//! bytes for faster copying and hashing.
19use std::array::from_fn;
20
21use crate::util::hash::*;
22use crate::util::heap::*;
23
24/// The values of `A`, `B`, `C` and `D` are used heavily to calculate room indices.
25const A: usize = 0;
26const B: usize = 1;
27const C: usize = 2;
28const D: usize = 3;
29const ROOM: usize = 4;
30const EMPTY: usize = 5;
31const COST: [usize; 4] = [1, 10, 100, 1000];
32
33/// Pack the room state into only 2 bytes.
34///
35/// We use 3 bits for each amphipod plus a marker bit for a maximum of 13 bits. The room is a
36/// stack with the amphipod closest to the hallway in the least significant position.
37///
38/// The marker bit is used to determine how full a room is and to disambiguate empty from the `A`
39/// type.
40///
41/// Some example rooms:
42/// * Empty room `0000000000000001`
43/// * Room with two `A`s `0000000001000000`
44/// * Room with `ABCD` where `A` is closest to hallway `0001011010001000`
45#[derive(Clone, Copy, Eq, Hash, PartialEq)]
46struct Room {
47 packed: u16,
48}
49
50impl Room {
51 /// Pack state into a compact `u16` representation.
52 fn new([a, b, c, d]: [usize; 4]) -> Self {
53 let packed = (1 << 12) | (a << 9) | (b << 6) | (c << 3) | d;
54 Self { packed: packed as u16 }
55 }
56
57 /// The marker bit is always in the most significant position, so can be used to find out the
58 /// size of a room.
59 fn size(self) -> usize {
60 (self.packed.ilog2() / 3) as usize
61 }
62
63 /// Find the type of an amphipod closest to the hallway.
64 fn peek(self) -> Option<usize> {
65 (self.packed > 1).then_some(usize::from(self.packed & 0b111))
66 }
67
68 /// Remove the top amphipod.
69 fn pop(&mut self) -> usize {
70 let pod = usize::from(self.packed & 0b111);
71 self.packed >>= 3;
72 pod
73 }
74
75 /// A room is "open" if amphipods of that type can move to it. This means that it must be
76 /// empty or only already contain amphipods of that type.
77 ///
78 /// We use a multiplication by a constant to figure out the bit pattern. For example, a room
79 /// with three `B`s would have a bit pattern of `0000001001001001` which is the marker bit
80 /// plus B << 6 + B << 3 + B << 0 = B × 64 + B × 8 + B = B × 73.
81 fn open(self, kind: usize) -> bool {
82 self.packed == 1
83 || self.packed == (1 << 3) + (kind as u16) // 1
84 || self.packed == (1 << 6) + (kind as u16 * 9) // 8 + 1
85 || self.packed == (1 << 9) + (kind as u16 * 73) // 64 + 8 + 1
86 || self.packed == (1 << 12) + (kind as u16 * 585) // 512 + 64 + 8 + 1
87 }
88
89 /// Return an amphipod to the correct room.
90 fn push(&mut self, kind: usize) {
91 self.packed = (self.packed << 3) | (kind as u16);
92 }
93
94 /// Returns the amphipod at a specific index from the *bottom* of the burrow.
95 /// 0 is the bottom amphipod furthest from the hallway, 1 the next closest and so on.
96 fn spaces(self, index: usize) -> usize {
97 let adjusted = 3 * (self.size() - 1 - index);
98 usize::from((self.packed >> adjusted) & 0b111)
99 }
100}
101
102/// Pack the state of the hallway into a `usize`. Each hallway position is represented by a nibble
103/// with the pod type (plus additionally empty or room entrance markers) for a total of 44 bits.
104#[derive(Clone, Copy, Eq, Hash, PartialEq)]
105struct Hallway {
106 packed: usize,
107}
108
109impl Hallway {
110 /// The initial hallway is empty. Room entrances are marked as type 4.
111 fn new() -> Self {
112 Self { packed: 0x55454545455 }
113 }
114
115 /// Find the amphipod at a specific location.
116 fn get(self, index: usize) -> usize {
117 (self.packed >> (index * 4)) & 0xf
118 }
119
120 /// Update the amphipod at a specific location.
121 fn set(&mut self, index: usize, value: usize) {
122 let mask = !(0xf << (index * 4));
123 let value = value << (index * 4);
124 self.packed = (self.packed & mask) | value;
125 }
126}
127
128/// Combine hallway and four rooms into a complete burrow representation in only
129/// 8 + 4 × 2 = 16 bytes.
130#[derive(Clone, Copy, Eq, Hash, PartialEq)]
131struct Burrow {
132 hallway: Hallway,
133 rooms: [Room; 4],
134}
135
136impl Burrow {
137 fn new(rooms: [[usize; 4]; 4]) -> Self {
138 Self { hallway: Hallway::new(), rooms: rooms.map(Room::new) }
139 }
140}
141
142/// Subtracts the ASCII value of `A` from each character of the input so that amphipod values
143/// match the constants defined above.
144pub fn parse(input: &str) -> Vec<Vec<usize>> {
145 input
146 .lines()
147 .map(|line| line.bytes().map(|b| usize::from(b.saturating_sub(b'A'))).collect())
148 .collect()
149}
150
151/// Part one is a special case of the full burrow where two amphipods of each type are already
152/// in the correct position in each room.
153pub fn part1(input: &[Vec<usize>]) -> usize {
154 let burrow = Burrow::new([
155 [A, A, input[3][3], input[2][3]],
156 [B, B, input[3][5], input[2][5]],
157 [C, C, input[3][7], input[2][7]],
158 [D, D, input[3][9], input[2][9]],
159 ]);
160 organize(burrow)
161}
162
163/// Part two adds the middle amphipods as specified in the problem statement.
164pub fn part2(input: &[Vec<usize>]) -> usize {
165 let burrow = Burrow::new([
166 [input[3][3], D, D, input[2][3]],
167 [input[3][5], B, C, input[2][5]],
168 [input[3][7], A, B, input[2][7]],
169 [input[3][9], C, A, input[2][9]],
170 ]);
171 organize(burrow)
172}
173
174/// A* search over all possible burrow states until we find the lowest cost to organize.
175///
176/// Each state is processed in one of two phases, "condense" or "expand".
177///
178/// In condense, amphipods move from the hallway or another burrow directly to their home burrow.
179/// Multiple moves are combined if possible and each burrow is tried from left to right.
180/// In terms of energy this is always an optimal move.
181///
182/// If no moves to home burrows are possible then the expand phase moves amphipods into the
183/// hallway.
184fn organize(burrow: Burrow) -> usize {
185 let mut todo = MinHeap::with_capacity(20_000);
186 let mut seen = FastMap::with_capacity(20_000);
187
188 // Initial calculation of the heuristic is expensive but future updates will be cheap.
189 todo.push(best_possible(&burrow), burrow);
190
191 while let Some((energy, mut burrow)) = todo.pop() {
192 let open: [_; 4] = from_fn(|i| burrow.rooms[i].open(i));
193
194 // Process each burrow that is open in left to right order. More than one amphipod may move.
195 let mut changed = false;
196 for (i, &open) in open.iter().enumerate() {
197 if open && burrow.rooms[i].size() < 4 {
198 let offset = 2 + 2 * i;
199 let forward = (offset + 1)..11;
200 let reverse = (0..offset).rev();
201 changed |= condense(&mut burrow, i, forward);
202 changed |= condense(&mut burrow, i, reverse);
203 }
204 }
205
206 if changed {
207 // If amphipods moved back to their home burrow in the condense phase then
208 // check if we're fully organized.
209 if burrow.rooms.iter().zip(open).all(|(room, open)| open && room.size() == 4) {
210 return energy;
211 }
212
213 // Moving back to home burrow does not change total energy due to the way the
214 // heuristic is calculated. For example, if we have spent 100 energy and the heuristic
215 // is 100, spending 10 to move an amphipod would result in 110 energy spent and a
216 // heuristic of 90.
217 if seen.get(&burrow).is_none_or(|&min| energy < min) {
218 todo.push(energy, burrow);
219 seen.insert(burrow, energy);
220 }
221 } else {
222 // If no amphipods can return to their home burrow then fan out into multiple states
223 // by moving the top amphipod from each burrow into the hallway.
224 for (i, &open) in open.iter().enumerate() {
225 if !open {
226 let offset = 2 + 2 * i;
227 let forward = (offset + 1)..11;
228 let reverse = (0..offset).rev();
229 expand(&mut todo, &mut seen, burrow, energy, i, forward);
230 expand(&mut todo, &mut seen, burrow, energy, i, reverse);
231 }
232 }
233 }
234 }
235
236 unreachable!()
237}
238
239/// Heuristic of the lowest possible energy to organize the burrow. Assumes that amphipods can
240/// move through the hallway unblocked.
241fn best_possible(burrow: &Burrow) -> usize {
242 let mut energy = 0;
243 // How many of each kind are outside their home burrow. Used to adjust the energy needed
244 // to move. The first amphipod will need to move all the way to the bottom, but the next
245 // will only need to move 1 space less.
246 let mut need_to_move = [0; 4];
247
248 for (original_kind, room) in burrow.rooms.iter().enumerate() {
249 let mut blocker = false;
250
251 // Search from bottom to top.
252 for depth in 0..room.size() {
253 let kind = room.spaces(depth);
254 let across = if kind != original_kind {
255 blocker = true; // Any amphipod above will need to move out of the way.
256 2 * kind.abs_diff(original_kind) // Distance between rooms.
257 } else if blocker {
258 2 // In home burrow but must still move for a lower amphipod of a different kind.
259 } else {
260 continue; // Already in correct position with no blocker below.
261 };
262 need_to_move[kind] += 1;
263 let up = 4 - depth;
264 let down = need_to_move[kind];
265 energy += COST[kind] * (up + across + down);
266 }
267 }
268
269 energy
270}
271
272/// Starting from a burrow of a specific kind, searches the hallway and other rooms from either
273/// left or right direction, returning all amphipods of that kind to the burrow.
274/// Stops searching immediately if blocked.
275fn condense(burrow: &mut Burrow, kind: usize, iter: impl Iterator<Item = usize>) -> bool {
276 let mut changed = false;
277
278 for hallway_index in iter {
279 match burrow.hallway.get(hallway_index) {
280 // Skip over empty spaces.
281 EMPTY => (),
282 // Move as many amphipods as possible from the room to their home burrow.
283 ROOM => {
284 let room_index = (hallway_index - 2) / 2;
285
286 while burrow.rooms[room_index].peek() == Some(kind) {
287 burrow.rooms[room_index].pop();
288 burrow.rooms[kind].push(kind);
289 changed = true;
290 }
291 }
292 // Move from hallway to home burrow.
293 pod if pod == kind => {
294 burrow.hallway.set(hallway_index, EMPTY);
295 burrow.rooms[kind].push(kind);
296 changed = true;
297 }
298 // We're blocked from any further progress in this direction.
299 _ => break,
300 }
301 }
302
303 changed
304}
305
306/// Searches the hallway in either the right or left direction, pushing a new state to the
307/// priority queue if it's possible to place an amphipod there.
308fn expand(
309 todo: &mut MinHeap<usize, Burrow>,
310 seen: &mut FastMap<Burrow, usize>,
311 mut burrow: Burrow,
312 energy: usize,
313 room_index: usize,
314 iter: impl Iterator<Item = usize>,
315) {
316 let kind = burrow.rooms[room_index].pop();
317
318 for hallway_index in iter {
319 match burrow.hallway.get(hallway_index) {
320 // Amphipods can't stop directly outside rooms.
321 ROOM => (),
322 // Check each empty space.
323 EMPTY => {
324 let mut next = burrow;
325 next.hallway.set(hallway_index, kind);
326
327 // If this move would result in a state that can never be finished then prune early.
328 if deadlock_left(&next)
329 || deadlock_right(&next)
330 || deadlock_room(&next, 0)
331 || deadlock_room(&next, 1)
332 || deadlock_room(&next, 2)
333 || deadlock_room(&next, 3)
334 {
335 continue;
336 }
337
338 // If the destination is outside of the direct path from our current burrow
339 // to our home burrow then add the extra energy to move there *and back* to the
340 // heuristic.
341 let start = 2 + 2 * room_index;
342 let end = 2 + 2 * kind;
343
344 let adjust = if start == end {
345 // If in our home burrow but moving out of the way of another kind,
346 // then assume the minimum possible distance of 1 place to either the
347 // left or right in the hallway.
348 let across = hallway_index.abs_diff(start);
349 across - 1
350 } else {
351 let lower = start.min(end);
352 let upper = start.max(end);
353 // One of these expressions will be zero depending on direction.
354 lower.saturating_sub(hallway_index) + hallway_index.saturating_sub(upper)
355 };
356
357 let extra = COST[kind] * 2 * adjust;
358
359 // Critical optimization. If we're not in our home burrow then we must move out of
360 // the way otherwise we'd become a blocker.
361 if kind != room_index && extra == 0 {
362 continue;
363 }
364
365 // Check that we haven't already seen this state before with lower energy
366 // in order to prune suboptimal duplicates.
367 let next_energy = energy + extra;
368
369 if seen.get(&next).is_none_or(|&min| next_energy < min) {
370 todo.push(next_energy, next);
371 seen.insert(next, next_energy);
372 }
373 }
374 // We're blocked from any further progress in this direction.
375 _ => break,
376 }
377 }
378}
379
380/// Checks for a situation where an `A` amphipod can block other amphipods in the leftmost burrow.
381///
382/// For example:
383/// ```none
384/// #############
385/// #...A.......#
386/// ### #.#.#.###
387/// #A#.#.#.#
388/// #A#.#.#.#
389/// #B#.#.#.#
390/// #########
391/// ```
392///
393/// The top two `A`s can move into the left hallway spaces but the `B` will then be stuck
394/// and we'll never be able to organize the burrow completely.
395fn deadlock_left(burrow: &Burrow) -> bool {
396 let room = &burrow.rooms[0];
397 let size = room.size();
398 burrow.hallway.get(3) == A && size >= 3 && room.spaces(size - 3) != A
399}
400
401/// Mirror image situation to `deadlock_left` where a `D` amphipod could block others.
402///
403/// For example:
404/// ```none
405/// #############
406/// #.......D...#
407/// ###.#.#.#A###
408/// #.#.#.#B#
409/// #.#.#.#C#
410/// #.#.#.#D#
411/// #########
412/// ```
413///
414/// The hallway has room for the top two amphipods but the `D` prevents the bottom two
415/// from returning to their home burrow.
416fn deadlock_right(burrow: &Burrow) -> bool {
417 let room = &burrow.rooms[3];
418 let size = room.size();
419 burrow.hallway.get(7) == D && size >= 3 && room.spaces(size - 3) != D
420}
421
422/// Detects situation where amphipods in the hallway need to move past each other but
423/// mutually block any further progress.
424///
425/// For example:
426/// ```none
427/// #############
428/// #.....D.A...#
429/// ###.#.#.#.###
430/// #.#.#.#.#
431/// #.#.#C#.#
432/// #.#.#C#.#
433/// #########
434/// ```
435///
436/// In this situation, neither `A` nor `D` can move into `C`'s room but also block each other
437/// from returning to their home burrow.
438///
439/// Another example:
440/// ```none
441/// #############
442/// #.....C.A...#
443/// ###.#.#.#.###
444/// #.#.#.#.#
445/// #.#.#B#.#
446/// #.#.#C#.#
447/// #########
448/// ```
449/// In this situation `C` blocks `A` from returning to its home burrow and `B` is also blocked
450/// from moving out of the way.
451fn deadlock_room(burrow: &Burrow, kind: usize) -> bool {
452 let left_kind = burrow.hallway.get(1 + 2 * kind);
453 let right_kind = burrow.hallway.get(3 + 2 * kind);
454
455 left_kind != EMPTY
456 && right_kind != EMPTY
457 && left_kind >= kind
458 && right_kind <= kind
459 && !(burrow.rooms[kind].open(kind) && (kind == right_kind || kind == left_kind))
460}