Skip to Content

Advent of Code 2020 - Day 17, in Kotlin - Conway Cubes

Kotlin solutions to parts 1 and 2 of Advent of Code 2020, Day 17: 'Conway Cubes'

Posted on

Today we’ll implement a variant of Conway’s Game of Life

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

Problem Input

Unlike yesterday , there is nothing complicated about today’s input, so we’ll read it in as a List<String>.

class Day17(private val input: List<String>) {

}

⭐ Day 17, Part 1

The puzzle text can be found here.

I’m about to spoil Part 2, so if you aren’t ready for that you might want to leave now.

Part 2 has the same input and end conditions as Part 1, except it uses a four-dimensional space instead of a three-dimensional space. Instead of writing Part 1 as if we didn’t know that, and then going off and refactoring things for Part 2, we’ll just start writing a solution that will work for both parts.

The first thing we’ll need is a class to represent our three-dimensional point, called Point3D:

// In Movement.kt

interface Point {
    val neighbors: List<Point>
}

data class Point3D(val x: Int, val y: Int, val z: Int) : Point {
    override val neighbors: List<Point3D> by lazy {
        (x - 1..x + 1).flatMap { dx ->
            (y - 1..y + 1).flatMap { dy ->
                (z - 1..z + 1).mapNotNull { dz ->
                    Point3D(dx, dy, dz).takeUnless { it == this }
                }
            }
        }
    }
}

As you can see, we are implementing a Point interface. This will come in handy when we have to define a Point4D and expect it to return its neighbors as well.

The only functionality in our Point3D class is to enumerate its neighbors. There are a couple of things to look for here. First, we’ll use the lazy delegate to define our property. Why? Well, if we don’t, we’ll have a property called neighbors that gets calculated when we create a Point3D. This will set up a stack overflow situation because if we define, say Point3D(0,0,0), we will need to eagerly define all of its neighbors, and so on. We could override get() on our property, but the lazy delegate will cache the results we create here, hopefully saving us a bit of time later on.

If you want to learn more about the difference between properties, overriding getters, and lazy, please see my post on them!

Now that we have a Point3D, we need a way to turn our input into a useful format. We’ll put everything in a Map<Point, Boolean> where Point is some point in space (a Point3D now, but a Point4D in part 2), and the Boolean is whether that point is active or not.

// In Day17

private fun parseInput(input: List<String>, pointFunction: (Int, Int) -> Point): Map<Point, Boolean> =
    input.flatMapIndexed { x, row ->
        row.mapIndexed { y, point ->
            pointFunction(x, y) to (point == '#')
        }
    }.toMap()

In order to avoid having two parseInput functions, we will pass a pointFunction to this function. The job of pointFunction is to create a Point object in the desired dimension, just from x and y. To use this, we’ll loop through our input and create a bunch of Pair<Point,Boolean>, and call toMap() on the resulting list. Note that we have to take care to set the map values properly here.

Next up is a function to perform one cycle on our Map<Point,Boolean>:

// In Day17

private fun Map<Point, Boolean>.nextCycle(): Map<Point, Boolean> {
    val nextMap = this.toMutableMap()
    keys.forEach { point ->
        point.neighbors.forEach { neighbor ->
            nextMap.putIfAbsent(neighbor, false)
        }
    }
    nextMap.entries.forEach { (point, active) ->
        val activeNeighbors = point.neighbors.count { this.getOrDefault(it, false) }
        nextMap[point] = when {
            active && activeNeighbors in setOf(2, 3) -> true
            !active && activeNeighbors == 3 -> true
            else -> false
        }
    }
    return nextMap
}

We implement our nextCycle() function as an extension function. First, we’ll create the new map we’ll end up populating. We’ll make it a MutableMap but return it as a Map so we can modify it internally but our callers can’t.

The very first thing to do (and this is what I did wrong for a good amount of time) is to go through and make sure that all possible neighbors are in the new map. This is because some of them can become populated because of our rules, and we need them because we want to enumerate them in the next step!

Next, we go through every point in our map and figure out what its new state should be. We do this by getting the neighbors for our point and counting how many of them are active in the map from the past cycle. We use that number to figure out what the new state (active = true, inactive = false) our point should have.

Next, we need a function to solve our puzzle, given a number of rounds (which turns out to always be 6), and a function to create a point, that we can pass to parseInput:

// In Day17

private fun solve(rounds: Int = 6, pointFunction: (Int, Int) -> Point): Int {
    var conwayGrid = parseInput(input, pointFunction)
    repeat(rounds) {
        conwayGrid = conwayGrid.nextCycle()
    }
    return conwayGrid.count { it.value }
}

And to solve part 1, we call this solve function, telling it how to create Point3D objects:

// In Day17

fun solvePart1() =
    solve { x, y ->
        Point3D(x, y, 0)
    }

One nice feature about Kotlin is that if you have a function that takes a lambda as its final argument, you can leave off the parens. That makes solve in this case look like a keyword, rather than a function. I think that makes situations like this look a lot cleaner.

Star earned! Onward!

⭐ Day 17, Part 2

The puzzle text can be found here.

Because we did so much work for Part 1, Part 2 is fairly straight forward: Define a four-dimensional Point, and tell our solve function how to create one.

We’ll start with the four-dimensional Point:

// In Movement.kt

data class Point4D(val x: Int, val y: Int, val z: Int, val w: Int) : Point {

    override val neighbors: List<Point4D> by lazy {
        (x - 1..x + 1).flatMap { dx ->
            (y - 1..y + 1).flatMap { dy ->
                (z - 1..z + 1).flatMap { dz ->
                    (w - 1..w + 1).mapNotNull { dw ->
                        Point4D(dx, dy, dz, dw).takeUnless { it == this }
                    }
                }
            }
        }
    }
}

The Point4D class looks a lot like the Point3D class except with one more dimension (w) to handle. It bothers me that we’re manually fiddling around with FOUR dimensions in our neighbors property. I might spend time and write a VariablePoint which takes some variable number of arguments and knows how to create all of its possible neighbors. But for a puzzle solution, this is probably fine as-is.

Now that we have that, we just need to tell our solve function how to create one in a lambda:

// In Day17

fun solvePart2(): Int =
    solve { x, y ->
        Point4D(x, y, 0, 0)
    }

Star earned! See you tomorrow!

Further Reading

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