|
| 1 | +import re |
| 2 | +from typing import List, Tuple |
| 3 | + |
| 4 | +from aoc.models.base import SolutionBase |
| 5 | + |
| 6 | + |
| 7 | +class Solution(SolutionBase): |
| 8 | + """Solution for Advent of Code 2024 - Day 13: Claw Contraption. |
| 9 | +
|
| 10 | + This class solves a puzzle involving a claw machine game where two buttons control |
| 11 | + the movement of a claw. Each button press moves the claw by specific X and Y |
| 12 | + coordinates, and the goal is to reach prize locations using the minimum number |
| 13 | + of button presses. |
| 14 | +
|
| 15 | + Input format: |
| 16 | + Groups of three lines representing claw machines, where each group contains: |
| 17 | + - Button A's movement coordinates (X+n, Y+n) |
| 18 | + - Button B's movement coordinates (X+n, Y+n) |
| 19 | + - Prize coordinates (X=n, Y=n) |
| 20 | +
|
| 21 | + This class inherits from `SolutionBase` and provides methods to parse machine |
| 22 | + configurations, calculate required button presses using Cramer's rule, and |
| 23 | + determine the minimum number of tokens needed to win prizes. |
| 24 | + """ |
| 25 | + |
| 26 | + button_regex = r"Button [AB]: X\+(\d+), Y\+(\d+)" |
| 27 | + prize_regex = r"Prize: X=(\d+), Y=(\d+)" |
| 28 | + OFFSET = 10000000000000 |
| 29 | + |
| 30 | + def extract(self, coord: str, ext_type: str) -> Tuple[int, int]: |
| 31 | + """Extract X and Y coordinates from button or prize position strings. |
| 32 | +
|
| 33 | + Args: |
| 34 | + coord: String containing coordinate information |
| 35 | + ext_type: Type of coordinates to extract ("button" or "prize") |
| 36 | +
|
| 37 | + Returns: |
| 38 | + Tuple of (x, y) coordinates extracted from the string |
| 39 | + """ |
| 40 | + pattern = self.button_regex if ext_type == "button" else self.prize_regex |
| 41 | + return tuple(map(int, re.findall(pattern, coord)[0])) |
| 42 | + |
| 43 | + def solve_machine( |
| 44 | + self, |
| 45 | + btn_a: Tuple[int, int], |
| 46 | + btn_b: Tuple[int, int], |
| 47 | + prize: Tuple[int, int], |
| 48 | + limit: int | None = 100, |
| 49 | + ) -> int | None: |
| 50 | + """Calculate minimum tokens needed to reach prize coordinates using button presses. |
| 51 | +
|
| 52 | + Uses Cramer's rule to solve the system of linear equations that determine |
| 53 | + how many times each button needs to be pressed to reach the prize coordinates. |
| 54 | +
|
| 55 | + Args: |
| 56 | + btn_a: Tuple of (x, y) movement per Button A press |
| 57 | + btn_b: Tuple of (x, y) movement per Button B press |
| 58 | + prize: Tuple of (x, y) target prize coordinates |
| 59 | + limit: Maximum allowed button presses (None for unlimited) |
| 60 | +
|
| 61 | + Returns: |
| 62 | + Total tokens needed (3 per Button A press + 1 per Button B press), |
| 63 | + or None if no valid solution exists |
| 64 | + """ |
| 65 | + denominator = btn_b[1] * btn_a[0] - btn_b[0] * btn_a[1] |
| 66 | + if denominator == 0: |
| 67 | + return None |
| 68 | + |
| 69 | + times_b = (prize[1] * btn_a[0] - prize[0] * btn_a[1]) / denominator |
| 70 | + times_a = (prize[0] - btn_b[0] * times_b) / btn_a[0] |
| 71 | + |
| 72 | + if times_a.is_integer() and times_b.is_integer(): |
| 73 | + if limit is None or (0 <= times_a <= limit and 0 <= times_b <= limit): |
| 74 | + return int(times_a) * 3 + int(times_b) |
| 75 | + |
| 76 | + return None |
| 77 | + |
| 78 | + def calculate_coins(self, data: List[str], part2: bool = False) -> int: |
| 79 | + """Process all claw machines and calculate total tokens needed. |
| 80 | +
|
| 81 | + Parses input data into individual machine configurations and calculates |
| 82 | + the minimum tokens needed for each winnable prize. |
| 83 | +
|
| 84 | + Args: |
| 85 | + data: List of strings containing machine configurations |
| 86 | + part2: Whether to apply the large coordinate offset for part 2 |
| 87 | +
|
| 88 | + Returns: |
| 89 | + Total tokens needed to win all possible prizes |
| 90 | + """ |
| 91 | + machines = ("\n".join(data)).split("\n\n") |
| 92 | + coins = 0 |
| 93 | + |
| 94 | + for machine in machines: |
| 95 | + btn_a, btn_b, prize = machine.split("\n") |
| 96 | + |
| 97 | + btn_a_coords = self.extract(btn_a, "button") |
| 98 | + btn_b_coords = self.extract(btn_b, "button") |
| 99 | + prize_coords = self.extract(prize, "prize") |
| 100 | + |
| 101 | + if part2: |
| 102 | + prize_coords = (prize_coords[0] + self.OFFSET, prize_coords[1] + self.OFFSET) |
| 103 | + |
| 104 | + if result := self.solve_machine( |
| 105 | + btn_a_coords, btn_b_coords, prize_coords, None if part2 else 100 |
| 106 | + ): |
| 107 | + coins += result |
| 108 | + |
| 109 | + return coins |
| 110 | + |
| 111 | + def part1(self, data: List[str]) -> int: |
| 112 | + """Calculate minimum tokens needed with standard prize coordinates. |
| 113 | +
|
| 114 | + Args: |
| 115 | + data: List of strings containing machine configurations |
| 116 | +
|
| 117 | + Returns: |
| 118 | + Total tokens needed when each button press is limited to 100 |
| 119 | + """ |
| 120 | + return self.calculate_coins(data) |
| 121 | + |
| 122 | + def part2(self, data: List[str]) -> int: |
| 123 | + """Calculate minimum tokens needed with offset prize coordinates. |
| 124 | +
|
| 125 | + Args: |
| 126 | + data: List of strings containing machine configurations |
| 127 | +
|
| 128 | + Returns: |
| 129 | + Total tokens needed when prizes are offset by 10^13 units |
| 130 | + """ |
| 131 | + return self.calculate_coins(data, True) |
0 commit comments