Dec 4, 2023

Advent of Code 2023 - Day 4: Scratchcards

Solving the 4th day of Advent of Code 2023 in Kotlin


Welcome back to another Advent of Code challenge. Today is the 4th of December, and I'm super excited about today's puzzle. I hope so are you!

Yesterday we continued our journey via a gondola which we had to fix. But we were not actually climbing up a mountain - above us suddenly an entire new landmass appears. We actually traveled to an island in the sky.

As we exit the gondola we find an elf sitting on the floor and ask him about the water sources. He tells us that he does not know where it is located but the gardener would know. But to get to the gardener we would need a boat, and luckily he would let us borrow his one, if we help him with a thing.

The Problem

Today's challenge can be found here

The elf tells us about his scratchcards which he got as a gift. But he can not figure out what he has won.

So our puzzle input for today's challenge are his scratchcards. Let's take a look at them (the example input):

Card 1: 41 48 83 86 17 | 83 86  6 31 17  9 48 53
Card 2: 13 32 20 16 61 | 61 30 68 82 17 32 24 19
Card 3:  1 21 53 59 44 | 69 82 63 72 16 21 14  1
Card 4: 41 92 73 84 69 | 59 84 76 51 58  5 54 83
Card 5: 87 83 26 28 32 | 88 30 70 12 93 22 82 36
Card 6: 31 18 13 56 72 | 74 77 10 23 35 67 36 11

Each card has two lists of numbers (divided by a pipe |) written on it. On the left side are all numbers which would lead to a win. And on the other side are all the numbers we scratched open.

The value calculation of a card works like this: Every number that we have (on the right side) which is part of the winning numbers (on the left) counts as one match. The first match counts as 1 point and every other match after that doubles our current points.

This can be written as a simple maths formula:

points = 2^(matches - 1)

You might wonder what happens when matches = 0. Actually points is also going to be 0 then because we are working with Integers, so the 2^(-1) = 0.5 just becomes 0 after converting it to an Integer.

What we are looking for today is the sum of all point values on each card.

Parsing

Let's start by once again parsing our input. I created a Card data class for this, which has the following signature:

data class Card(val id: Int, val winning: List<Int>, val actual: List<Int>)

Then I created a function to map each line of our input to a Card object:

private fun parseInput(input: List<String>): List<Card> {
    return input.mapIndexed { index, line ->
        val (winning, actual) = line
            .substringAfter(':')
            .split("|")
            .map { numbers ->
                numbers
                    .split(" ")
                    .filter(String::isNotBlank)
                    .map(String::toInt)
            }

        return@mapIndexed Card(index + 1, winning, actual)
    }
}

This actually looks more complicated than it actually is, let me explain. So in the first place I'm once again using the mapIndexed function just like in the second day since the id is just incremented by 1 every line.

Then I remove everything in front of the : and split on the | to get the winning (left) and the actual (right) side of the numbers. But since this two sides are still respresentd as a String we also have to map them to a List of Int. I'm doing this by splitting on whitespace and then filtering out every blank String because there are cases in which 2 or more whitespaces are in between numbers. Lastly I'm converting every number to an Integer.

In the end I'm just passing both number lists and also the index offset by 1 (Because the Card id is not zero based) to the Card constructor.

⭐ Part 1

After parsing all of this, the only thing which is left to do, is to calculate the points of each card and then sum them up. And this is how I did it:

override fun partOne(input: List<String>): Int {
    return parseInput(input).sumOf { card ->
        2.toDouble().pow(card.actual.count(card.winning::contains) - 1).toInt()
    }
}

I'm not going to explain the sumOf function and things like that. Please read the previous days blog posts if you don't know about this stuff yet. The only thing which I don't like about this solution is that the pow function does not work with Integers. That's because we first have to convert the base which is 2 to a Double and also convert the result back to an Integer.

But this is actually all for the first part, let's get to testing:

