Skip to content

Commit 4ded2fe

Browse files
committed
Day 20 solution
1 parent 2d08139 commit 4ded2fe

File tree

1 file changed

+168
-0
lines changed

1 file changed

+168
-0
lines changed

solutions/day20.py

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
from collections import deque
2+
from typing import List, Optional, Tuple
3+
4+
from aoc.models.base import SolutionBase
5+
6+
7+
class Solution(SolutionBase):
8+
"""Solution for Advent of Code 2023 - Day 20: Race Condition.
9+
10+
This class solves a puzzle about finding shortcuts in a racetrack maze.
11+
Part 1 finds valid 2-move cheats, while Part 2 finds valid 20-move cheats.
12+
Both parts count cheats that save a minimum number of steps.
13+
14+
Input format:
15+
- Grid representation of the racetrack where:
16+
* '#' represents walls
17+
* '.' represents valid path positions
18+
* 'S' marks the start position
19+
* 'E' marks the end position
20+
21+
The solution finds the shortest path from `S` to `E` and then identifies valid
22+
cheat moves that save steps by passing through walls.
23+
24+
This class inherits from `SolutionBase` and implements the required methods
25+
to parse input data and solve both parts of the puzzle. It provides helpers
26+
for path finding and cheat detection.
27+
"""
28+
29+
def parse_data(
30+
self, data: List[str]
31+
) -> Tuple[List[List[str]], Tuple[int, int], Tuple[int, int]]:
32+
"""Parse input data into grid and start/end positions.
33+
34+
Args:
35+
data (List[str]): Raw input lines
36+
37+
Returns:
38+
Tuple[List[List[str]], Tuple[int, int], Tuple[int, int]]:
39+
Tuple containing (grid, start_position, end_position)
40+
"""
41+
grid, start, end = [], None, None
42+
for row in range(len(data)):
43+
row_data = list(data[row].strip())
44+
grid.append(row_data)
45+
for col in range(len(row_data)):
46+
if row_data[col] == "S":
47+
start = (row, col)
48+
elif row_data[col] == "E":
49+
end = (row, col)
50+
51+
return grid, start, end
52+
53+
def find_shortest_path(
54+
self, grid: List[List[str]], start: Tuple[int, int], end: Tuple[int, int]
55+
) -> Optional[List[Tuple[int, int]]]:
56+
"""Find the shortest path from start to end in the grid.
57+
58+
Uses BFS to find the shortest path while avoiding walls.
59+
60+
Args:
61+
grid (List[List[str]]): The maze grid
62+
start (Tuple[int, int]): Starting position (row, col)
63+
end (Tuple[int, int]): Ending position (row, col)
64+
65+
Returns:
66+
Optional[List[Tuple[int, int]]]: List of positions in shortest path,
67+
or None if no path exists
68+
"""
69+
rows, cols = len(grid), len(grid[0])
70+
queue = deque([(start, 0, [start])])
71+
visited = {start}
72+
73+
while queue:
74+
position, steps, path = queue.popleft()
75+
if position == end:
76+
return path
77+
78+
row, col = position
79+
for dr, dc in [(0, 1), (1, 0), (0, -1), (-1, 0)]:
80+
new_row, new_col = row + dr, col + dc
81+
if (
82+
0 <= new_row < rows
83+
and 0 <= new_col < cols
84+
and grid[new_row][new_col] != "#"
85+
and (new_row, new_col) not in visited
86+
):
87+
visited.add((new_row, new_col))
88+
queue.append(((new_row, new_col), steps + 1, path + [(new_row, new_col)]))
89+
90+
return None
91+
92+
def find_cheat_pairs(self, path: List[Tuple[int, int]], savings: int, cheat_moves: int) -> int:
93+
"""Find valid cheat moves that save the required number of steps.
94+
95+
Args:
96+
path (List[Tuple[int, int]]): The shortest path from start to end
97+
savings (int): Minimum number of steps a cheat must save
98+
cheat_moves (int): Maximum number of moves allowed for a cheat
99+
100+
Returns:
101+
int: Number of valid cheats found
102+
"""
103+
coords_steps = {coord: i for i, coord in enumerate(path)}
104+
cheats = 0
105+
106+
possible_ranges = [
107+
(dy, dx, abs(dy) + abs(dx))
108+
for dy in range(-cheat_moves, cheat_moves + 1)
109+
for dx in range(-cheat_moves, cheat_moves + 1)
110+
if 0 < abs(dy) + abs(dx) <= cheat_moves
111+
]
112+
113+
for y, x in path:
114+
for dy, dx, manhattan in possible_ranges:
115+
ny, nx = y + dy, x + dx
116+
if (ny, nx) in coords_steps:
117+
steps_saved = coords_steps[(ny, nx)] - coords_steps[(y, x)] - manhattan
118+
if steps_saved >= savings:
119+
cheats += 1
120+
121+
return cheats
122+
123+
def solve_part(self, data: List[str], cheat_moves: int) -> int:
124+
"""Common solution logic for both parts.
125+
126+
Args:
127+
data (List[str]): Input data lines
128+
cheat_moves (int): Maximum number of moves allowed for cheats
129+
130+
Returns:
131+
int: Number of valid cheats found
132+
"""
133+
grid, start, end = self.parse_data(data)
134+
path = self.find_shortest_path(grid, start, end)
135+
if path is None:
136+
return 0
137+
138+
return self.find_cheat_pairs(path, savings=2, cheat_moves=cheat_moves)
139+
140+
def part1(self, data: List[str]) -> int:
141+
"""Count valid cheats using maximum 2-move teleports.
142+
143+
Finds all valid ways to cheat through walls using at most 2 moves in any direction.
144+
A valid cheat must return to a normal path position after teleporting and must
145+
save at least the required number of steps compared to the normal path.
146+
147+
Args:
148+
data (List[str]): Input lines containing the maze grid with start 'S' and end 'E'
149+
150+
Returns:
151+
int: Number of valid cheats found that save the required minimum steps
152+
"""
153+
return self.solve_part(data, cheat_moves=2)
154+
155+
def part2(self, data: List[str]) -> int:
156+
"""Count valid cheats using maximum 20-move teleports.
157+
158+
Similar to part 1, but allows for longer teleport distances of up to 20 moves.
159+
This enables finding shortcuts that bypass larger sections of walls, but still
160+
requires ending on a valid path position and saving the minimum required steps.
161+
162+
Args:
163+
data (List[str]): Input lines containing the maze grid with start 'S' and end 'E'
164+
165+
Returns:
166+
int: Number of valid cheats found that save the required minimum steps
167+
"""
168+
return self.solve_part(data, cheat_moves=20)

0 commit comments

Comments
 (0)