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