Skip to content

Commit 2afbe56

Browse files
committed
Day 10 solution + tests
1 parent 4fdf9ab commit 2afbe56

File tree

4 files changed

+228
-0
lines changed

4 files changed

+228
-0
lines changed

solutions/day10.py

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
from collections import deque
2+
from typing import Dict, List, Set, Tuple
3+
4+
from aoc.models.base import SolutionBase
5+
6+
7+
class Solution(SolutionBase):
8+
"""Solution for Advent of Code 2024 - Day 10: Hoof It.
9+
10+
This class solves a puzzle involving finding valid hiking trails on a topographic
11+
map. A valid hiking trail must start at height 0, end at height 9, and increase
12+
by exactly 1 at each step. The solution calculates scores for each trailhead based
13+
on how many height-9 positions can be reached via valid hiking trails.
14+
15+
Input format:
16+
List of strings where each string represents a row in the grid and each character
17+
is a single digit (0-9) representing the height at that position in the grid.
18+
All rows have the same length, forming a rectangular grid.
19+
20+
This class inherits from `SolutionBase` and provides methods to find valid paths
21+
and calculate trailhead scores.
22+
"""
23+
24+
def get_neighbors(self, grid: List[List[int]], x: int, y: int, target_height: int) -> List[Tuple[int, int]]:
25+
"""Find valid neighboring positions with a specific target height.
26+
27+
Args:
28+
grid (List[List[int]]): The height grid
29+
x (int): Current `x` coordinate
30+
y (int): Current `y` coordinate
31+
target_height (int): The height value we're looking for in neighbors
32+
33+
Returns:
34+
List[Tuple[int, int]]: List of valid neighbor coordinates (x, y) that have
35+
the target height. Only considers up, down, left, and right neighbors
36+
within grid boundaries.
37+
"""
38+
neighbors = []
39+
for dx, dy in [(0, 1), (1, 0), (0, -1), (-1, 0)]: # right, down, left, up
40+
new_x, new_y = x + dx, y + dy
41+
if 0 <= new_x < len(grid) and 0 <= new_y < len(grid[0]) and grid[new_x][new_y] == target_height:
42+
neighbors.append((new_x, new_y))
43+
44+
return neighbors
45+
46+
def find_paths_to_nine(self, grid: List[List[int]], start_x: int, start_y: int) -> Set[Tuple[int, int]]:
47+
"""Find all height-9 positions reachable via valid hiking trails from a starting position.
48+
49+
A valid hiking trail must:
50+
- Start at height 0
51+
- Increase by exactly 1 at each step
52+
- Only move up, down, left, or right (no diagonals)
53+
- End at height 9
54+
55+
Args:
56+
grid (List[List[int]]): The height grid
57+
start_x (int): Starting `x` coordinate
58+
start_y (int): Starting `y` coordinate
59+
60+
Returns:
61+
Set[Tuple[int, int]]: Set of coordinates (x, y) of height-9 positions
62+
that can be reached via valid hiking trails from the start position.
63+
Returns empty set if start position isn't height 0.
64+
"""
65+
if grid[start_x][start_y] != 0: # Must start at height 0
66+
return set()
67+
68+
reachable_nines = set()
69+
queue = deque([(start_x, start_y, 0)]) # (x, y, current_height)
70+
visited = set()
71+
72+
while queue:
73+
x, y, height = queue.popleft()
74+
current_state = (x, y, height)
75+
76+
if current_state in visited:
77+
continue
78+
79+
visited.add(current_state)
80+
81+
if height == 9: # Found a path to height 9
82+
reachable_nines.add((x, y))
83+
continue
84+
85+
# Look for positions with height + 1
86+
next_height = height + 1
87+
for next_x, next_y in self.get_neighbors(grid, x, y, next_height):
88+
queue.append((next_x, next_y, next_height))
89+
90+
return reachable_nines
91+
92+
def count_unique_paths(self, grid: List[List[int]], start_x: int, start_y: int) -> int:
93+
"""Count number of unique paths from start position to any height-9 position.
94+
95+
A valid path must:
96+
- Start at height 0
97+
- Increase by exactly 1 at each step
98+
- Only move up, down, left, or right (no diagonals)
99+
- End at height 9
100+
101+
Uses dynamic programming to count paths efficiently by tracking the number
102+
of paths to each position at each height level.
103+
104+
Args:
105+
grid (List[List[int]]): The height grid
106+
start_x (int): Starting x coordinate
107+
start_y (int): Starting y coordinate
108+
109+
Returns:
110+
int: Number of unique valid paths from start position to any height-9
111+
position. Returns 0 if start position isn't height 0.
112+
"""
113+
if grid[start_x][start_y] != 0:
114+
return 0
115+
116+
# paths[(x, y, h)] represents the number of paths to (x,y) at height h
117+
paths: Dict[Tuple[int, int, int], int] = {(start_x, start_y, 0): 1}
118+
queue = deque([(start_x, start_y, 0)]) # (x, y, current_height)
119+
total_paths = 0
120+
121+
while queue:
122+
x, y, height = queue.popleft()
123+
current_paths = paths[(x, y, height)]
124+
125+
if height == 9:
126+
total_paths += current_paths
127+
continue
128+
129+
next_height = height + 1
130+
for next_x, next_y in self.get_neighbors(grid, x, y, next_height):
131+
next_state = (next_x, next_y, next_height)
132+
if next_state not in paths:
133+
queue.append((next_x, next_y, next_height))
134+
paths[next_state] = current_paths
135+
136+
else:
137+
paths[next_state] += current_paths
138+
139+
return total_paths
140+
141+
def part1(self, data: List[str]) -> int:
142+
"""Calculate the sum of scores for all trailheads on the topographic map.
143+
144+
A trailhead's score is the number of height-9 positions that can be reached
145+
from that trailhead via valid hiking trails. A valid trail must increase by
146+
exactly 1 in height at each step and can only move in cardinal directions.
147+
148+
Args:
149+
data (List[str]): Input lines containing the height grid
150+
151+
Returns:
152+
int: Sum of scores for all trailheads (positions with height 0)
153+
"""
154+
# Parse input into grid
155+
grid = [[int(coord) for coord in line.strip()] for line in data]
156+
rows, cols = len(grid), len(grid[0])
157+
158+
# Find all starting positions (height 0)
159+
total_score = 0
160+
for i in range(rows):
161+
for j in range(cols):
162+
if grid[i][j] == 0:
163+
reachable = self.find_paths_to_nine(grid, i, j)
164+
total_score += len(reachable)
165+
166+
return total_score
167+
168+
def part2(self, data: List[str]) -> int:
169+
"""Calculate the sum of ratings for all trailheads on the topographic map.
170+
171+
A trailhead's rating is the number of distinct hiking trails that begin at
172+
that trailhead. A valid trail must start at height 0, increase by exactly 1
173+
at each step, only move in cardinal directions (up, down, left, right), and
174+
end at height 9.
175+
176+
Args:
177+
data (List[str]): Input lines containing the height grid
178+
179+
Returns:
180+
int: Sum of ratings (number of unique paths to height 9) for all
181+
trailheads (positions with height 0)
182+
"""
183+
grid = [[int(c) for c in line.strip()] for line in data]
184+
rows, cols = len(grid), len(grid[0])
185+
186+
total_rating = 0
187+
for i in range(rows):
188+
for j in range(cols):
189+
if grid[i][j] == 0:
190+
rating = self.count_unique_paths(grid, i, j)
191+
total_rating += rating
192+
193+
return total_rating

tests/data/day10/test_01_input.txt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
89010123
2+
78121874
3+
87430965
4+
96549874
5+
45678903
6+
32019012
7+
01329801
8+
10456732

tests/data/day10/test_02_input.txt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
89010123
2+
78121874
3+
87430965
4+
96549874
5+
45678903
6+
32019012
7+
01329801
8+
10456732

tests/test_10.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
from aoc.models.tester import TestSolutionUtility
2+
3+
4+
def test_day10_part1():
5+
TestSolutionUtility.run_test(
6+
day=10,
7+
is_raw=False,
8+
part_num=1,
9+
expected=36,
10+
)
11+
12+
13+
def test_day10_part2():
14+
TestSolutionUtility.run_test(
15+
day=10,
16+
is_raw=False,
17+
part_num=2,
18+
expected=81,
19+
)

0 commit comments

Comments
 (0)