Skip to Content

Advent of Code 2024 - Day 12, in Kotlin - Garden Groups

Kotlin solutions to parts 1 and 2 of Advent of Code 2024, Day 12: 'Garden Groups'

Posted on

So far, Advent of Code 2024 has been really enjoyable for me. This puzzle lets us write another Breadth-First Search, and we do some fun checks to find patterns in our garden.

If you’d rather just view the code, my GitHub Repository is here.

Puzzle Input

We will take today’s input as a List<String> and unlike previous days, we’ll call it garden to make the code below look better. We’ll declare this as a private val local to the class, rather than parse it any further here.

class Day12(private val garden: List<String>) {

}

⭐ Day 12, Part 1

The puzzle text can be found here.

Once again we need a couple of extension functions to make working with our garden easier, and once again I am going to declare them private to the Day12 class rather than put them in a general extensions file.

First up, we’ll implement the contains operator so we can say things like point in garden or point !in garden to test if a point is in the garden.

// In Day12

private operator fun List<String>.contains(at: Point2D): Boolean =
    at.y in indices && at.x in get(at.y).indices

Next, we’ll implement the get operator which unlike previous days returns a nullable Char?. This is because we’re doing a lot of comparisons like “is this char the same as that other char, or null?” and returning null makes these tests look better.

// In Day12

private operator fun List<String>.get(at: Point2D): Char? =
    if (at in this) this[at.y][at.x] else null

We also need a data class to store data about each Region. We absolutely need the area and the perimeter. You can leave off the region’s name if you want, I found it helpful for debugging but it isn’t used for any of the calculations.

// In Day12

private data class Region(
	val name: Char, val area: Int, val perimeter: Int
)

Next we need to know, given a start point, how to find the entire region that Point2D is part of. We also want to make sure that we keep track of the places we’ve seen, so we’ll also take in a MutableSet<Point2D>. This will be used to track the set of points that make up regions we’ve already evaluated. Another way to do this would have been to null out places in the garden, but this seemed more clear to me and kept our garden immutable.

We will call our region finding function findRegion and it will be implemented as a Breadth-First Search.

// In Day12

private fun findRegion(start: Point2D, seen: MutableSet<Point2D>): Region {
    val target: Char = garden[start]!!
    val queue = mutableListOf(start)
    var area = 0
    var perimeter = 0

    while (queue.isNotEmpty()) {
        val place = queue.removeFirst()
        if (garden[place] == target && place !in seen) {
            seen += place
            area++
            val neighbors = place.cardinalNeighbors()
            queue.addAll(neighbors)
            perimeter += neighbors.count { garden[it] != target }
        }
    }
    return Region(target, area, perimeter)
}

The first thing we want to do is get the target, or the letter of the region at the start point. We also need a work queue of places to examine, which we will seed with the start point. We’ll declare vars for both total area and total perimeter.

While we have work to do (the queue.isNotEmpty), we will remove the first place from the queue. If the place in the garden is the same as our target, and we haven’t seen it yet, we will consider this new place part of the region we’re working on.

In that case, we’ve now seen this place, and we can consider it part of our area, which we increment by 1. Next, we find all of the cardinalNeighbors() and add them to the work queue. We could filter out places outside the garden here but since we account for that when pulling places off of the work queue as part of the target equality test, it isn’t really required here. Next we count how many of the neighbors do not match the target (either because they are a different region or they are outside of the garden) and add that number to our perimeter count.

Finally, we return the Region with its area total and perimeter total.

Now we need to go through the garden and find all of the regions, which we will do with findRegions.

// In Day12

private fun findRegions(): List<Region> {
    val seen = mutableSetOf<Point2D>()
    return garden.flatMapIndexed { y, row ->
        row.mapIndexedNotNull { x, _ ->
            val place = Point2D(x, y)
            if (place !in seen) findRegion(place, seen)
            else null
        }
    }
}

Because we don’t want to find the same region more than once, we need to keep track of all the places that we’ve seen already. As we saw above when we implemented findRegion, we have a single seen set that we share between invocations that findRegion uses to say “I’m claiming this place for a region”. We’ll declare that set here.

Next, we map/index through all of the x/y spots in the garden and if we haven’t seen them yet, we know we’re looking at a new region. We’ll create a Point2D and use it to call findRegion. If we have seen the place, we can return null from this inner loop, indicating this place is already part of some other region we’ve found.

Because the inner loop uses mapIndexedNotNull, we can safely return null there, which means findRegions can safely return a List<Region>, which are all non-null.

Calling findRegions and taking the sumOf the products of area and perimeter gives us the answer to part 1.

