Skip to main content

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