Skip to content

Commit 7c82a8d

Browse files
committed
chore: aoc 2025 day 9 modularise
1 parent 0c9792c commit 7c82a8d

File tree

1 file changed

+170
-121
lines changed

1 file changed

+170
-121
lines changed

_2025/solutions/day09.py

Lines changed: 170 additions & 121 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,31 @@
1212
"""
1313

1414
from collections import deque
15+
from dataclasses import dataclass
16+
from typing import ClassVar
1517

1618
from aoc.models.base import SolutionBase
1719

1820

21+
@dataclass(frozen=True, slots=True)
22+
class CompressedTiles:
23+
xs: list[int]
24+
ys: list[int]
25+
coords: list[tuple[int, int]] # (x_idx, y_idx)
26+
27+
@property
28+
def width(self) -> int:
29+
return len(self.xs)
30+
31+
@property
32+
def height(self) -> int:
33+
return len(self.ys)
34+
35+
@property
36+
def N(self) -> int: # noqa: N802
37+
return len(self.coords)
38+
39+
1940
class Solution(SolutionBase):
2041
"""Find largest valid rectangles in a movie theater tile grid.
2142
@@ -30,6 +51,124 @@ class Solution(SolutionBase):
3051
rectangle search is combined with a grid mask to validate candidate areas.
3152
"""
3253

54+
DIRECTIONS: ClassVar[tuple[tuple[int, int], ...]] = ((1, 0), (-1, 0), (0, 1), (0, -1))
55+
56+
def parse_data(self, data: list[str]) -> CompressedTiles:
57+
tiles = [tuple(map(int, line.split(","))) for line in data if line.strip()]
58+
59+
xs = sorted({x for x, _ in tiles})
60+
ys = sorted({y for _, y in tiles})
61+
62+
x_to_idx = {x: i for i, x in enumerate(xs)}
63+
y_to_idx = {y: i for i, y in enumerate(ys)}
64+
65+
coords = [(x_to_idx[x], y_to_idx[y]) for x, y in tiles]
66+
return CompressedTiles(xs=xs, ys=ys, coords=coords)
67+
68+
def calculate_area(self, x1: int, y1: int, x2: int, y2: int) -> int:
69+
return (abs(x2 - x1) + 1) * (abs(y2 - y1) + 1)
70+
71+
def construct_grid(self, height: int, width: int, value: int = 0) -> list[list[int]]:
72+
return [[value] * width for _ in range(height)]
73+
74+
def construct_bool_grid(
75+
self, height: int, width: int, *, value: bool = False
76+
) -> list[list[bool]]:
77+
return [[value] * width for _ in range(height)]
78+
79+
def mark_red_tiles(self, grid: list[list[int]], coords: list[tuple[int, int]]) -> None:
80+
for x_idx, y_idx in coords:
81+
grid[y_idx][x_idx] = 1
82+
83+
def mark_green_boundary(self, grid: list[list[int]], coords: list[tuple[int, int]]) -> None:
84+
N = len(coords) # noqa: N806
85+
for i in range(N):
86+
x1, y1 = coords[i]
87+
x2, y2 = coords[(i + 1) % N]
88+
89+
if x1 == x2:
90+
y_min, y_max = sorted((y1, y2))
91+
for y in range(y_min, y_max + 1):
92+
if grid[y][x1] == 0:
93+
grid[y][x1] = 2
94+
95+
elif y1 == y2:
96+
x_min, x_max = sorted((x1, x2))
97+
for x in range(x_min, x_max + 1):
98+
if grid[y1][x] == 0:
99+
grid[y1][x] = 2
100+
101+
else:
102+
err_msg = "Non axis-aligned segment in input"
103+
raise ValueError(err_msg)
104+
105+
def seed_if_outside_empty(
106+
self,
107+
grid: list[list[int]],
108+
outside: list[list[bool]],
109+
queue: deque[tuple[int, int]],
110+
x: int,
111+
y: int,
112+
) -> None:
113+
if grid[y][x] == 0 and not outside[y][x]:
114+
outside[y][x] = True
115+
queue.append((x, y))
116+
117+
def flood_fill_outside_zeros(self, grid: list[list[int]]) -> list[list[bool]]:
118+
height = len(grid)
119+
width = len(grid[0])
120+
121+
outside = self.construct_bool_grid(height, width, value=False)
122+
queue: deque[tuple[int, int]] = deque()
123+
124+
for x in range(width):
125+
self.seed_if_outside_empty(grid, outside, queue, x, 0)
126+
self.seed_if_outside_empty(grid, outside, queue, x, height - 1)
127+
128+
for y in range(height):
129+
self.seed_if_outside_empty(grid, outside, queue, 0, y)
130+
self.seed_if_outside_empty(grid, outside, queue, width - 1, y)
131+
132+
while queue:
133+
x, y = queue.popleft()
134+
for dx, dy in self.DIRECTIONS:
135+
nx, ny = x + dx, y + dy
136+
if (
137+
nx in range(width)
138+
and ny in range(height)
139+
and not outside[ny][nx]
140+
and grid[ny][nx] == 0
141+
):
142+
outside[ny][nx] = True
143+
queue.append((nx, ny))
144+
145+
return outside
146+
147+
def fill_interior_as_green(self, grid: list[list[int]], outside: list[list[bool]]) -> None:
148+
height = len(grid)
149+
width = len(grid[0])
150+
151+
for y in range(height):
152+
for x in range(width):
153+
if grid[y][x] == 0 and not outside[y][x]:
154+
grid[y][x] = 2
155+
156+
def rectangle_all_non_zero(
157+
self,
158+
grid: list[list[int]],
159+
x_left: int,
160+
x_right: int,
161+
y_top: int,
162+
y_bottom: int,
163+
) -> bool:
164+
for y in range(y_top, y_bottom + 1):
165+
row = grid[y]
166+
for x in range(x_left, x_right + 1):
167+
if row[x] == 0:
168+
return False
169+
170+
return True
171+
33172
def part1(self, data: list[str]) -> int:
34173
r"""Find largest rectangle area using two red tiles as opposite corners.
35174
@@ -45,41 +184,24 @@ def part1(self, data: list[str]) -> int:
45184
-------
46185
int: Largest rectangle area using two red tiles as opposite corners
47186
"""
48-
tiles = [tuple(map(int, line.split(","))) for line in data]
187+
tiles = self.parse_data(data)
49188

50-
if len(tiles) < 2:
189+
if tiles.N < 2:
51190
return 0
52191

53-
# Coordinate compression (space distortion)
54-
xs = sorted({x for x, _ in tiles})
55-
ys = sorted({y for _, y in tiles})
56-
57-
x_to_idx = {x: i for i, x in enumerate(xs)}
58-
y_to_idx = {y: i for i, y in enumerate(ys)}
59-
60-
compressed_tiles: list[tuple[int, int]] = [(x_to_idx[x], y_to_idx[y]) for x, y in tiles]
61-
62192
max_area = 0
63-
n = len(compressed_tiles)
193+
N = tiles.N # noqa: N806
64194

65-
for i in range(n):
66-
x1_idx, y1_idx = compressed_tiles[i]
67-
for j in range(i + 1, n):
68-
x2_idx, y2_idx = compressed_tiles[j]
69-
70-
# Opposite corners must differ in both x and y to form area
195+
for i in range(N):
196+
x1_idx, y1_idx = tiles.coords[i]
197+
for j in range(i + 1, N):
198+
x2_idx, y2_idx = tiles.coords[j]
71199
if x1_idx == x2_idx or y1_idx == y2_idx:
72200
continue
73201

74-
# Get original coordinates
75-
x1 = xs[x1_idx]
76-
y1 = ys[y1_idx]
77-
x2 = xs[x2_idx]
78-
y2 = ys[y2_idx]
79-
80-
width = abs(x2 - x1) + 1
81-
height = abs(y2 - y1) + 1
82-
area = width * height
202+
x1, y1 = tiles.xs[x1_idx], tiles.ys[y1_idx]
203+
x2, y2 = tiles.xs[x2_idx], tiles.ys[y2_idx]
204+
area = self.calculate_area(x1, y1, x2, y2)
83205

84206
if area > max_area:
85207
max_area = area
@@ -105,111 +227,38 @@ def part2(self, data: list[str]) -> int:
105227
int: Largest rectangle area that uses red tiles as opposite corners
106228
and includes only red or green tiles inside
107229
"""
108-
tiles = [tuple(map(int, line.split(","))) for line in data if line.strip()]
230+
tiles = self.parse_data(data)
109231

