Labyrinthes

Sébastien Boisgérault, Mines Paris – PSL

Friday 25 august 2023

https://github.com/boisgera/python-fr

#7da617f

Edition

Table des matières

“Maze” par Mitchell Luo sur Unsplash

Labyrinthes

Nous nous intéressons aux labyrinthes définis au sein d’une grille 30x30. Nous les représenterons en Python par des ensembles de paires d’entiers compris entre 0 et 29, paires qui désignent les coordonnées des cellules vides (traversables) du labyrinthe. La paire (0, 0) correspond au coin en haut à gauche de la grille.

Un labyrinthe généré (pseudo-)aléatoirement avec un mélange de 75% de cellules vides (en blanc) et 25% de murs (en noir).

Bibliothèque de labyrinthes

Solution

Pour obtenir le labyrinthe du fichier "random-maze.py":

filename = "mazes/random-maze.py"
file = open(filename, mode="rt", encoding="utf-8")
random_maze_repr = file.read()
file.close()
random_maze = eval(random_maze_repr)

Visualisation

Développez avec pygame une fonction display_maze de visualisation de labyrinthe.

Solution
# Pygame
import pygame as pg


# Constants
WIDTH, HEIGHT = 30, 30
CELL_SIZE = 20
FPS = 10
WHITE = (255, 255, 255)
BLACK = (0, 0, 0)

def draw_background(screen):
    screen.fill(BLACK)

def draw_walls(screen, maze):
    h = CELL_SIZE
    for x, y in maze:
        pg.draw.rect(screen, WHITE, (x * h, y * h, h, h))

def display_maze(maze):
    pg.init()
    pg.display.set_caption("Labyrinthes")
    width_height = (WIDTH * CELL_SIZE, HEIGHT * CELL_SIZE)
    screen = pg.display.set_mode(width_height)
    clock = pg.time.Clock()
    while True:
        events = pg.event.get()
        if any(event.type == pg.QUIT for event in events):
            break
        draw_background(screen)
        draw_walls(screen, maze)
        pg.display.update()
        clock.tick(FPS)
    pg.quit()

Créez vos propres labyrinthes

… puis ajoutez-les à la biblothèque existante !

Solution

Par exemple pour créer un labyrinthe sans mur :

empty_maze = set()
for y in range(0, HEIGHT):
    for x in range(0, WIDTH):
        empty_maze.add((x, y))

Puis pour le sauvegarder

empty_maze_repr = repr(empty_maze)
file = open("mazes/empty_maze.py", mode="wt", encoding="utf-8")
file.write(empty_maze_repr)
file.close()

Graphes et chemins

🏷️ Un graphe (orienté et pondéré) est un triplet composé :

🏷️ Un chemin d’un graphe est une suite de sommets du graphe tels que chaque élément de la suite et son successeur forment une arête du graphe.

Labyrinthes et graphes

On souhaite associer à un labyrinthe un graphe dont

Quelle structure de données Python utiliserait-t’on naturellement pour représenter ces graphes ? ⚠️ On ne cherche pas ici la structure la plus compacte ou performante mais à traduire aussi litéralement que possible la description mathématique du graphe.

Implémentez une fonction maze_to_graph qui construit le graphe associé à un labyrinthe.

Solution

Il semble naturel de représenter les sommets comme un ensemble de paires d’entiers, les arêtes comme un ensemble de paires de sommets et les poids comme un dictionnaire ayant comme clés les arêtes et comme valeur unique l’entier 1.

def maze_to_graph(maze):
    vertices = set(maze)
    edges = set()
    weights = {}
    for vertex in vertices:
        x, y = vertex
        for (dx, dy) in [(-1, 0), (0, -1), (1, 0), (0, 1)]:
            neighbor = (x + dx, y + dy)
            if neighbor in vertices:
                edge = (vertex, neighbor)
                edges.add(edge)
                weights[edge] = 1
    return (vertices, edges, weights)

Cellules atteignables

Implémentez une fonction reachable_cells qui renvoie l’ensemble des cellules d’un labyrinthe maze qui sont atteignables depuis la cellule source.

