E452. Qui se répète perd
Diophante fixe un entier naturel n ≥ 2. Zig et Puce partent d'une ligne vide, le premier joueur écrit
"0" ou "1" puis chacun à son tour ajoute "0" ou "1" à la fin de la séquence de "0" et de "1"
précédemment écrite. Un joueur perd si le chiffre qu'il ajoute fait apparaître un bloc de n chiffres consécutifs qui se répète pour la deuxième fois. Les deux blocs qui se répètent peuvent se
chevaucher Par exemple:
- pour n = 3, à partir de la séquence 0011100 le second joueur perd en écrivant "1" car le bloc de 3 chiffres "001" se répète dans la séquence 00111001.
- pour n = 5, à partir de la séquence 101010 le premier joueur perd en écrivant "1" car le bloc de 5 chiffres "10101" se répète dans la séquence 1010101.
Q1 Démontrer que quel que soit n ≥ 2, la partie se termine toujours en un nombre fini de tours.
Q2 n = 3 et Puce commence la partie. Qui est vainqueur?
Q3 n = 4 et Zig commence la partie. Qui est vainqueur?
Q4 n = 5 et Zig commence la partie. Qui est vainqueur?
Pour les plus courageux: peut-on déterminer qui a une stratégie gagnante en fonction de n?
Solution proposée par Claudio Baiocchi
Dans une chaîne de 0/1 de longueur on a sous-chaines de longueur (si les sous- chaîne peuvent se chevaucher). Les chaîne distinctes de longueur étant , on aura
nécessairement une répétition dès que .
Naturellement le joueur qui est destiné à perdre et celui qui peut gagner vont suivre des stratégies opposées : le futur perdant cherchera de renvoyer la fin construisant un mot le mot le plus long possible ; l’autre, au contraire, cherchera de gagner avec le mot le plus court possible. En appelant optimale une stratégie qui respecte ces deux buts, les chaînes suivantes (qu’on a obtenues grâce à un programme Pascal détaillé et commenté ci-dessous) sont toutes optimales ; les points
d’interrogation qui terminent chaque chaîne expriment que tout choix ultérieur va engendrer une répétition :
Pour : une suite gagnante est 0010?
Pour : une suite gagnante est 01011001?
Pour : une suite gagnante est 001000110111001?
Pour : une suite gagnante est 010101100110100101?
On désigne par n°1 le joueur qui commence la partie et par n°2 l'autre joueur.
Pour ce qui concerne les questions Q₂,Q₃ et Q₄, les résultats des cas et suggèrent que, pour impair, le joueur n°2 vaincra en écrivant un chiffre distinct de celui choisi par le joueur n°1; mais l’optimalité de la stratégie n’est pas évidente, et on n’a pas d’indication pour ce qui concerne le joueur n°1.
Un programme-arbitre
Ce type de jeux, ainsi que bien d’autres, rentre dans le schéma caractérisé par les règles suivantes:
1) Il n'existe pas de match nul ; toute partie se termine après un nombre fini de coups.
2) La partie est finie lorsqu’il n’y a plus de coups admissibles.
3) Chaque joueur, à son tour, est placé devant l'alternative:
a) si la partie n'est pas finie, le joueur exécute un coup, à son choix, parmi les coups admis et il consigne à l'adversaire la situation résultante,
b) sinon, il déclare sa défaite.
Un défi entre deux humains peut être "fastidieux" à analyser manuellement : savoir si un caractère engendre une sous-chaine déjà présente devient de plus en plus compliqué lorsque croit.
Faisant usage du type « string », présent dans presque tous les langages de programmation, on peut aisément construire un programme-arbitre qui gère le jeu en empêchant en particulier les coups interdits et qui, créant au fur et à mesure une liste des coups admis dans la situation actuelle, peut déclarer la fin du jeu.
Dans le programme suivant, écrit en Dev-Pascal, on a fixé ; et on a rajouté la possibilité de jouer avec plus de symboles (il suffit de changer la valeur de la variable last ; par exemple avec last=’2’ on admettrait les trois symboles . Pour des raisons techniques on a préféré travailler à la façon des arabes : le caractère c à rajouter est rattaché à la gauche (au lieu qu’à la droite) de la situation actuelle now : la motivation étant que, en Pascal, isoler le bloc de gauche est plus simple que isoler le bloc de gauche ; c’est la fonction rep qui s’occupe de cela mais naturellement, pour la visualisation, on utilise la fonction reverse. La fonction fini donne un résultat booléen et, grâce au fait qu’on a déclaré VAR son paramètre permis, elle modifie aussi la variable ss de la routine coup qui fournit la liste des choix permis à partir du contenu de la situation actuelle now.
uses crt;
(* E452. Qui se répète perd *)
const noms:array[false..true]of string=('Puce',' Zig');
color:array[false..true]of byte=(2,15);
var joueur:boolean; last:char; n:byte; now:string;
procedure init;var c:char; p:byte;
begin
clrscr; last:='2'; n:=3; now:='';
writeln(noms[true],' contre ',noms[false]);
write(noms[true],' joue en premier ? (o/n) ');
repeat c:=readkey; p:=pos(upcase(c),'N,OSY') until p>0;
writeln(c); joueur:=p>1 end;
function rep(c:char; s:string):boolean;
begin
if length(s)<n then rep:=false else rep:=pos(copy(c+s,1,n),s)>0 end;
function reverse(s:string):string; var t:string; x:byte;
begin
t:=''; for x:=1 to length(s) do t:=s[x]+t; reverse:=t end;
function fini(s:string; var permis:string):boolean; var c:char;
begin
permis:=''; for c:='0' to last do
if not rep(c,s) then permis:=permis+c;
fini:=permis='' end;
procedure coup(j:boolean); var c:char; ss:string;
begin
textcolor(color[j]);write(noms[j],' doit ');
if now='' then write('commencer; choix ? ') else
write('rajouter un caract',#138,'re ',#131,' ',reverse(now));
if fini(now,ss) then begin
writeln(' mais tout symbole est interdit! '); now:='?' end else begin
repeat c:=readkey until pos(c,ss)>0;
writeln(c);now:=c+now end end;
begin init; repeat
coup(joueur); joueur:=not joueur until now='?'; write('Termin',#130,' : ');
writeln(noms[joueur],' a perdu !');
readln end.
Comment "soudoyer" le programme-arbitre
On remarquera que, en accord avec les règles du jeu (qui parlent uniquement de coups perdants) notre arbitre, grâce à la function rep, sait uniquement quels sont les coups perdants et, en empêchant de jouer un coup perdant, le programme-arbitre interdit aux joueurs de se suicider. Et si on voulait
"soudoyer" l’arbitre ?
En fait l’arbitre connait bien plus de choses que l'on pourrait croire de prime abod : la fonction rep sait distinguer les coups (perdants ou non-perdants) mais la fonction fini permet de distinguer les situations : on peut appeler perdante une situation à partir de laquelle tout coup est perdant ! A partir de là on peut définir coups gagnants et situations gagnantes, définissant gagnant tout coup dont l’exécution consigne à l’adversaire une situation perdante, et définissant gagnante toute
situation où l’on dispose d’au moins un coup gagnant. Naturellement implémenter ces idées dans le programme demande uniquement un cycle sur l’ensemble des coups, contrôlant le résultat de l’exécution de chaque coup admis.
Bien sûr, on peut continuer : un coup est perdant au niveau 2 s’il engendre une situation gagnante ; et une situation est perdante au niveau 2 si tous les coup admis sont perdants au niveau 2 ; un coup est gagnant au niveau 2 si il engendre une situation perdante au niveau 2 ; et une situation est gagnante au niveau 2 s’il existe un coup gagnant au niveau 2… En théorie rien n’empêche de continuer ; ainsi que dans le jeu d’échecs, où la force d’un joueur peut se mesurer en termes de nombre de coups et contrecoups qu’il sait prévoir, ici un bon programme est un programme qui sait rechercher des bons coups de niveau assez élevé. Mais comment réaliser tout cela ?
D’ici peu on construira une routine next qui permet de résoudre des problèmes un peu plus
difficiles ; l’idée de base étant que la routine next fera appel à soi-même pour passer de niveau ! Il s’agit de ce qu’on appelle fonction récursive.
Remarques pour qui n’a jamais vu des programmes récursifs
Pour ceux qui ne connaissent pas le sujet : est récursive une fonction qui, au cours de son exécution, peut exécuter une copie de soi-même. Lors de cette exécution le programme doit sauver quelque part toutes les valeurs qu’il utilise, car la copie utilisera des variables de même nom ; à fin exécution on devra reconstruire toute valeur de départ. La zone de mémoire réservée à cette mémorisation prend le nom de pile (stack en anglais).
Comme exemple on peut penser à la fonction puissance :
(absente dans Pascal) qui, pour , peut être définie par :
function power ( x :real ; n :byte ) : real ;
begin if n=1 then power := x else power := x*power(x,n-1) end ;
Des réalisations nettement plus performantes (et moins « couteuses » du point de vue des ressources employées) sont les constructions itératives ; par exemple :
function power ( x :real ; n :byte ) : real ; var y : real;
begin y:=1; for x:=1 to n do y:=y*x; power:=y end;
Malheureusement, pour notre programme de jeu, il n’existe pas de versions itératives.
Seulement quelques logiciels admettent la récursion. Par exemple Basic ne l’admet pas ; le bon vieux Turbo-Pascal admet la récursion, mais d’un niveau très bas car sa pile est très petite. De son côté, Dev-Pascal réserve beaucoup d’espace mais manque d’une diagnostique efficiente : lorsque la pile est pleine il se plante…
De l’arbitre soudoyé à l’Oracle
En réalité les problèmes dont on est parti, qui demandent « Qui va gagner ? » demandent un peu plus que les problèmes du type « Que jouer maintenant ? » : au lieu que chercher les meilleurs coups, l’un après l’autre, il faut construire un développement complet du jeu qui soit optimal dans le sens précisé tout au début : le joueur qui est destiné à perdre et celui qui peut gagner vont suivre des stratégies opposées car le futur perdant cherchera de renvoyer la fin construisant un mot le mot le plus long possible ; l’autre, au contraire, cherchera de gagner avec le mot le plus court possible. La différence essentielle est que, au lieu de mémoriser le meilleur coup à partir d’une situation donnée, on doit mémoriser le développement tout entier qui décrit la partie optimale.
Par rapport au programme-arbitre, la routine init ne servira plus ; les fonctions rep, reverse et fini resteront telles quelles ; l’évaluation, à un niveau imprécisé, d’une situation now sera le maximum des valeurs v attribuées aux coups admis. La variable global opt est la chaine qui mémorisera le développement optimal du jeu : initialisée à ‘0’ (inutile de suivre aussi les développements qui commencent par ‘1’) elle est passée comme VAR à la routine next, qui mémorisera dans elle l’évolution optimale du jeu. Partant d’une valeur myv initialisé avec pessimisme à -1, la routine next évalue les coups permis, pour éventuellement porter myv à +1 lorsqu’on trouve un coup gagnant :
function next(now:string; var best:string):shortint;
var c:char; mybest,permis:string; v,myv:shortint;
begin
myv:=-1; mybest:='';
if fini(now, permis) then mybest:='?'+now else begin
for c:='0' to last do
if pos(c,permis)>0 then begin v:=-next(c+now,best);
Là il faut faire bien attention : pour croitre de niveau, la routine next demande à elle-même
l’évaluation de la situation modifiée ; naturellement il faut changer de signe le résultat car la routine donne valeur -1 aux situations perdantes (pour notre adversaire, donc gagnantes pour nous) et +1 aux situations gagnantes (pour l’adversaire, et perdantes pour nous). On remarquera aussi que le paramètre best (qui est global car il a été déclaré VAR) contient la meilleure poursuite du jeu ; donc, lorsqu’on met à jour la variable myv, il se peut qu’on doive mettre à jour aussi la variable mybest (copie locale de best) ; la mise à jour est faite toujours lorsqu’on passe d’une situation perdante à une gagnante ; tandis que, lorsque la situation reste telle quelle (perdante ou gagnante) on applique la stratégie naturelle : le gagnant cherche des solutions rapides, le perdant veut rallonger tant qu’il peut. La routine next continue donc sous la forme :
if v>myv then begin myv:=v; mybest:=best end else begin
if myv=1 then begin
if length(mybest)>length(best) then mybest:=best
end else if length(mybest)<length(best) then mybest:=best end end end;
best:=mybest; next:=myv end;
Pour mieux comprendre le résultat final la chaine best sera affichée par une routine qui écrit les choix des joueurs en couleur différents ; par exemple, dans l’usuel écran noir, en couleurs vert et blanc :
procedure print(best:string);var x,m:byte;
begin
reverse(best);
m:=2;for x:=1 to length(best) do begin textcolor(m); write(' ',best[x]); m:=17-m;
end; textcolor(15) end;
Il ne reste qu’à lancer le programme : begin
last := '1'; for n:=2 to 5 do begin
opt:='';write(#10#13'n = ',n,' ; qui joue en premier ');
if next('0',opt)=-1 then writeln('peut gagner') else writeln('n''a pas d''espoir');
write('meilleurs coups : ');
print(reverse(opt)); writeln end; write('Fini ! ');readln end.
Remarques finales Les réponses du programme dans chaque cas ( ) ont été pratiquement immédiates. En fait j’ai essayé aussi le cas de mais, après une nuit de travail, le programme
n’a sorti aucune réponse. Il est probable que l'analyse des longues chaines a engendré une pile trop pleine, et peut être une adaptation qui arrive à (établir et) couper les branches inutiles pourrait aider.
En fait, pour ce qui concerne l’exploration des arbres, on connait une technique (dite , ou en anglais Alpha–beta pruning) qui devrait permettre de réduire les temps d’élaboration car on coupe (au lieu qu’examiner en détail) branches et feuilles inutiles de l’arbre de jeu. Son
implémentation est toutefois assez délicate, er demanderait une fonction d’évaluation plus
sophistiquée : les valeurs fournies par la routine next ne sont pas suffisantes à décider l’inutilité de branches et feuilles…