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'
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
- Index of All Solutions - All posts and solutions for 2023, in Kotlin.
- My Github repo - Solutions and tests for each day.
- Solution - Full code for day 7
- Advent of Code - Come join in and do these challenges yourself!