Day 7: Camel Cards
Puzzle description
https://adventofcode.com/2023/day/7
Part 1 Solution
The problem, in its essence, is a simplified version of the classic poker problem where you are required to compare poker hands according to certain rules.
Domain
We'll start by defining the domain of the problem:
type Card = Char
type Hand = String
case class Bet(hand: Hand, bid: Int)
enum HandType:
case HighCard, OnePair, TwoPair, ThreeOfAKind, FullHouse, FourOfAKind, FiveOfAKind
We can then define the constructors to create a Bet
and a HandType
:
object Bet:
def apply(s: String): Bet = Bet(s.take(5), s.drop(6).toInt)
object HandType:
def apply(hand: Hand): HandType =
val cardCounts: List[Int] =
hand.groupBy(identity).values.toList.map(_.length).sorted.reverse
cardGroups match
case 5 :: _ => HandType.FiveOfAKind
case 4 :: _ => HandType.FourOfAKind
case 3 :: 2 :: Nil => HandType.FullHouse
case 3 :: _ => HandType.ThreeOfAKind
case 2 :: 2 :: _ => HandType.TwoPair
case 2 :: _ => HandType.OnePair
case _ => HandType.HighCard
end apply
A Bet
is created from a String
e.g. "5678A 364"
- that is, the hand and the bid amount.
A HandType
is a bit more complicated: it is calculated from Hand
- e.g. "5678A"
- according to the rules specified in the challenge. Since the essence of hand scoring lies in how many occurrences of a given card there are in the hand, we utilize Scala's declarative collection capabilities to group the cards and calculate their occurrences. We can then use a match
expression to look for the occurrences patterns as specified in the challenge, in descending order of value.
Comparison
The objective of the challenge is to sort bets and calculate the final winnings. Let's address the sorting part. Scala collections are good enough at sorting, so we don't need to implement the sorting proper. But for Scala to do its job, it needs to know the ordering function of the elements. We need to define how to compare two bets:
val ranks = "23456789TJQKA"
given cardOrdering: Ordering[Card] = Ordering.by(ranks.indexOf(_))
given handOrdering: Ordering[Hand] = (h1: Hand, h2: Hand) =>
val h1Type = HandType(h1)
val h2Type = HandType(h2)
if h1Type != h2Type then h1Type.ordinal - h2Type.ordinal
else h1.zip(h2).find(_ != _).map( (c1, c2) => cardOrdering.compare(c1, c2) ).getOrElse(0)
given betOrdering: Ordering[Bet] = Ordering.by(_.hand)
We define three orderings: one for cards, one for hands, and one for bets.
The card ordering is simple: we compare the cards according to their rank. The hand ordering is implemented according to the spec of the challenge: we first compare the hand types, and if they are equal, we compare the individual cards of the hands.
The bet ordering is then defined in terms of hand ordering.
Calculating the winnings
Given the work we've done so far, calculating the winnings is a matter of sorting the bets and calculating the winnings for each:
def calculateWinnings(bets: List[Bet]): Int =
bets.sorted.zipWithIndex.map { case (bet, index) => bet.bid * (index + 1) }.sum
def parse(input: String): List[Bet] =
input.linesIterator.toList.map(Bet(_))
def part1(input: String): Int =
calculateWinnings(parse(input))
We read the bets from the input string, sort them, and calculate the winnings for each bet.
Part 2 Solution
The second part of the challenge changes the meaning of the J
card. Now it's a Joker, which can be used as any card to produce the best hand possible. In practice, it means determining the prevailing card of the hand and becoming that card: such is the winning strategy of using the Joker. Another change in the rules is that now J
is the weakest card when used in tiebreaking comparisons.
We can re-use most of the logic of the Part 1 solution. However because of the different set of rules, we need to create an abstraction to describe the rules for each part, then change the hand scoring logic to take the rules abstraction into account.
Rules
We define a Rules
trait that encapsulates the rules of the game and implement it for both cases:
trait Rules:
val rankValues: String
val wildcard: Option[Card]
val standardRules = new Rules:
val rankValues = "23456789TJQKA"
val wildcard = None
val jokerRules = new Rules:
val rankValues = "J23456789TQKA"
val wildcard = Some('J')
Comparison
We then need to change the hand type estimation logic to take the rules into account:
object HandType:
def apply(hand: Hand)(using rules: Rules): HandType =
val cardCounts: Map[Card, Int] =
hand.groupBy(identity).mapValues(_.length).toMap
val cardGroups: List[Int] = rules.wildcard match
case Some(card) if cardCounts.keySet.contains(card) =>
val wildcardCount = cardCounts(card)
val cardGroupsNoWildcard = cardCounts.removed(card).values.toList.sorted.reverse
cardGroupsNoWildcard match
case Nil => List(wildcardCount)
case _ => cardGroupsNoWildcard.head + wildcardCount :: cardGroupsNoWildcard.tail
case _ => cardCounts.values.toList.sorted.reverse
cardGroups match
case 5 :: _ => HandType.FiveOfAKind
case 4 :: _ => HandType.FourOfAKind
case 3 :: 2 :: Nil => HandType.FullHouse
case 3 :: _ => HandType.ThreeOfAKind
case 2 :: 2 :: _ => HandType.TwoPair
case 2 :: _ => HandType.OnePair
case _ => HandType.HighCard
end apply
end HandType
The logic is the same as in the Part 1 solution, except that now we need to take the wildcard into account. If the wildcard is present in the hand, we need to calculate the hand type as if the wildcard was not present, and then add the wildcard count to the largest group of cards. If the wildcard is not present, we calculate the hand type as before. We also handle the case when the hand is composed entirely of wildcards.
We then need to change the card comparison logic to also depend on the rules:
given cardOrdering(using rules: Rules): Ordering[Card] = Ordering.by(rules.rankValues.indexOf(_))
The rest of the orderings stay the same, except we need to make them also depend on the Rules
as they all use cardOrdering
in some way:
given handOrdering(using Rules): Ordering[Hand] = (h1: Hand, h2: Hand) =>
val h1Type = HandType(h1)
val h2Type = HandType(h2)
if h1Type != h2Type then h1Type.ordinal - h2Type.ordinal
else h1.zip(h2).find(_ != _).map( (c1, c2) => cardOrdering.compare(c1, c2) ).getOrElse(0)
given betOrdering(using Rules): Ordering[Bet] = Ordering.by(_.hand)
Calculating the winnings
The winnings calculation also stays the same, except for the addition of the Rules
parameter, which is required for sorting the bets.
def calculateWinnings(bets: List[Bet])(using Rules): Int =
bets.sorted.zipWithIndex.map { case (bet, index) => bet.bid * (index + 1) }.sum
Finally, we can calculate the winnings as before while specifying the rules under which to do the calculation:
def part2(input: String): Int =
calculateWinnings(parse(input))(using jokerRules)
Complete Code
type Card = Char
type Hand = String
case class Bet(hand: Hand, bid: Int)
object Bet:
def apply(s: String): Bet = Bet(s.take(5), s.drop(6).toInt)
enum HandType:
case HighCard, OnePair, TwoPair, ThreeOfAKind, FullHouse, FourOfAKind, FiveOfAKind
object HandType:
def apply(hand: Hand)(using rules: Rules): HandType =
val cardCounts: Map[Card, Int] =
hand.groupBy(identity).mapValues(_.length).toMap
val cardGroups: List[Int] = rules.wildcard match
case Some(card) if cardCounts.keySet.contains(card) =>
val wildcardCount = cardCounts(card)
val cardGroupsNoWildcard = cardCounts.removed(card).values.toList.sorted.reverse
cardGroupsNoWildcard match
case Nil => List(wildcardCount)
case _ => cardGroupsNoWildcard.head + wildcardCount :: cardGroupsNoWildcard.tail
case _ => cardCounts.values.toList.sorted.reverse
cardGroups match
case 5 :: _ => HandType.FiveOfAKind
case 4 :: _ => HandType.FourOfAKind
case 3 :: 2 :: Nil => HandType.FullHouse
case 3 :: _ => HandType.ThreeOfAKind
case 2 :: 2 :: _ => HandType.TwoPair
case 2 :: _ => HandType.OnePair
case _ => HandType.HighCard
end apply
end HandType
trait Rules:
val rankValues: String
val wildcard: Option[Card]
val standardRules = new Rules:
val rankValues = "23456789TJQKA"
val wildcard = None
val jokerRules = new Rules:
val rankValues = "J23456789TQKA"
val wildcard = Some('J')
given cardOrdering(using rules: Rules): Ordering[Card] = Ordering.by(rules.rankValues.indexOf(_))
given handOrdering(using Rules): Ordering[Hand] = (h1: Hand, h2: Hand) =>
val h1Type = HandType(h1)
val h2Type = HandType(h2)
if h1Type != h2Type then h1Type.ordinal - h2Type.ordinal
else h1.zip(h2).find(_ != _).map( (c1, c2) => cardOrdering.compare(c1, c2) ).getOrElse(0)
given betOrdering(using Rules): Ordering[Bet] = Ordering.by(_.hand)
def calculateWinnings(bets: List[Bet])(using Rules): Int =
bets.sorted.zipWithIndex.map { case (bet, index) => bet.bid * (index + 1) }.sum
def parse(input: String): List[Bet] =
input.linesIterator.toList.map(Bet(_))
def part1(input: String): Int =
println(calculateWinnings(parse(input))(using standardRules))
def part2(input: String): Int =
println(calculateWinnings(parse(input))(using jokerRules))
Solutions from the community
- Solution by Spamegg
- Solution by Niels Prins
- Solution by Thanh Le
- Solution by Rui Alves
- Solution by Philippus Baalman
- Solution by g.berezin
- Solution by Jamie Thompson
- Solution by Alexandru Nedelcu
- Solution by Guillaume Vandecasteele
- Solution by Yann Moisan
- Solution by jnclt
- Solution by thanhbv
- Solution by Marconi Lanna
- Solution of Jan Boerman.
- Solution of Joel Edwards
- Solution by Paweł Cembaluk
Share your solution to the Scala community by editing this page.