Skip to content

Commit 53b9e48

Browse files
committed
Day 21 solution
1 parent 9ab09b5 commit 53b9e48

File tree

1 file changed

+219
-0
lines changed

1 file changed

+219
-0
lines changed

solutions/day21.py

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
from collections import defaultdict
2+
from functools import cache
3+
from itertools import product
4+
from typing import Dict, List, Tuple
5+
6+
from aoc.models.base import SolutionBase
7+
8+
9+
class Solution(SolutionBase):
10+
"""Solution for Advent of Code 2024 - Day 21: Keypad Conundrum.
11+
12+
This class solves a puzzle about robots translating movement codes on keypads.
13+
Part 1 calculates movement complexity with 2 robots, while Part 2 extends
14+
the chain to 25 robots for more complex translations.
15+
16+
Input format:
17+
- List of codes, one per line
18+
- Each code ends with 'A'
19+
- Codes can be numeric (0-9) or directional (^v<>)
20+
- Numeric part of code determines weighting in complexity calculation
21+
22+
This class inherits from `SolutionBase` and provides methods to parse keypad
23+
layouts, translate movement codes, and calculate total complexity scores.
24+
"""
25+
26+
numeric_keypad = ["789", "456", "123", "#0A"]
27+
directional_keypad = ["#^A", "<v>"]
28+
29+
def add_move(
30+
self, moves: Dict[Tuple[str, str], List[str]], key1: str, key2: str, movement: str
31+
) -> None:
32+
"""Add a valid movement sequence between two keys to the moves dictionary.
33+
34+
Validates and stores a movement sequence between two keys, excluding any moves
35+
involving the '#' key or self-moves. Appends 'A' to confirm each movement.
36+
37+
Args:
38+
moves: Dictionary mapping key pairs to their valid movement sequences
39+
key1: Starting key position
40+
key2: Target key position
41+
movement: String of directional moves (combination of ^v<>)
42+
"""
43+
if key1 != "#" and key2 != "#" and key1 != key2:
44+
moves[(key1, key2)].append(movement + "A")
45+
46+
def parse_moves(self, keypad_layout: List[str]) -> Dict[Tuple[str, str], List[str]]:
47+
"""Generate all possible moves between keys on a given keypad layout.
48+
49+
Maps out every valid movement sequence between pairs of keys, considering:
50+
- Direct horizontal moves using < and >
51+
- Direct vertical moves using ^ and v
52+
- Diagonal moves trying both horizontal-then-vertical and vertical-then-horizontal
53+
- Avoiding the '#' obstacle and invalid positions
54+
55+
Args:
56+
keypad_layout: List of strings representing rows of the keypad
57+
58+
Returns:
59+
Dictionary mapping key pairs (start, end) to lists of valid movement sequences
60+
"""
61+
positions = {
62+
key: (r, c) for r, row in enumerate(keypad_layout) for c, key in enumerate(row)
63+
}
64+
65+
moves = defaultdict(list)
66+
keys = sorted(positions.keys())
67+
68+
for key1, key2 in product(keys, repeat=2):
69+
if key1 == "#" or key2 == "#" or key1 == key2:
70+
continue
71+
72+
r1, c1 = positions[key1]
73+
r2, c2 = positions[key2]
74+
r_hash, c_hash = positions["#"]
75+
76+
if r1 == r2:
77+
self.add_move(moves, key1, key2, (">" if c2 > c1 else "<") * abs(c2 - c1))
78+
79+
elif c1 == c2:
80+
self.add_move(moves, key1, key2, ("v" if r2 > r1 else "^") * abs(r2 - r1))
81+
82+
else:
83+
if r1 != r_hash or c2 != c_hash:
84+
self.add_move(
85+
moves,
86+
key1,
87+
key2,
88+
(">" if c2 > c1 else "<") * abs(c2 - c1)
89+
+ ("v" if r2 > r1 else "^") * abs(r2 - r1),
90+
)
91+
92+
if c1 != c_hash or r2 != r_hash:
93+
self.add_move(
94+
moves,
95+
key1,
96+
key2,
97+
("v" if r2 > r1 else "^") * abs(r2 - r1)
98+
+ (">" if c2 > c1 else "<") * abs(c2 - c1),
99+
)
100+
101+
return moves
102+
103+
def build_combinations(self, arrays: List[List[str]]) -> List[List[str]]:
104+
"""Generate all possible combinations of movement sequences.
105+
106+
Uses itertools.product to efficiently generate all possible combinations
107+
of movement sequences for a series of moves.
108+
109+
Args:
110+
arrays: List of lists where each inner list contains possible movements
111+
for a single step in the sequence
112+
113+
Returns:
114+
List of all possible movement sequence combinations
115+
"""
116+
return list(product(*arrays))
117+
118+
@cache
119+
def translate(self, code: str, depth: int) -> int:
120+
"""Calculate minimum moves needed for a chain of robots to input a code.
121+
122+
Recursively determines the shortest sequence of moves needed for the robot
123+
chain to input the given code, where each robot translates the movements
124+
of the previous robot.
125+
126+
Args:
127+
code: The input code to translate (either numeric or directional)
128+
depth: Number of robots in the chain (2 for part 1, 25 for part 2)
129+
130+
Returns:
131+
Minimum number of total moves required to input the code
132+
"""
133+
moves = self.translate_numpad(code) if code[0].isnumeric() else self.translate_keypad(code)
134+
135+
if depth == 0:
136+
return min(sum(map(len, move)) for move in moves)
137+
138+
return min(
139+
sum(self.translate(curr_code, depth - 1) for curr_code in move) for move in moves
140+
)
141+
142+
def translate_numpad(self, code: str) -> List[List[str]]:
143+
"""Convert a numeric code into possible movement sequences.
144+
145+
Translates a sequence of numeric inputs into all possible movement
146+
combinations on the numeric keypad.
147+
148+
Args:
149+
code: String of numeric characters to translate
150+
151+
Returns:
152+
List of possible movement sequence combinations to input the code
153+
"""
154+
code = "A" + code # Start from A position
155+
moves = [self.moves1[(a, b)] for a, b in zip(code, code[1:])]
156+
return self.build_combinations(moves)
157+
158+
def translate_keypad(self, code: str) -> List[List[str]]:
159+
"""Convert a directional code into possible movement sequences.
160+
161+
Translates a sequence of directional inputs into all possible movement
162+
combinations on the directional keypad, handling self-moves with 'A'.
163+
164+
Args:
165+
code: String of directional characters to translate
166+
167+
Returns:
168+
List of possible movement sequence combinations to input the code
169+
"""
170+
code = "A" + code # Start from A position
171+
moves = [self.moves2[(a, b)] if a != b else ["A"] for a, b in zip(code, code[1:])]
172+
return self.build_combinations(moves)
173+
174+
def part1(self, data: List[str]) -> int:
175+
"""Calculate total complexity with 2-robot chains.
176+
177+
Processes each code using a chain of 2 robots, where each robot translates
178+
the movements of the previous robot. Complexity is calculated as the product
179+
of the minimum moves required and the numeric part of each code.
180+
181+
Args:
182+
data: List of input codes, each ending with 'A'
183+
184+
Returns:
185+
Total complexity score summed across all codes
186+
"""
187+
self.moves1 = self.parse_moves(self.numeric_keypad)
188+
self.moves2 = self.parse_moves(self.directional_keypad)
189+
190+
total_complexity = 0
191+
for code in data:
192+
code = code.strip()
193+
min_len = self.translate(code, 2) # Depth 2 for the chain of commands
194+
numeric_part = int(code[:-1]) # Remove 'A' and convert to int
195+
total_complexity += min_len * numeric_part
196+
197+
return total_complexity
198+
199+
def part2(self, data: List[str]) -> int:
200+
"""Calculate total complexity with 25-robot chains.
201+
202+
Similar to part 1 but uses chains of 25 robots instead of 2, resulting in
203+
more complex movement translations and potentially higher complexity scores.
204+
205+
Args:
206+
data: List of input codes, each ending with 'A'
207+
208+
Returns:
209+
Total complexity score summed across all codes using 25-robot chains
210+
"""
211+
self.moves1 = self.parse_moves(self.numeric_keypad)
212+
self.moves2 = self.parse_moves(self.directional_keypad)
213+
214+
complexities = 0
215+
for code in data:
216+
min_len = self.translate(code, 25) # 25 robots instead of 2
217+
complexities += min_len * int(code[:-1])
218+
219+
return complexities

0 commit comments

Comments
 (0)