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 from 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 w in words.windows(2) {
103        match w[1] {
104            "floor" => floor <<= 1,
105            "compatible" => *pairs.entry(w[0]).or_insert(0) |= floor,
106            "generator" => *pairs.entry(w[0]).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            if (pair & PAIR1) != 0 {
122                non_empty = true;
123            }
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
159        // When moving down, try one item first; try two only if one didn't work.
160        // Don't move down from bottom floor, or down into empty region
161        if !(state.elevator == 0
162            || (state.elevator == 1 && (state.pairs & FLOOR1) == 0)
163            || (state.elevator == 2 && (state.pairs & (FLOOR1 | FLOOR2) == 0)))
164        {
165            let mut added = false;
166
167            for i in items.biterator() {
168                if let Some(next) = state.move_floor(false, 1 << i)
169                    && seen.insert(next)
170                {
171                    added = true;
172                    todo.push_back((next, steps + 1));
173                }
174            }
175
176            if !added {
177                for i in items.biterator() {
178                    for j in items.biterator().take_while(|&j| j < i) {
179                        if let Some(next) = state.move_floor(false, (1 << i) | (1 << j))
180                            && seen.insert(next)
181                        {
182                            todo.push_back((next, steps + 1));
183                        }
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                    if let Some(next) = state.move_floor(true, (1 << i) | (1 << j))
197                        && seen.insert(next)
198                    {
199                        added = true;
200                        todo.push_back((next, steps + 1));
201                    }
202                }
203            }
204
205            if !added {
206                for i in items.biterator() {
207                    if let Some(next) = state.move_floor(true, 1 << i)
208                        && seen.insert(next)
209                    {
210                        todo.push_back((next, steps + 1));
211                    }
212                }
213            }
214        }
215    }
216
217    unreachable!()
218}