GameTheory 8 - Jeux Combinatoires (Twin C#)

Navigation : << 7-ExtensiveForm | Index | 9-BackwardInduction >>

Twin C# (.NET Interactive) de GameTheory-08-CombinatorialGames-Python.ipynb — marathon #4956 (parité .NET ⇄ Python).

Les jeux combinatoires sont des jeux à information complète, hasard absent, où les joueurs jouent à tour de rôle jusqu’à ce qu’aucun coup ne soit possible. Le perdant est celui qui ne peut plus jouer (convention normal play). Cette théorie admet une solution complète : le théorème de Sprague-Grundy (1935-1939) ramène tout jeu combinatoire impartial à une somme de Nim.

Plan pédagogique

  1. Classification P/N — positions gagnantes (N) vs perdantes (P), jeux de soustraction
  2. Nim et nim-sum — théorème de Bouton (1902), xor des tailles de tas
  3. mex (minimum excludant) — brique de base des valeurs de Grundy
  4. Valeurs de Grundy — calcul par DP, réduction à Nim
  5. Théorème de Sprague-Grundy — somme de jeux = xor des Grundy
  6. Exercices

Parité #4956 : le notebook Python implémente déjà les algorithmes from-scratch (numpy pour les tableaux, matplotlib pour la viz). Ce twin C# (BCL .NET 9, 0 NuGet) traduit la logique en C# pur, la visualisation matplotlib devient des tables console (convention GT-4c). L’élève voit le même cœur algorithmique dans deux langages.

1. Classification P/N

Une position est P (Previous-player win) si le joueur qui vient de jouer peut gagner — i.e. le joueur dont c’est le tour perd. Elle est N (Next-player win) sinon. Règle récursive :

  • La position terminale (aucun coup) est P.
  • Une position est N si elle peut atteindre au moins une P-position.
  • Une position est P si tous ses coups mènent à des N-positions.

Jeu de soustraction 1-2-3

On part de \(n\) jetons. À chaque tour on retire 1, 2 ou 3 jetons. Qui ne peut plus jouer perd.

using System.Linq;
using System.Text;

// Helpers d'affichage
static void Show(string s) { s.Display(); }

// Classification P/N d'un jeu de soustraction defini par un ensemble de coups.
// Retourne un tableau 'P'/'N' pour les positions 0..nMax.
static char[] ClassifySubtraction(int nMax, int[] moves)
{
    char[] pos = new char[nMax + 1];
    pos[0] = 'P';   // position terminale -> P
    for (int n = 1; n <= nMax; n++)
    {
        bool canReachP = false;
        foreach (int t in moves)
            if (n >= t && pos[n - t] == 'P') { canReachP = true; break; }
        pos[n] = canReachP ? 'N' : 'P';
    }
    return pos;
}

// Jeu 1-2-3 : P-positions = multiples de 4.
var pos123 = ClassifySubtraction(16, new[] { 1, 2, 3 });
var sb = new StringBuilder();
sb.AppendLine("Position | Type | Motif");
sb.AppendLine(new string('-', 30));
for (int n = 0; n <= 16; n++)
{
    string motif = (n % 4 == 0) ? "Perdante (n % 4 == 0)" : "Gagnante";
    sb.AppendLine($"  {n,2}     |  {pos123[n]}   | {motif}");
}
Show(sb.ToString());
"Verdict : P-positions = 0, 4, 8, 12, 16 (n % 4 == 0).".Display();
Position | Type | Motif
------------------------------
   0     |  P   | Perdante (n % 4 == 0)
   1     |  N   | Gagnante
   2     |  N   | Gagnante
   3     |  N   | Gagnante
   4     |  P   | Perdante (n % 4 == 0)
   5     |  N   | Gagnante
   6     |  N   | Gagnante
   7     |  N   | Gagnante
   8     |  P   | Perdante (n % 4 == 0)
   9     |  N   | Gagnante
  10     |  N   | Gagnante
  11     |  N   | Gagnante
  12     |  P   | Perdante (n % 4 == 0)
  13     |  N   | Gagnante
  14     |  N   | Gagnante
  15     |  N   | Gagnante
  16     |  P   | Perdante (n % 4 == 0)
Verdict : P-positions = 0, 4, 8, 12, 16 (n % 4 == 0).

La table de classification montre clairement la périodicité des positions : toutes les positions où n est multiple de 4 (0, 4, 8, 12, 16) sont marquées P (perdantes).

La périodicité n % 4 == 0 est la signature du jeu de soustraction S({1,2,3}) — chaque joueur pouvant retirer 1, 2 ou 3 allumettes, les multiples de 4 sont perdants. Pour S({1,3,4}), la structure est différente (P-positions 0, 2, 7, …), comme le révèle l’analyse de Grundy plus loin.

2. Nim et nim-sum (théorème de Bouton)

Le Nim se joue avec \(k\) tas de jetons. Un coup = choisir un tas et retirer au moins un jeton. Bouton (1902) : une position \((h_1, \ldots, h_k)\) est P si et seulement si la nim-sum (xor des tailles) vaut 0 :

\[\bigoplus_{i=1}^{k} h_i = 0 \iff \text{P-position}\]

où \(\oplus\) est le ou-exclusif bit à bit (opérateur ^ en C#).

// Nim-sum : XOR des tailles de tas (Bouton's theorem).
static int NimSum(params int[] heaps)
{
    int r = 0;
    foreach (int h in heaps) r ^= h;
    return r;
}

static char NimPositionType(params int[] heaps) => NimSum(heaps) == 0 ? 'P' : 'N';

// Cas canoniques
(int[] heaps, string label)[] examples = {
    (new[] { 1, 2, 3 }, "(1,2,3)"),
    (new[] { 1, 2, 4 }, "(1,2,4)"),
    (new[] { 3, 5, 6 }, "(3,5,6)"),
    (new[] { 7, 7 }, "(7,7)"),
    (new[] { 1, 1, 1 }, "(1,1,1)"),
};

var sb2 = new StringBuilder();
sb2.AppendLine("Tas           | Nim-sum | Type");
sb2.AppendLine(new string('-', 35));
foreach (var (h, lab) in examples)
{
    int ns = NimSum(h);
    sb2.AppendLine($"{lab,13} | {ns,7} | {NimPositionType(h)}");
}
Show(sb2.ToString());
"Verdict : (1,2,3), (3,5,6), (7,7) sont P (xor=0) ; (1,2,4), (1,1,1) sont N.".Display();
Tas           | Nim-sum | Type
-----------------------------------
      (1,2,3) |       0 | P
      (1,2,4) |       7 | N
      (3,5,6) |       0 | P
        (7,7) |       0 | P
      (1,1,1) |       1 | N
Verdict : (1,2,3), (3,5,6), (7,7) sont P (xor=0) ; (1,2,4), (1,1,1) sont N.

La sortie valide concrètement ce principe : les positions (1,2,3), (3,5,6), (7,7) ont un XOR nul et sont donc correctement identifiées comme P-positions.

2.1 Trouver un coup gagnant

Si la position est N (nim-sum \(\neq 0\)), il existe toujours un coup qui ramène la nim-sum à 0. Pour le tas \(i\) de taille \(h_i\) : la nouvelle taille \(h_i' = h_i \oplus s\) où \(s\) est la nim-sum courante. On choisit le tas pour lequel \(h_i' < h_i\) (on doit retirer, pas ajouter).

// Trouve un coup gagnant au Nim : (index_tas, nouvelle_taille) ou null si P-position.
static (int idx, int newSize)? FindWinningMove(int[] heaps)
{
    int ns = NimSum(heaps);
    if (ns == 0) return null;   // P-position, pas de coup gagnant
    for (int i = 0; i < heaps.Length; i++)
    {
        int newH = heaps[i] ^ ns;
        if (newH < heaps[i])   // on doit retirer, pas ajouter
            return (i, newH);
    }
    return null;
}

int[] heaps = { 3, 5, 7 };
int s = NimSum(heaps);
$"Position: [{string.Join(", ", heaps)}]  Nim-sum: {s}  Type: {NimPositionType(heaps)}".Display();
var mv = FindWinningMove(heaps);
if (mv is (int idx, int newSize))
{
    int[] newHeaps = (int[])heaps.Clone();
    newHeaps[idx] = newSize;
    $"Coup gagnant : reduire tas {idx} de {heaps[idx]} a {newSize}".Display();
    $"Nouvelle position: [{string.Join(", ", newHeaps)}]  Nim-sum: {NimSum(newHeaps)}".Display();
}
Position: [3, 5, 7]  Nim-sum: 1  Type: N
Coup gagnant : reduire tas 0 de 3 a 2
Nouvelle position: [2, 5, 7]  Nim-sum: 0

L’algorithme ne se contente pas de classer la position [3, 5, 7] comme N (gagnante) : il identifie concrètement un coup gagnant qui consiste à réduire le tas 0 de 3 à 2.

3. mex (minimum excludant)

Le mex (minimum excludant) d’un ensemble \(S \subseteq \mathbb{N}\) est le plus petit entier \(\geq 0\) absent de \(S\) :

\[\operatorname{mex}(S) = \min\{ n \in \mathbb{N} : n \notin S \}\]

C’est la brique de base des valeurs de Grundy.

// mex : plus petit entier >= 0 absent de l'ensemble.
static int Mex(IEnumerable<int> s)
{
    var set = new HashSet<int>(s);
    int n = 0;
    while (set.Contains(n)) n++;
    return n;
}

// Cas canoniques
(int[] vals, string label)[] mexExamples = {
    (Array.Empty<int>(), "mex({})"),
    (new[] { 0 }, "mex({0})"),
    (new[] { 0, 1, 2 }, "mex({0,1,2})"),
    (new[] { 1, 2, 3 }, "mex({1,2,3})"),
    (new[] { 0, 2, 4 }, "mex({0,2,4})"),
};
foreach (var (v, lab) in mexExamples)
    $"{lab} = {Mex(v)}".Display();
mex({}) = 0
mex({0}) = 1
mex({0,1,2}) = 3
mex({1,2,3}) = 0
mex({0,2,4}) = 1

Les cinq cas canoniques se lisent comme autant de façons de chercher le premier trou : mex({1,2,3}) = 0 parce que 0 manque dès le départ, mex({0}) = 1 et mex({0,2,4}) = 1 parce que le premier entier absent est 1 — les entiers plus grands présents dans l’ensemble n’y changent rien. Les deux bornes sont mex({}) = 0 (ensemble vide) et mex({0,1,2}) = 3 (préfixe complet 0..2).

4. Valeurs de Grundy

La valeur de Grundy \(\mathcal{G}(G)\) d’une position \(G\) est définie récursivement :

\[\mathcal{G}(G) = \operatorname{mex}\{ \mathcal{G}(G') : G' \text{ position accessible depuis } G \}\]

Une position est P si et seulement si \(\mathcal{G}(G) = 0\).

Nim à un tas

Pour un seul tas de \(n\) jetons (Nim), les positions accessibles sont \(0, 1, \ldots, n-1\). On montre par récurrence que \(\mathcal{G}(\text{nim}(n)) = n\).

// Valeur de Grundy de nim(n) : un seul tas de n jetons. Spoiler : G = n.
static int GrundyNim(int n, Dictionary<int, int> memo = null)
{
    memo ??= new Dictionary<int, int>();
    if (memo.TryGetValue(n, out int v)) return v;
    if (n == 0) return 0;
    // Positions accessibles : nim(0), nim(1), ..., nim(n-1)
    var reachable = Enumerable.Range(0, n).Select(i => GrundyNim(i, memo));
    int result = Mex(reachable);
    memo[n] = result;
    return result;
}

var sb3 = new StringBuilder();
sb3.AppendLine(" n  | grundy(nim(n)) | Verification");
sb3.AppendLine(new string('-', 35));
for (int n = 0; n < 10; n++)
{
    int g = GrundyNim(n);
    string ok = (g == n) ? "OK" : "ERREUR";
    sb3.AppendLine($"{n,2}  | {g,14} | {ok}");
}
Show(sb3.ToString());
"Verdict : grundy(nim(n)) = n pour tout n (identite du Nim).".Display();
 n  | grundy(nim(n)) | Verification
-----------------------------------
 0  |              0 | OK
 1  |              1 | OK
 2  |              2 | OK
 3  |              3 | OK
 4  |              4 | OK
 5  |              5 | OK
 6  |              6 | OK
 7  |              7 | OK
 8  |              8 | OK
 9  |              9 | OK
Verdict : grundy(nim(n)) = n pour tout n (identite du Nim).

L’identité G(nim(n)) = n est ce qui fait du Nim un jeu canonique en théorie combinatoire des jeux : chaque tas se comporte indépendamment et sa contribution au nim-sum est simplement sa taille.

5. Jeux de soustraction et théorème de Sprague-Grundy

5.1 Grundy d’un jeu de soustraction

Pour \(S(M)\) — jeu où on retire \(m \in M\) jetons — la valeur de Grundy satisfait :

\[\mathcal{G}(n) = \operatorname{mex}\{ \mathcal{G}(n - m) : m \in M, m \leq n \}\]

5.2 Théorème de Sprague-Grundy

La somme de deux jeux combinatoires impartiaux \(G_1 + G_2\) (les joueurs choisissent à chaque tour dans quel composant jouer) a pour valeur de Grundy :

\[\mathcal{G}(G_1 + G_2) = \mathcal{G}(G_1) \oplus \mathcal{G}(G_2)\]

Ce théorème ramène tout jeu combinatoire impartial à un Nim équivalent. La stratégie optimale : calculer la nim-sum des Grundy ; si \(\neq 0\) (N-position), trouver le coup qui l’annule.

// Valeur de Grundy pour un jeu de soustraction S(moves).
static int GrundySubtraction(int n, int[] moves, Dictionary<int, int> memo = null)
{
    memo ??= new Dictionary<int, int>();
    if (memo.TryGetValue(n, out int v)) return v;
    if (n == 0) return 0;
    var reachable = new List<int>();
    foreach (int m in moves)
        if (m <= n) reachable.Add(GrundySubtraction(n - m, moves, memo));
    int result = Mex(reachable);
    memo[n] = result;
    return result;
}

// Analyser S({1, 3, 4})
int[] moves = { 1, 3, 4 };
var memo = new Dictionary<int, int>();
var sb4 = new StringBuilder();
sb4.AppendLine($"Jeu de soustraction S({{1, 3, 4}})");
sb4.AppendLine();
sb4.AppendLine(" n  | Grundy | Type");
sb4.AppendLine(new string('-', 20));
for (int n = 0; n < 20; n++)
{
    int g = GrundySubtraction(n, moves, memo);
    char t = (g == 0) ? 'P' : 'N';
    sb4.AppendLine($"{n,2}  | {g,6} | {t}");
}
Show(sb4.ToString());
"P-positions de S({1,3,4}) : n = 0, 2, 7, ... (Grundy = 0).".Display();
Jeu de soustraction S({1, 3, 4})

 n  | Grundy | Type
--------------------
 0  |      0 | P
 1  |      1 | N
 2  |      0 | P
 3  |      1 | N
 4  |      2 | N
 5  |      3 | N
 6  |      2 | N
 7  |      0 | P
 8  |      1 | N
 9  |      0 | P
10  |      1 | N
11  |      2 | N
12  |      3 | N
13  |      2 | N
14  |      0 | P
15  |      1 | N
16  |      0 | P
17  |      1 | N
18  |      2 | N
19  |      3 | N
P-positions de S({1,3,4}) : n = 0, 2, 7, ... (Grundy = 0).

Pour le jeu de soustraction S({1, 3, 4}), les valeurs de Grundy varient selon la position : G(0) = 0, G(1) = 1, G(2) = 0, G(3) = 1, G(4) = 2, révélant une structure périodique complexe.

Les valeurs de Grundy pour S({1, 3, 4}) révèlent que la périodicité n’est pas toujours simple : G(5) = 3, G(6) = 2, montrant un cycle plus complexe que la simple alternance.

5.3 Vérification du théorème sur \(S(\{1,2\}) + S(\{1,3\})\)

On calcule les valeurs de Grundy de \(G_1 = S(\{1,2\})\) et \(G_2 = S(\{1,3\})\) séparément, puis on vérifie que la Grundy de la somme \(G_1 + G_2\) à l’état \((n_1, n_2)\) vaut bien \(\mathcal{G}_1(n_1) \oplus \mathcal{G}_2(n_2)\).

Démonstration de principe : pour \(S(\{1,2\})\), la séquence de Grundy est \(0,1,2,0,1,2,\ldots\) (période 3). Pour \(S(\{1,3\})\), c’est \(0,1,0,1,\ldots\) (période 2). Le xor terme à terme donne la Grundy de la somme.

// Verifie le theoreme de Sprague-Grundy sur S({1,2}) + S({1,3}).
// La somme (n1, n2) a pour Grundy G1(n1) xor G2(n2).
int nMax = 12;
var g1 = new Dictionary<int, int>();
var g2 = new Dictionary<int, int>();
int[] m12 = { 1, 2 };
int[] m13 = { 1, 3 };

var sb5 = new StringBuilder();
sb5.AppendLine("G1 = S({1,2}) : ");
for (int n = 0; n < nMax; n++) g1[n] = GrundySubtraction(n, m12);
sb5.AppendLine("  " + string.Join(", ", Enumerable.Range(0, nMax).Select(n => g1[n])));
sb5.AppendLine("G2 = S({1,3}) : ");
for (int n = 0; n < nMax; n++) g2[n] = GrundySubtraction(n, m13);
sb5.AppendLine("  " + string.Join(", ", Enumerable.Range(0, nMax).Select(n => g2[n])));
sb5.AppendLine("XOR (Grundy de la somme) : ");
sb5.AppendLine("  " + string.Join(", ", Enumerable.Range(0, nMax).Select(n => g1[n] ^ g2[n])));
Show(sb5.ToString());

// Verifier une position de la somme : (n1=3, n2=4)
int n1 = 3, n2 = 4;
int gSum = g1[n1] ^ g2[n2];
$"Somme S({{1,2}})({n1}) + S({{1,3}})({n2}) : Grundy = {g1[n1]} xor {g2[n2]} = {gSum}".Display();
$"Type: {(gSum == 0 ? "P-position (perdante)" : "N-position (gagnante)")}".Display();
"Verdict : la Grundy de la somme = xor des Grundy. Sprague-Grundy verifie.".Display();
G1 = S({1,2}) : 
  0, 1, 2, 0, 1, 2, 0, 1, 2, 0, 1, 2
G2 = S({1,3}) : 
  0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1
XOR (Grundy de la somme) : 
  0, 0, 2, 1, 1, 3, 0, 0, 2, 1, 1, 3
Somme S({1,2})(3) + S({1,3})(4) : Grundy = 0 xor 0 = 0
Type: P-position (perdante)
Verdict : la Grundy de la somme = xor des Grundy. Sprague-Grundy verifie.

La séquence XOR de la sortie — 0, 0, 2, 1, 1, 3, puis le même motif — illustre une conséquence directe du théorème : la composition d’une période 3 (pour S({1,2})) et d’une période 2 (pour S({1,3})) donne une période 6, visible sur les douze valeurs affichées. Le point de contrôle (n1, n2) = (3, 4) tombe sur g1(3) = 0 et g2(4) = 0 : la somme est classée P-position (perdante pour le joueur au trait).

5.4 Analyse complète d’une position de Nim

Pour le Nim standard, \(\mathcal{G}(h_i) = h_i\) (taille du tas). La nim-sum des Grundy = xor des tailles = nim-sum de Bouton. Les deux théories coïncident.

// Analyse complete d'une position de Nim (synthese Bouton + Sprague-Grundy).
static string AnalyzeNimGame(int[] heaps)
{
    int[] grundyValues = (int[])heaps.Clone();   // Pour le Nim, grundy(h) = h
    int totalGrundy = NimSum(grundyValues);
    var wm = FindWinningMove(heaps);
    var sb = new StringBuilder();
    sb.AppendLine($"Position: [{string.Join(", ", heaps)}]");
    sb.AppendLine($"Valeurs de Grundy: [{string.Join(", ", grundyValues)}]");
    sb.AppendLine($"Nim-sum: {totalGrundy}");
    sb.AppendLine($"Type: {(totalGrundy == 0 ? "P" : "N")}-position");
    if (wm is (int idx, int newSize))
        sb.AppendLine($"Coup gagnant: tas {idx}: {heaps[idx]} -> {newSize}");
    return sb.ToString();
}

Show(AnalyzeNimGame(new[] { 4, 7, 9 }));
Position: [4, 7, 9]
Valeurs de Grundy: [4, 7, 9]
Nim-sum: 10
Type: N-position
Coup gagnant: tas 2: 9 -> 3

L’analyse complète de la position [4, 7, 9] montre les valeurs de Grundy [4, 7, 9] pour chaque tas, un nim-sum de 10, et un type N-position (gagnante).

Le coup proposé applique la règle de la section 2.1 : nouvelle taille = h_i ⊕ s, soit 9 ⊕ 10 = 3 pour le tas 2. C’est le seul tas éligible — 4 ⊕ 10 = 14 et 7 ⊕ 10 = 13 dépasseraient la taille courante, et un coup de Nim retire des jetons, il n’en ajoute jamais. Les valeurs de Grundy [4, 7, 9] étant égales aux tailles, les deux lectures (Bouton et Sprague-Grundy) prédisent le même coup.

6. Exercices

Convention C.1 : les stubs s’exécutent sans erreur (jamais throw). Remplir le corps, re-exécuter, vérifier.

Exercice 1 — Classification P/N avec ensemble de soustraction arbitraire

Objectif : classifier les positions P/N pour \(S(M)\) avec \(M\) arbitraire, et détecter la période de la séquence de Grundy.

Indices : - Étape 1 : utiliser ClassifySubtraction avec le bon ensemble moves. - Étape 2 : les P-positions sont les \(n\) avec grundy(n) == 0. - Étape 3 : la période = plus petit \(T > 0\) tel que la séquence se répète.

// Exercice 1 : classification P/N + periode pour S({1,3}) puis S({2,5}).
// TODO etudiant : retourner (positions P/N, liste des P-positions, periode detectee).
static (char[] positions, List<int> pPositions, int? period) ExoClassifyPN(int nMax, int[] moves)
{
    // Indice : les P-positions sont celles ou grundy(n) == 0.
    // Pour la periode : chercher le plus petit T > 0 tel que pos[n] == pos[n+T] pour tout n assez grand.
    return (Array.Empty<char>(), new List<int>(), null);  // TODO etudiant
}

"Exercice a completer".Display();
Exercice a completer

Exercice 2 — Sprague-Grundy sur la somme \(S(\{1,2\}) + S(\{1,3\})\)

Objectif : vérifier explicitement le théorème en comparant (a) la Grundy de la somme calculée directement, et (b) le xor des Grundy des composants.

Indice : la somme \((n_1, n_2)\) a pour coups \((n_1 - m, n_2)\) ou \((n_1, n_2 - m)\). Calculer sa Grundy par DP, puis comparer à \(\mathcal{G}_1(n_1) \oplus \mathcal{G}_2(n_2)\).

// Exercice 2 : verifier Sprague-Grundy sur S({1,2}) + S({1,3}).
// TODO etudiant : calculer g1, g2, xor, et un boolen verification_ok.
static (List<int> g1, List<int> g2, List<int> xorVals, bool verificationOk) ExoSpragueGrundySum(int nMax = 15)
{
    // Indice : g1 = grundy_subtraction pour S({1,2}), g2 pour S({1,3}).
    // verification_ok = true si pour tout n, la Grundy de la somme (n, n) egale g1[n] xor g2[n].
    return (new List<int>(), new List<int>(), new List<int>(), false);  // TODO etudiant
}

"Exercice a completer".Display();
Exercice a completer

Exercice 3 — Comparer \(\text{Nim}(\{4\})\) et \(S(\{1,3\})\) à la position 4

Objectif : un tas de Nim de taille 4 a-t-il la même valeur de Grundy que le jeu de soustraction \(S(\{1,3\})\) à la position 4 ? On pourrait le croire, car les deux jeux autorisent le retrait de 1 ou de 3 jetons. Mais est-ce vraiment le cas ?

Indice : calculer GrundyNim(4) et GrundySubtraction(4, new[]{1, 3}), puis comparer. - Rappel (section 4) : pour le Nim standard, \(\mathcal{G}(n) = n\), donc \(\mathcal{G}(4) = 4\). - Pour \(S(\{1,3\})\), la séquence des valeurs de Grundy a été calculée à la section 5 (cellule précédente) : 0, 1, 0, 1, 0, 1, ... — elle est périodique de période 2, donc \(\mathcal{G}(4) = 0\).

Les deux valeurs diffèrent (\(4 \neq 0\)) : l’équivalence intuitive est fausse. L’exercice consiste à le vérifier dans le stub ci-dessous, puis à expliquer pourquoi : bien que les coups légaux soient les mêmes {1, 3}, la structure du jeu diffère. Dans le Nim, un tas de \(n\) jetons est une somme disjointe d’objets indépendants et \(\mathcal{G}(n) = n\) (théorème de Bouton). Dans un jeu de soustraction, on ne peut retirer qu’un nombre fixé à l’avance de jetons (ici 1 ou 3), et la valeur de Grundy se calcule par récurrence \(\mathcal{G}(n) = \text{mex}\{\mathcal{G}(n-1), \mathcal{G}(n-3)\}\), ce qui produit une séquence périodique — pas l’identité. La capacité de retirer arbitrairement jusqu’à \(n\) jetons (Nim) n’est pas réductible à un ensemble fixe de retraits (soustraction).

// Exercice 3 : comparer Nim({4}) vs S({1,3}) a la position 4.
// TODO etudiant : calculer GrundyNim(4) et GrundySubtraction(4, new[]{1, 3}) puis comparer.
// ATTENTION : les deux jeux ont les memes coups {1, 3} MAIS ne sont PAS equivalents a la position 4
//   (GrundyNim(4) = 4 via Bouton, GrundySubtraction(4, {1,3}) = 0 via la sequence periodique 0,1,0,1,...).
// Expliquer pourquoi (voir l'enonce ci-dessus).
static (int grundyNim4, int grundySub4, bool equivalent) ExoEquivalence()
{
    // TODO etudiant : calculer les deux Grundy et renvoyer le verdict d'equivalence.
    return (0, 0, false);  // TODO etudiant
}

"Exercice a completer".Display();
Exercice a completer

Conclusion

Ce que vous avez appris

  • Classification P/N — récurrence sur les positions accessibles (un coup vers P => N ; tout coup vers N => P).
  • Nim-sum (Bouton 1902) — xor des tailles de tas ; P si et seulement si nim-sum = 0.
  • Coup gagnant au Nim — réduire le tas \(i\) à \(h_i \oplus s\) quand \(h_i \oplus s < h_i\).
  • mex — minimum excludant, brique des valeurs de Grundy.
  • Valeur de Grundy — \(\mathcal{G}(G) = \operatorname{mex}\) des Grundy accessibles ; calcul par DP avec mémoïsation.
  • Théorème de Sprague-Grundy — la somme de jeux a pour Grundy le xor des Grundy : tout jeu combinatoire impartial se ramène à un Nim.

Pont avec la version Python

La version Python (GameTheory-08-CombinatorialGames-Python.ipynb) implémente les mêmes algorithmes from-scratch (numpy pour les tableaux, matplotlib pour visualiser les périodes de Grundy). Ce twin C# traduit la logique en C# pur (BCL .NET 9), la viz matplotlib devient des tables console. Le notebook suivant (GameTheory-09-BackwardInduction-Python) passe aux jeux combinatoires en forme extensive (induction arrière, Arbres).

Parité #4956

Twin de parité légitime : les deux langages implémentent from-scratch. L’intérêt est la traduction du cœur algorithmique (mex, xor, DP Grundy) et la confirmation des cas canoniques (Nim-sum, séquences de Grundy périodiques).


Marathon #4956 (parité .NET ⇄ Python).

Retour au sommet