// In Day12

fun solvePart1(): Int =
    findRegions().sumOf { it.area * it.perimeter }

Star earned! Onward!

⭐ Day 12, Part 2

The puzzle text can be found here.

We’re going to have to do a bit of refactoring to track the number of sides in each Region. First, we need to add a sides variable to our Region (highlighted below).

// In Day12

private data class Region(
	val name: Char, 
	val area: Int, 
	val perimeter: Int, 
	val sides: Int
)

How are we going to calculate the number of sides in a given region? Initially I had started off with something to find all of the edge places and count up the number of unique X and Y coordinates (depending on the orientation of the side), but that seemed really confusing when I coded it up. While drawing pictures of regions to get some insight, it occurred to me that ion order to count the number of sides one of our regions has, we can also count the number of corners it has. They’ll get us the same answer. Go ahead and try to draw a shape with 90 degree angles like today’s regions and not have sides equal corners.

This means we need a function that for every Point2D in a region, we can answer the question “how many corners does this represent?”.

We will do this by adding a countCorners() extension on Point2D, private to today’s solution class. Now to find a corner, we probably want to know what we’re looking for. Let’s look at a picture of an inner corner and an outer corner.


  Outer 

  BBBBBB
  BBaAAA
  BBAAAA
  BBAAAA

I’ve lowercased one of the A’s to make the example more clear, pretend it is uppercase. If we examine that “a”, we can see it is an outer corner. We can identify it because the spot immediately to the North (up) of it is from a different region, as is the spot immediately to the West (left) of it. The North and West spots may be in different regions from one another, the important part is they aren’t in the same region as “a”.


  Inner

  BBBBBB
  BBAAAA
  BAaAAA
  BAAAAA 

Same deal as above, I’ve lowercased an A to make the example easier to follow. A spot can be an inner corner because both the North (up) and West (left) spots in are the same region but the NorthWest (up + left) spots is in a different region.

This is all well and good for North and West, but we need to try every adjacent combination: North & East, East & South, South & West, West & North for both methods. If we can code this, we can identify corners, which allows us to count the number of sides for reach region.

// In Day12

private fun Point2D.countCorners(): Int =
    listOf(Point2D.NORTH, Point2D.EAST, Point2D.SOUTH, Point2D.WEST, Point2D.NORTH)
        .zipWithNext()
        .map { (first, second) ->
            listOf(
                garden[this],
                garden[this + first],
                garden[this + second],
                garden[this + first + second]
            )
        }.count { (target, side1, side2, corner) ->
            (target != side1 && target != side2) || 
            (side1 == target && side2 == target && corner != target)
        }

The first thing countCorners does is set up a list of offset points. You’ll notice NORTH is in there twice, this is because we use zipWithNext to pair them up and we want a loop. Leaving off that last NORTH wouldn’t give us the WEST to NORTH relationship we want.

Next, we destructure the Pairs created by zipWithNext into first and second to make the code easier to read and map those into the values at each of the points we care about: the point we’re looking at, the point in one direction (say, North), the point in the adjacent direction (say, East), and the point in between (say, NorthEast). We can do this because our Point2D class implements the addition operator.

Finally, we count how many of these relationship pairs meet the criteria in the diagrams above. We’ll also deconstruct the list into target, side1, side2, and corner to make the code easier to read.

Now we can do some refactoring to findRegion to track corners (which I have highlighted below).

// In Day12 

private fun findRegion(start: Point2D, seen: MutableSet<Point2D>): Region {
    val target: Char = garden[start]!!
    val queue = mutableListOf(start)
    var area = 0
    var perimeter = 0
    var corners = 0

    while (queue.isNotEmpty()) {
        val place = queue.removeFirst()
        if (garden[place] == target && place !in seen) {
            seen += place
            area++
            val neighbors = place.cardinalNeighbors()
            queue.addAll(neighbors)
            perimeter += neighbors.count { garden[it] != target }
            corners += place.countCorners()
        }
    }
    return Region(target, area, perimeter, corners)
}

We will keep track of the number of corners a region has and increment that number for every new place in the region. Don’t forget to add it to the Region that gets returned.

Calling findRegions and taking the sumOf the products of area and side gives us the answer to part 2.

// In Day12

fun solvePart2(): Int =
    findRegions().sumOf { it.area * it.sides }

Star earned! See you tomorrow!

Further Reading

  1. Index of All Solutions - All posts and solutions for 2024, in Kotlin.
  2. My Github repo - Solutions and tests for each day.
  3. Solution - Full code for day 12
  4. Advent of Code - Come join in and do these challenges yourself!