Game of life

Le “jeu de la vie” est un automate cellulaire simple imaginé par John Conwell en 1970.

Il est composé d'une grille dont chaque cellule peut être dans deux états : vivante ou morte, modifié au cours du temps selon les trois règles suivantes :

  1. Une cellule vivante ayant moins de deux voisines vivantes meurt de solitude.
  2. Une cellule vivante ayant plus de trois voisines vivantes meurt de surpopulation.
  3. Une cellule morte ayant exactement trois voisines vivantes vit.

Les voisines d'une cellule sont les huit cellules l'entourant : N, S, E, O, NE, NO, SE, SO.

Introduction

La programmation de cet automate est assez simple, il s'agit généralement de créer et manipuler un tableau de deux dimensions.

Ce travail - en python - vise à créer le moteur fonctionnel de l'automate (et son affichage en console) le plus optimisé possible… en moins de lignes possible.

Source

Ce programme utilise au maximum générateurs, listes et diverses syntaxes courtes, et un tableau d'une seule dimension parcouru par une seule boucle associé à une technique d'index relatifs permettant de l'analyser comme une grille en deux dimensions.

Le programme crée une grille de taille donnée, la remplit aléatoirement selon un taux de remplissage, la calcule et met à jour selon les règles de l'automate cellulaire puis l'affiche en console (en indiquant chaque cellule vivante par son nombre de voisins) quand l'utilisateur le demande, et permet de quitter le programme simplement.

Le code fait 13 lignes et 334 caractères.

from random import random
l=10
r=range(l*l)
n=(1,-1,l,-l,l+1,l-1,-l+1,-l-1)
g=list(int(random()*1.8) for i in r)
while not raw_input():
  p = list(g)
  for i in r:
    s = sum(p[i+x] for x in n if 0<i+x<l*l)    
    if not p[i] and s==3 : g[i]=1
    if p[i] and (s<2 or s>3): g[i]=0
    print chr(32+p[i]*(16+s)),
    if i+1 and not (i+1)%l: print

Appuyer sur Enter pour calculer un cycle, rester appuyé pour calculer en boucle.

Appuyer sur n'importe quelle touche et appuyer sur Enter pour quitter.

Explication

Je décrit ci-dessous le fonctionnement du programme, ligne par ligne.

Préparation des variables

from random import random

Importation du module d'aléatoire utilisé pour remplir initialement la grille.

l=10

l comme length : définit la largeur (et longueur) de la grille en nombre de cases. Pour être visible sans problème en console, préférer un tableau inférieur à 50×50 cases. Le programme peut toutefois calculer sans problème un tableau de 600×600 cases par exemple (hors affichage).

r=range(l*l)

r comme range : liste des index des cases de la grille utilisée pour la créer et la parcourir. La grille est de taille l*l afin de correspondre à une grille de deux-dimensions de côté l.

Cette liste est pré-calculée pour une question de performance, cf. python benchmark.

n=(1,-1,l,-l,l+1,l-1,-l+1,-l-1)

n comme neighbours : Cette liste décrit les index relatifs des huit voisins dans le tableau d'une seule dimension. Le processus est détaillé plus bas.

Initialisation de la grille

g=list(int(random()*1.8) for i in r)

g comme grid.

Il s'agit d'un générateur de liste utilisant la syntaxe :

list(value for i in range)

C'est proche de la syntaxe courte de boucle :

[value for i in range]

Mais préférable pour des questions de performance. Cf. Generator Expressions.

La valeur ajoutée à la liste est une manière d'obtenir aléatoirement soit 0 soit 1 : convertir en entier la valeur à virgule flottante aléatoire comprise entre 0 et 1 renvoyée par random().

int(random()*1.8)

1.8 est donc un “taux de remplissage” de la grille pouvant être manipulé entre 0 (0*0 < résultat < 0*1, donc grille toujours vide) et 1.99 (1.99*0 < résultat < 1.99*1, donc grille aléatoirement remplie).

Exécution des cycles

while not raw_input():
  p=list(g)

Cette boucle permet de mettre à jour la grille en appuyant sur Enter (la condition d'exécution est vraie puisque raw_input() n'a rien renvoyé).

