Josiah Winslow solves Advent of Code

Garden Groups

Published: 2026-06-02 Original Prompt

Part 1

Another day, another grid puzzle. In fact, this is our fifth (!) grid puzzle this year; please revisit my solutions to Day 4, Day 6, Day 8, and Day 10 if you want a refresher on how I handle grids.

Once we have our grid, though, we’ll want to be able to iterate through all of the interconnected regions of flowers. Something like the flood fill algorithm should come to mind; in effect, we can gather the points of a region by “flood filling” from an arbitrary point, and we can do this for every point to get every region.

2024\day12\solution.py
from collections.abc import Iterator
def iter_regions(grid: Grid[str]) -> Iterator[set[GridPoint]]:
seen: set[GridPoint] = set()
for point in grid:
if point in seen:
continue
region: set[GridPoint] = set()
# Regions will be determined using flood fill
queue = [point]
while queue:
current = queue.pop()
if current in region:
continue
region.add(current)
queue.extend(
n for n in neighbors(current, num_directions=4)
if grid.get(n) == grid[current]
)
seen |= region
yield region

The implementation above is mostly straightforward, except we have to do a bit of bookkeeping to avoid “flood filling” from any point twice.1 The seen set will contain every point seen by a flood fill, and the |= operator is used to add each region’s points to that seen set as they’re found.

Now for each region, we need to calculate a price based on its perimeter and area. The area is easy; that’s just the length of the set of points making up the region. But how do we calculate the perimeter? Let’s look in detail at the sample input:

AAAA
BBCD
BBCC
EEEC
+-+-+-+-+
|A A A A|
+-+-+-+-+
|B B|C|D|
+ + +-+
|B B|C C|
+-+-+-+ |
|E E E|C|
+-+-+-+-+

That one-tile D region is the simplest to calculate the perimeter of: it has 4 sides, none of which border a matching tile, so its perimeter is 4. In fact, every single individual tile technically has a perimeter of 4… except we only want to count the external borders of each tile, and not the internal borders between matching neighbor tiles. For instance, in the long horizontal E region:

So to calculate a region’s perimeter, we can loop through all its points, add 4 for that point, and subtract the number of matching neighbors of that point. This leaves us with a count of external borders — exactly the perimeter we wanted — which we can use to calculate our final answer.

2024\day12\solution.py
...
class Solution(StrSplitSolution):
def part_1(self) -> int:
grid = parse_grid(self.input)
price = 0
for region in iter_regions(grid):
perimeter = 0
for point in region:
# Count all non-matching neighbors of this point
num_matching_neighbors = len(
[
n for n in neighbors(point, num_directions=4)
if grid.get(n) == grid[point]
]
)
perimeter += 4 - num_matching_neighbors
price += perimeter * len(region)
return price

Before we move on to Part 2, however, I’d like to point out that we’ve got a bit of repeated logic: both the iter_regions function and our perimeter calculation depend on checking the matching neighbors of a point in the grid. If we want to avoid repeating ourselves,2 we can factor out this logic into a matching_neighbors function like so:

2024\day12\solution.py
def matching_neighbors(
grid: Grid[str],
point: GridPoint,
) -> Iterator[GridPoint]:
for n in neighbors(point, num_directions=4):
if grid.get(n) == grid[point]:
yield n
def iter_regions(grid: Grid[str]) -> Iterator[set[GridPoint]]:
...
for point in grid:
...
while queue:
...
queue.extend(
n for n in neighbors(current, num_directions=4)
if grid.get(n) == grid[current]
)
queue.extend(matching_neighbors(grid, current))
...
class Solution(StrSplitSolution):
def part_1(self) -> int:
...
for region in iter_regions(grid):
...
for point in region:
...
# Count all non-matching neighbors of this point
num_matching_neighbors = len(
[
n for n in neighbors(point, num_directions=4)
if grid.get(n) == grid[point]
]
)
perimeter += 4 - num_matching_neighbors
perimeter += 4 - len(list(matching_neighbors(grid, point)))
...
...

I’d say this abstraction is a good idea here; it makes the relevant portions more readable.

Part 2

We were a bit lucky that we could calculate each region’s perimeter one tile at a time; as far as I know, we can’t count the number of sides this way (without some rather complex bookkeeping). But luckily, any polygon has exactly as many sides as corners, and we can count the number of corners one tile at a time. So let’s turn this into a unified solve function, and mentally prepare to count each region’s corners.

