\n",
"\n",
"# Properly Ordered Card Hands\n",
"\n",
"The 538 Riddler [presented](https://fivethirtyeight.com/features/who-will-capture-the-most-james-bonds/) this problem by Matt Ginsberg:\n",
" \n",
"> *You play so many card games that you’ve developed a very specific organizational obsession. When you’re dealt your hand, you want to organize it such that the cards of a given suit are grouped together and, if possible, such that no suited groups of the same color are adjacent. (Numbers don’t matter to you.) Moreover, when you receive your randomly ordered hand, you want to achieve this organization with a single motion, moving only one adjacent block of cards to some other position in your hand, maintaining the original order of that block and other cards, except for that one move.*\n",
">\n",
"> *Suppose you’re playing pitch, in which a hand has six cards. What are the odds that you can accomplish your obsessive goal? What about for another game, where a hand has N cards, somewhere between 1 and 13?*\n",
"\n",
"# Brute Force \n",
"\n",
"The first thing to decide is how many `N`-card hands are there? If there are only a few, I could just enumerate them all and count how many are orderable. If there are a lot, I'll have to be more clever. The answer is (52 choose `N`), so we have:\n",
"\n",
"- 6 cards: 20,358,520 hands\n",
"- 13 cards: 635,013,559,600 hands \n",
"\n",
"I could handle 6-card hands, but 600 billion is a lot of hands. Can we reduce the number of hands we have to deal with?\n",
"\n",
"# Abstract Hands: Suits Only\n",
"\n",
"The problem says *\"Numbers don’t matter,\"* so I can abstract away from \"seven of spades\" to just \"a spade.\" Then there are only 4*N* abstract hands (for *N* ≤ 13), so we have:\n",
"\n",
"- 6 cards: 4,096 abstract hands\n",
"- 13 cards: 67,108,864 abstract hands\n",
"\n",
"That's a big improvement! \n",
"\n",
"Now let's start coding:\n",
"- There are two red suits and two black suits, so I'll represent the four suits as `'rRbB'`.\n",
"- An abstract hand can be represented as a string of suits: `'rrBrbr'` is a 6-card hand. \n",
"- `deals(N)` will return a dict of all possible abstract hands of length *N*, each mapped to the probability of the hand. \n",
"- With actual hands, every hand has the same probability, because every card is equally likely to be the next card dealt. But with abstract hands, the probability of the next suit depends on how many cards of that suit have already been dealt. If I've already dealt the 12 cards `'rrrrrrrrrrrr'`, then the probability of the next card being an `'r'` is 1/40, and the probability of it being a `'b'` is 13/40. So as I build up the abstract hands, I'll need to keep track of the number of remaining cards of each suit.\n",
"- I'll use `Fraction` to get exact arithmetic and `cache` to avoid repeated computations."
]
},
{
"cell_type": "code",
"execution_count": 1,
"metadata": {
"collapsed": false,
"jupyter": {
"outputs_hidden": false
}
},
"outputs": [],
"source": [
"from fractions import Fraction\n",
"from functools import cache\n",
"from typing import Iterable\n",
"\n",
"type Hand = str\n",
"type Probability = Fraction | float\n",
"\n",
"suits = 'rbRB'\n",
"\n",
"@cache\n",
"def deals(N: int) -> dict[Hand, Probability]:\n",
" \"\"\"A dict of {hand: probability} for all hands of N cards.\"\"\"\n",
" if N == 0:\n",
" return {'': Fraction(1)}\n",
" else:\n",
" P = deals(N - 1)\n",
" return {hand + suit: P[hand] * (13 - hand.count(suit)) / (52 - len(hand))\n",
" for hand in P\n",
" for suit in suits}"
]
},
{
"cell_type": "code",
"execution_count": 2,
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"{'r': Fraction(1, 4),\n",
" 'b': Fraction(1, 4),\n",
" 'R': Fraction(1, 4),\n",
" 'B': Fraction(1, 4)}"
]
},
"execution_count": 2,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"deals(1)"
]
},
{
"cell_type": "code",
"execution_count": 3,
"metadata": {
"collapsed": false,
"jupyter": {
"outputs_hidden": false
}
},
"outputs": [
{
"data": {
"text/plain": [
"{'rr': Fraction(1, 17),\n",
" 'rb': Fraction(13, 204),\n",
" 'rR': Fraction(13, 204),\n",
" 'rB': Fraction(13, 204),\n",
" 'br': Fraction(13, 204),\n",
" 'bb': Fraction(1, 17),\n",
" 'bR': Fraction(13, 204),\n",
" 'bB': Fraction(13, 204),\n",
" 'Rr': Fraction(13, 204),\n",
" 'Rb': Fraction(13, 204),\n",
" 'RR': Fraction(1, 17),\n",
" 'RB': Fraction(13, 204),\n",
" 'Br': Fraction(13, 204),\n",
" 'Bb': Fraction(13, 204),\n",
" 'BR': Fraction(13, 204),\n",
" 'BB': Fraction(1, 17)}"
]
},
"execution_count": 3,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"deals(2)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"Is that right? Yes it is. For `deals(1)`, each suit has probability 1/4. For `deals(2)`, the probability of `'BB'` is 1/17, beause the probability of the first `'B'` is 1/4, and when we deal the second card, one `'B'` is gone, so the probability is 12/51, so that simplifies to 1/4 × 12/51 = 1/17. The probability of `'BR'` is 1/4 × 13/51 = 13/204.\n",
"\n",
"# More Abstraction: Collapsed Hands\n",
"\n",
"Now for a second abstraction: an abstract hand can be *collapsed* by replacing a run of cards of the same suit with a single card, so that `'BBBBBrrrrBBBB'` collapses to `'BrB'`. We're interested in grouping cards of the same suit together, so any number of cards of the same suit is the same as a single card of that suit. "
]
},
{
"cell_type": "code",
"execution_count": 4,
"metadata": {},
"outputs": [],
"source": [
"type CollapsedHand = str\n",
"\n",
"def collapse(hand: Hand) -> CollapsedHand:\n",
" \"\"\"Collapse multiple adjacent cards of the same suit into a single card of that suit.\"\"\"\n",
" return ''.join(hand[i] for i in range(len(hand)) if i == 0 or hand[i] != hand[i - 1])"
]
},
{
"cell_type": "code",
"execution_count": 5,
"metadata": {
"collapsed": false,
"jupyter": {
"outputs_hidden": false
}
},
"outputs": [],
"source": [
"assert collapse('BBBBBrrrrBBBB') == 'BrB'"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# Properly Ordered Hands\n",
"\n",
"A hand is considered properly `ordered` if *\"the cards of a given suit are grouped together and, if possible, such that no suited groups of the same color are adjacent.\"* I was initially confused about the meaning of *\"if possible\";* Matt Ginsberg confirmed it means *\"if it is possible to separate the colors in any number of moves\"*, and thus that the hand `'BBBbbb'` is properly ordered, because the suits are all together, and it is not possible to separate the two black suits, while `'BBBbbR'` is not properly ordered, because the red card could be inserted between the two black runs.\n",
"\n",
"In other words: a hand is properly ordered if and only if its collapsed hand is properly ordered, and a collapsed hand is properly ordered if each suit appears only once, and either all the colors are the same, or suits of the same color don't appear adjacent to each other."
]
},
{
"cell_type": "code",
"execution_count": 6,
"metadata": {
"collapsed": false,
"jupyter": {
"outputs_hidden": false
}
},
"outputs": [],
"source": [
"def ordered(hand: Hand) -> bool:\n",
" \"\"\"Properly ordered if each suit run appears only once, and same color suits are not adjacent (or there is only one color).\"\"\"\n",
" hand1 = collapse(hand)\n",
" return once_each(hand1) and (len(set(colors(hand1))) == 1 or not adjacent_colors(hand1))\n",
"\n",
"colors = str.lower # The color of both 'B' and 'b' is 'b'; the color of both 'R' and 'r' is 'r'\n",
" \n",
"def adjacent_colors(hand: Hand) -> bool: \n",
" \"\"\"Do two suits of the same color appear next to each other in a hand?\"\"\"\n",
" return 'bb' in colors(hand) or 'rr' in colors(hand)\n",
"\n",
"def once_each(hand: CollapsedHand) -> bool: \n",
" \"\"\"Do all the suits in a collapsed hand appear just once each?\"\"\"\n",
" return len(hand) == len(set(hand))"
]
},
{
"cell_type": "code",
"execution_count": 7,
"metadata": {
"collapsed": false,
"jupyter": {
"outputs_hidden": false
}
},
"outputs": [],
"source": [
"assert ordered('BBBbbb')\n",
"assert ordered('BBBRbb')\n",
"assert not ordered('BBBbbR') \n",
"assert not ordered('BBBrBB') "
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# Moving Cards to Make a Collapsed Hand Ordered\n",
"\n",
"I'll say that a hand is `orderable` if any of the possible `moves` of a block of consecutive cards makes the hand `ordered`. It is more efficient to do this on collapsed hands. I'll throw a `cache` onto `orderable` so that it won't have to repeat computations.\n",
"\n",
"To find all possible `moves`, consider every possible block of cards within a hand, and every way of placing the block into any position in the rest of the cards.\n",
"\n",
"I'll define `orderable_probability(N)` to give the probability that a random *N*-card hand is orderable. "
]
},
{
"cell_type": "code",
"execution_count": 8,
"metadata": {},
"outputs": [],
"source": [
"@cache\n",
"def orderable(hand: CollapsedHand) -> bool: \n",
" \"\"\"Can this collapsed hand be put into proper order in one move?\"\"\"\n",
" return any(ordered(new_seq) for new_seq in moves(hand))\n",
"\n",
"def moves(hand: Hand) -> Iterable[Hand]:\n",
" \"\"\"All ways of moving a block of cards to get a new hand.\"\"\"\n",
" N = len(hand)\n",
" for i in range(N):\n",
" for j in range(i + 1, N + 1):\n",
" block = hand[i:j]\n",
" rest = hand[:i] + hand[j:]\n",
" for k in range(len(rest) + 1):\n",
" yield rest[:k] + block + rest[k:]\n",
"\n",
"def orderable_probability(N: int) -> Probability:\n",
" \"\"\"What's the probability that an N-card hand is orderable?\"\"\"\n",
" P = deals(N)\n",
" return sum(P[hand] for hand in P if orderable(collapse(hand)))"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# First Answer\n",
"\n",
"Here's the answer for 6 cards:"
]
},
{
"cell_type": "code",
"execution_count": 9,
"metadata": {
"collapsed": false,
"jupyter": {
"outputs_hidden": false
}
},
"outputs": [
{
"data": {
"text/plain": [
"Fraction(51083, 83895)"
]
},
"execution_count": 9,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"orderable_probability(6) "
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"And an easier-to-read answer for everything up to 6 cards:"
]
},
{
"cell_type": "code",
"execution_count": 10,
"metadata": {
"collapsed": false,
"jupyter": {
"outputs_hidden": false
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
" 1: 100.000% or 1\n",
" 2: 100.000% or 1\n",
" 3: 100.000% or 1\n",
" 4: 100.000% or 1\n",
" 5: 85.242% or 213019/249900\n",
" 6: 60.889% or 51083/83895\n"
]
}
],
"source": [
"def report(Ns: range) -> None:\n",
" \"\"\"Show the probability of orderability, for each N in Ns.\"\"\"\n",
" for N in Ns:\n",
" P = orderable_probability(N)\n",
" print('{:2}: {:8.3%} or {}'.format(N, float(P), P))\n",
" \n",
"report(range(1, 7))"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"Let's make sure that each `deals(N)` covers everything: that I got all 4N hands, and that the probabilities sum to 1:"
]
},
{
"cell_type": "code",
"execution_count": 11,
"metadata": {
"collapsed": false,
"jupyter": {
"outputs_hidden": false
}
},
"outputs": [],
"source": [
"for N in range(7):\n",
" assert len(deals(N)) == 4 ** N\n",
" assert sum(deals(N).values()) == 1"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# Getting to 13-Card Hands\n",
"\n",
"So far so good, but if we want to get to 13-card hands, we would have to handle 413 = 67,108,864 `deals`, which would take multiple minutes. But I discovered two key properties that can speed things up:\n",
"\n",
"1. **An orderable collapsed hand can have at most 7 characters.** We know that a properly ordered CollapsedHand can have at most 4 characters. But a move can reduce the number of characters by at most 3: one can be reduced when we remove the block of cards (if the cards on either side of the block are the same), and up to two more can be reduced when we re-insert the block (if the left and/or right ends of the block match the surrounding suits). Here's an example of moving a block (in parens) to reduce the number of runs from 6 to 3:\n",
"\n",
" bRB(bR)B => b(bR)RBB = bRB\n",
" \n",
"3. **Adding cards to the end of an unorderable collapsed hand can't make it orderable.** To show that, take an unordered CollapsedHand, and see what happens if you take the extra suit and insert it anywhere in the CollapsedHand. If the CollapsedHand was unordered because it repeats a suit, adding a suit can't fix that. If it was unordered because suits of the same color are adjacent, then adding a suit of the other color *could* fix that: `'bBR'` could be fixed by inserting a `'r'` to get `'brBR'`. But here's the catch: `'bBR'` is not unorderable. And if we are going to insert a new suit between two others, that means that the original CollapsedHand must have had at most three suits (because when we add one, we can't get more than four suits in an ordered CollapsedHand), and *every* three-suit CollapsedHand is orderable. "
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"I'll define `orderable_deals(N)` to return a {hand: probability\\} dict of just the orderable hands, and\n",
"I'll redefine `orderable` and `orderable_probability` to take advantage of this."
]
},
{
"cell_type": "code",
"execution_count": 12,
"metadata": {
"collapsed": false,
"jupyter": {
"outputs_hidden": false
}
},
"outputs": [],
"source": [
"@cache\n",
"def orderable_deals(N: int) -> dict[Hand, Probability]:\n",
" \"\"\"A dict of {hand: probability} for all orderable hands of length N.\"\"\"\n",
" if N == 0:\n",
" return {'': Fraction(1)}\n",
" else:\n",
" P = orderable_deals(N - 1)\n",
" return {hand + suit: P[hand] * (13 - hand.count(suit)) / (52 - len(hand))\n",
" for hand in P\n",
" for suit in suits\n",
" if orderable(collapse(hand + suit))}\n",
" \n",
"@cache\n",
"def orderable(hand: CollapsedHand) -> bool: \n",
" \"\"\"Can this collapsed hand be put into proper order in one move?\"\"\"\n",
" return len(hand) <= 7 and any(ordered(move) for move in moves(hand))\n",
"\n",
"def orderable_probability(N: int) -> Probability:\n",
" \"\"\"What's the probability that an N-card hand is orderable?\"\"\"\n",
" return sum(orderable_deals(N).values())"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# Final Answer\n",
"\n",
"We're finaly ready to go up to *N* = 13:"
]
},
{
"cell_type": "code",
"execution_count": 13,
"metadata": {
"collapsed": false,
"jupyter": {
"outputs_hidden": false
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
" 0: 100.000% or 1\n",
" 1: 100.000% or 1\n",
" 2: 100.000% or 1\n",
" 3: 100.000% or 1\n",
" 4: 100.000% or 1\n",
" 5: 85.242% or 213019/249900\n",
" 6: 60.889% or 51083/83895\n",
" 7: 37.321% or 33606799/90047300\n",
" 8: 20.185% or 29210911/144718875\n",
" 9: 9.861% or 133194539/1350709500\n",
"10: 4.432% or 367755247/8297215500\n",
"11: 1.859% or 22673450197/1219690678500\n",
"12: 0.736% or 1751664923/238130084850\n",
"13: 0.277% or 30785713171/11112737293000\n",
"CPU times: user 2.18 s, sys: 33.6 ms, total: 2.22 s\n",
"Wall time: 2.22 s\n"
]
}
],
"source": [
"%time report(range(14))"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# Inspecting the Cache\n",
"\n",
"Let's look at the cache for `orderable(Hand)`:"
]
},
{
"cell_type": "code",
"execution_count": 14,
"metadata": {
"collapsed": false,
"jupyter": {
"outputs_hidden": false
}
},
"outputs": [
{
"data": {
"text/plain": [
"CacheInfo(hits=1438512, misses=1540, maxsize=None, currsize=1540)"
]
},
"execution_count": 14,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"orderable.cache_info()"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"So `hits` say we looked at 1.4 million hands, but only 1540 were unique: distinct collapsed hands. And once we hit *N* = 7, we've seen all the collapsed hands we're ever going to see. From *N* = 8 and up, almost all the computation goes into computing the probability of each hand, and collapsing each hand, not into deciding orderability.\n",
"\n",
"We also save a lot of space in the `deals(N)` caches. Instead of storing all 413 hands for `deals(13)`, the `report` above says that just 0.277% of the hands are orderable, so we reduced the storage requirements by a factor of 360.\n",
"\n",
"# Unit Tests\n",
"\n",
"To gain confidence in this project, here are some unit tests. Before declaring my answers definitively correct, I would want a lot more tests, and some independent code reviews."
]
},
{
"cell_type": "code",
"execution_count": 15,
"metadata": {
"collapsed": false,
"jupyter": {
"outputs_hidden": false
}
},
"outputs": [
{
"data": {
"text/plain": [
"True"
]
},
"execution_count": 15,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"def test():\n",
" assert deals(1) == {'B': 1/4, 'R': 1/4, 'b': 1/4, 'r': 1/4}\n",
" assert ordered('BBBBBrrrrBBBB') is False\n",
" assert ordered('BBBBBrrrrRRRR') is False\n",
" assert ordered('BBBbbr') is False # Bb\n",
" assert ordered('BBBbbrB') is False # two B's\n",
" assert ordered('BBBbbb') \n",
" assert ordered('BBBbbbB') is False # two B's\n",
" assert ordered('BBBBBrrrrbbbb')\n",
" assert colors('BBBBBrrrrbbbb') == 'bbbbbrrrrbbbb'\n",
" assert once_each('Bb')\n",
" assert once_each('BbRr')\n",
" assert once_each('BbRB') is False\n",
" assert adjacent_colors('BbR')\n",
" assert adjacent_colors('Brb') is False\n",
" assert collapse('BBBBBrrrrBBBB') == 'BrB'\n",
" assert collapse('brBBrrRR') == 'brBrR'\n",
" assert collapse('bbbbBBBrrr') == 'bBr'\n",
" assert set(moves('bRb')) == {'Rbb', 'bRb', 'bbR'}\n",
" assert set(moves('bRB')) == {'BbR', 'RBb', 'RbB', 'bBR', 'bRB'}\n",
" assert orderable('bBr') # move 'r' between 'bB'\n",
" assert orderable('bBrbRBr') # move 'bRB' after first 'b' to get 'bbRBBrr'\n",
" assert orderable('bBrbRBrb') is False\n",
" return True\n",
"\n",
"test()"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python [conda env:base] *",
"language": "python",
"name": "conda-base-py"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.13.9"
}
},
"nbformat": 4,
"nbformat_minor": 4
}