Pour stopper le programme, entrer n'importe quelle valeur puis appuyer sur Enter (la condition d'exécution est fausse puisque raw_input() a renvoyé une valeur).

p comme previous est une copie de la grille permettant par la suite l'analyse de la grille dans son état précédent, non modifié, tout en modifiant l'état actuel.

Analyse et modification de la grille

  for i in r:

On répète l'opération suivante pour chaque élément de r, i correspondant donc tour à tour à l'index de chaque cellule de la grille.

Somme des voisins

Pour savoir le nombre de voisins vivants, il faut faire la somme de l'état des huit voisins.

    s=sum(p[i+x] for x in n if 0<i+x<l*l) 

Il s'agit encore d'un générateur de liste créant la liste des états des huit voisins, intégré à la fonction sum() retournant directement la somme chacun des éléments de la liste.

Cette ligne est similaire à :

nbrsList = []
for x in n:
  if 0 < i+x < l*l:
    nbrsList.append(last[i+x])
s = sum(nbrsList)   

On ajoute les index relatifs stockés dans n à l'index courant i pour déterminer l'index des voisins i+x. L'état des voisin lors du cycle précédent est alors déterminé par last[i+x].

Par exemple n[0] est 1, l'index de ce voisin est i+1 et son état p[i+1], ce qui correspond à la case suivante du tableau, donc au voisin de droite.

Description des index relatifs :

  • 1 : case suivante du tableau, EST.
  • -1 : case précédente du tableau, OUEST.
  • l : case située une longueur de ligne après, SUD.
  • -l : case située une longueur de ligne avant, NORD.
  • l+1 : case située une longueur de ligne plus une case après, SUD-EST.
  • l-1 : case située une longueur de ligne moins une case après, SUD-OUEST.
  • -l+1 : case située une longueur de ligne plus une case avant, NORD-EST.
  • -l-1 : case située une longueur de ligne moins une case avant, NORD-OUEST.
Bordures

La condition impose que l'index du voisin soit bien dans le tableau (une cellule au bord de la grille va observer des cases qui n'existent pas), c'est à dire que son index soit supérieur à 0 et inférieur à la taille du tableau :

  if 0<i+x<l*l

La syntaxe x < y < z, peu courante dans d'autres langages, correspond à x < y and y < z.

La technique de la première version de ”bordure morte” n'a pas été réutilisée car elle s'avère peu utile dans un tableau uni-dimensionnel n'ayant que deux bords à vérifier. Mais du coup les bords droite/gauche de la grille sont infinis (un objet sortant à droite réapparaît à gauche).

Modification de la grille

Les règles énoncées plus haut peuvent être décrites en deux règles générales de changement.

    if not p[i] and s==3:g[i]=1

Si l'état de la cellule courante est 0 (morte), mais que la somme des voisins vivants est égal à 3, on change son état à 1 (vivante).

    if p[i] and(s<2 or s>3):g[i]=0

Si l'état de la cellule courante est 1 (vivante), mais que la somme des voisins vivants n'est pas comprise entre 2 et 3, on change son état à 0 (morte).

Si la cellule ne correspond pas à ces règles, on ne change pas son état.

Affichage

    print chr(32+p[i]*(16+s)),

Cette ligne affiche un espace pour les cellules mortes et le nombre de voisins pour les cellules vivantes, en générant le code ASCII en base 10 du caractère visé. Cf. ASCII et chr()

Si la cellule est morte, le calcul sera 32+0*(16+s) donc toujours 32, qui est le code ASCII du caractère Espace.

Si la cellule est vivante, le calcul sera 32+1*(16+s) donc toujours 48+s. A 48 (le code du chiffre 0) est rajouté le nombre de voisins, ce qui donne 49 (le code du chiffre 1), 50 (le code du chiffre 2), etc…

    if i+1 and not(i+1)%l:print

On va à la ligne pour les index multiplicateurs de la largeur de la grille afin d'afficher le tableau en deux dimensions.

Benchmark

Les temps ci-après concernent l'exécution du programme légèrement modifié afin d'indiquer une moyenne (sur 100 cycles) des temps de calcul sans affichage de la grille en console :

