Theorie Des Jeux
Theorie Des Jeux
Theorie Des Jeux
Dans ce chapitre, on donne une introduction à l’étude de la théorie des jeux. Les jeux que l’on va étudier
sont des jeux à information complète, ce qui exclut la plupart des jeux de cartes (chaque joueur n’a pas
connaissance du jeu adverse et éventuellement du reste du paquet), et sans hasard (ce qui exclut les jeux de
dés par exemple).
On ne s’intéresse qu’à des jeux d’accessibilité à deux joueurs, qui peuvent être modélisés par des graphes
orientés, où jouer un coup consiste à suivre un arc. Dans un premier temps, on discute de jeux qui sont
suffisamment simples pour pouvoir être résolus complètement : l’espace des états possibles n’est pas trop
grand. Dans un second temps, on aborde la notion de stratégie avec heuristique, qui peut s’appliquer aux
jeux qu’il est impossible (à l’heure actuelle, au vu des contraintes matérielles) de résoudre complètement,
comme le jeu d’échecs ou le jeu de go.
I.2. Vocabulaire
Définition :
Un graphe orienté G = (S, A) est dit biparti s’il existe une partition de S en deux sous-ensembles S A
et SB , telle qu’aucun arc du graphe ne relie deux éléments de S A , ou deux éléments de SB . Autrement
dit, si l’origine d’un arc est dans S A , l’extrémité est dans SB et réciproquement.
Lorsque le graphe biparti est associé à un jeu à deux joueurs A et B , il s’appelle une arène, et les
éléments de S A (resp. SB ) sont dits contrôlés par le joueur A (resp. B ).
Par exemple, dans le graphe figure 2, les sommets contrôlés par le joueur A sont en gris clair et ceux
contrôlés par le joueur B en gris foncé.
Définition :
– Un chemin dans un graphe orienté G est dit maximal s’il est infini ou si l’extrémité de son dernier arc
est un sommet terminal, c’est-à-dire qui n’a pas de successeur.
– Soit G = (S, A), S A , SB un graphe de jeu. Une partie débutant en s ∈ S est un chemin maximal
d’origine s .
Définition :
– Une stratégie pour le joueur
A est une application σ qui à tout sommet s ∈ S A associe un sommet
σ (s) ∈ SB avec s, σ (s) ∈ A (on définit de la même façon une stratégie pour le joueur B ).
– Une partie P = (s0 , s1 , . . . , s p , . . .) est dite conforme à la stratégie σ pour le joueur A si, pour tout
s k ∈ P ∩ S A , s k +1 = σ ( s k ) .
Définition :
Soit G = (S, A), S A , SB un graphe de jeu. On dit qu’il s’agit d’un jeu d’accessibilité si
– il n’y a pas de match nul ;
– le joueur A (resp. B ) gagne une partie si et seulement si cette partie contient un sommet d’un
certain sous-ensemble VA (resp. VB ). VA et VB sont les conditions de victoire pour le joueur A
(resp. B ).
Définition :
Dans un jeu d’accessibilité, on dit qu’une stratégie σ est une stratégie gagnante depuis un sommet
s0 pour un joueur J si ce joueur gagne la partie lorsque cette partie est conforme à la stratégie σ , et
cela, quelle que soit la stratégie suivie par l’autre joueur.
Dans le jeu de Nim, puisque la partie est perdue dès qu’un joueur prend la dernière allumette, on peut
enlever du graphe les sommets 0 A et 0B ; dans ce cas, VA = {1B } et VB = {1 A } .
I.3. Attracteur
Il s’agit ici de trouver un algorithme permettant de déterminer l’ensemble des positions gagnantes pour un
joueur.
Soit donc un jeu d’accessibilité, représenté par un graphe de jeu G = (S, A), S A , SB , et VA une condition
de victoire pour le joueur A . On définit par récurrence l’attracteur de VA de la façon suivante :
on pose A0 (VA ) = VA ;
pour tout entier j l’ensemble A j+1 (VA ) contient :
– les sommets de A j (VA ) ;
– les sommets contrôlés par le joueur A pour lesquels il existe au moins un arc d’extrémité un sommet
de A j (VA )
– les sommets contrôlés par le joueur B dont tous les arcs aboutissent à des sommets de A j (VA ) .
La suite des A j (VA ) est évidemment croissante, et elle est stationnaire puisque les A j (VA ) sont inclus dans
S , ensemble fini.
Par définition, l’attracteur de VA est A(VA ) = A j (VA ) (réunion finie en fait). Cet ensemble contient
S
j ∈N
l’ensemble des positions gagnantes pour le joueur A .
Programmation
On commence par écrire une fonction Inverse Graphe(G) qui permet de construire la liste (sous forme d’un
dictionnaire) des prédécesseurs des sommets du graphe, c’est-à-dire d’inverser le sens des arêtes du graphe.
def Inverse_Graphe(G):
G_inv = {s: [] for s in G}
for s in G:
for s1 in G[s]:
G_inv[s1].append(s)
return G_inv
On peut ensuite, soit donner directement les conditions de victoire pour chaque joueur, soit les déterminer,
en considérant qu’un sommet est victorieux pour un joueur si l’autre ne peut plus jouer (en effet, on a
supposé qu’il n’y a pas de match nul). C’est ce que fait la fonction Victoire(G, joueur).
Enfin la fonction Attracteur(G, Joueur) détermine l’ensemble des positions gagnantes pour un joueur
donné, en construisant la suite des A j (V ) comme ci-dessus.
for s2 in G[s1]:
if not s2 in Aj:
convient = False
break
if convient:
if s1 not in Aj:
Aj.append(s1)
if len(Aj) == len(A):
break
else:
A = Aj
return(A)
Rem: Pour obtenir une stratégie gagnante pour un joueur à partir d’un sommet s , il suffit, une fois
déterminé l’attracteur A(V ) , de chercher un successeur de s dans G qui soit dans cet attracteur.
La profondeur d’un nœud est sa distance à la racine (la racine est donc l’unique nœud à profondeur zéro).
Pour la représentation graphique, tous les nœuds à une même profondeur sont représentés sur une même
ligne horizontale. Le fait que le graphe soit connexe et acyclique impose qu’il existe un unique chemin
simple (sans sommet en double) de la racine r à un nœud n quelconque de l’arbre. Le long de ce chemin il
y a une relation de parenté entre nœuds successifs : si p précède q , p est le père de q et q est unfils de p .
Un nœud sans fils est appelé une feuille.
Arbre pour un jeu Min-Max.
Un jeu Min-Max se représente comme un arbre, où les nœuds représentent des positions du jeu. Puisque les
joueurs jouent alternativement, les nœuds d’un même niveau sont alternativement contrôlés par un même
joueur. Par symétrie, on peut supposer que :
1. Aux jeux d’échecs, Deeper Blue bat le champion du monde en titre Garry Kasparov en 1997. Il a fallu attendre 2017 pour que le
champion du monde du jeu de Go Ke Jie soit battu par le programme informatique AlphaGo.
Max
10 4 -1
Mais Max peut aussi tenir compte du coup que va ensuite jouer Min, et donc calculer la valeur des positions
atteintes au deuxième niveau :
Max
-10 1 -5 0 -2 -4 -5 1 0
Ici, on voit que Max aura plutôt intérêt à jouer le deuxième coup : en effet, s’il joue le 1er, Min pourra obtenir
-10, s’il joue le second, Min pourra obtenir -4 et s’il joue le troisième, Min pourra obtenir -5.
(on rappelle que Max veut maximiser son score et Min le minimiser).
On peut réitérer le raisonnement, et tenir compte du coup suivant joué par Max :
Max
-5 -2 3 1 -2 9 0 -2 -4 2 -2 6 0 1 4 1 -2 2 2 1 3 0 7 8 0 -2 4
Maintenant, si Max joue le 1er coup, il peut espérer au maximum un score de 0 , s’il joue le 2ème, il peut
espérer un score de 2, et s’il joue le 3ème, un score de 3.
L’algorithme min-max
Pour calculer le meilleur coup de Max, il faut donc commencer par calculer la valeur de l’heuristique de
toutes les positions atteignables en n coups (les feuilles de l’arbre).
Si n est pair, Min aura joué le dernier coup : le père de chacune de ces feuilles se verra donc attribuer le
minimum des valeurs de ses fils.
À l’inverse, si n est impair, le père de chacune de ces feuilles se verra attribuer la valeur maximale de ses
fils (car Max aura joué en dernier). Ainsi, de proche en proche chaque nœud de l’arbre se verra attribuer
une valeur.
Voici comment sera rempli l’arbre précédent, selon que c’est Max ou Min qui commence :
Max
0 2 3
3 9 0 6 4 2 3 8 4
-5 -2 3 1 -2 9 0 -2 -4 2 -2 6 0 1 4 1 -2 2 2 1 3 0 7 8 0 -2 4
Min
-2 0 1
-5 -2 -4 -2 0 -2 1 0 -2
-5 -2 3 1 -2 9 0 -2 -4 2 -2 6 0 1 4 1 -2 2 2 1 3 0 7 8 0 -2 4
L’algorithme Min-Max est naturellement récursif, et peut s’écrire de la façon simplifiée suivante.
Algorithme 1 : Min-Max
Données : pos : une position du jeu
n : un entier naturel, la profondeur de la recherche
On suppose connues une fonction h (heuristique) et une fonction
successeurs donnant les positions jouables après pos
Résultat : valeur de la position pos
def MinMax(pos, n) :
si pos est une feuille (partie terminée) alors
retourner −∞ , +∞ ou 0
finsi
si n = 0 alors
retourner h( pos)
finsi
si Max joue alors n o
retourner max MinMax(suivante, n − 1) pour suivante ∈ successeurs( pos)
sinon n o
retourner min MinMax(suivante, n − 1) pour suivante ∈ successeurs( pos)
finsi
fin
Certains trouvent qu’il est plus lisible de ≪ découper ≫ cet algorithme en deux parties, l’une MaxMin lorsque
c’est à Max de jouer, et l’autre, MinMax, lorsque c’est au tour de Min.
Cela s’écrira alors de la façon suivante :
def minmax(pos, n) :
# Calcule la valeur du nœud en prenant le minimum des valeurs
# des nœuds fils, ces valeurs étant calculées par maxmin
if successeurs(pos) = []:
return # à compléter selon la nature de la feuille
if n == 0 :
return h(pos)
score = inf
for suivante in successeurs(pos) :
s = maxmin(suivante, n-1)
if s < score:
score = s
return score
def maxmin(pos, n) :
# Calcule la valeur du nœud en prenant le maximum des valeurs
# des nœuds fils, ces valeurs étant calculées par minmax
if successeurs(pos) = []:
return # à compléter selon la nature de la feuille
if n == 0 :
return h(pos)
score = - inf
for suivante in successeurs(pos) :
s = minmax(suivante, n-1)
if s > score:
score = s
return score
Pour connaı̂tre le coup à jouer, il suffira en plus de retenir le mouvement qui a permis de minimiser ou
maximiser la valeur à la première étape.
Max
1
1 2 3 -1 ? ? ?
Ici, c’est à Max de jouer, et l’algorithme Min-Max a attribué au premier noeud la valeur 1.
On étudie alors le second noeud, où c’est à Min de jouer. Or le premier coup de Min a obtenu le score
-1 ; on peut donc déjà affirmer que la valeur du second nœud sera inférieure à -1, et il est donc inutile de
poursuivre l’étude des autres coups : Max ne jouera pas le second coup.
On a effectué ci-dessus une coupure alpha : lors de l’exploration du 1er coup, la variable α a reçu la valeur
1, et quand on examine les coups suivants, on arrête l’étude dès que l’on trouve une valeur inférieure à α .
On a une situation symétrique lorsque c’est à Min de jouer :
Min
3
1 2 3 4 ? ? ?
Ici, le second nœud aura de toutes façons un score supérieur à 4, il est donc inutile de l’étudier en entier.
On a effectué ici une coupure beta : la variable β a reçu la valeur 3 lors de l’exploration du premier coup,
et on arrête l’étude dès que l’on trouve une valeur supérieure à β .
Mise en œuvre
L’algorithme Min-Max va donc être modifié en ajoutant deux variables alpha et beta, initialisées à −∞ et
+∞ respectivement, et telles que les valeurs de tous les nœuds examinés soient toujours comprises entre α
et β .
if successeurs(pos) = []:
return # à compléter selon la nature de la feuille
if n == 0 :
return h(pos)
score = inf
for suivante in successeurs(pos) :
s = maxmin(alpha, beta, suivante, n-1)
if s < score:
score = s
if score <= alpha: # coupure alpha
return score
beta = min(beta, score)
return score
if successeurs(pos) = []:
return # à compléter selon la nature de la feuille
if n == 0 :
return h(pos)
score = - inf
for suivante in successeurs(pos) :
s = minmax(alpha, beta, suivante, n-1)
if s > score:
score = s
if score >= beta: # coupure beta
return score
alpha = max(alpha, score)
return score
# puissance 4
from copy import deepcopy
from math import inf as infini
import sys
import random
def adversaire(joueur):
return jaune if joueur == rouge else rouge
def humain_gagne():
print('Bravo, vous avez gagné!')
sys.exit()
def ordi_gagne():
print("J'ai gagné!")
sys.exit()
def partie_nulle():
print('Grille pleine, partie nulle')
sys.exit()
def coups_possibles(grille):
# liste des coups possibles (ligne, colonne)
pass
else:
score -= valeurs_cases[ligne][colonne]
return [adversaire(joueur), grille_nouv, nb_coups+1, score]
def poids_cases():
# calcule le poids des cases:
# c'est le nombre de lignes gagnantes auxquelles appartient la case
# et construit la liste de tous les alignements
# remplit les var. globales: valeurs_cases, liste_tous_alignements, liste_alignements_par_case
'''
Pour vérifier votre programme, si nb_lignes = 6 et nb_colonnes = 7 et long_ligne_gagnante = 4,
le poids des cases doit être :
[ [3, 4, 5, 7, 5, 4, 3],
[4, 6, 8, 10, 8, 6, 4],
[5, 8, 11, 13, 11, 8, 5],
[5, 8, 11, 13, 11, 8, 5],
[4, 6, 8, 10, 8, 6, 4],
[3, 4, 5, 7, 5, 4, 3] ]
'''
liste_tous_alignements = []
liste_alignements_par_case = {}
valeurs_cases = [ [0 for j in range(nb_colonnes)] for i in range(nb_lignes) ]
# alignements horizontaux
# alignements verticaux
def etude_alignements(grille):
# si il y a un alignement gagnant, renvoie True et +/- infini selon le joueur concerné
# sinon renvoie False et une valeur selon le nombre de lignes gagnantes à 1, 2 ou 3 trous près
# par exemple 0111 1011 1101 1110 :coeff 5
# par exemple 0011 0101 1100 : coeff 1
# par exemple 0001 0010 0100 1000 : coeff 0.1
nb_trous = {rouge:0, jaune:0}
def resultat_partie_aleatoire(position):
# joue une partie aléatoire à partir de position
# renvoie -1 ou 1 selon joueur gagnant ou 0 si nul
pos = deepcopy(position) # la position initiale n'est pas modifiée !
pass
def resultat_aleatoire(position):
nb_parties = 30 # à voir selon le temps
def heuristique(position):
# s'il y a une position gagnante renvoie +- infini
# on suppose que la position n'est pas une partie nulle (testé avant)
# pondère la position selon l'intérêt des cases
# puis ajoute un coeff selon le nombre de lignes à un seul joueur
# et un autre selon le nombre de parties aléatoires gagnantes
joueur, grille, nb_coups, poids = position
result = etude_alignements(grille)
if result[0]:
return result[1] # il y a un gagnant, on renvoie sa couleur
pass
liste_coups = coups_possibles(position[1])
if liste_coups == []: # partie nulle
return 0
if n == 0: # profondeur atteinte, on évalue
return heuristique(position)
mini = infini
for coup in liste_coups:
pos = jouer(coup, position)
score = max_min(alpha, beta, pos, coup, n-1)
if score < mini:
mini = score
if mini <= alpha: # coupure alpha
return mini
beta = min(beta, mini)
return mini
liste_coups = coups_possibles(position[1])
if liste_coups == []:
return 0
if n == 0:
return heuristique(position)
maxi = -infini
for coup in liste_coups:
pos = jouer(coup, position)
score = min_max(alpha, beta, pos, coup, n-1)
if score > maxi:
maxi = score
if maxi >= beta: # coupure beta
return maxi
alpha = max(alpha, maxi)
return maxi
def humain_joue(position):
joueur, grille, _, _ = position
liste_coups = coups_possibles(grille)
if liste_coups == []:
partie_nulle()
def ordi_joue(position):
joueur, grille, nb_coups, _ = position
liste_coups = coups_possibles(position[1])
if liste_coups == []:
partie_nulle()
n = profondeur_arbre
if nb_coups > 15: # à voir selon rapidité de l'ordi
n += 2
alpha = -infini
beta = infini
best = random.choice(liste_coups) # au cas où le pgm perd, coup au hasard
if joueur == rouge:
# on cherche à maximiser
maxi = -infini
for coup in liste_coups:
pos = jouer(coup, position)
score = min_max(alpha, beta, pos, coup, n-1)
if score > maxi:
maxi = score
best = coup
alpha = max(alpha, maxi)
else:
mini = infini
for coup in liste_coups:
pos = jouer(coup, position)
score = max_min(alpha, beta, pos, coup, n-1)
if score < mini:
mini = score
best = coup
# initialisation de la grille
grille_vide = [ [vide for j in range(nb_colonnes)] for i in range(nb_lignes) ]
# initialisation du jeu
correct = False
while not correct:
ch = input('Voulez-vous les rouges ou les jaunes (R/J) ? ')
correct = ch in ['r', 'R', 'j', 'J']
if not correct:
print("Je n'ai pas compris, recommencez.")
if ch == 'R' or ch == 'r':
humain = rouge; ordi = jaune
else:
humain = jaune; ordi = rouge
correct = False
while not correct:
ch = input('Voulez-vous commencer (O/N) ? ')
correct = ch in ['o', 'O', 'n', 'N']
if not correct:
print("Je n'ai pas compris, recommencez.")
humain_commence = (ch == 'O' or ch == 'o')