clavier ouvert

Blog d'enseignement d'Adrien Foucart.
Toutes les opinions présentées ici n'engagent que moi. Blog garanti sans pub, sans traqueurs, et 100% rédigé par un humain.

2026.05.13
Spirales et chevaliers | 1

Après avoir vu cette fascinante vidéo sur la chaîne Numberphile avec le Professeur Neil Sloane, j’ai eu très envie de faire mon propre générateur de visualisations, pour pouvoir tester différentes règles et configurations. Et parce que le problème a l’air intéressant. Voyons où ça nous mène.

Résumé de la vidéo

Passez directement à la section suivante si vous avez vu la vidéo, je vais juste donner un peu plus de contexte avant de commencer!

La vidéo décrit un “problème” (ou plutôt une curiosité) découverte par Jonas Karlsson. On démarre avec une grille de taille infinie, dans laquelle une case est marquée de l’indice 0. On attribue ensuite des indices croissants en formant une spirale carrée. On a donc, schématiquement, pour les dix premiers indices:

......
.432..
.501..
.6789.
......

On colore ensuite progressivement la grille en suivant la règle suivante:

Sélectionner la case avec le plus petit indice qui est:

Dans le schéma ci-dessus, je pourrai donc colorier 0, 1, 2, 3 sans soucis, mais pas 4 (à un déplacement de cavalier de 1), 5 (vu par 2), 6 (vu par 3 et 1), 7 (vu par 2), 8 (vu par 3) ou 9 (vu par 0 et 2). La case libre suivante sera la case 20.

Le site de l’OEIS (l’encyclopédie des séquences entières) nous donne les premiers éléments de la séquence: https://oeis.org/A308885.

On y retrouve aussi le schéma montrant la position des cases “coloriées”, qui forme un motif régulier:

Visualisation des 26 premières “couches” de la spirale

Des règles plus complexes permettent de générer des “grilles colorées” de plus en plus étranges. Par exemple, la séquence présentée dans la vidéo utilise deux “joueurs” (rouge et noir) qui colorient à tour de rôle, avec la règle: sélectionner la case avec le plus petit indice qui est libre et ne peut pas atteindre une case coloriée de l’autre couleur. La séquence se trouve également sur OEIS, et donne le schéma suivant:

D’autres exemples sont donnés dans la vidéo, avec plus ou moins de joueurs et des pièces différentes. Un tas d’illustrations pour des combinaisons de pièces différentes se trouve sur le site de Jonas Karlsson.

Objectif

On retrouve des liens vers le code de Jonas Karlsson sur le site de l’OEIS, mais je ne vais pas le regarder. Je trouve l’exercice intéressant. Mon objectif sera donc de faire un programme permettant de générer des visualisations pour:

Idéalement, j’aimerais bien pouvoir partir d’une fichier de configuration simple, et générer l’image. Quelque chose comme (à raffiner)

{
    "n_layers": 10,
    "players": [
        {"color": "black", "moves": [[1, 2], [1, -2], [2, 1], [2, -1], [-2, 1], [-2, -1], [-1, 2], [-2, -2]]},
        {"color": "red", "moves": [[1, 2], [1, -2], [2, 1], [2, -1], [-2, 1], [-2, -1], [-1, 2], [-2, -2]]}
    ]
}

"moves" décrit les mouvements autorisés pour la pièce (ici, j’ai mis ceux du cavalier).

Échauffement

Je vais essayer de procéder pas à pas, en restant flexible sur la façon dont le code va s’agencer. Je suis beaucoup influencé pour l’instant par Ron Jeffries et son approche extrêmement fluide de la programmation: des petits pas, un code qui reste toujours fonctionnel, qu’on modifie très progressivement, avec un focus sur un code très clair, très expressif, bien maîtrisé. L’approche opposée du vibe coding, en somme.

Première étape: essayons simplement de dessiner la “spirale carrée”, pour commencer à comprendre et cerner le problème.

Mon premier instinct — qui n’est pas nécessairement bon! — est de faire un générateur de position, qui produit les coordonnées successives des “positions sur la grille” en suivant la spirale. Une fonction qui ferait:

from typing import Generator

def position_generator(max_n: int) -> Generator[tuple[int, int], None, None]:
    ...

Qu’on pourrait utiliser ensuite dans un main pour afficher les points reliés dans l’ordre. Juste pour tester le processus, commençons par simplement mettre les points dans l’ordre sur une droite. On fera les virages par la suite.

from typing import Generator

def position_generator(max_n: int) -> Generator[tuple[int, int], None, None]:
    x, y = 0, 0
    for _ in range(max_n):
        yield x, y
        x += 1

if __name__ == "__main__":
    for x, y in position_generator(10):
        print(x, y)

J’obtient bien:

0 0
1 0
...
9 0

L’affichage maintenant:

from typing import Generator, Iterable
from matplotlib import pyplot as plt