from time import time
t=time()
cycles = 100
 
from random import random
l=100
r=range(l*l)
n=(1,-1,l,-l,l+1,l-1,-l+1,-l-1)
g=list(int(random()*1.8) for i in r)
for bench in range(cycles):
  p=list(g)
  for i in r:
    s=sum(p[i+x] for x in n if 0<i+x<l*l)
    if not p[i] and s==3:g[i]=1
    if p[i] and(s<2 or s>3):g[i]=0
 
print str((time()-t) / cycles)
Taille Nombre de cellules Secondes par cycle Cycles par seconde
50 5.000 0.0068 147
100 10.000 0.0267 37.2
500 250.000 0.6934 1.67
600 360.000 1.0087 0.99

Version précédente

Cette version utilise certaines syntaxes courtes, un tableau à deux dimensions et affiche l'état de toutes les cellules un certain nombre de fois.

19 lignes, les trois lignes d'affichage en console comprises.

from random import random
l = 500
rng = range(1, l+1)
d = (0,1, 0,-1, 1,0, -1,0, 1,1, 1,-1, -1,1, -1,-1)
w = [[0]*(l+2)]*(l+2)
for i in rng:
  for j in rng: w[i][j] = int(random()*1.8)
for t in range(5):
  w2 = list(w)
  for i in rng:
    for j in rng:
      sum = 0
      for y in range(0,len(d),2): sum += w2[i+d[y]][j+d[y+1]]
      if w2[i][j] == 0 :
        if sum == 3 : w[i][j] = 1
      elif sum < 2 or sum > 3: w[i][j] = 0
      print str(w[i][j]),
    print
  print

Bordures mortes

Un problème à résoudre pour l'optimisation du code était de trouver un moyen court et efficace d'éviter les cases inexistantes. Lorsqu'une cellule est au bord de la grille, elle n'a pas huit voisins, le processus doit donc s'assurer de ne pas demander l'état de cases en dehors du tableau.

Pour éviter l'ajout de conditions, lourdes car à calculer pour chaque case pour chaque cycle et prenant plusieurs lignes, il a été choisi d'ajouter au tableau de côté l une bordure d'une cellule d'état 0.

Le tableau réel est donc de côté l+2

[[0]*(l+2)]*(l+2)

…et le processus de mise à jour et l'affichage ne concerne que les case de (1, 1) à (l, l) :

range(1, l+1)

Toutes les cellules mises à jour ayant alors bien huit voisines et les bordures n'ayant aucune influence sur le résultat des règles car toujours définies à 0, il n'est plus besoin d'ajouter une quelconque condition durant le processus.

Benchmark

Cette version s'exécute (hors affichage en console) en 0.9613 secondes par cycle (1.04 cycles par seconde) pour une grille de 500×500, soit 250.000 cellules.

Discussion

Loïc Teixeira, 2010/12/09 10:05

C'est assez intéressant de voir que le script tend toujours vers :

  • la mort de toutes les cellules
  • une situation bloqué
  • * 2 situations s'interchanges (121 horizontal & 121 vertical)
  • * 1 situation n'évolue plus (des 3 en carré, des 2 en hexagone ou en forme plus bizares)

Avec une grille plus grande (20×20), on semble observer de petits sursauts de vie mais l'issue reste la même. Il serait intéressant d'ajouter le pourcentage de cellule vivante afin de corroborer cette impression.

Et pourquoi pas un petit script qui évoluerai de manière automatique toutes les secondes ?

Nicolas, 2010/12/10 22:34

Bonsoir Loïc,

Ce type d'évolution est intrinsèquement lié au jeu de la vie (et particulièrement aux automate cellulaire de type 4, cf. Automate cellulaire : Classification de Wolfram), pas à mon implémentation. Mais oui, c'est très intéressant! Pour les formes que tu as repéré, elles sont connues sous les noms de blinkers, beehives, gliders… cf. Conway's Game of Life : Examples of patterns.

Et pourquoi pas un petit script qui évoluerai de manière automatique toutes les secondes ?

Je n'ai pas bien saisi. Entends-tu par là qui se remettrait à zéro toutes les secondes?