2024\day12\solution.py
...
class Solution(StrSplitSolution):
def solve(self) -> tuple[int, int]:
...
perimeter_price, side_price = 0, 0
for region in iter_regions(grid):
# NOTE Each region has exactly as many sides as corners.
# Thus, we can simply count the corners to count the sides.
perimeter, num_corners = 0, 0
for point in region:
# Count all non-matching neighbors of this point
perimeter += 4 - len(list(matching_neighbors(grid, point)))
# TODO Add count-each-corner code
num_corners += 0
perimeter_price += perimeter * len(region)
side_price += num_corners * len(region)
return perimeter_price, side_price

Just like before, the absolute simplest case for counting corners is a region with only a single tile; obviously, it has 4 corners. Extending that to a general corner-counting strategy is tricky, but similarly to Part 1, it will involve looking at the tiles around each individual tile’s corners and checking which corners are external to the region.3 Let’s see what corner-counting criteria we can come up with.

In my estimation, there are two types of corners we could be dealing with, which I will call “outer corners” and “inner corners”. To show you what I mean, here’s an ASCII-art illustration of both kinds of corner.

outer inner
+-+
X B B#A|
##+ +## +
B#A| |A A|
+-+ +-+-+

Here, we’re looking at the top-left corner of the A tile on the bottom-right. And here’s what I want you to notice:

In fact, the contents of the three tiles surrounding each corner are enough to determine whether a corner is one of these two types — and thus, whether it is a corner of the region. This way of categorizing corners does seem to make sense when looking at all 8 possible cases of what those tiles could be (where A is inside the region and B is outside):

is an outer corner
+-+
B B |A|B
##+ +-##+
B#A| B#A|
+-+ +-+
is an inner corner
+-+
B#A|
+## +
|A A|
+-+-+
is NOT a corner
+-+-+ +-+ +-+-+ +-+
|A A| B B B|A| |A A| |A|B
+ # + +-#-+ # + +-# + + #-+
|A A| |A A| B|A| B|A| |A A|
+-+-+ +-+-+ +-+ +-+ +-+-+

Attention

Those last two “not a corner” cases sure look like they’re corners… and if I were manually counting corners as a human, I would agree.

But remember, we’re looking at the top-left corner of the bottom-right A tile; those two cases would count as inner corners in relation to some different tile, and we don’t want to count the same point as a corner more than once! This is subtle, but important to the correctness of our counting.

Now all we have to do is turn our corner criteria into code! To get the three tiles surrounding each potential corner, I’m using a convenience function from my grids module called offsets; from there, it’s a matter of translating our corner cases (no pun intended) into conditions, and incrementing our corner count if one of those conditions is true.

2024\day12\solution.py
...
class Solution(StrSplitSolution):
def solve(self) -> tuple[int, int]:
...
for region in iter_regions(grid):
...
for point in region:
...
row, col = point
# For each possible offset of a corner
for dr, dc in offsets(num_directions=4, diagonals=True):
has_row_neighbor = (row + dr, col) in region
has_col_neighbor = (row, col + dc) in region
has_diagonal_neighbor = (row + dr, col + dc) in region
# Is this an outer corner?
if not has_row_neighbor and not has_col_neighbor:
num_corners += 1
# Is this an inner corner?
if (
has_row_neighbor and has_col_neighbor
and not has_diagonal_neighbor
):
num_corners += 1
...
...

I don’t think it would’ve been this easy to count each region’s sides directly. This just goes to show that it sometimes pays to ask a different question with the same answer, because it may make the answering process easier!

Footnotes

  1. If memory usage were more of a concern, we could avoid revisiting any points of a region by removing those points from the grid when we find them. But our memory usage isn’t really that bad for our purposes, and there’s otherwise not much benefit to this destructive bookkeeping.

  2. The commonly-promoted “DRY” principle (Don’t Repeat Yourself) would tell us that it would be a good idea to factor out repeated logic. This is in contrast to the creatively-named “WET” principle (Write Everything Twice). But in my opinion, the best approach is somewhere in between those two extremes… in other words, the “DAMP” principle (Don’t Abstract Methods Prematurely).

  3. When I say this, I’m not referring to internal vs. external angles. By “external” corners, I mean tile corners that are properly counted as corners of the region, because they’re on the outside; this is analogous to how I counted “external” borders in Part 1 as tile borders on the outside of their region.