override val partOneTestExamples: Map<List<String>, Int> = mapOf(
    listOf(
        "Card 1: 41 48 83 86 17 | 83 86  6 31 17  9 48 53",
        "Card 2: 13 32 20 16 61 | 61 30 68 82 17 32 24 19",
        "Card 3:  1 21 53 59 44 | 69 82 63 72 16 21 14  1",
        "Card 4: 41 92 73 84 69 | 59 84 76 51 58  5 54 83",
        "Card 5: 87 83 26 28 32 | 88 30 70 12 93 22 82 36",
        "Card 6: 31 18 13 56 72 | 74 77 10 23 35 67 36 11",
    ) to 13
)

The test passes (13) and also the output for my personal puzzle input (21485) is correct. First star ⭐ collected, let's move on!

⭐ Part 2

We are just about to report our result to the Elf, but then one of us suddenly notices that the rules have been actually written to the backside of the cards.

There's no such thing as points. Instead, scratchcards only cause us to win more scratchcards equal to the number of winning numbers we have. Let me explain this process real quick by looking at the first few lines of our example input:

Card 1: 41 48 83 86 17 | 83 86  6 31 17  9 48 53
Card 2: 13 32 20 16 61 | 61 30 68 82 17 32 24 19

When we take a look at the first card, we find four matching numbers (48, 17, 86 and 83). This means we win one copy each of the next four cards (ids 2, 3, 4 and 5). Moving on to the second card. We have two matching numbers (61 and 32), so we win one copy each of cards 3 and 4. But since we own card 2 exactly two times we get another copy of cards 3 and 4. And so on.

When I first solved the part this morning, I created a list and added new Card instances every time we would get copies. But this was a super inefficient solution (took about 6.5 seconds to run), so I thought about it more deeply. So first the obvious part, since all the cards we get are just copies of already existing ones, we only have to store how many copies of each card we have.

I'm doing this by creating a Map which associates every card id to 1, since we have 1 instance of each card in the beginning.

val cards = parseInput(input)
val copies = cards
    .associate { it.id to 1 }
    .toMutableMap()

And also make sure that the Map is mutable because we have to increment these values in the next step.

Now we again loop over every card like in the first part and find the number of winning (matching in this snippet) ones:

cards.forEach { card ->
    val matching = card.actual.count(card.winning::contains)
}

Next we have to determine all the card ids which we have to copy. I'm doing this by creating an Range starting at the id of the current Card offset by 1 to the amount of matching numbers plus the id of the current Card offset again by 1.

val cardsToCopy = (card.id + 1)..(card.id + matching)

The parentheses are actually not needed here, but I still include them for better readability.

Now we get to the interesting part. At first, you might think that we need 2 more nested loops for the copying process. But it's actually only one, let me show you:

cardsToCopy.forEach { id ->
    copies[id] = copies[id]!! + copies[card.id]!!
}

As you can see I'm not reevaluating all the matching numbers of the copied cards, instead I'm just adding the amount of copies of our current card to the other ones, which is kind of a recursive approach.

So with all of that done the only thing that's left is to count how many Cards we have in total, since that is what is asked for part two.

My final solution is the following:

override fun partTwo(input: List<String>): Int {
    val cards = parseInput(input)
    val copies = cards
        .associate { it.id to 1 }
        .toMutableMap()

    cards.forEach { card ->
        val matching = card.actual.count(card.winning::contains)
        val cardsToCopy = (card.id + 1)..(card.id + matching)

        cardsToCopy.forEach { id ->
            copies[id] = copies[id]!! + copies[card.id]!!
        }
    }

    return copies.values.sum()
}

Let's also test this to see if it works, and indeed it does. The test returns 30 and my input gives us 11024379 and this is both correct.

Second star ⭐ acquired!

Final Words

In my opinion today was again a lot easier than yesterday's challenge, but despite that it was a fun puzzle today. And I'm pretty sure the difficulty is going to raise up really fast in the next few day's.

As always you can find my full solution here.

See you tomorrow!