Skip to Content

Advent of Code 2023 - Day 7, in Kotlin - Camel Cards

Kotlin solutions to parts 1 and 2 of Advent of Code 2023, Day 7: 'Camel Cards'

Posted on

Another Advent of Code card game! I liked this one and hope my solution is clear. This post is pretty long, but really only because I refactored a lot between part 1 and part 2.

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

Puzzle Input

We will take in the input today as a List<String>.

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

}

Strategy

Since it can always be determine which hand of cards beats another, independent of the rather unique rules today’s puzzle gives us, that means there exists a natural ordering for all hands. Meaning: if we generated every hand possible, we could number them, and put them in an order.

So let’s do that. We will define a Hand class which contains the cards and the bid. When sorting the Hand objects we will do it in a way that puts them into their natural order, starting with weaker hands first and progressing up to stronger hands.

⭐ Day 7, Part 1

The puzzle text can be found here.

Let’s start with our Hand class, since it will end up doing all of the work. We’ll have to rework it for part 2. As in previous days, we will make this class private to our Day07 class.

// In Day07

private class Hand(cards: String, val bid: Int) : Comparable<Hand> {
    private val identity: Int = calculateIdentity(cards)

    override fun compareTo(other: Hand): Int =
        this.identity - other.identity

    private fun calculateIdentity(cards: String): Int {
        val category = categoryCalculation(cards)
        return cards.fold(CATEGORIES.indexOf(category)) { acc, card ->
            (acc shl 4) or STRENGTH.indexOf(card)
        }
    }

    private fun calculateCategory(cards: String): List<Int> =
        cards.groupingBy { it }.eachCount().values.sortedDescending()

    companion object {
        private val CATEGORIES = listOf(
            listOf(1, 1, 1, 1, 1),
            listOf(2, 1, 1, 1),
            listOf(2, 2, 1),
            listOf(3, 1, 1),
            listOf(3, 2),
            listOf(4, 1),
            listOf(5)
        )

        private const val STRENGTH = "23456789TJQKA"
    }
}

Our Hand class takes in the cards that make it up as well as the bid, which we define as a property. It also implements Comparable<Hand> which is used to put them into a natural order.

The only other property we define is identity, which means of all the possible hands we could generate, which one is this? We’ll calculate it in a minute, let’s fist go over the tools we need.

If you look in the companion object, you will find two things. First, CATEGORIES which is a List<List<Int>>. Each of the inner lists represents counts of cards. So if we have 5 individual cards that are not pairs, we have List(1, 1, 1, 1, 1). On the other end of the range, we have List(5) to represent when all 5 cards are the same. We have lists for a single pair, two pair, three of a kind, full house, and four of a kind. Thankfully, there are no straights in Camel Cards like in poker, or this model would’t really work.

Second, we have STRENGTH which puts the cards in order from weakest to strongest. We’ll use their index to assign them a value later.

The first order of business is to calculate the category. We do this in the calculateCategory function (this will change names in part 2). To get the List<Int> that represents what kind of hand we have, we use groupingBy from the Kotlin Standard Library, which allows us to group the cards (effectively, not actually) into a Map<Char, List<Char>>. We can then call eachCount to reduce the value of List<Char> to an Int, representing the size of the list. Since we only care about the values we take them, and then sortDescending to put these into the order you see in CATEGORIES.

Overview of Identity

In order to build our identity, we will convert the category of the hand 4 of a kind, etc) and the relative value of each card into a single Int. Picture each of these taking up 4 bits (one half byte).

0000 0000 0000 0000 0000 CC11 2233 4455 -> 32 bits
--byte1--|--byte2--|--byte3--|--byte4--

Where - 0 = unused bit
        C = Category
        1 = Card 1
        2 = Card 2
        3 = Card 3
        4 = Card 4
        5 = Card 5

Putting the bits into this order will guarantee the order we want. All 5 of a kind hands come before all 4 of a kind hands, and within those categories, the hands are in order.

To accomplish this, we will use fold, seeding it with the category. For each loop through the cards, we shift the acc (accumulation) number to the left by 4 bits and perform a binary OR with that number and the number representing the strength of the individual card.

Comparing Hands

We override compareTo from the Comparable interface, which lets us compare two Hand objects. We will put these into an order such that weaker hands come before stronger hands. So the weakest hand (“23456”) comes before the second weakest hand (“23457”).

Playing Camel Cards

Finally, some parsing!

// In Day07