def show_points(positions: Iterable[tuple[int, int]]) -> None:
    plt.figure()
    for x, y in positions:
        plt.plot(x, y, 'bo')
    plt.show()


if __name__ == "__main__":
    show_points(position_generator(10))

Ce qui me donne:

Une spirale pas encore très spirale.

Bien. Tout à l’air de fonctionner, mis à part que cela ne fait pas encore ce qu’il faut !

Comment faire pour faire “tourner” les points ? Si on regarde ce que fait la spirale carrée, on suit le processus suivant, à partir de (0, 0) et en imaginant que l’axe x va “à droite” et l’axe y va “vers le haut”:

On a donc deux “cycles”: “droite”, “haut”, “gauche”, “bas”, et une augmentation de la longueur du segment tous les deux changements de direction.

C’est une bonne occasion de ressortir itertools, cet outil indispensable de la librairie standard Python. Ici, on a itertools.cycle qui nous permet de tourner entre les différentes directions à l’infini:

LEFT = (-1, 0)
RIGHT = (1, 0)
UP = (0, 1)
DOWN = (0, -1)
DIRECTION_CYCLE = [RIGHT, UP, LEFT, DOWN]

def position_generator(max_n: int) -> Generator[tuple[int, int], None, None]:
    x, y = 0, 0
    for i, direction in enumerate(itertools.cycle(DIRECTION_CYCLE)):
        if i >= max_n:
            return
        yield x, y
        dx, dy = direction
        x += dx
        y += dy
        i += 1
Au moins, ça tourne.

Maintenant, le “pas”: lorsqu’on passe de “haut” vers “gauche” et lorsqu’on passe de “bas” vers “droit”, on doit augmenter la longueur du segment. Renommons DIRECTION_CYCLE pour y ajouter cette information, et utilisons-là dans la boucle:

SPIRAL_CYCLE = [(RIGHT, 1), (UP, 0), (LEFT, 1), (DOWN, 0)]

def position_generator(max_n: int) -> Generator[tuple[int, int], None, None]:
    x, y = 0, 0
    length = 0
    for i, (direction, dl) in enumerate(itertools.cycle(SPIRAL_CYCLE)):
        if i >= max_n:
            return
        yield x, y
        length += dl
        dx, dy = direction
        x += dx*length
        y += dy*length
        i += 1
Spirale, pas encore terrible.

On a deux problèmes: c’est difficile de voir l’ordre des points, et on ne doit normalement pas “sauter” d’un bout du segment à l’autre comme ça. Il manque tous les points intermédiaires. Commençons par modifier la vue, toujours à l’aide de itertools, mais cette fois-ci avec itertools.pairwise pour parcourir les positions deux à deux, ce qui va me permettre de relier les points successifs.

def show_points(positions: Iterable[tuple[int, int]]) -> None:
    plt.figure()
    for (x1, y1), (x2, y2) in itertools.pairwise(positions):
        plt.plot(x1, y1, 'bo')
        plt.plot([x1, x2], [y1, y2], 'k-')
    plt.show()
Spirale, et on voit ce qui se passe.

Pour afficher tous les points, il faut un tout petit peu réorganiser position_generator. Au passage, je me rend compte que j’avais un i += 1 inutile en fin de boucle (puisque i était mis à jour par enumerate). De toute façon, le compteur bouge de place:

def position_generator(max_n: int) -> Generator[tuple[int, int], None, None]:
    x, y = 0, 0
    length = 0
    n_points = 1
    yield x, y
    for _, (direction, dl) in enumerate(itertools.cycle(SPIRAL_CYCLE)):
        length += dl
        dx, dy = direction
        for _ in range(length):
            if n_points >= max_n:
                return
            n_points += 1
            x += dx
            y += dy
            yield x, y
Spirale correcte!

On y est. Le code n’est pas hyper lisible, cependant, essayons de rendre ça un peu plus clair. Je n’aime pas le fait d’avoir deux yield, ni les boucles imbriquées.

Mettons une seule boucle for sur le nombre de points, avec une condition pour détecter le changement de position:

def position_generator(max_n: int) -> Generator[tuple[int, int], None, None]:
    x, y = 0, 0
    dx, dy = 0, 0
    length = 0
    current_pos_in_segment = 0
    cycle = itertools.cycle(SPIRAL_CYCLE)
    
    for _ in range(max_n):
        yield x, y 
        if current_pos_in_segment == length:
            direction, dl = next(cycle)
            dx, dy = direction
            length += dl
            current_pos_in_segment = 0
        x += dx
        y += dy
        current_pos_in_segment += 1

C’est déjà mieux… et on va s’arrêter ici pour l’instant.

Commentaires, remarques, erreurs qu'il faut absolument me faire remarquer? Contactez-moi sur Mastodon ou par mail (adrien@adfoucart.be)