13
votes
Day 7: Camel Cards
Today's problem description: https://adventofcode.com/2023/day/7
Please post your solutions in your own top-level comment. Here's a template you can copy-paste into your comment to format it nicely, with the code collapsed by default inside an expandable section with syntax highlighting (you can replace python
with any of the "short names" listed in this page of supported languages):
<details>
<summary>Part 1</summary>
```python
Your code here.
```
</details>
oof, I did terrible on this one, I read too quickly and used traditional poker rules first; then I realized I had a typo in my values dict where I gave
Q
11 and thenJ
12 and thenT
10, and THEN I had another mistake where I checked for 2 distinct values if there's 1 pair instead of 4. Took me an hour and a half for just part 1. This one was the first one where the example being so short really hurt me too, because that last mistake didn't affect the test result at all.Part 2 was easy afterwards, but I tried to be clever about it & then when I gave up and did it in a less-clever way I'd wasted 15 minutes already. Today took me longer than day 5 did.
My final code is too long and bad with huge chunks commented & several unused functions to post here, but it's in my repo
C++
I've been doing these with another group of folks, but since we're here I might as well try to share the stuff I write until it becomes too time consuming or I forget.
Going back and cleaning up the code takes quite a bit of effort (if you program fast enough your code becomes hieroglyphics), but I think it's now legible enough to stick here.
Part 1 Explanation
I saw the hand descriptions and started worrying that I'd have to write Poker rules, but luckily these rules aren't that bad.The first important thing we see is that you can distinguish almost all the hands using the most frequent card in that hand (5 of a kind > 4 of a kind > (full house/3 of a kind) > (two-pair/pair) > high card).
Then, (full house/3 of a kind) and (two-pair/pair) can be distinguished using a variety of methods (e.g. second most frequent card). I used "number of distinct card ranks in hand" which sounds ridiculous but is surprisingly simple when expressed in code.
Then, the tiebreaker is just comparing the hands in lexicographic order according to card strength (but without initially sorting by card strength, since I think poker does do that).
Once we have a way of getting hand strength, the rest of the problem is just sorting the list of hands according to (hand strength, the actual cards in the hand) and then multiplying each hand's bet by the index of that hand (+1 if your arrays start at 0, or reversed if you sorted by descending rank). In this case I just used a C++ custom comparator (wrapped in a custom struct) since the standard sort function will happily take your < function if you provide one.
Part 1 Code
Part 2 Explanation
Aside from changing the strength order a bit, the main question here is how to deal with the Jokers, which will change in a way to give the strongest hand.Funnily enough, you can brute force the transformation of the Jokers into every possible card (despite how awful that sounds, assuming each hand is distinct you can prove that you'll only have to evaluate at most 245 = 7962624 hands, which is merely kind of bad and runs in <1 second on my machine).
But we can be smarter than that. Stealing our observation of distinguishing hands by their most frequent cards from before, we realize that a Joker always does best by converting to the most frequent card in the hand. Since my implementation already converts a hand into a list of card frequencies, this is almost* as simple as adding the number of jokers to the most frequent hand.
*almost, of course, because the problem writers knew you would think of this and likely threw a hand of all Jokers at you.
Part 2 Code
This was a fun problem, though I think I might have overcomplicated things a bit. I always like to try to write a single program that does both part 1 and 2 at once if possible, and that was a little trickier than usual today. Nevertheless, I'm glad the problems are more interesting now. Today's still wasn't too difficult (especially compared to day 5), but it wasn't just parsing the input like the first few. Part 2 probably could have changed things up a bit more, though.
Solution
Language: Python
This was fun. More enjoyable than I initially thought (though I've done card sorting code before).
Part 1
This was pretty straightforward: create a histogram of the cards in each hand to determine their type, and if there is a tie-breaker, compare each card pairwise. I use the Counter class from collections to do the counting, and then had a dictionary/table to convert labels to numeric values for comparison. I used a very OOP approach and wrote a magic method for comparing hands and used that with Python's builtin sort. I even got to use
Enum
!Part 2
For the second part, I just had to add some post-processing code to convert the jokers into actual cards. The key insight is to find the highest and most numerous non-Joker card and convert all the Jokers to that card label.
This had two edge cases that tripped me up:
'JJJJJ': There is no other non-Joker here, so I messed up and ranked this the lowest because I ended up removing all counts.
'JJJ12': This also messed me up b/c the Joker was the most numerous card, and I didn't handle that properly.
Once I fixed the post-processing code though, everything else remained the same. Below, I only show the parts that changed from Part A.
GitHub Repo
Very OOP I love it! I chose to write a cmp-style comparison function instead of operator overloading. I see we both thought to use
collections.Counter
.I started even later than usual, but this one was fun! In fact, it felt like the easiest out of all the puzzles so far.
(Rust)
Data structures
Parser
Part 1
Part 2
I should probably have used a brute-force solution here, because going through
the rules multiple-times to figure out what to classify each hand took a bit of time.
Performance
I like your solution. One thing I'm confused about: how does this part work? My custom sorting was much clunkier.
This is simply a comparison of two 2-tuples, because Rust automatically compares these from left to right when using
cmp
on them. The first element of the tuple is the HandType returned byself.classify()
, and since that itself isOrd
, it gets sorted in the right order, from low to high.self.cards
is the[Card; 5]
array. Same as with the tuple, Rust automatically compares the array entries from left to right, and as with HandType, Card implementsOrd
, so it automatically gets sorted lexicographically. In Part 2 I just wrap the Hand struct in a newtype and override theOrd
implementation to account for the new rules, though it is very clunky to convert the individual cards to Part2Card.I later realized that I probably didn't even need to implement
Ord
because you can just do something likehands.sort_by_cached_key(|k| (k.classify(), k.cards))
.I see, I didn’t know about the automatic sorting left to right. Very handy for this.
Part one took me a while because I assumed something that wasn't in the wording of the problem.
Once I realized that, it was a quick fix and part two didn't take long.
What I assumed
I assumed that the cards in the hand needed to be sorted, not left in the order they appear in.Rust
Took a bit of thinking in order to classify the hand types elegantly, but I think I came up with a pretty elegant solution based on the number of types of cards in the hand and the number of the most common card.
The joker was pretty straightforward once I realized it was always advantageous to add the joker to the card you already have the most of.
https://git.venberg.xyz/Gabe/advent_of_code_2023/src/branch/main/days/day07
Rust
Discussion (spoilers)
I implemented a custom sorting for my struct, so I couldn't think of a way to have both parts cleanly in 1, so part 2 is a copy/paste of part 1 with slight modifications to the custom sorting.Part 1
Part 2
Posting this a day late, but here are my Python solutions. Got to use some stdlib goodies like
functools.cmp_to_key
andcollections.Counter
!Part 1
Part 2
My solution in golang
A pretty interesting problem today. I was a bit lucky with my solution, it was pretty easy to use my part1 with a few minor tweaks, but if part2 had been different, I would have had more work to do in part 2.
I was slowed down by this Golang behavior that I always forget about: the object you get in a
for range
loop is a copy of the original object, so if you want to modify the elements as you loop over it, you have to use an integer index loop and access the slice element by index. My mental model for the for each type loop is rooted in Python, so the difference is hard to keep track of. Not saying one is right or wrong, per se. I like Go over all, but there are some warts on their loop syntax 1 2.Additional thoughts:
Part 1 Spoilers
I think the key insight for part1 is that for determining the hand type, the rank of the cards doesn't matter, you just care about the size of the groups.
Part 2 Spoilers
The insight here is that to get the best hand, you add the jokers to the largest group made by the non-joker cards. I have a proof of this in the code comments if anyone is interested.
I really had fun with this one. It felt like just the right balance of challenging without being overwhelming. These AoC exercises are making me fall even more in love with Elixir, which I started learning earlier this year.
Part 2 Solution (Elixir)
Today felt straightforward enough. I initially thought I'd have to handwrite the branches in part 2, but I just tried adding up jokers and then copypasting part 1 logic and it worked so shrug.
Kotlin
I spent like two hours because I didn't realize
J
is already part of the part 1 card list and it was moved, rather than added, in part 2. Then I refactored a lot of the code in part 2 as the counting algorithm start rearing its various flaws.Today's language is C++23. I like all the modern stuff in C++, like span, array, constexpr. Still, it's too easy to not knowing what to use in modern C++ and drop down to unsafe C, so I wouldn't write my production code in C++. With C++ crossed off the list, that should be every single "hard" languages I've ever written an application in, that I hopefully can solve these problems.
Quite happy with this one today. It's not particularly complicated, but my initial naive implementation was quite slow (>100us each for both parts, IIRC), so I had to really figure out how to bring that down to get under my allotted per-day 42us. I've not benchmarked the final code properly, it might still be slightly over, but it's come down a lot!
Parsing/evaluating hands
The rest of the code is on GitHub.
Optimisation thoughts
I wrote a fairly naive version initially, which had two problems. The first was that I didn't think hard enough about how jokers worked, and created a really complicated switch statement to check the hand type. Then I looked online at other people's solutions and realised that jokers will always work best if they add to the most common number, which made things a lot easier — cleaner, too!
The second and more complicated one was that I was doing a lot of sorting, which was slooow. I don't think there's a way to avoid sorting all the elements at the end, so that's fair enough, but I was also doing a sort while figuring out the hand type — I counted up how many of each card type was present, and then sorted that result, so I could check how many of the most common card there were, how many of the second most common card, etc. The result was O(n + k · log(k)) —
n
for the number of cards in the hand (5), andk
for the number of possible card types (13).At first, I tried running everything in parallel (hey, it's Rust, fearless concurrency &c), on the basis that if the problem is the parsing, I could split the parsing and run that in parallel. In practice, that was just slower because of the overheads of threads.
In the end, I figured that I didn't need to properly sort the items, I just needed to largest and second-largest numbers, which I can do in roughly O(n + k) time (once through the hand to count up how many of each card type there is, once through each card type to find the highest/second highest). After that, I realised that I don't even need the k iteration — I can calculate which card has appeared most often during one loop of the cards. So now parsing a card is pretty much O(n), where n is the number of cards in the hand.
I really haven't done much performance work, but I wonder if you could use a Radix sort to speed up the final sorting, since we're basically sorting length six 'strings' (hand type + 5 cards). Since it is not based on comparisons, Radix sort can break the
O(n*log(n))
barrier IIRC, but one would have to try it in order to see whether or not it ends up being faster than Rust's fairly optimized built-in sort on the puzzle input.Good shout, I can have a look into that. Especially given that the hand type and card options are limited (less than four bits per value) that could be pretty effective. Thanks for the suggestion!
Elixir
I split my part 1 and 2 solutions into separate modules, mostly just to namespace the functions so I didn't have to have a bunch of
parse_p1
parse_p2
etc names.Common logic
The sort key, a tuple of the form
{type_score :: integer, hand :: list(integer)}
takes advantage of elixir/erlang's default term ordering. Unlike a lot of C-family languages, comparison operators like<
,==
, etc do a deep comparison by default. Tuples are compared left to right, tie-breaker style. The second elements are checked only if the first elements are equal, and so on. The same goes for lists. So, using this value as a sort key lets me sort the list of plays exactly as the puzzle describes.Logic specific to part 1
Logic specific to part 2