diff --git a/advent_of_code/__init__.py b/advent_of_code/__init__.py index f69ec94..b630c26 100644 --- a/advent_of_code/__init__.py +++ b/advent_of_code/__init__.py @@ -5,7 +5,7 @@ __version__ = "1.0.0" from typing import Callable -from advent_of_code import cubes, gears, scratchcards, trebuchet +from advent_of_code import cubes, gears, network, scratchcards, trebuchet Solver = Callable[[str], int] @@ -15,4 +15,5 @@ solvers: dict[int, tuple[Solver, Solver]] = { 2: (cubes.solve_part_1, cubes.solve_part_2), 3: (gears.solve_part_1, gears.solve_part_2), 4: (scratchcards.solve_part_1, scratchcards.solve_part_2), + 8: (network.solve_part_1, network.solve_part_2), } diff --git a/advent_of_code/network.py b/advent_of_code/network.py new file mode 100644 index 0000000..f31e684 --- /dev/null +++ b/advent_of_code/network.py @@ -0,0 +1,103 @@ +"Day 8: Haunted Wasteland" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from enum import Enum +from functools import cache +from math import lcm +from typing import ClassVar, NamedTuple + +from frozendict import frozendict + + +class Instruction(Enum): + Left = "L" + Right = "R" + + +@dataclass(frozen=True) +class Network: + instructions: tuple[Instruction, ...] + nodes: frozendict[str, tuple[str, str]] + + node_pattern: ClassVar[re.Pattern] = re.compile( + r"([0-9A-Z]{3}) = \(([0-9A-Z]{3}), ([0-9A-Z]{3})\)$" + ) + + @staticmethod + def _parse_instructions(instructions_segment: str) -> tuple[Instruction, ...]: + try: + return tuple(Instruction(symbol) for symbol in instructions_segment) + except: + raise ValueError("Network instructions are invalid") + + @staticmethod + def _parse_node(node_desc) -> tuple[str, tuple[str, str]]: + node_match = Network.node_pattern.match(node_desc) + if not node_match: + raise ValueError("Network node description is invalid") + return ( + node_match.group(1), + ( + node_match.group(2), + node_match.group(3), + ), + ) + + @classmethod + def parse(cls, network_desc: str) -> Network: + instructions_segment, _, node_desc_segment = network_desc.partition("\n\n") + + instructions = cls._parse_instructions(instructions_segment) + nodes = frozendict( + cls._parse_node(node_desc) + for node_desc in node_desc_segment.split("\n") + if node_desc != "" + ) + + return Network(instructions, nodes) + + @cache + def traverse(self, start_node: str) -> str: + cursor = start_node + for instruction in self.instructions: + if cursor not in self.nodes: + raise Exception(f"Dead end: node {cursor} not found") + left, right = self.nodes[cursor] + match instruction: + case Instruction.Left: + cursor = left + case Instruction.Right: + cursor = right + return cursor + + @cache + def distance(self, start_node: str, stop_flag: str) -> int: + cursor = start_node + cycles = 0 + while not cursor.endswith(stop_flag): + cursor = self.traverse(cursor) + cycles += 1 + return cycles * len(self.instructions) + + @cache + def linked_distance(self, start_flag: str, stop_flag: str) -> int: + return lcm( + *( + self.distance(node, stop_flag) + for node in self.nodes.keys() + if node.endswith(start_flag) + ) + ) + + +def solve_part_1(input: str) -> int: + network = Network.parse(input) + return network.distance("AAA", "ZZZ") + + +def solve_part_2(input: str) -> int: + network = Network.parse(input) + return network.linked_distance("A", "Z") diff --git a/pyproject.toml b/pyproject.toml index ee0693c..0df5ed4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "advent_of_code" version = "0.1.0" -dependencies = ["requests"] +dependencies = ["requests", "frozendict"] [project.optional-dependencies] test = ["pytest", "pytest-cov", "types-requests"] diff --git a/tests/network_test.py b/tests/network_test.py new file mode 100644 index 0000000..67afca8 --- /dev/null +++ b/tests/network_test.py @@ -0,0 +1,99 @@ +from textwrap import dedent + +import pytest +from frozendict import frozendict + +from advent_of_code.network import Instruction, Network, solve_part_1, solve_part_2 + +mock_network = Network( + ( + Instruction.Left, + Instruction.Left, + Instruction.Right, + ), + frozendict( + { + "AAA": ("BBB", "BBB"), + "BBB": ("AAA", "ZZZ"), + "ZZZ": ("ZZZ", "ZZZ"), + } + ), +) + + +def test_network_parse(): + mock_input = dedent( + """\ + LLR + + AAA = (BBB, BBB) + BBB = (AAA, ZZZ) + ZZZ = (ZZZ, ZZZ) + """ + ) + assert Network.parse(mock_input) == mock_network + + assert Network.parse("LR") == Network((Instruction.Left, Instruction.Right), {}) + + with pytest.raises(ValueError): + Network.parse("invalid\nAAA = (BBB, CCC)") + + with pytest.raises(ValueError): + Network.parse("LR\n\ninvalid") + + +def test_network_traverse(): + assert mock_network.traverse("AAA") == "BBB" + assert mock_network.traverse("BBB") == "ZZZ" + + with pytest.raises(Exception): + network = Network( + (Instruction.Left, Instruction.Left), + frozendict({"AAA": ("BBB", "BBB")}), + ) + network.traverse("AAA") + + +def test_network_distance(): + assert mock_network.distance("AAA", "ZZZ") == 6 + + +def test_solve_part_1(): + assert ( + solve_part_1( + dedent( + """\ + RL + + AAA = (BBB, AAA) + BBB = (CCC, BBB) + CCC = (DDD, CCC) + DDD = (ZZZ, DDD) + ZZZ = (ZZZ, ZZZ) + """ + ) + ) + == 8 + ) + + +def test_solve_part_2(): + assert ( + solve_part_2( + dedent( + """\ + LR + + 11A = (11B, XXX) + 11B = (XXX, 11Z) + 11Z = (11B, XXX) + 22A = (22B, XXX) + 22B = (22C, 22C) + 22C = (22Z, 22Z) + 22Z = (22B, 22B) + XXX = (XXX, XXX) + """ + ) + ) + == 6 + )