110-
if len(tiles) < 2:
232+
if tiles.N < 2:
111233
return 0
112234

113-
# Coordinate compression
114-
xs = sorted({x for x, _ in tiles})
115-
ys = sorted({y for _, y in tiles})
116-
x_to_idx = {x: i for i, x in enumerate(xs)}
117-
y_to_idx = {y: i for i, y in enumerate(ys)}
118-
compressed = [(x_to_idx[x], y_to_idx[y]) for x, y in tiles]
235+
grid = self.construct_grid(tiles.height, tiles.width, 0)
236+
self.mark_red_tiles(grid, tiles.coords)
237+
self.mark_green_boundary(grid, tiles.coords)
119238

120-
w, h = len(xs), len(ys)
121-
# 0 = empty, 1 = red, 2 = green
122-
grid = [[0] * w for _ in range(h)]
239+
outside = self.flood_fill_outside_zeros(grid)
240+
self.fill_interior_as_green(grid, outside)
123241

124-
# Mark red tiles
125-
for cx, cy in compressed:
126-
grid[cy][cx] = 1
127-
128-
# Draw green boundary segments between consecutive reds (wrap around)
129-
n = len(compressed)
130-
for i in range(n):
131-
x1, y1 = compressed[i]
132-
x2, y2 = compressed[(i + 1) % n]
133-
if x1 == x2:
134-
ys_min, ys_max = sorted((y1, y2))
135-
for y in range(ys_min, ys_max + 1):
136-
if grid[y][x1] == 0:
137-
grid[y][x1] = 2
138-
elif y1 == y2:
139-
xs_min, xs_max = sorted((x1, x2))
140-
for x in range(xs_min, xs_max + 1):
141-
if grid[y1][x] == 0:
142-
grid[y1][x] = 2
143-
else:
144-
err_msg = "Non axis-aligned segment in input"
145-
raise ValueError(err_msg)
146-
147-
# Flood-fill outside empty cells
148-
outside = [[False] * w for _ in range(h)]
149-
q: deque[tuple[int, int]] = deque()
150-
151-
# Seed flood fill from outer boundary
152-
for x in range(w):
153-
if grid[0][x] == 0:
154-
outside[0][x] = True
155-
q.append((x, 0))
156-
if grid[h - 1][x] == 0:
157-
outside[h - 1][x] = True
158-
q.append((x, h - 1))
159-
160-
for y in range(h):
161-
if grid[y][0] == 0:
162-
outside[y][0] = True
163-
q.append((0, y))
164-
if grid[y][w - 1] == 0:
165-
outside[y][w - 1] = True
166-
q.append((w - 1, y))
167-
168-
while q:
169-
x, y = q.popleft()
170-
for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1)):
171-
nx, ny = x + dx, y + dy
172-
if 0 <= nx < w and 0 <= ny < h and not outside[ny][nx] and grid[ny][nx] == 0:
173-
outside[ny][nx] = True
174-
q.append((nx, ny))
175-
176-
# Interior empty cells become green
177-
for y in range(h):
178-
for x in range(w):
179-
if grid[y][x] == 0 and not outside[y][x]:
180-
grid[y][x] = 2
181-
182-
# Search for largest valid rectangle (only red/green inside)
183242
max_area = 0
184-
n = len(compressed)
243+
N = tiles.N # noqa: N806
185244

