Skip to main content

aoc/year2016/
day11.rs

1//! # Radioisotope Thermoelectric Generators
2//!
3//! Solves using a [BFS](https://en.wikipedia.org/wiki/Breadth-first_search) from the
4//! starting position where each next state is the possible elevator moves either one floor up or
5//! down. This was faster than using [A*](https://en.wikipedia.org/wiki/A*_search_algorithm)
6//! with a heuristic.
7//!
8//! A huge critical optimization is the observation that generator and chip pairs are *fungible*.
9//! A configuration that starts with two pairs on floor one takes the same number of steps to
10//! solve whether pair A or pair B is moved first (that is, the setup `[-;-;AG,AM;BG,BM]` while on
11//! floor 2 is indistinguishable from `[-;-;BG,BM;AG,AM]` on floor 2 in terms of the final result).
12//! However, the relative positions of pairs still matter (the setup `[AM;AG;BG;BM]` on floor two
13//! can move BG up or down, but the setup `[AM;BG;AG;BM]` on floor two can only move AG up). To
14//! maximize state sharing, represent each pair's generator and microchip position as hex
15//! digits, but merge all permutations by sorting those hex digit pairs during the hash
16//! function. Including the elevator position, the hash value requires up to 30 useful bits
17//! (2 + 7*4) if densely packed, although this uses a 64-bit struct with one-hot encodings.
18//!
19//! Next, observe that adding a chip and generator pair on floor 1 adds 12 moves to the final
20//! solution. Likewise, removing a pair from floor 1 (but only if there is still something
21//! else left on the floor) can be solved in 12 fewer moves. Tracking a smaller number of
22//! chip and generator pairs, then adjusting by 12 times the number of ignored pairs,
23//! is inherently faster.
24//!
25//! The rules for a valid floor are either:
26//!
27//! * Any number of microchips only with no generators.
28//! * Any microchip on a floor with at least one generator must have its own generator on that
29//!   floor.
30//!
31//! This allows us to efficiently memoize previously seen states and reject any that we've seen
32//! before extremely quickly. Other optimizations:
33//!
34//! * If we can move 2 items up, then skip only moving 1 item up.
35//! * If we can move 1 item down, then skip moving 2 items down.
36//! * Moving a microchip and generator together is only safe if they are the same type (if they are
37//!   not of the same type, then the old floor will necessarily have the generator that pairs with
38//!   the chip being moved, leaving that chip to be fried on its new floor).
39//! * If floor 1 is empty then don't move items back down to it, similarly if both floor 1 and floor
40//!   2 are empty then don't move items to them.
41use std::collections::VecDeque;
42
43use crate::util::bitset::*;
44use crate::util::hash::*;
45
46// A one-hot encoding is more efficient than 0-3. For each byte, the generator is the
47// high nibble, and the microchip the low nibble. Only 5 bytes matter, because the part two
48// pairs contribute a constant input. The used bytes are stored in little-endian order, and
49// unused lanes will be 0.
50const MASK: u64 = 0x0000000101010101;
51const FLOOR1: u64 = (MASK << 4) | MASK;
52const FLOOR2: u64 = FLOOR1 << 1;
53const FLOOR3: u64 = FLOOR2 << 1;
54const FLOOR4: u64 = FLOOR3 << 1;
55const PAIR1: u8 = (1 << 4) | 1;
56
57#[derive(Clone, Copy, Default, Eq, Hash, PartialEq)]
58pub struct State {
59    elevator: u8, // 0-3
60    pairs: u64,   // One-hot encoded floors for up to 5 item pairs.
61}
62
63impl State {
64    // Reject any inconsistent setup.
65    fn valid(&self, floor: u8) -> bool {
66        let chips = (self.pairs) & (MASK << floor);
67        let gens = (self.pairs >> 4) & (MASK << floor);
68        gens == 0 || (chips & !gens) == 0
69    }
70
71    // Critical optimization treating generators and microchips as fungible.
72    // Rearrange the pairs into canonical order. Endianness matters for getting valid slice indices.
73    fn canon(mut self) -> Self {
74        let mut array = self.pairs.to_le_bytes();
75        array[..5].sort_unstable();
76        self.pairs = u64::from_le_bytes(array);
77        self
78    }
79
80    // Attempt to adjust state by moving one or two items up or down.
81    fn move_floor(self, up: bool, item_mask: u64) -> Option<Self> {
82        // Build the new state.
83        let mut state = self;
84
85        if up {
86            state.pairs += item_mask;
87            state.elevator += 1;
88        } else {
89            state.pairs -= item_mask >> 1;
90            state.elevator -= 1;
91        }
92
93        (state.valid(self.elevator) && state.valid(state.elevator)).then(|| state.canon())
94    }
95}
96
97pub fn parse(input: &str) -> u32 {
98    let mut pairs = FastMap::new();
99
100    // Find all items, and set an entry in state.pairs for each element name.
101    let mut floor = 1;
102    let words: Vec<_> = input.split(&[' ', ',', '.', '-']).skip(3).collect();
103
104    for &[first, second] in words.array_windows() {
105        match second {
106            "floor" => floor <<= 1,
107            "compatible" => *pairs.entry(first).or_insert(0) |= floor,
108            "generator" => *pairs.entry(first).or_insert(0) |= floor << 4,
109            _ => (),
110        }
111    }
112
113    // Optimize search by pre-handling item pairs starting on non-empty floor 1.
114    let mut floors = [0_u8; 8];
115    let mut non_empty = false;
116    let mut steps = 0;
117    let mut i = 0;
118
119    for pair in pairs.into_values() {
120        if non_empty && pair == PAIR1 {
121            steps += 12;
122        } else {
123            non_empty |= pair & PAIR1 != 0;
124            floors[i] = pair;
125            i += 1;
126        }
127    }
128
129    // Little-endian matters, based on the indices that canon() will use.
130    let state = State { elevator: 0, pairs: u64::from_le_bytes(floors) };
131    bfs(state.canon(), steps)
132}
133
134pub fn part1(input: &u32) -> u32 {
135    *input
136}
137
138pub fn part2(input: &u32) -> u32 {
139    // Both pairs add 12 steps each.
140    *input + 24
141}
142
143fn bfs(start: State, steps: u32) -> u32 {
144    let mut todo = VecDeque::new();
145    let mut seen = FastSet::with_capacity(500);
146
147    todo.push_back((start, steps));
148    seen.insert(start);
149
150    while let Some((state, steps)) = todo.pop_front() {
151        // Done if all items are on the top floor (the elevator will necessarily be there too).
152        if state.pairs & FLOOR4 == state.pairs {
153            return steps;
154        }
155
156        // Iterate over items that can be moved.
157        let items = state.pairs & (FLOOR1 << state.elevator);
158        let mut push = |up: bool, mask: u64| -> bool {
159            if let Some(next) = state.move_floor(up, mask)
160                && seen.insert(next)
161            {
162                todo.push_back((next, steps + 1));
163                true
164            } else {
165                false
166            }
167        };
168
169        // When moving down, try one item first. Try two only if one didn't work.
170        // Don't move down from bottom floor, or down into empty region.
171        if !(state.elevator == 0
172            || (state.elevator == 1 && (state.pairs & FLOOR1) == 0)
173            || (state.elevator == 2 && (state.pairs & (FLOOR1 | FLOOR2) == 0)))
174        {
175            let mut added = false;
176
177            for i in items.biterator() {
178                added |= push(false, 1 << i);
179            }
180            if !added {
181                for i in items.biterator() {
182                    for j in items.biterator().take_while(|&j| j < i) {
183                        push(false, (1 << i) | (1 << j));
184                    }
185                }
186            }
187        }
188
189        // When moving up, try two items first. Try one only if two didn't work.
190        // Don't move up from top floor.
191        if state.elevator < 3 {
192            let mut added = false;
193
194            for i in items.biterator() {
195                for j in items.biterator().take_while(|&j| j < i) {
196                    added |= push(true, (1 << i) | (1 << j));
197                }
198            }
199            if !added {
200                for i in items.biterator() {
201                    push(true, 1 << i);
202                }
203            }
204        }
205    }
206
207    unreachable!()
208}