Game Theory
Authors: Benjamin Qi, Mihnea Brebenel
Contributor: Salma J
Solving games that are usually two-player to find the winner.
Resources | ||||
---|---|---|---|---|
TC | ||||
John H. Conway | ||||
E. R. Berlekamp | ||||
cp-algo | ||||
cp-algo |
Nim
Focus Problem – try your best to solve this problem before continuing!
Rules
Nim might be one of the most well-known examples in game theory. In this game there are considered piles of some item. The problem uses sticks, while some others use stones; we'll be using sticks to stay consistent with the CSES problem. Regardless, players take turns removing a nonzero number of sticks from a certain pile, and the player who takes the last stick wins.
Let's represent the current state of the piles with , where is the number of sticks in pile . You'll soon see that it doesn't matter whether we include empty piles in our array.
We now propose something that may seem unintuitive at first:
The player who moves first can always win if the xor-sum of the sizes of the piles is nonzero.
Proof
For our own sanity, we'll refer to a state with a xor-sum of 0 as a "losing state" and a state with a nonzero xor-sum as a "winning state."
To prove this, we have to show that from a losing state, any move we make will result in a winning state, and from a winning state, at least one move that we can make will result in a losing state. Notice that when we make our move, the game switches to a "sub-game" of sorts where the second player becomes the first player. Thus, moving to a winning state for the next turn means that they win and we lose.
However, it's probably good to prove our base cases first. is a losing state, since the first player can't make any moves and thus loses. On the other hand, where is any positive integer is a winning state since we can take sticks from the only pile. As we can see, the first state has a xor-sum of while the second has a xor-sum of which is nonzero.
Winning
LosingLet's first prove that any losing state will move to a winning state for the other player.
Since XOR is a commutative and associative operation, we can assume WLOG that the move we make is on pile .
The current xor-sum of the state is . Notice that this can be thought of XOR the xor-sum of the rest of the numbers. Let's call that xor-sum of everything else .
Since the xor-sum is , . Removing any number of sticks from will break this equality and turn nonzero, which is precisely the definition for our winning state.
Losing
WinningNow we have to prove that any winning state can move to a losing state for the other player. Notice the use of can instead of will in our statement due to how these games work.
This time, let's define as the xor-sum of all the piles instead of all except for one. Then, say we have an such that . Note that is the xor-sum of all the other elements, since XOR is its own inverse.
With this, we can take sticks from , then makes it equivalent to and the xor-sum of the whole board will be , thus forming a losing state for the second player.
But all of this hinges on the existence of an such that . Does it always exist?
Suppose that is the position of the most significant bit in . Then, we know that there must exist some with a set bit at as well. Whether is the most significant bit in or not, XOR-ing the two gives a result with a at bit . This then guarantees that there exists an .
Variable | Value |
---|---|
0...01?...? | |
?...?1?...? | |
?...?0?...? |
Since the first part of is all 0
s, the first parts of and
are the same.
Implementation
Time Complexity:
C++
#include <iostream>#include <vector>using std::cout;using std::endl;using std::vector;int main() {int test_num;std::cin >> test_num;
Java
import java.io.*;import java.util.*;public class Main {public static void main(String[] args) throws IOException {BufferedReader read = new BufferedReader(new InputStreamReader(System.in));int testNum = Integer.parseInt(read.readLine());for (int t = 0; t < testNum; t++) {int pileNum = Integer.parseInt(read.readLine());StringTokenizer pileST = new StringTokenizer(read.readLine());
Python
for _ in range(int(input())):pile_num = int(input())sizes = [int(p) for p in input().split()]assert pile_num == len(sizes)xor_sum = 0for s in sizes:xor_sum ^= sprint("first" if xor_sum != 0 else "second")
Optional: What if we could add?
In some cases, players may be allowed to add sticks as well as remove them. Funnily enough, this doesn't change how the winning and losing states are determined, i.e. the outcome of the game.
Sprague-Grundy theorem
Resources | ||||
---|---|---|---|---|
cp-algo | ||||
Youtube |
Before we get into this theorem, we first have to define what "nimbers" (sometimes referred to as "grundy values") are. Each nimber — say 0, 3, or 4 — represents a one-pile game of Nim with that many stones. Notice that for any positive nimber, the first player always wins since they can take all the stones from the pile and leave none for the second. On the other hand, the nimber 0 is a losing state, since the first player can't take any more stones.
What the Sprague-Grundy theorem allows us to do is to reduce the state of certain types of games into a nimber.
The way we do this is with a recursive definition. If we can compute the nimbers of all states reachable from a certain state , then itself can be reduced to the nimber
For convenience, we have defined as the function that reduces game states to nimbers.
The function takes a list of numbers and returns the smallest non-negative integer that is not included in the list. Notice that the name is a portmanteau of "min" and "excluded".
One-pile Example
Let's take a look at an altered version case of Nim. Here's there's only one pile, but each player can only remove 1, 2, or 3 stones.
According to the theorem, the nimber value is the of the reachable nimber value. In the case of this game, each state can reach the previous values because we can only remove 1, 2 or 3 stones in one move.
Here's how the values are computed:
This gives us the following table of nimber values:
Here's the table of nimber values:
All nimber reductions that are equal to 0 are losing states; see and .
Recall that a winning state means that you can put the other player in a losing state. On the other hand, a losing state means that every move results in a winning state for the opponent. For example, because , , and are winning. Similarly, , because we can't reach in a single move.
Multiple-pile Example
How about we try reducing multiple-pile Nim games with this theorem?
We know that a state is a losing state if the xor-sum is 0. This matches up exactly with how our nimbers work, as the 0 nimber represents a losing state.
Thus, to reduce a Nim game of multiple piles to a nimber, we can take the xor-sum of the nimbers of the single piles. Notice that the nimber of only one pile is the size of the pile.
This is equivalent to the formula using mex, but a proof of that is beyond the scope of this module. We merely try to give some intuition for why this is true.
This xor-sum reduction can be extended to more things than just Nim. If we can decompose a game into multiple disjoint parts where one player makes a chooses a subgame then makes a move, then the nimbers of these games can be combined with the xor-sum.
Applications
S-NIM
Focus Problem – try your best to solve this problem before continuing!
Explanation
We can compute the nimber values of each pile with the mex definition and then combine them using the XOR operator.
Implementation
Time Complexity: , where is the maximum pile size.
C++
#include <algorithm>#include <iostream>#include <set>#include <vector>using std::cout;using std::endl;using std::vector;const int MAX_PILE = 1e4;
Python
MAX_PILE = 10**4can_remove = [int(i) for i in input().split()][1:]nimbers = [0 for _ in range(MAX_PILE + 1)]for i in range(1, MAX_PILE + 1):reachable = {nimbers[i - n] for n in can_remove if i - n >= 0}for n in range(MAX_PILE + 1):if n not in reachable:nimbers[i] = n
Chessboard Game, Again!
Focus Problem – try your best to solve this problem before continuing!
Explanation
Here we see a case of using the xor-sum to combine things that aren't straight Nim games.
In this problem, we can decompose each game into a bunch of subgames, where each subgame is a single coin. Players choose the subgame (coin) and then make a move.
We first apply the mex formula to calculate nimbers for individual coins, and then combine them at the end with a xor-sum
Implementation
Time Complexity: , since the board is constant size.
C++
#include <iostream>#include <map>#include <set>#include <vector>using std::cout;using std::endl;using std::vector;const int BOARD_LEN = 15;
Python
from functools import lru_cacheBOARD_LEN = 15@lru_cachedef nimber(r: int, c: int) -> int:""":return: The nimber value of a coin at a single position"""if not (0 <= r < BOARD_LEN and 0 <= c < BOARD_LEN):return -1 # Return -1 to not interfere with the mex operation
Problems
Status | Source | Problem Name | Difficulty | Tags | |
---|---|---|---|---|---|
CF | Easy | Show TagsGame Theory, Nimbers | |||
CF | Easy | Show TagsBitmasks, DP, Game Theory, Nimbers | |||
CF | Easy | Show TagsBitmasks, DP, Game Theory, Nimbers | |||
IOI | Easy | Show TagsGame Theory | |||
IOI | Normal | Show TagsGame Theory | |||
Baltic OI | Normal | Show TagsGame Theory | |||
AC | Normal | Show TagsGame Theory, Nimbers, Tree | |||
AC | Normal | Show TagsGame Theory, Nimbers | |||
GCJ | Hard | Show TagsGame Theory, Nimbers | |||
AC | Hard | Show TagsGame Theory, NIM | |||
POI | Very Hard | ||||
POI | Very Hard | ||||
POI | Insane |
Module Progress:
Join the USACO Forum!
Stuck on a problem, or don't understand a module? Join the USACO Forum and get help from other competitive programmers!