Etendez la fonction display_maze pour différencier graphiquement un ensemble de cellules. Puis utilisez-là pour représenter l’ensemble des cellules atteignables depuis le coin en haut à gauche du labyrinthe random_maze.

Cellules atteignables depuis le coin en haut à gauche (en vert). Un groupe de cellules vides sont inatteignables (en blanc) dans le coin en bas à gauche.
Solution
def reachable_cells(maze, source):
    vertices, edges, _ = maze_to_graph(maze)
    todo = {source}
    done = set()
    while todo:
        current = todo.pop()
        neighbors = {
            v for v in vertices 
            if (current, v) in edges
        }
        for n in neighbors:
            if n not in done:
                todo.add(n)
        done.add(current)
    return done
LIGHT_GREEN = (128, 255, 128)

def draw_cells(screen):
    h = CELL_SIZE
    for x, y in cells:
        pg.draw.rect(screen, LIGHT_GREEN, (x * h, y * h, h, h))

def display_maze(maze, cells=None):
    pg.init()
    pg.display.set_caption("Labyrinthes")
    width_height = (WIDTH * CELL_SIZE, HEIGHT * CELL_SIZE)
    screen = pg.display.set_mode(width_height)
    clock = pg.time.Clock()
    while True:
        events = pg.event.get()
        if any(event.type == pg.QUIT for event in events):
            break
        draw_background(screen)
        draw_walls(screen, maze)
        if cells is not None:
            draw_cells(screen)
        pg.display.update()
        clock.tick(FPS)
    pg.quit()
TOP_LEFT = (0, 0)
cells = reachable_cells(random_maze, source=TOP_LEFT)
display_maze(random_maze, cells=cells)

Labyrinthes et chemins

Implémentez une fonction path_from qui prend comme arguments :

et renvoie

Utilisez cette fonction pour trouvez un chemin joignant les coins en haut à gauche et en bas à droite du labyrinthe random_maze et représenter graphiquement le résulat en mettant à jour votre function display_maze.

Un chemin joignant les coins en haut à gauche et en bas à droite. Ce chemin minimise le nombre de déplacements nécessaires (58 = 29 + 29) ; mais c’est un coup de chance, car rien ne garantissait a priori que cela soit le cas !
Solution

Une solution possible consiste à définir :

def path_from(maze, source):
    vertices, edges, _ = maze_to_graph(maze)
    todo = set()
    done = set()
    path = {}
    if source in maze:
       todo.add(source)
       path[source] = [source]
    while todo:
        current = todo.pop()
        neighbors = {
            v for v in vertices 
            if (current, v) in edges
        }
        for n in neighbors:
            if n not in done and n not in todo:
                path[n] = path[current] + [n]
                todo.add(n)
        done.add(current)
    return path

puis à étendre notre fonction display_maze de la façon suivante :

PINK = (255, 128, 128)

def draw_path(screen, path):
    h = CELL_SIZE
    for x, y in path:
        pg.draw.rect(screen, PINK, (x * h, y * h, h, h))

def display_maze(maze, cells=None, path=None):
    pg.init()
    pg.display.set_caption("Labyrinthes")
    width_height = (WIDTH * CELL_SIZE, HEIGHT * CELL_SIZE)
    screen = pg.display.set_mode(width_height)
    clock = pg.time.Clock()
    while True:
        events = pg.event.get()
        if any(event.type == pg.QUIT for event in events):
            break
        draw_background(screen)
        draw_walls(screen, maze)
        if cells is not None:
            draw_cells(screen)
        if path is not None:
            draw_path(screen, path)
        pg.display.update()
        clock.tick(FPS)
    pg.quit()

On exploite ensuite ces fonctions de la façon suivante:

target_to_path = path_from(random_maze, TOP_LEFT)
BOTTOM_RIGHT = (WIDTH - 1, HEIGHT - 1)
path = target_to_path[BOTTOM_RIGHT]
display_maze(random_maze, path=path)

Etendre à nouveau la fonction display_maze pour qu’elle accepte comme argument supplémentaire le dictionnaire produit par path_from et affiche chaque cellule atteignable dans une couleur dépendant de la longueur du chemin qui y mène.

