Skip to content

Commit 046136f

Browse files
committed
feat: aoc 2025 day 5 solution + tests
1 parent 4f7adc3 commit 046136f

File tree

4 files changed

+194
-0
lines changed

4 files changed

+194
-0
lines changed

_2025/solutions/day05.py

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
"""Day 5: Cafeteria
2+
3+
This module provides the solution for Advent of Code 2025 - Day 5.
4+
5+
It processes a database of fresh ingredient ID ranges and a list of available
6+
ingredient IDs to determine which ingredients are fresh and how many IDs fall
7+
within any fresh range.
8+
9+
The module contains a Solution class that inherits from SolutionBase and
10+
implements logic for checking freshness and merging overlapping ranges.
11+
"""
12+
13+
import re
14+
15+
from typing import ClassVar
16+
17+
from aoc.models.base import SolutionBase
18+
19+
20+
class Solution(SolutionBase):
21+
"""Analyze ingredient ID ranges to determine freshness and coverage.
22+
23+
This solution works with inclusive integer ranges representing fresh
24+
ingredient IDs. Part 1 counts how many available ingredient IDs are fresh
25+
(they fall into at least one range). Part 2 merges overlapping and adjacent
26+
ranges and computes how many distinct IDs are covered in total.
27+
28+
The implementation uses regex-based parsing for ranges and a standard
29+
interval merge algorithm to handle overlapping fresh ID ranges.
30+
"""
31+
32+
REGEX: ClassVar[re.Pattern[str]] = re.compile(r"(\d+)-(\d+)$")
33+
34+
def parse_data(self, data: str) -> tuple[list[tuple[int, int]], list[int]]:
35+
"""Parse fresh ID ranges and available ingredient IDs from input.
36+
37+
The input consists of a block of inclusive fresh ID ranges, a blank
38+
line, and then a block of available ingredient IDs, one per line.
39+
40+
Args:
41+
data: Raw puzzle input as a single string
42+
43+
Returns
44+
-------
45+
tuple[list[tuple[int, int]], list[int]]:
46+
- List of (start, end) tuples for fresh ID ranges
47+
- List of available ingredient IDs
48+
"""
49+
fresh, available = data.split("\n\n")
50+
51+
ranges: list[tuple[int, int]] = []
52+
for line in fresh.splitlines():
53+
if not line:
54+
continue
55+
56+
match = self.REGEX.search(line)
57+
if not match:
58+
msg = f"Invalid ingredient range: {line}"
59+
raise ValueError(msg)
60+
61+
start, end = int(match.group(1)), int(match.group(2))
62+
ranges.append((start, end))
63+
64+
return ranges, [int(x) for x in available.splitlines() if x]
65+
66+
@staticmethod
67+
def is_fresh(ingredient: int, interval: tuple[int, int]) -> bool:
68+
"""Check whether an ingredient ID is fresh for a single range.
69+
70+
Args:
71+
ingredient: Ingredient ID to check
72+
interval: Inclusive range (start, end) for fresh IDs
73+
74+
Returns
75+
-------
76+
bool: True if ingredient is within the range, False otherwise
77+
"""
78+
start, end = interval
79+
return start <= ingredient <= end
80+
81+
def part1(self, data: str) -> int:
82+
"""Count available ingredient IDs that are fresh.
83+
84+
An ingredient ID is considered fresh if it falls into at least one
85+
of the inclusive fresh ID ranges.
86+
87+
Args:
88+
data: Raw puzzle input as a single string
89+
90+
Returns
91+
-------
92+
int: Number of available ingredient IDs that are fresh
93+
"""
94+
ranges, ingredients = self.parse_data(data)
95+
count = 0
96+
97+
for ingredient in ingredients:
98+
for interval in ranges:
99+
if self.is_fresh(ingredient, interval):
100+
count += 1
101+
break
102+
103+
return count
104+
105+
def part2(self, data: str) -> int:
106+
"""Count total number of distinct fresh ingredient IDs.
107+
108+
This part merges overlapping and adjacent fresh ID ranges and then
109+
counts how many distinct ingredient IDs are covered by the merged
110+
ranges.
111+
112+
Args:
113+
data: Raw puzzle input as a single string
114+
115+
Returns
116+
-------
117+
int: Total count of distinct ingredient IDs that are fresh
118+
"""
119+
ranges, _ = self.parse_data(data)
120+
ranges = sorted(ranges, key=lambda interval: interval[0])
121+
122+
total = 0
123+
current_start, current_end = ranges[0]
124+
125+
for start, end in ranges[1:]:
126+
if start <= current_end + 1:
127+
current_end = max(current_end, end)
128+
else:
129+
total += current_end - current_start + 1
130+
current_start, current_end = start, end
131+
132+
total += current_end - current_start + 1
133+
return total
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
3-5
2+
10-14
3+
16-20
4+
12-18
5+
6+
1
7+
5
8+
8
9+
11
10+
17
11+
32
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
3-5
2+
10-14
3+
16-20
4+
12-18
5+
6+
1
7+
5
8+
8
9+
11
10+
17
11+
32

_2025/tests/test_05.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
"""Test suite for Day 5: Cafeteria
2+
3+
This module contains tests for the Day 5 solution, which analyzes fresh
4+
ingredient ID ranges and available ingredient IDs. The tests verify:
5+
6+
1. Part 1: Counting available IDs that are fresh
7+
2. Part 2: Counting total distinct fresh IDs after merging ranges
8+
"""
9+
from aoc.models.tester import TestSolutionUtility
10+
11+
12+
def test_day05_part1() -> None:
13+
"""Test counting fresh available ingredient IDs.
14+
15+
This test runs the solution for Part 1 of the puzzle against the
16+
provided test input and compares the result with the expected output.
17+
"""
18+
TestSolutionUtility.run_test(
19+
year=2025,
20+
day=5,
21+
is_raw=True,
22+
part_num=1,
23+
expected=3,
24+
)
25+
26+
27+
def test_day05_part2() -> None:
28+
"""Test counting total distinct fresh IDs after merging ranges.
29+
30+
This test runs the solution for Part 2 of the puzzle against the
31+
provided test input and compares the result with the expected output.
32+
"""
33+
TestSolutionUtility.run_test(
34+
year=2025,
35+
day=5,
36+
is_raw=True,
37+
part_num=2,
38+
expected=14,
39+
)

0 commit comments

Comments
 (0)