Advent of Code: Day 7 - Camel Cards

Grant Riordan - Dec 9 '23 - - Dev Community

Day 7: Camel Cards

link to challenge

This challenge was another fun one, and speaking to friends a lot of people went down a similar route, either that or there was an abundance of if/else if (I wanted to avoid this), and leant heavily on LINQ.

link to Github

Brief
Solution
Breakdown
Comparer Class
Final Step

TLDR of Problem

Part 1:
Given 'n' number of Poker Hands, rank the hands based on their Poker hand rankings, e.g:

Five-of-a-Kind
Four-of-a-Kind
Full House
Three-of-a-Kind
Two Pair
One Pair
High Card only

Once the ranking of the hands has been given you need to get a sum of all the Hands bid multiplied by their ranking.

Part 2:

Part 2 added complexity in that you could now use the 'J' card as a Joker (wildcard) meaning it could be used to increase your hand, e.g if you had KKKQJ rather than Three-of-a-kind on Kings, could up to Four-of-a-kind Kings with the Joker card.

Solution:

class Program
{
  static void Main()
  {
    string input = File.ReadAllText("./puzzle.txt");
    string[] lines = input.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries);

    Console.WriteLine(Part1(lines));
    Console.WriteLine(Part2(lines));
  }

  static long Part1(string[] lines)
  {
    var hands = lines.Select(Hand.Parse).ToArray();
    return TotalWinnings(hands);
  }

  static long Part2(string[] lines)
  {
    Hand[] hands = [.. lines.Select(s => s.Replace('J', '*')).Select(Hand.Parse)];
    return TotalWinnings(hands);
  }

  public static long TotalWinnings(Hand[] hands)
  {
    var sorted = hands.OrderBy(hand => hand.WinningHandRank)
                      .ThenBy(hand => hand.Cards, new CardArrayComparer());

    return sorted.Select((hand, index) => hand.Bid * (index + 1)).Sum();
  }


  #region Records
  public record Hand(Card[] Cards, long Bid)
  {

    public WinningHandRank WinningHandRank => GetWinningHandRank(Cards);

    public static Hand Parse(string input)
    {
      string[] parts = input.Split(new char[0], StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
      Card[] cards = [.. parts[0].Select(Card.Parse)];
      long bid = long.Parse(parts[1]);
      return new Hand(cards, bid);
    }

    public static WinningHandRank GetWinningHandRank(Card[] cards)
    {
      var nonWildCards = cards.Where(c => c.Rank > 1); // gets all Non-Joker cards
      int count = nonWildCards.Count();

      // no substitutions needed
      if (count == 5) { return GetTypeOfHand(cards); }
      int wildCards = 5 - count;

      IEnumerable<Card[]> substitutions = Card.All.Select(c => Enumerable.Repeat(c, wildCards).ToArray());

      WinningHandRank best = WinningHandRank.HighCard;
      foreach (Card[] substitution in substitutions)
      {
        Card[] p = [.. nonWildCards, .. substitution];
        WinningHandRank current = GetTypeOfHand(p);
        best = (WinningHandRank)Math.Max((int)best, (int)current);
      }
      return best;
    }

    private static WinningHandRank GetTypeOfHand(Card[] cards)
    {
      int[] counts = [..
            cards
                .GroupBy(c => c)
                .Select(g => g.Count())
                .OrderDescending()
      ];
      return counts switch
      {
      [5] => WinningHandRank.FiveOfAKind,
      [4, 1] => WinningHandRank.FourOfAKind,
      [3, 2] => WinningHandRank.FullHouse,
      [3, 1, 1] => WinningHandRank.ThreeOfAKind,
      [2, 2, 1] => WinningHandRank.TwoPair,
      [2, 1, 1, 1] => WinningHandRank.OnePair,
        _ => WinningHandRank.HighCard,
      };
    }
  }

  public record Card(long Rank, char mappedCharacter)
  {
    public static Card[] All { get; } =
    [
        new Card(2, '2'),
        new Card(3, '3'),
        new Card(4, '4'),
        new Card(5, '5'),
        new Card(6, '6'),
        new Card(7, '7'),
        new Card(8, '8'),
        new Card(9, '9'),
        new Card(10, 'T'),
        new Card(12, 'Q'),
        new Card(13, 'K'),
        new Card(14, 'A'),
    ];

    public static Card Parse(char ch)
    {
      int rank = ch switch
      {
        '*' => 1,
        '2' => 2,
        '3' => 3,
        '4' => 4,
        '5' => 5,
        '6' => 6,
        '7' => 7,
        '8' => 8,
        '9' => 9,
        'T' => 10,
        'J' => 11,
        'Q' => 12,
        'K' => 13,
        'A' => 14,
        _ => throw new InvalidOperationException($"Invalid card rank: '{ch}'")
      };
      return new Card(rank, ch);
    }
  }

  public enum WinningHandRank
  {
    HighCard = 0,
    OnePair = 1,
    TwoPair = 2,
    ThreeOfAKind = 3,
    FullHouse = 4,
    FourOfAKind = 5,
    FiveOfAKind = 6,
  }

  public class CardArrayComparer : IComparer<Card[]>
  {
    public int Compare(Card[] x, Card[] y)
    {
      for (int i = 0; i < x.Length; i++)
      {
        int compareResult = x[i].Rank.CompareTo(y[i].Rank);
        if (compareResult != 0)
        {
          return compareResult;
        }
      }

      return 0; // Arrays are equal
    }
  }

}

#endregion

Enter fullscreen mode Exit fullscreen mode

BreakDown

var hands = lines.Select(Hand.Parse).ToArray();
Enter fullscreen mode Exit fullscreen mode

