Skip to content

Commit ce302f0

Browse files
committed
feat: aoc 2025 day 9 solution + tests
1 parent a66037a commit ce302f0

File tree

4 files changed

+252
-0
lines changed

4 files changed

+252
-0
lines changed

_2025/solutions/day09.py

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
"""Boilerplate solution template for Advent of Code daily challenges.
2+
3+
This module provides a template class for solving Advent of Code puzzle problems.
4+
It includes a base structure with two method stubs (part1 and part2) that can be
5+
implemented for specific day's challenges.
6+
7+
The template follows the SolutionBase pattern used across the Advent of Code solutions,
8+
allowing for consistent handling of input parsing and solution execution.
9+
"""
10+
11+
from collections import deque
12+
13+
from aoc.models.base import SolutionBase
14+
15+
16+
class Solution(SolutionBase):
17+
"""Solution template for Advent of Code daily puzzle.
18+
19+
This class provides a standardized structure for implementing solutions to
20+
daily Advent of Code challenges. It inherits from SolutionBase and includes
21+
method stubs for part1 and part2 of the puzzle.
22+
23+
Subclasses should override these methods with specific implementation logic
24+
for parsing input and solving the puzzle requirements.
25+
"""
26+
27+
def part1(self, data: list[str]) -> int:
28+
"""Solve the first part of the daily puzzle.
29+
30+
Args:
31+
data: List of input strings to be processed
32+
33+
Returns
34+
-------
35+
int: Solution for part 1 of the puzzle
36+
"""
37+
tiles = [tuple(map(int, line.split(","))) for line in data]
38+
39+
if len(tiles) < 2:
40+
return 0
41+
42+
# Coordinate compression ("space distortion")
43+
xs = sorted({x for x, _ in tiles})
44+
ys = sorted({y for _, y in tiles})
45+
46+
x_to_idx = {x: i for i, x in enumerate(xs)}
47+
y_to_idx = {y: i for i, y in enumerate(ys)}
48+
49+
# Compressed positions of red tiles
50+
compressed_tiles: list[tuple[int, int]] = [(x_to_idx[x], y_to_idx[y]) for x, y in tiles]
51+
52+
# Group tiles by compressed row/column if you want small pruning later
53+
# but the brute-force over all pairs is already fine for AoC sizes.
54+
max_area = 0
55+
n = len(compressed_tiles)
56+
57+
for i in range(n):
58+
x1_idx, y1_idx = compressed_tiles[i]
59+
for j in range(i + 1, n):
60+
x2_idx, y2_idx = compressed_tiles[j]
61+
62+
# Opposite corners must differ in both x and y to form area
63+
if x1_idx == x2_idx or y1_idx == y2_idx:
64+
continue
65+
66+
# Get original coordinates
67+
x1 = xs[x1_idx]
68+
y1 = ys[y1_idx]
69+
x2 = xs[x2_idx]
70+
y2 = ys[y2_idx]
71+
72+
width = abs(x2 - x1) + 1
73+
height = abs(y2 - y1) + 1
74+
area = width * height
75+
76+
if area > max_area:
77+
max_area = area
78+
79+
return max_area
80+
81+
def part2(self, data: list[str]) -> int:
82+
"""Solve the second part of the daily puzzle.
83+
84+
Args:
85+
data: List of input strings to be processed
86+
87+
Returns
88+
-------
89+
int: Solution for part 2 of the puzzle
90+
"""
91+
tiles = [tuple(map(int, line.split(","))) for line in data if line.strip()]
92+
93+
if len(tiles) < 2:
94+
return 0
95+
96+
xs = sorted({x for x, _ in tiles})
97+
ys = sorted({y for _, y in tiles})
98+
x_to_idx = {x: i for i, x in enumerate(xs)}
99+
y_to_idx = {y: i for i, y in tiles}
100+
y_to_idx = {y: i for i, y in enumerate(ys)}
101+
compressed = [(x_to_idx[x], y_to_idx[y]) for x, y in tiles]
102+
103+
w, h = len(xs), len(ys)
104+
grid = [[0] * w for _ in range(h)] # 0 = empty, 1 = red, 2 = green
105+
106+
# Mark red tiles
107+
for cx, cy in compressed:
108+
grid[cy][cx] = 1
109+
110+
# Draw green boundary segments between consecutive reds (wrap)
111+
n = len(compressed)
112+
for i in range(n):
113+
x1, y1 = compressed[i]
114+
x2, y2 = compressed[(i + 1) % n]
115+
if x1 == x2:
116+
ys_min, ys_max = sorted((y1, y2))
117+
for y in range(ys_min, ys_max + 1):
118+
if grid[y][x1] == 0:
119+
grid[y][x1] = 2
120+
elif y1 == y2:
121+
xs_min, xs_max = sorted((x1, x2))
122+
for x in range(xs_min, xs_max + 1):
123+
if grid[y1][x] == 0:
124+
grid[y1][x] = 2
125+
else:
126+
raise ValueError("Non axis-aligned segment in input")
127+
128+
# Flood-fill outside empty cells
129+
outside = [[False] * w for _ in range(h)]
130+
q: deque[tuple[int, int]] = deque()
131+
132+
for x in range(w):
133+
if grid[0][x] == 0:
134+
outside[0][x] = True
135+
q.append((x, 0))
136+
if grid[h - 1][x] == 0:
137+
outside[h - 1][x] = True
138+
q.append((x, h - 1))
139+
for y in range(h):
140+
if grid[y][0] == 0:
141+
outside[y][0] = True
142+
q.append((0, y))
143+
if grid[y][w - 1] == 0:
144+
outside[y][w - 1] = True
145+
q.append((w - 1, y))
146+
147+
while q:
148+
x, y = q.popleft()
149+
for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1)):
150+
nx, ny = x + dx, y + dy
151+
if 0 <= nx < w and 0 <= ny < h and not outside[ny][nx] and grid[ny][nx] == 0:
152+
outside[ny][nx] = True
153+
q.append((nx, ny))
154+
155+
# Interior empty cells become green
156+
for y in range(h):
157+
for x in range(w):
158+
if grid[y][x] == 0 and not outside[y][x]:
159+
grid[y][x] = 2
160+
161+
max_area = 0
162+
n = len(compressed)
163+
164+
for i in range(n):
165+
x1i, y1i = compressed[i]
166+
for j in range(i + 1, n):
167+
x2i, y2i = compressed[j]
168+
if x1i == x2i or y1i == y2i:
169+
continue
170+
171+
xl, xr = sorted((x1i, x2i))
172+
yt, yb = sorted((y1i, y2i))
173+
174+
# Check rectangle only contains red/green
175+
ok = True
176+
for yy in range(yt, yb + 1):
177+
row = grid[yy]
178+
for xx in range(xl, xr + 1):
179+
if row[xx] == 0:
180+
ok = False
181+
break
182+
if not ok:
183+
break
184+
185+
if not ok:
186+
continue
187+
188+
x1, y1 = xs[x1i], ys[y1i]
189+
x2, y2 = xs[x2i], ys[y2i]
190+
area = (abs(x2 - x1) + 1) * (abs(y2 - y1) + 1)
191+
if area > max_area:
192+
max_area = area
193+
194+
return max_area
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
7,1
2+
11,1
3+
11,7
4+
9,7
5+
9,5
6+
2,5
7+
2,3
8+
7,3
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
7,1
2+
11,1
3+
11,7
4+
9,7
5+
9,5
6+
2,5
7+
2,3
8+
7,3

