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 crate::util::hash::*;
20use crate::util::heap::*;
21use std::array::from_fn;
22use std::hash::*;
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(Copy, Clone, PartialEq, Eq, Hash)]
46struct Room {
47    packed: u16,
48}
49
50impl Room {
51    /// Pack state into a compact `u16` representation.
52    fn new(spaces: [usize; 4]) -> Room {
53        let packed = (1 << 12) | (spaces[0] << 9) | (spaces[1] << 6) | (spaces[2] << 3) | spaces[3];
54        Room { 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        ((15 - self.packed.leading_zeros()) / 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((self.packed & 0b111) as usize)
66    }
67
68    /// Remove the top amphipod.
69    fn pop(&mut self) -> usize {
70        let pod = (self.packed & 0b111) as usize;
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        ((self.packed >> adjusted) & 0b111) as usize
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(Copy, Clone, PartialEq, Eq, Hash)]
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() -> Hallway {
112        Hallway { 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    /// Updated 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(Copy, Clone, PartialEq, Eq, Hash)]
131struct Burrow {
132    hallway: Hallway,
133    rooms: [Room; 4],
134}
135
136impl Burrow {
137    fn new(rooms: [[usize; 4]; 4]) -> Burrow {
138        Burrow { hallway: Hallway::new(), rooms: from_fn(|i| Room::new(rooms[i])) }
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| b.saturating_sub(b'A') as usize).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, ampihpods 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: [bool; 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().enumerate().all(|(i, r)| open[i] && r.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            let min = seen.get(&burrow).unwrap_or(&usize::MAX);
218            if energy < *min {
219                todo.push(energy, burrow);
220                seen.insert(burrow, energy);
221            }
222        } else {
223            // If no amphipods can return to their home burrow then fan out into multiple states
224            // by moving the top amphipod from each burrow into the hallway.
225            for (i, &open) in open.iter().enumerate() {
226                if !open {
227                    let offset = 2 + 2 * i;
228                    let forward = (offset + 1)..11;
229                    let reverse = (0..offset).rev();
230                    expand(&mut todo, &mut seen, burrow, energy, i, forward);
231                    expand(&mut todo, &mut seen, burrow, energy, i, reverse);
232                }
233            }
234        }
235    }
236
237    unreachable!()
238}
239
240/// Heuristic of the lowest possible energy to organize the burrow. Assumes that amphipods can
241/// move through the hallway unblocked.
242fn best_possible(burrow: &Burrow) -> usize {
243    let mut energy = 0;
244    // How many of each kind are outside their home burrow. Used to adjust the energy needed
245    // to move. The first amphipod will need to move all the way to the bottom, but the next
246    // will only need to move 1 space less.
247    let mut need_to_move = [0; 4];
248
249    for (original_kind, room) in burrow.rooms.iter().enumerate() {
250        let mut blocker = false;
251
252        // Search from bottom to top
253        for depth in 0..room.size() {
254            let kind = room.spaces(depth);
255            if kind != original_kind {
256                // Any amphipod above us will need to move out of the way.
257                blocker = true;
258                need_to_move[kind] += 1;
259                // Calculate the energy to return directly to our home burrow
260                // taking into account how many other amphipods of our kind also need to move.
261                let up = 4 - depth;
262                let across = 2 * kind.abs_diff(original_kind); // Distance between rooms.
263                let down = need_to_move[kind];
264                energy += COST[kind] * (up + across + down);
265            } else if blocker {
266                // Even though we're in our home burrow we need to move out of the way of a lower
267                // amphipod of a different kind.
268                need_to_move[kind] += 1;
269                // Calculate the energy assuming we can move to one of the nearest hallway
270                // spaces on either side.
271                let up = 4 - depth;
272                let across = 2; // Nearest spot then back
273                let down = need_to_move[kind];
274                energy += COST[kind] * (up + across + down);
275            }
276        }
277    }
278
279    energy
280}
281
282/// Starting from a burrow of a specific kind, searches the hallway and other rooms from either
283/// left or right direction, returning all amphipods of that kind to the burrow.
284/// Stops searching immediately if blocked.
285fn condense(burrow: &mut Burrow, kind: usize, iter: impl Iterator<Item = usize>) -> bool {
286    let mut changed = false;
287
288    for hallway_index in iter {
289        match burrow.hallway.get(hallway_index) {
290            // Skip over empty spaces.
291            EMPTY => (),
292            // Move as many amphipods as possible from the room to their home burrow.
293            ROOM => {
294                let room_index = (hallway_index - 2) / 2;
295
296                while burrow.rooms[room_index].peek() == Some(kind) {
297                    burrow.rooms[room_index].pop();
298                    burrow.rooms[kind].push(kind);
299                    changed = true;
300                }
301            }
302            // Move from hallway to home burrow.
303            pod if pod == kind => {
304                burrow.hallway.set(hallway_index, EMPTY);
305                burrow.rooms[kind].push(kind);
306                changed = true;
307            }
308            // We're blocked from any further progress in this direction.
309            _ => break,
310        }
311    }
312
313    changed
314}
315
316/// Searches the hallway in either the right or left direction, pushing a new state to the
317/// priority queue if it's possible to place an amphipod there.
318fn expand(
319    todo: &mut MinHeap<usize, Burrow>,
320    seen: &mut FastMap<Burrow, usize>,
321    mut burrow: Burrow,
322    energy: usize,
323    room_index: usize,
324    iter: impl Iterator<Item = usize>,
325) {
326    let kind = burrow.rooms[room_index].pop();
327
328    for hallway_index in iter {
329        match burrow.hallway.get(hallway_index) {
330            // Amphipods can't stop directly outside rooms.
331            ROOM => (),
332            // Check each empty space
333            EMPTY => {
334                let mut next = burrow;
335                next.hallway.set(hallway_index, kind);
336
337                // If this move would result in a state that can never be finished then prune early.
338                if deadlock_left(&next)
339                    || deadlock_right(&next)
340                    || deadlock_room(&next, 0)
341                    || deadlock_room(&next, 1)
342                    || deadlock_room(&next, 2)
343                    || deadlock_room(&next, 3)
344                {
345                    continue;
346                }
347
348                // If the destination is outside of the direct path from our current burrow
349                // to our home burrow then add the extra energy to move there *and back* to the
350                // heuristic.
351                let start = 2 + 2 * room_index;
352                let end = 2 + 2 * kind;
353
354                let adjust = if start == end {
355                    // If in our home burrow but moving out of the way of another kind,
356                    // then assume the minimum possible distance of 1 place to either the
357                    // left or right in the hallway.
358                    let across = hallway_index.abs_diff(start);
359                    across - 1
360                } else {
361                    let lower = start.min(end);
362                    let upper = start.max(end);
363                    // One of these expressions will be zero depending on direction.
364                    lower.saturating_sub(hallway_index) + hallway_index.saturating_sub(upper)
365                };
366
367                let extra = COST[kind] * 2 * adjust;
368
369                // Critical optimization. If we're not in our home burrow then we must move out of
370                // the way otherwise we'd become a blocker.
371                if kind != room_index && extra == 0 {
372                    continue;
373                }
374
375                // Check that we haven't already seen this state before with lower energy
376                // in order to prune suboptimal duplicates.
377                let next_energy = energy + extra;
378                let min = seen.get(&next).unwrap_or(&usize::MAX);
379
380                if next_energy < *min {
381                    todo.push(next_energy, next);
382                    seen.insert(next, next_energy);
383                }
384            }
385            // We're blocked from any further progress in this direction.
386            _ => break,
387        }
388    }
389}
390
391/// Checks for a situation where an `A` amphipod can block other amphipods in the leftmost burrow.
392///
393/// For example:
394/// ```none
395///     #############
396///     #...A.......#
397///     ### #.#.#.###
398///       #A#.#.#.#
399///       #A#.#.#.#
400///       #B#.#.#.#
401///       #########
402/// ```
403///
404/// The top two `A`s can move into the left hallways spaces but the `B` will then be stuck
405/// and we'll never be able to organize the burrow completely.
406fn deadlock_left(burrow: &Burrow) -> bool {
407    let room = &burrow.rooms[0];
408    let size = room.size();
409    burrow.hallway.get(3) == A && size >= 3 && room.spaces(size - 3) != A
410}
411
412/// Mirror image situation to `deadlock_left` where a `D` amphipod could block others.
413///
414/// For example:
415/// ```none
416///     #############
417///     #.......D...#
418///     ###.#.#.#A###
419///       #.#.#.#B#
420///       #.#.#.#C#
421///       #.#.#.#D#
422///       #########
423/// ```
424///
425/// The hallway has room for the top two amphipods but the `D` prevents the bottom two
426/// from returning to their home burrow.
427fn deadlock_right(burrow: &Burrow) -> bool {
428    let room = &burrow.rooms[3];
429    let size = room.size();
430    burrow.hallway.get(7) == D && size >= 3 && room.spaces(size - 3) != D
431}
432
433/// Detects situation where amphipods in the hallway need to move past each other but
434/// mutually block any further progress.
435///
436/// For example:
437/// ```none
438///     #############
439///     #.....D.A...#
440///     ###.#.#.#.###
441///       #.#.#.#.#
442///       #.#.#C#.#
443///       #.#.#C#.#
444///       #########
445/// ```
446///
447/// In this situation, neither `A` nor `D` can move into `C`'s room but also block each other
448/// from returning to their home burrow.
449///
450/// Another example:
451/// ```none
452///     #############
453///     #.....C.A...#
454///     ###.#.#.#.###
455///       #.#.#.#.#
456///       #.#.#B#.#
457///       #.#.#C#.#
458///       #########
459/// ```
460/// In this situation `C` blocks `A` from returning to its home burrow and `B` is also blocked
461/// from moving out of the way.
462fn deadlock_room(burrow: &Burrow, kind: usize) -> bool {
463    let left_kind = burrow.hallway.get(1 + 2 * kind);
464    let right_kind = burrow.hallway.get(3 + 2 * kind);
465
466    left_kind != EMPTY
467        && right_kind != EMPTY
468        && left_kind >= kind
469        && right_kind <= kind
470        && !(burrow.rooms[kind].open(kind) && (kind == right_kind || kind == left_kind))
471}