Après le principe du script était de faire quelque chose de fonctionnel en quelques lignes, cela m'interdisait des jeux de la vie avec physique et 3d, des algorithmes poussés, etc… ;)

Loïc Teixeira, 2010/12/11 11:13

Non. Un script qui, une fois lancé, évolue seul à une vitesse définie alors qu'actuellement, il faut appuyer sur entrer pour “avancer dans la vie”. D'ailleurs, dans le deuxième lien que tu donnes, le Game of Life est défini comme suit :

The “game” is a zero-player game, meaning that its evolution is determined by its initial state, requiring no further input. One interacts with the Game of Life by creating an initial configuration and observing how it evolves.

Quand à la classification de Wolfram, je suis un peu confus. La version française définit le type 4 comme suit :

Emergence de structures complexes capables de se propager.

La version anglaise ne semble pas parler de propagation, juste d'une évolution complexe et intéressante (j'ajoute également le type 2 puisqu'il en est question dans le type 4) :

Class 4: Nearly all initial patterns evolve into structures that interact in complex and interesting ways. Class 2 type stable or oscillating structures may be the eventual outcome, but the number of steps required to reach this state may be very large, even when the initial pattern is relatively simple. Local changes to the initial pattern may spread indefinitely.

Class 2: Nearly all initial patterns evolve quickly into stable or oscillating structures. Some of the randomness in the initial pattern may filter out, but some remains. Local changes to the initial pattern tend to remain local.

Hormis le fait que la version anglaise semble plus proche de ce que l'on peut observer, je pense qu'il sera plus sage de se baser sur celle-ci car elle est plus complète, moins raccourcis et donc, plus juste.

En revanche, je n'arrive pas à me décider quand au caractère pessimiste de ce “jeu”. On arrive une situation stable ou oscillatoire. La situation stable décrit la mort de toutes les cellules ou, la vie de certaines cellules mais avec une évolution nulle. On a donc 3 états.

  • Si l'on considère le simple fait de vivre, la vie a alors 2 chances sur 3 de triompher.
  • Si l'on considère une évolution nulle comme une forme de mort, la vie n'a alors aucune chances de triompher (puisque quelque part, une situation oscillatoire n'évolue plus).

Je crois qu'on s'éloigne un peu du script en lui même mais, comment vois-tu le jeu Nicolas ?

Pour ma part, je retourne à mon terminal, je n'ai toujours pas pu observer de Pulsar !

Nicolas, 2010/12/11 13:40

Hop,

Un script qui, une fois lancé, évolue seul à une vitesse définie alors qu'actuellement, il faut appuyer sur entrer pour “avancer dans la vie”.

Je suis passé par cette étape, mais cela me rajoutait une ou plusieurs lignes… Ce qui allait à l'encontre du projet.
Je préfère de toute manière laisser la main à l'utilisateur quand au rythme d'évolution, afin de pouvoir faire pause, accélérer… Tant qu'il n'y a ”no further input”, quand au processus intrinsèque d'évolution, c'est bon!

La version anglaise ne semble pas parler de propagation[…]

Elle parle d’interactions, ce qui est presque synonyme dans ce type de structure : cela nécessite du mouvement pour aller chercher l'autre. Mais de toute manière oui, l'explication anglaise est carrément plus précise.

En revanche, je n'arrive pas à me décider quand au caractère pessimiste de ce “jeu”.

C'est pour moi une observation si ce n'est optimiste disons merveilleuse : le postulat de départ est quand même de rassembler des pseudo-cellules très peu “intelligentes” réagissant basiquement à leur environnement, dans une soupe chaotique. Quel est le constat alors? Que des chosent se passent, en dehors du chaos.
Il ne faut pas être déçu que la boite de Pétri s'éteigne, il faut s'émerveiller qu'elle eut évoluée pendant un temps!

Pour ma part, je retourne à mon terminal, je n'ai toujours pas pu observer de Pulsar !

Les chances de tomber sur une structure aussi complexe avec une génération aléatoire sont extrêmement faibles! L'observation de ce genre de choses se fait généralement avec des applications permettant de “dessiner” l'état initial.

Content que ça te plaise!

Entrer votre commentaire