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}