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}