private fun playCamelCards(): Int =
    input
        .asSequence()
        .map { row -> row.split(" ") }
        .map { parts -> Hand(parts.first(), parts.last().toInt()) }
        .sorted()
        .mapIndexed { index, hand -> hand.bid * (index + 1) }
        .sum()

We look through our input and split each row by space. We then map that array into a Hand object, make sure the results are sorted and then use mapIndexed to calculate the value of each hand. Remember, mapIndexed starts at 0, but we want to start at 1, so we have to add 1 to the result before getting the sum.

All that’s left to do is playCamelCards!

// In Day07

fun solvePart1(): Int =
    playCamelCards()

Star earned! Onward!

⭐ Day 7, Part 2

The puzzle text can be found here.

Luckily, despite the somewhat extensive refactoring, we’re not far off from the answer.

The only real change is in how we calculate the category. All other aspects of Hand stay the same.

Here is a full version of Hand after refactoring. I’ll go over interesting parts below.

// In Day07

private class Hand(cards: String, val bid: Int, jokersWild: Boolean) : Comparable<Hand> {
    private val identity: Int = calculateIdentity(
        cards,
        if (jokersWild) STRENGTH_WITH_JOKERS else STRENGTH_WITHOUT_JOKERS,
        if (jokersWild) this::calculateCategoryWithJokers else this::calculateCategoryWithoutJokers
    )

    override fun compareTo(other: Hand): Int =
        this.identity - other.identity

    private fun calculateIdentity(
        cards: String,
        strength: String,
        categoryCalculation: (String) -> List<Int>
    ): Int {
        val category = categoryCalculation(cards)
        return cards.fold(CATEGORIES.indexOf(category)) { acc, card ->
            (acc shl 4) or strength.indexOf(card)
        }
    }

    private fun calculateCategoryWithoutJokers(cards: String): List<Int> =
        cards.groupingBy { it }.eachCount().values.sortedDescending()

    private fun calculateCategoryWithJokers(cards: String): List<Int> {
        val cardsWithoutJokers = cards.filterNot { it == 'J' }
        val numberOfJokers = cards.length - cardsWithoutJokers.length

        return if (numberOfJokers == 5) listOf(5)
        else calculateCategoryWithoutJokers(cardsWithoutJokers).toMutableList().apply {
            this[0] += numberOfJokers
        }
    }

    companion object {
        private val CATEGORIES = listOf(
            listOf(1, 1, 1, 1, 1),
            listOf(2, 1, 1, 1),
            listOf(2, 2, 1),
            listOf(3, 1, 1),
            listOf(3, 2),
            listOf(4, 1),
            listOf(5)
        )

        private const val STRENGTH_WITHOUT_JOKERS = "23456789TJQKA"
        private const val STRENGTH_WITH_JOKERS = "J23456789TQKA"
    }
}

The first thing to notice is that we now have to account for whether jokers are wild or not. So we will add a jokersWild variable to our Hand constructor. The calculateIdentity function also changes to take in the proper strength mapping and a function to calculate the category.

The calculateCategoryWitoutJokers function is the same as the calculateCategory function above, just renamed. Similarly, STRENGTH_WITHOUT_JOKERS is simply STRENGTH renamed. We’ve added STRENGTH_WITH_JOKERS which gives J the lowest point value. The compareTo function does not change at all, nor does CATEGORIES.

To calculate the category of a hand with jokers, we implement calculateCategoryWithJokers. The first thing we do is remove all of the jokers from the list of cards. If we have a hand with all jokers, we don’t have any calculating to do - we can return a List(5) directly (yes, we could also do the same for no jokers). Otherwise, we take the cards that aren’t jokers and use the calculateCategoryWitoutJokers function to get the category list. Because we always want to use the joker to represent the best card, we add the number of jokers to the leftmost value in the category. This has the effect of promoting the jokers to whatever the best card in the hand is, whatever it is.

We also need to make some minor changes to playCamelCards:

// In Day07

private fun playCamelCards(jokersWild: Boolean = false): Int =
    input
        .asSequence()
        .map { row -> row.split(" ") }
        .map { parts -> Hand(parts.first(), parts.last().toInt(), jokersWild) }
        .sorted()
        .mapIndexed { index, hand -> hand.bid * (index + 1) }
        .sum()

We will take in a jokersWild boolean flag and default it to false. We pass that value to the Hand constructor.

Calling playCamelCards indicating true for jokers are wild gives us the solution to part 2.

// In Day07

fun solvePart2(): Int =
    playCamelCards(true)

Star earned! See you tomorrow!

Further Reading

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