|
| 1 | +#! /usr/bin/env python3 |
| 2 | + |
| 3 | +def load_data(filename): |
| 4 | + with open(filename, 'r') as f: |
| 5 | + for line in f: |
| 6 | + match = re.findall(r' a (?:(\w+)(?:-compatible)? (microchip|generator))(?:\.|,| and)', line) |
| 7 | + yield match |
| 8 | + |
| 9 | +import re |
| 10 | +from typing import Callable |
| 11 | +from collections import namedtuple |
| 12 | +from collections.abc import Iterator |
| 13 | +from itertools import combinations |
| 14 | +import networkx as nx |
| 15 | + |
| 16 | +type FS = frozenset[str] |
| 17 | + |
| 18 | +FloorNT = namedtuple('FloorNT', ('generators', 'microchips')) |
| 19 | + |
| 20 | +class Floor(FloorNT): |
| 21 | + generators: FS |
| 22 | + microships: FS |
| 23 | + |
| 24 | + @classmethod |
| 25 | + def empty(cls) -> 'Floor': |
| 26 | + return cls(frozenset(), frozenset()) |
| 27 | + |
| 28 | + @classmethod |
| 29 | + def from_input(cls, items: list[tuple[str, str]]) -> 'Floor': |
| 30 | + microchips = set() |
| 31 | + generators = set() |
| 32 | + for element, type in items: |
| 33 | + if type == 'microchip': |
| 34 | + microchips.add(element) |
| 35 | + elif type == 'generator': |
| 36 | + generators.add(element) |
| 37 | + return cls(frozenset(generators), frozenset(microchips)) |
| 38 | + |
| 39 | + def is_valid(self) -> bool: |
| 40 | + return not self.generators or not (self.microchips - self.generators) |
| 41 | + |
| 42 | + def to_input(self) -> list[tuple[str, str]]: |
| 43 | + return [ (element, 'generator') for element in self.generators ] + \ |
| 44 | + [ (element, 'microchip') for element in self.microchips ] |
| 45 | + |
| 46 | + def set_op(self, op: Callable[[FS, FS], FS], other: 'Floor') -> 'Floor': |
| 47 | + return Floor(op(self.generators, other.generators), op(self.microchips, other.microchips)) |
| 48 | + |
| 49 | + def __sub__(self, other: 'Floor') -> 'Floor': |
| 50 | + return self.set_op(frozenset.difference, other) |
| 51 | + |
| 52 | + def __add__(self, other: 'Floor') -> 'Floor': |
| 53 | + return self.set_op(frozenset.union, other) |
| 54 | + |
| 55 | +StateNT = namedtuple('StateNT', ('elevator_floor', 'floors')) |
| 56 | + |
| 57 | +class State(StateNT): |
| 58 | + |
| 59 | + elevator_floor: int |
| 60 | + floors: tuple['Floor'] |
| 61 | + |
| 62 | + @classmethod |
| 63 | + def from_input(cls, data: list) -> 'State': |
| 64 | + return cls(0, tuple( Floor.from_input(items) for items in data ) ) |
| 65 | + |
| 66 | + @classmethod |
| 67 | + def end(cls, s: 'State') -> 'State': |
| 68 | + full_floor = Floor.empty() |
| 69 | + for floor in s.floors: |
| 70 | + full_floor = full_floor + floor |
| 71 | + assert full_floor.generators == full_floor.microchips |
| 72 | + last_floor = len(s.floors)-1 |
| 73 | + return cls(last_floor, tuple( Floor.empty() if i < last_floor else full_floor for i in range(last_floor+1) )) |
| 74 | + |
| 75 | + def next_states(self) -> Iterator['State']: |
| 76 | + if self.elevator_floor > 0: |
| 77 | + # The elevator can go one floor down |
| 78 | + yield from self.next_states_floor(self.elevator_floor-1) |
| 79 | + if self.elevator_floor < len(self.floors)-1: |
| 80 | + # The elevator can go one floor up |
| 81 | + yield from self.next_states_floor(self.elevator_floor+1) |
| 82 | + |
| 83 | + def next_states_floor(self, next_floor_n: int) -> Iterator['State']: |
| 84 | + this_floor = self.floors[self.elevator_floor] |
| 85 | + next_floor = self.floors[next_floor_n] |
| 86 | + |
| 87 | + def try_next_state(next_floor_n, elevator): |
| 88 | + new_this_floor = this_floor - elevator |
| 89 | + new_next_floor = next_floor + elevator |
| 90 | + if new_this_floor.is_valid() and new_next_floor.is_valid(): |
| 91 | + # So what |
| 92 | + yield State(next_floor_n, tuple( new_this_floor if floor is this_floor else new_next_floor if floor is next_floor else floor for floor in self.floors )) |
| 93 | + |
| 94 | + items = this_floor.to_input() |
| 95 | + |
| 96 | + # Try to pick one item |
| 97 | + for item in items: |
| 98 | + elevator = Floor.from_input([item]) |
| 99 | + # Heuristic 1: don't take down microchips |
| 100 | + if next_floor_n < self.elevator_floor and item[1] == 'microchip': |
| 101 | + continue |
| 102 | + yield from try_next_state(next_floor_n, elevator) |
| 103 | + |
| 104 | + # Heuristic 2: don't take down any pairs |
| 105 | + if next_floor_n < self.elevator_floor: |
| 106 | + return |
| 107 | + |
| 108 | + # Try all possible pairs |
| 109 | + for item1, item2 in combinations(items, 2): |
| 110 | + elevator = Floor.from_input([item1, item2]) |
| 111 | + yield from try_next_state(next_floor_n, elevator) |
| 112 | + |
| 113 | +# Part One |
| 114 | + |
| 115 | +def steps(input): |
| 116 | + |
| 117 | + start_state = State.from_input(input) |
| 118 | + end_state = State.end(start_state) |
| 119 | + |
| 120 | + G = nx.Graph() |
| 121 | + |
| 122 | + to_check = [start_state] |
| 123 | + while to_check: |
| 124 | + state = to_check.pop(-1) |
| 125 | + for new_state in state.next_states(): |
| 126 | + if new_state not in G.nodes: |
| 127 | + to_check.append(new_state) |
| 128 | + G.add_edge(state, new_state) |
| 129 | + |
| 130 | + p = nx.shortest_path(G, start_state, end_state) |
| 131 | + |
| 132 | + return len(p)-1 |
| 133 | + |
| 134 | +input = list(load_data("input.txt")) |
| 135 | + |
| 136 | +print(steps(input)) |
| 137 | + |
| 138 | +# Part Two |
| 139 | + |
| 140 | +input[0].extend([ |
| 141 | + ('elerium', 'generator'), |
| 142 | + ('elerium', 'microchip'), |
| 143 | + ('dilithium', 'generator'), |
| 144 | + ('dilithium', 'microchip'), |
| 145 | +]) |
| 146 | + |
| 147 | +print(steps(input)) |
| 148 | + |
0 commit comments