The first thing to do was Parse each line to a Hand object. Giving each card a weighting, meaning 2 got a value of 2, through to Ace Card which got a value of 14.

The Card.Parse function takes the character, maps it to a weighting value, and then returns a Card object with a rank and the original character.

TotalWinning

Once we have our hands it's the big task of working out the winnings, via the TotalWinnings method.

This is done in two stages,

var sorted = hands.OrderBy(hand => hand.WinningHandRank)
                      .ThenBy(hand => hand.Cards, new CardArrayComparer());
Enter fullscreen mode Exit fullscreen mode

First Stage - Ordering By Winning Hand Rank

Order the hands, by their WinningHandRank, which is a computed property on the Hand object.

 public WinningHandRank WinningHandRank => GetWinningHandRank(Cards);
Enter fullscreen mode Exit fullscreen mode

This says when the WinningHandRank is called, return the GetWinningHandRank(Cards) function with the current Hand's cards.

GetWinningHandRank Breakdown

This is where all the work happens.

For Part 1 -> it was a lot simpler we just didn't have to concern ourselves with Joker cards, and substitutions, and could just run the GetTypeOfHand function.

However for the full solution

Step 1:

 var nonWildCards = cards.Where(c => c.Rank > 1); 
 int count = nonWildCards.Count();
Enter fullscreen mode Exit fullscreen mode

Get all the cards that have a higher rank of 1 (remember we gave * a rank of 1 earlier (our wildcard initial value). Then count how many of these we have.

Step 2:

// no substitutions needed
if (count == 5) { return GetTypeOfHand(cards); }
Enter fullscreen mode Exit fullscreen mode

If the count is 5 we know that there are no jokers. So, therefore, we don't need any substitutions and just find the Type of Hand which we have.

Step 3:
If the count is < 5 we know that there must be at least one Joker card, and need to apply a substitution.

But wait how do we know what is the best card to change it to?

Well, at this moment we don't, which is where Step 4 comes in.

Step 4:

IEnumerable<Card[]> substitutions = Card.All.Select(c => Enumerable.Repeat(c, wildCards).ToArray());
Enter fullscreen mode Exit fullscreen mode

Card.All is a property on the Card record, which returns ALL of the cards we could change it to with their Rank and corresponding character (which we will need later to help us switch * -> character.

You should now be familiar with the Select function, but for a recap, it simply applies the function passed to it to each element in the collection.

In this case, we're saying return Enumerable.Repeat(c, wildCards) for each element in Card.All.

Wait a new method, what is that doing ?

Repeat takes a constant value which will be returned each time, as well as a number of how many times to repeat.

So here, we're passing in a constant, which is the Card (c) variable, and wildcards (the count of how many wildcards we want to swap.

Example:

Starting Hand => KKJJ2 (King King Joker Joker 2).
After Parsing => KK**2
Wildcards = 2

1st iteration: C is 2, Wildcards is 2, returns [2,2] two 2 cards.

2nd Iteration: C is 3, Wildcards is 2, returns [3,3]
and so on... up to C is A, Wildcards is 2 returning [A, A].

This means at the end of the code substitutions now has a List of arrays of possible cards and their quantity.

So we'd have a list of [2,2],[3,3],[4,4] -> [A,A];

Step 5:
Now we have our substitutions (the possible cards the wildcards could be) bearing in mind we want them to be the same, as that's worth more, e.g it would be pointless doing 1 x 2 and 1 x K, when 2 x K is worth more, and we want the highest ranking hand we can get.

We combine the substitutions, with the non wild cards to make a hand.

Step 6:
Loop over all the possible substitutions and run each through our GetHandType method to get our hand type value.

Then compare the current HandType with the best. If our current hand is better than the best one, we re-assign the best hand to our current. We keep going until all the substitutions have been handled.

By the end of it, we have the highest hand we can make the substitutions, and this is returned.

So taking the example earlier, we'd cycle through all the substitutions, 22KKQ, 33KKQ, 44KKQ -> hit KKKKQ this is now the best, as the final substitution of [A, A] AAKKQ isn't as good (Two Pair vs 4 of a kind).

Back to TotalWinnings

So using the enum value returned from GetWinningHandRank within the OrderBy method, the items can be sorted.

Once we've sorted them by HandType, we then order them again by our customer Comparer.

Comparer Class

The CardArrayComparer Class inherits from IComparer<Card[]>, and as a result must implement the Compare method. To learn more about inheritance and interfaces check out my tutorial here.

The Compare method,

 for (int i = 0; i < x.Length; i++)
      {
        int compareResult = x[i].Rank.CompareTo(y[i].Rank);
        if (compareResult != 0)
        {
          return compareResult;
        }
      }

      return 0; // Arrays are equal
    }
Enter fullscreen mode Exit fullscreen mode

The method iterates through each index of the arrays (x and y) in a loop.

At each index i, it compares the ranks of the i-th cards in the arrays using the CompareTo method.

If the ranks are not equal (compareResult != 0), the method returns the result of the comparison immediately, indicating the relative order of the two arrays.

If the ranks are equal for all cards in the arrays, the method proceeds to the next index.

Final Step

return sorted.Select((hand, index) => hand.Bid * (index + 1)).Sum();
Enter fullscreen mode Exit fullscreen mode

Now the hands are sorted we can utilise Linq to Select the Hand.Bid multiplied by the index + 1, we add 1 as the index begins at 0 and we want the minimum rank is 1.

Once all rankings and bids have been calculated, we can Sum() the winnings.

I hope this rather extensive tutorial is helpful and helps you solve the challenge, and maybe teach you something new about C#.

As always, don't forget to follow for future posts, or reach out on Twitter.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .