Skip to Content

Advent of Code 2018 - Day 18, in Kotlin

Kotlin solutions to parts 1 and 2 of Advent of Code 2018, Day 18: 'Settlers of The North Pole'

Posted on

Pretty much everything we’ll do today is an extension function on Array<CharArray>. I love extension functions but try not to overuse them when I could just as easily actually extend a class. We don’t have that luxury today so we’ll lean on Kotlin’s extension functions in order to make our code look like these functions always existed and came in the standard library, even though they are very specific to our work here and don’t have more general use cases.

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

Problem Input

Our input is file with a picture of the forest. We will use our standard approach to this - pass it into our class as a List<String> and convert it to an Array<CharArray>. Since there are only three values we could use a ByteArray, or even a Boolean? but we already have some nice extensions from days past on Array<CharArray>, so we’ll stick with that.

Speaking of extensions - I’ve moved the three from yesterday into our Extensions.kt file because we’ll use them again today.

typealias Forest = Array<CharArray>

class Day18(rawInput: List<String>) {

    private val forest: Forest = rawInput.map { it.toCharArray() }.toTypedArray()

}

We will also provide the Forest typealias for Array<CharArray> to make things read easier later.

Day 18, Part 1

On the outskirts of the North Pole base construction project, many Elves are collecting lumber.

The lumber collection area is 50 acres by 50 acres; each acre can be either open ground (.), trees (|), or a lumberyard (#). You take a scan of the area (your puzzle input).

Strange magic is at work here: each minute, the landscape looks entirely different. In exactly one minute, an open acre can fill with trees, a wooded acre can be converted to a lumberyard, or a lumberyard can be cleared to open ground (the lumber having been sent to other projects).

The change to each acre is based entirely on the contents of that acre as well as the number of open, wooded, or lumberyard acres adjacent to it at the start of each minute. Here, “adjacent” means any of the eight acres surrounding that acre. (Acres on the edges of the lumber collection area might have fewer than eight adjacent acres; the missing acres aren’t counted.)

In particular:

An open acre will become filled with trees if three or more adjacent acres contained trees. Otherwise, nothing happens. An acre filled with trees will become a lumberyard if three or more adjacent acres were lumberyards. Otherwise, nothing happens. An acre containing a lumberyard will remain a lumberyard if it was adjacent to at least one other lumberyard and at least one acre containing trees. Otherwise, it becomes open. These changes happen across all acres simultaneously, each of them using the state of all acres at the beginning of the minute and changing to their new form by the end of that same minute. Changes that happen during the minute don’t affect each other.

For example, suppose the lumber collection area is instead only 10 by 10 acres with this initial configuration:

Initial state:

.#.#...|#.
.....#|##|
.|..|...#.
..|#.....#
#.#|||#|#|
...#.||...
.|....|...
||...#|.#|
|.||||..|.
...#.|..|.

After 1 minute:

.......##.
......|###
.|..|...#.
..|#||...#
..##||.|#|
...#||||..
||...|||..
|||||.||.|
||||||||||
....||..|.

After 2 minutes:

.......#..
......|#..
.|.|||....
..##|||..#
..###|||#|
...#|||||.
|||||||||.
||||||||||
||||||||||
.|||||||||

After 3 minutes:

.......#..
....|||#..
.|.||||...
..###|||.#
...##|||#|
.||##|||||
||||||||||
||||||||||
||||||||||
||||||||||

After 4 minutes:

.....|.#..
...||||#..
.|.#||||..
..###||||#
...###||#|
|||##|||||
||||||||||
||||||||||
||||||||||
||||||||||

After 5 minutes:

....|||#..
...||||#..
.|.##||||.
..####|||#
.|.###||#|
|||###||||
||||||||||
||||||||||
||||||||||
||||||||||

After 6 minutes:

...||||#..
...||||#..
.|.###|||.
..#.##|||#
|||#.##|#|
|||###||||
||||#|||||
||||||||||
||||||||||
||||||||||

After 7 minutes:

...||||#..
..||#|##..
.|.####||.
||#..##||#
||##.##|#|
|||####|||
|||###||||
||||||||||
||||||||||
||||||||||

After 8 minutes:

..||||##..
..|#####..
|||#####|.
||#...##|#
||##..###|
||##.###||
|||####|||
||||#|||||
||||||||||
||||||||||

After 9 minutes:

..||###...
.||#####..
||##...##.
||#....###
|##....##|
||##..###|
||######||
|||###||||
||||||||||
||||||||||

After 10 minutes:

.||##.....
||###.....
||##......
|##.....##
|##.....##
|##....##|
||##.####|
||#####|||
||||#|||||
||||||||||

After 10 minutes, there are 37 wooded acres and 31 lumberyards. Multiplying the number of wooded acres by the number of lumberyards gives the total resource value after ten minutes: 37 * 31 = 1147.

What will the total resource value of the lumber collection area be after 10 minutes?

This is another puzzle that mimics Conways’s Game of Life. I enjoy puzzles like this because I think it is really interesting how simple rules can lead to complex behaviors. Let’s get to it.

Support Code

Let’s make life easier by defining some constants for ourselves:

// In Day18 companion

companion object {
    private const val OPEN = '.'
    private const val LUMBERYARD = '#'
    private const val TREE = '|'
}

And since we need all of our neighboring cells, not just the cardinal direction neighbors, we’ll add another function to Point to do this for us.

// In Point

fun neighbors(allowNegative: Boolean = true): List<Point> =
    // Note: Generate in reading order!
    listOf(
        Point(x - 1, y - 1),
        Point(x, y - 1),
        Point(x + 1, y - 1),
        Point(x - 1, y),
        Point(x + 1, y),
        Point(x - 1, y + 1),
        Point(x, y + 1),
        Point(x + 1, y + 1)
    ).filter { allowNegative || it.x >= 0 && it.y >= 0 }

Generating Trees

In order to generate a new spot, we have to examine all of its neighbors. Luckily we have a function to get all of the neighbors on Point now. We can use that to write a function to determine what the next generation should be for a single spot, given its neighbors. The first thing we’ll need is a function to count what types of neighbors we have. We’ll write this as an extension on Forest (which is really Array<CharArray>):

// In Day18

private fun Forest.countNeighbors(at: Point): Map<Char, Int> =
    at.neighbors(false)
        .filterNot { it.x >= this[0].size }
        .filterNot { it.y >= this.size }
        .map { this[it] }
        .groupingBy { it }
        .eachCount()

This function also eliminates Points that are larger than we want. I could have delegated that to the neighbors() function but I decided not to support ranges there, to make it look cleaner. We’re making use of Kotlin’s groupingBy and eachCount built-ins, which is really nice to make Maps of things, where the value is a count. We now have enough information to figure out what the next generation of each spot will be:

// In Day18

private fun Forest.nextGenerationSpot(at: Point): Char {
    val neighborMap = this.countNeighbors(at)
    return when (val space = this[at]) {
        OPEN -> if (neighborMap.getOrDefault(TREE, 0) >= 3) TREE else OPEN
        TREE -> if (neighborMap.getOrDefault(LUMBERYARD, 0) >= 3) LUMBERYARD else TREE
        LUMBERYARD -> if (neighborMap.getOrDefault(LUMBERYARD, 0) >= 1 &&
            neighborMap.getOrDefault(TREE, 0) >= 1) LUMBERYARD else OPEN
        else -> space
    }
}

Once we can mutate a given spot from generation to generation, we’re able to mutate the entire Forest, again with an extension function:

// In Day18

private fun Forest.nextGeneration(): Array<CharArray> =
    this.mapIndexed { y, row ->
        row.mapIndexed { x, _ -> this.nextGenerationSpot(Point(x, y)) }.toCharArray()
    }.toTypedArray()

We’re looping through both arrays, calling our nextGenerationSpot on each x/y pair, and returning a new Forest at the end, rather than mutate the Forest in-place. This is done because if we did mutate it in place, the changes we make would impact other cells, and the answer would be wrong. This handles one single generation to the next, what we really need is a way to do this continuously until we are ready to stop. For that, we’ll write a sequence, which Kotlin makes really easy:

// In Day18

private fun growTrees(initial: Forest = forest): Sequence<Forest> = sequence {
    var current = initial
    while (true) {
        current = current.nextGeneration()
        yield(current)
    }
}

Sequences in Kotlin are nice because they are lazy - meaning, they don’t generate any more data than you want, and only when you want it. By yielding a result, execution of the sequence is effectively paused until the next time a value is requested, and the loop starts all over again. The only thing left is to figure out what the score is for a given generation of Forest. We will do that with yet another extension function, because why ruin the theme?

Calculating Scores

// In Day18

private fun Forest.value(): Int =
    this.flatMap { row ->
        row.map { it }
    }
        .groupingBy { it }
        .eachCount()
        .run {
            getOrDefault(TREE, 0) * getOrDefault(LUMBERYARD, 0)
        }

We see our friends groupingBy and eachCount again - they really do come in handy quite often. To turn this into a single expression (which I like to do if I can and it doesn’t become messy), we use run (which acts like map) and calculate our result. Now we can solve part 1!

The Solution

// In Day18

fun solvePart1(): Int =
    growTrees().drop(9).first().value()

Grow trees, ignore the first 9 generations, get the 10th generation, and calculate it’s value.

Star earned! Onward!

Day 18, Part 2

This important natural resource will need to last for at least thousands of years. Are the Elves collecting this lumber sustainably?

What will the total resource value of the lumber collection area be after 1,000,000,000 minutes?

If you’ve solved Day 11, this approach might seem familiar. We will generate sequence of Forest objects until we’ve detected a cycle. We’ll use the length of the cycle to figure out how close to the billionth result we are, and calculate the remaining generations from that in order to get our score. Before we do all that, we need to make a decision whether to store off entire generations of Forest or just a hash of it. I opted for a hash because I didn’t know that the period would be so short (28 in my input). This probably leads to some double work - I’m effectively trading memory for CPU time. Either works, you should experiment with it!

When writing our hash function it is important to remember to use Arrays.deepHashCode() or the results will vary from run to run.

// In Day18

private fun Forest.hash(): Int =
    Arrays.deepHashCode(this)

And now we can write our solution to part 2, using most of the code from part 1. Again, we’ll save off the hash of each generation and the generation number it happened in. This information will let us detect a cycle and determine how long it is. From that, we subtract the number of generations before the cycle from our target before figuring out how far off we are, using the modulus operator.

// In Day18

fun solvePart2(find: Int = 1_000_000_000): Int {
    val seen = mutableMapOf<Int, Int>()
    var generation = 0
    val firstRepeating = growTrees().dropWhile { tree ->
        val treeHash = tree.hash()
        generation++
        if (treeHash in seen) {
            false
        } else {
            seen[treeHash] = generation
            true
        }
    }.first()

    val cycleLength = generation - seen[firstRepeating.hash()]!!
    val remainingSteps = (find - generation) % cycleLength

    // Fast forward to the target
    return growTrees(firstRepeating).drop(remainingSteps - 1).first().value()
}

Star earned!

Further Reading

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