186-
for i in range(n):
187-
x1i, y1i = compressed[i]
188-
for j in range(i + 1, n):
189-
x2i, y2i = compressed[j]
190-
if x1i == x2i or y1i == y2i:
245+
for i in range(N):
246+
x1_idx, y1_idx = tiles.coords[i]
247+
for j in range(i + 1, N):
248+
x2_idx, y2_idx = tiles.coords[j]
249+
if x1_idx == x2_idx or y1_idx == y2_idx:
191250
continue
192251

193-
xl, xr = sorted((x1i, x2i))
194-
yt, yb = sorted((y1i, y2i))
195-
196-
# Check rectangle only contains red/green
197-
ok = True
198-
for yy in range(yt, yb + 1):
199-
row = grid[yy]
200-
for xx in range(xl, xr + 1):
201-
if row[xx] == 0:
202-
ok = False
203-
break
204-
if not ok:
205-
break
206-
207-
if not ok:
252+
x_left, x_right = sorted((x1_idx, x2_idx))
253+
y_top, y_bottom = sorted((y1_idx, y2_idx))
254+
255+
if not self.rectangle_all_non_zero(grid, x_left, x_right, y_top, y_bottom):
208256
continue
209257

210-
x1, y1 = xs[x1i], ys[y1i]
211-
x2, y2 = xs[x2i], ys[y2i]
212-
area = (abs(x2 - x1) + 1) * (abs(y2 - y1) + 1)
258+
x1, y1 = tiles.xs[x1_idx], tiles.ys[y1_idx]
259+
x2, y2 = tiles.xs[x2_idx], tiles.ys[y2_idx]
260+
area = self.calculate_area(x1, y1, x2, y2)
261+
213262
if area > max_area:
214263
max_area = area
215264

0 commit comments

Comments
 (0)