Number of nodes in a game tree for a binary choice card game

85 Views Asked by At

Say you have a game played with a deck of $M$ cards. Every turn, you draw a card and either choose to add the card to your hand, or decline to add it to your hand. If you take the card, your opponent draws a new card and play continues as normal. If you decline to take the card, your opponent doesn't draw a new card and instead decides on whether to take the card you passed up; if they don't, the card is discarded, and play returns to you. The game ends when either player reaches $N$ cards in their hand, or the deck is empty. (Normally at the end of the game scores would be determined by the contents of each player's hands, but for the purposes of this question that's irrelevant.)

I want to know how many nodes there are in the game tree for various values of $M$ and $N$, and also how many of those are leaf nodes. I wrote a program to brute-force the solution by searching every possible game state (see below), but I feel like there should be a way to calculate the answer in constant time. Does such a solution exist?


Brute force script:

import datetime
import time

class GameState ():
    DECK_SIZE = 16
    HAND_LIMIT = 8
    INITIAL_DECK = list(range(DECK_SIZE))
    INITIAL_HAND = []

    def __init__(self, otherState = None):
        # global state
        self.deck = getattr(otherState, 'deck', self.INITIAL_DECK).copy()
        self.player = getattr(otherState, 'player', True) # True for player one, False for player two
        self.passed = getattr(otherState, 'passed', False)
        # per-player state
        self.playerOneHand = getattr(otherState, 'playerOneHand', self.INITIAL_HAND).copy()
        self.playerTwoHand = getattr(otherState, 'playerTwoHand', self.INITIAL_HAND).copy()

    @property
    def terminal (self):
        return (
            not self.deck or
            len(self.playerOneHand) == self.HAND_LIMIT or
            len(self.playerTwoHand) == self.HAND_LIMIT
        )

    def takeTurn (self, takeCard):
        if takeCard:
            hand = self.playerOneHand if self.player else self.playerTwoHand
            hand.append(self.deck.pop())
            self.passed = False
        elif self.passed:
            # discard
            self.deck.pop()
            self.passed = False
        else:
            # don't discard and give other player a chance to take
            self.passed = True

        self.player = not self.player

        return self

    def __repr__ (self):
        innards = [
            f"deck={self.deck}",
            f"player={self.player}",
            f"passed={self.passed}",
            f"playerOneHand={self.playerOneHand}",
            f"playerTwoHand={self.playerTwoHand}",
            f"terminal={self.terminal}"
        ]

        return f"GameState({', '.join(innards)})"


node_count = 0
leaf_count = 0

to_visit = [GameState()]

start = time.monotonic()
while to_visit:
    node = to_visit.pop()
    node_count += 1

    print(node)

    if node.terminal:
        leaf_count += 1
    else:
        to_visit.extend([
            GameState(node).takeTurn(True),
            GameState(node).takeTurn(False),
        ])
end = time.monotonic()

calc_seconds = end - start

print()
print(f"leaves: {leaf_count:,}")
print(f"total nodes: {node_count:,}")
print(f"calculated in {datetime.timedelta(seconds=calc_seconds)}")

One interesting property I noticed from this is that there are always $(TotalNodes+1)/2$ leaf nodes. I would have expected something more like $(TotalNodes-1)/2$, with the $-1$ accounting for the root node.

Here are the values I've gotten so far, excluding leaf node counts:

M   N   Nodes
1   1   5 
1   2   5 
1   3   5 
1   4   5 
1   5   5 
1   6   5 
1   7   5 
1   8   5 
2   1   9 
2   2   17 
2   3   17 
2   4   17 
2   5   17 
2   6   17 
2   7   17 
2   8   17 
3   1   13 
3   2   45 
3   3   53 
3   4   53 
3   5   53 
3   6   53 
3   7   53 
3   8   53 
4   1   17 
4   2   97 
4   3   153 
4   4   161 
4   5   161 
4   6   161 
4   7   161 
4   8   161 
5   1   21 
5   2   181 
5   3   405 
5   4   477 
5   5   485 
5   6   485 
5   7   485 
5   8   485 
6   1   25 
6   2   305 
6   3   969 
6   4   1,361 
6   5   1,449 
6   6   1,457 
6   7   1,457 
6   8   1,457 
7   1   29 
7   2   477 
7   3   2,101 
7   4   3,693 
7   5   4,261 
7   6   4,365 
7   7   4,373 
7   8   4,373 
8   1   33 
8   2   705 
8   3   4,177 
8   4   9,409 
8   5   12,217 
8   6   12,993 
8   7   13,113 
8   8   13,121 
9   1   37 
9   2   997 
9   3   7,717 
9   4   22,357 
9   5   33,845 
9   6   38,205 
9   7   39,221 
9   8   39,357 
10  1   41 
10  2   1,361 
10  3   13,409 
10  4   49,553 
10  5   89,769 
10  6   110,257 
10  7   116,649 
10  8   117,937 
11  1   45 
11  2   1,805 
11  3   22,133 
11  4   102,917 
11  5   226,293 
11  6   310,285 
11  7   343,557 
11  8   352,525 
12  1   49 
12  2   2,337 
12  3   34,985 
12  4   201,569 
12  5   540,105 
12  6   845,857 
12  7   997,401 
12  8   1,048,609 
13  1   53 
13  2   2,965 
13  3   53,301 
13  4   374,765 
13  5   1,220,181 
13  6   2,219,725 
13  7   2,840,661 
13  8   3,094,621 
14  1   57 
14  2   3,697 
14  3   78,681 
14  4   665,553 
14  5   2,614,929 
14  6   5,581,009 
14  7   7,897,353 
14  8   9,029,905 
15  1   61 
15  2   4,541 
15  3   113,013 
15  4   1,135,229 
15  5   5,334,853 
15  6   13,410,037 
15  7   21,323,973 
15  8   25,957,165 
16  3   158,497 
16  4   1,868,673 
16  8   73,224,577 
24  3   1,309,809 
24  4   41,210,945 
32  3   5,779,521 
32  4   354,275,073