|
| 1 | +"""Day 11: Monkey in the Middle |
| 2 | +
|
| 3 | +This module provides the solution for Advent of Code 2022 - Day 11. |
| 4 | +
|
| 5 | +It simulates monkeys throwing items based on worry levels and divisibility tests, |
| 6 | +tracking inspection counts to calculate the level of monkey business. |
| 7 | +
|
| 8 | +The module contains a Monkey class for individual monkey behavior and a Solution |
| 9 | +class that inherits from SolutionBase for running the simulation. |
| 10 | +""" |
| 11 | + |
| 12 | +from collections.abc import Callable |
| 13 | +from math import lcm |
| 14 | +import re |
| 15 | + |
| 16 | +from aoc.models.base import SolutionBase |
| 17 | + |
| 18 | + |
| 19 | +class Monkey: |
| 20 | + """Represent a monkey with items, operations, and throwing rules. |
| 21 | +
|
| 22 | + Each monkey holds items with worry levels, performs an operation on each |
| 23 | + item during inspection, tests divisibility, and throws items to other |
| 24 | + monkeys based on the test result. |
| 25 | + """ |
| 26 | + |
| 27 | + def __init__(self, data: str) -> None: |
| 28 | + """Parse monkey configuration from input block. |
| 29 | +
|
| 30 | + Args: |
| 31 | + data: Multi-line string containing monkey ID, starting items, |
| 32 | + operation, divisibility test, and throw targets |
| 33 | + """ |
| 34 | + lines = data.strip().split("\n") |
| 35 | + |
| 36 | + match = re.search(r"Monkey (\d+):", lines[0]) |
| 37 | + if match is None: |
| 38 | + err_msg = "Invalid monkey ID format" |
| 39 | + raise ValueError(err_msg) |
| 40 | + self._id = int(match.group(1)) |
| 41 | + |
| 42 | + match = re.search(r"Starting items: ([\d, ]+)", lines[1]) |
| 43 | + if match is None: |
| 44 | + err_msg = "Invalid starting items format" |
| 45 | + raise ValueError(err_msg) |
| 46 | + self.items = [int(x) for x in match.group(1).split(", ")] |
| 47 | + |
| 48 | + match = re.search(r"Operation: new = (.*)$", lines[2]) |
| 49 | + if match is None: |
| 50 | + err_msg = "Invalid operation format" |
| 51 | + raise ValueError(err_msg) |
| 52 | + operation_str = match.group(1) |
| 53 | + self.operation = self._parse_operation(operation_str) |
| 54 | + |
| 55 | + match = re.search(r"Test: divisible by (\d+)$", lines[3]) |
| 56 | + if match is None: |
| 57 | + err_msg = "Invalid test format" |
| 58 | + raise ValueError(err_msg) |
| 59 | + self.divisor = int(match.group(1)) |
| 60 | + |
| 61 | + match = re.search(r"If true: throw to monkey (\d+)$", lines[4]) |
| 62 | + if match is None: |
| 63 | + err_msg = "Invalid if_true format" |
| 64 | + raise ValueError(err_msg) |
| 65 | + self.if_true = int(match.group(1)) |
| 66 | + |
| 67 | + match = re.search(r"If false: throw to monkey (\d+)$", lines[5]) |
| 68 | + if match is None: |
| 69 | + err_msg = "Invalid if_false format" |
| 70 | + raise ValueError(err_msg) |
| 71 | + self.if_false = int(match.group(1)) |
| 72 | + |
| 73 | + self.inspection_count = 0 |
| 74 | + |
| 75 | + def _parse_operation(self, operation_str: str) -> Callable[[int], int]: |
| 76 | + """Parse operation string into executable function. |
| 77 | +
|
| 78 | + Args: |
| 79 | + operation_str: Expression like "old * 19" or "old + 6" |
| 80 | +
|
| 81 | + Returns |
| 82 | + ------- |
| 83 | + Callable[[int], int]: Function that applies operation to worry level |
| 84 | + """ |
| 85 | + return lambda old: eval(operation_str, {"old": old}) # noqa: S307 |
| 86 | + |
| 87 | + def inspect_items(self, lcm_value: int, *, is_relieved: bool) -> list[tuple[int, int]]: |
| 88 | + """Inspect all held items and determine where to throw them. |
| 89 | +
|
| 90 | + For each item: increment inspection count, apply operation, optionally |
| 91 | + divide by 3 (Part 1) or modulo by LCM (Part 2), test divisibility, |
| 92 | + and determine target monkey. |
| 93 | +
|
| 94 | + Args: |
| 95 | + is_relieved: If True, divide worry level by 3 after inspection (Part 1) |
| 96 | + lcm_value: Least common multiple of all monkey divisors for modulo |
| 97 | + operation to keep numbers manageable (Part 2) |
| 98 | +
|
| 99 | + Returns |
| 100 | + ------- |
| 101 | + list[tuple[int, int]]: List of (worry_level, target_monkey_id) tuples |
| 102 | + for items to be thrown |
| 103 | + """ |
| 104 | + throws: list[tuple[int, int]] = [] |
| 105 | + |
| 106 | + for item in self.items: |
| 107 | + self.inspection_count += 1 |
| 108 | + worry_level = self.operation(item) |
| 109 | + |
| 110 | + if is_relieved: |
| 111 | + worry_level //= 3 |
| 112 | + else: |
| 113 | + worry_level %= lcm_value |
| 114 | + |
| 115 | + # Test and determine target |
| 116 | + if worry_level % self.divisor == 0: |
| 117 | + throws.append((worry_level, self.if_true)) |
| 118 | + |
| 119 | + else: |
| 120 | + throws.append((worry_level, self.if_false)) |
| 121 | + |
| 122 | + self.items = [] |
| 123 | + return throws |
| 124 | + |
| 125 | + |
| 126 | +class Solution(SolutionBase): |
| 127 | + """Simulate monkey item-throwing game and calculate monkey business. |
| 128 | +
|
| 129 | + This solution simulates monkeys playing keep-away with items. Part 1 |
| 130 | + runs 20 rounds with worry relief (divide by 3), while Part 2 runs |
| 131 | + 10,000 rounds without relief, using LCM modulo arithmetic to keep |
| 132 | + worry levels manageable. |
| 133 | +
|
| 134 | + Monkey business is the product of the two highest inspection counts. |
| 135 | + """ |
| 136 | + |
| 137 | + def simulate(self, data: str, rounds: int, *, is_relieved: bool) -> int: |
| 138 | + """Run monkey simulation for specified rounds. |
| 139 | +
|
| 140 | + Each round, monkeys take turns inspecting and throwing all their items. |
| 141 | + Uses LCM of all divisors to prevent worry levels from growing too large |
| 142 | + in Part 2 while preserving divisibility test results. |
| 143 | +
|
| 144 | + Args: |
| 145 | + data: Raw input containing monkey configurations |
| 146 | + rounds: Number of rounds to simulate |
| 147 | + is_relieved: If True, divide worry by 3 after each inspection |
| 148 | +
|
| 149 | + Returns |
| 150 | + ------- |
| 151 | + int: Monkey business level (product of top 2 inspection counts) |
| 152 | + """ |
| 153 | + monkeys = [Monkey(block) for block in data.split("\n\n")] |
| 154 | + lcm_value = lcm(*[m.divisor for m in monkeys]) |
| 155 | + |
| 156 | + for _ in range(rounds): |
| 157 | + for monkey in monkeys: |
| 158 | + throws = monkey.inspect_items(lcm_value, is_relieved=is_relieved) |
| 159 | + for item, target_id in throws: |
| 160 | + monkeys[target_id].items.append(item) |
| 161 | + |
| 162 | + counts = sorted([m.inspection_count for m in monkeys], reverse=True) |
| 163 | + return counts[0] * counts[1] |
| 164 | + |
| 165 | + def part1(self, data: str) -> int: |
| 166 | + """Calculate monkey business after 20 rounds with worry relief. |
| 167 | +
|
| 168 | + After each inspection, worry level is divided by 3 (rounded down) |
| 169 | + because you're relieved the item wasn't damaged. |
| 170 | +
|
| 171 | + Args: |
| 172 | + data: Raw input containing monkey configurations |
| 173 | +
|
| 174 | + Returns |
| 175 | + ------- |
| 176 | + int: Product of top 2 inspection counts after 20 rounds |
| 177 | + """ |
| 178 | + return self.simulate(data, rounds=20, is_relieved=True) |
| 179 | + |
| 180 | + def part2(self, data: str) -> int: |
| 181 | + """Calculate monkey business after 10,000 rounds without relief. |
| 182 | +
|
| 183 | + Worry levels are no longer divided by 3, causing them to grow rapidly. |
| 184 | + Uses modulo by LCM of all divisors to keep numbers manageable while |
| 185 | + preserving divisibility test outcomes. |
| 186 | +
|
| 187 | + Args: |
| 188 | + data: Raw input containing monkey configurations |
| 189 | +
|
| 190 | + Returns |
| 191 | + ------- |
| 192 | + int: Product of top 2 inspection counts after 10,000 rounds |
| 193 | + """ |
| 194 | + return self.simulate(data, rounds=10_000, is_relieved=False) |
0 commit comments