|
| 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 |
0 commit comments