_2025/tests/test_09.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
"""Test suite for Advent of Code daily challenge solution.
2+
3+
This module contains the test cases for the solution to the Advent of Code
4+
daily puzzle. It uses the TestSolutionUtility to run tests for both parts
5+
of the challenge.
6+
7+
The template provides a structure for defining tests for a specific day's
8+
solution, ensuring that the implementation meets the expected outputs for
9+
given test inputs.
10+
"""
11+
12+
from aoc.models.tester import TestSolutionUtility
13+
14+
15+
def test_day09_part1() -> None:
16+
"""Test the solution for Part 1 of the daily puzzle.
17+
18+
This test runs the solution for Part 1 of the puzzle against the
19+
provided test input and compares the result with the expected output.
20+
"""
21+
TestSolutionUtility.run_test(
22+
year=2025,
23+
day=9,
24+
is_raw=False,
25+
part_num=1,
26+
expected=50,
27+
)
28+
29+
30+
def test_day09_part2() -> None:
31+
"""Test the solution for Part 2 of the daily puzzle.
32+
33+
This test runs the solution for Part 2 of the puzzle against the
34+
provided test input and compares the result with the expected output.
35+
"""
36+
TestSolutionUtility.run_test(
37+
year=2025,
38+
day=9,
39+
is_raw=False,
40+
part_num=2,
41+
expected=24,
42+
)

0 commit comments

Comments
 (0)