Exploiter cette fonctionnalité avec le labyrinthe random_maze avec comme source le coin en haut à gauche.

La carte des longueurs des chemins issus du coin en haut à gauche (violet pour de petits nombres, jaune pour de grands nombres).

🗝️ On pourra utiliser la fonction colormap suivante qui associe aux nombres flottants entre 0.0 et 1.0 un triplet RGB d’entiers représentant une couleur exploitable avec pygame.

import matplotlib.cm  # matplotlib colormaps

COLORMAP = matplotlib.cm.viridis

def colormap(x):
    x = float(x)
    rgba = COLORMAP(x)
    rgb = rgba[0:3]
    RGB = [min(int(256 * c), 255) for c in rgb]
    return RGB
assert colormap(0.0) == [68, 1, 84]     # purple
assert colormap(0.5) == [32, 145, 140]  # turquoise
assert colormap(1.0) == [254, 231, 36]  # yellow
Solution
def draw_map(screen, map):
    h = CELL_SIZE
    v_max = max(v for v in map.values())
    for (x, y), v in map.items():
        pg.draw.rect(
            screen,
            colormap(float(v / v_max)),
            (x * h, y * h, h, h),
        )

def display_maze(maze, cells=None, path=None, map=None):
    pg.init()
    pg.display.set_caption("Labyrinthes")
    width_height = (WIDTH * CELL_SIZE, HEIGHT * CELL_SIZE)
    screen = pg.display.set_mode(width_height)
    clock = pg.time.Clock()
    while True:
        events = pg.event.get()
        if any(event.type == pg.QUIT for event in events):
            break
        draw_background(screen)
        draw_walls(screen, maze)
        if cells is not None:
            draw_cells(screen, cells)
        if map is not None:
            draw_map(screen, map)
        if path is not None:
            draw_path(screen, path)
        pg.display.update()
        clock.tick(FPS)
    pg.quit()
map = {
    target: len(path) - 1 
    for target, path in target_to_path.items()
}
display_maze(random_maze, map=map)

Chemin optimal

Pouvez-vous trouvez sur la carte précédente des cibles où le chemin trouvé n’est pas de longueur minimale ?

Implémentez une fonction shortest_path_from(maze, origin) qui renvoie un dictionnaire dont les clés sont les cellules atteignables depuis l’origine et les valeurs un des chemins associés le plus courts (nécessitant le moins de déplacements) qui joignent la source et la cible.

Vous pourrez tester votre résultat graphiquement en invoquant display_maze comme à la question précédente.

La carte des longueurs des plus courts chemins chemins issus du coin en haut à gauche (violet pour de petits nombres, jaune pour de grands nombres).
Solution

Par construction, si à chaque cellule cible le chemin associé est le plus court possible, les longueurs des chemins entre deux cellules vides voisines ne peuvent différer que de -1, 0 ou 1. Par conséquent, il suffit de constater des écarts de couleurs importants entre cellules voisines de la carte (correspondant à un écart de longueur égal au moins à deux) pour en conclure qu’on a trouvé un chemin non optimal. Et c’est bien le cas à quelques endroits sur la carte des longueurs associée à l’algorithme path_from.

On va donc développer un algorithme nous assurant que la longueur est effectivement minimale.

import math

def shortest_path_from(maze, source): 
    vertices, edges, weight = maze_to_graph(maze)
    distance, path = {}, {}
    todo = {source}
    distance[source] = 0
    path[source] = [source]
    while todo:
        current = todo.pop()
        neighbors = {
            v for v in vertices 
            if (current, v) in edges
        }
        for n in neighbors:
            d = distance[current] + weight[(current, n)]
            if d < distance.get(n, math.inf):
                distance[n] = d
                path[n] = path[current] + [n]
                todo.add(n)
    return path

On peut tracer la carte de couleurs correspondantes avec :

target_to_path = shortest_path_from(random_maze, TOP_LEFT)
map = {
    target: len(path) - 1 
    for target, path in target_to_path.items()
}
display_maze(random_maze, map=map)