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
Classification P/N — positions gagnantes (N) vs perdantes (P), jeux de soustraction
Nim et nim-sum — théorème de Bouton (1902), xor des tailles de tas
mex (minimum excludant) — brique de base des valeurs de Grundy
Valeurs de Grundy — calcul par DP, réduction à Nim
Théorème de Sprague-Grundy — somme de jeux = xor des Grundy
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'affichagestaticvoidShow(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.staticchar[]ClassifySubtraction(int nMax,int[] moves){char[] pos =newchar[nMax +1]; pos[0]='P';// position terminale -> Pfor(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 =newStringBuilder();sb.AppendLine("Position | Type | Motif");sb.AppendLine(newstring('-',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();
The below script needs to be able to find the current output cell; this is an easy method to get it.
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)
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 :
où \(\oplus\) est le ou-exclusif bit à bit (opérateur ^ en C#).
// Nim-sum : XOR des tailles de tas (Bouton's theorem).staticintNimSum(paramsint[] heaps){int r =0;foreach(int h in heaps) r ^= h;return r;}staticcharNimPositionType(paramsint[] 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 =newStringBuilder();sb2.AppendLine("Tas | Nim-sum | Type");sb2.AppendLine(newstring('-',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)returnnull;// P-position, pas de coup gagnantfor(int i =0; i < heaps.Length; i++){int newH = heaps[i]^ ns;if(newH < heaps[i])// on doit retirer, pas ajouterreturn(i, newH);}returnnull;}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.staticintMex(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.staticintGrundyNim(int n, Dictionary<int,int> memo =null){ memo ??=new Dictionary<int,int>();if(memo.TryGetValue(n,outint v))return v;if(n ==0)return0;// 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 =newStringBuilder();sb3.AppendLine(" n | grundy(nim(n)) | Verification");sb3.AppendLine(newstring('-',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 :
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).staticintGrundySubtraction(int n,int[] moves, Dictionary<int,int> memo =null){ memo ??=new Dictionary<int,int>();if(memo.TryGetValue(n,outint v))return v;if(n ==0)return0;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 =newStringBuilder();sb4.AppendLine($"Jeu de soustraction S({{1, 3, 4}})");sb4.AppendLine();sb4.AppendLine(" n | Grundy | Type");sb4.AppendLine(newstring('-',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 =newStringBuilder();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();
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).staticstringAnalyzeNimGame(int[] heaps){int[] grundyValues =(int[])heaps.Clone();// Pour le Nim, grundy(h) = hint totalGrundy =NimSum(grundyValues);var wm =FindWinningMove(heaps);var sb =newStringBuilder(); 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}));
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).