Exploration de grottes souterraines — Graphes sur grille
Un groupe de spéléologues souhaite explorer un réseau de grottes souterraines. Le réseau est modélisé par un graphe où chaque nœud représente une salle et chaque arête un tunnel reliant deux salles. Chaque tunnel a un poids dépendant du type de salle d'arrivée.
L'entrée de la grotte est la case 'E', la sortie est la case 'S'. Les spéléologues doivent trouver leur chemin, éviter les zones dangereuses et optimiser leur exploration.
Objectif
Modéliser une grotte en graphe, puis implémenter BFS, DFS, Dijkstra et des algorithmes avancés (points d'articulation, collecte de trésors) sur une grille 2D.
grotte = [
['E', '.', '.', '#', '.', '.', '.'],
['.', '#', '.', '#', '.', '#', '.'],
['.', '#', '.', '.', '.', '#', 'T'],
['.', '.', '#', '#', '.', '.', '.'],
['#', '.', '.', '.', '#', '.', 'S'],
['.', '.', '#', '.', '.', '.', '#'],
['.', '#', '.', '.', '#', '.', '.'],
['.', '.', '.', '#', '.', 'D', '.'],
]
N = len(grotte) # 8 lignes
M = len(grotte[0]) # 7 colonnes
# Poids des tunnels selon le type de salle d'arrivée
POIDS = {'.': 1, 'T': 1, 'D': 3, 'E': 1, 'S': 1, 'O': 2}
# Légende des cases :
# 'E' = entrée '.' = salle accessible
# 'S' = sortie '#' = paroi (infranchissable)
# 'T' = trésor 'D' = salle dangereuse
# 'O' = obstacle temporaire (rocher instable)
# Directions possibles (haut, bas, gauche, droite)
DIRECTIONS = [(-1, 0), (1, 0), (0, -1), (0, 1)]
- Deux salles sont reliées si elles sont adjacentes horizontalement ou verticalement (pas en diagonale).
- Une case
'#'est une paroi infranchissable. - Le poids d'un tunnel est le poids de la salle d'arrivée selon le dictionnaire
POIDS.
Avant d'appliquer des algorithmes de graphes, on convertit la grille en une structure exploitable : vérification de validité, calcul des voisins, construction du graphe d'adjacence et localisation des cases spéciales.
Écrire une fonction est_valide(grotte, ligne, col) qui retourne True si la position (ligne, col) est dans les limites de la grotte et n'est pas une paroi '#'.
Exemples
est_valide(grotte, 0, 0) # 'E' est_valide(grotte, 0, 3) # '#' est_valide(grotte, 9, 0) # hors limites
True False False
def est_valide(grotte, ligne, col):
N = len(grotte)
M = len(grotte[0])
if ligne < 0 or ligne >= N or col < 0 or col >= M:
return False
return grotte[ligne][col] != '#'
est_valide(grotte, 0, 0) → True est_valide(grotte, 0, 3) → False est_valide(grotte, 9, 0) → False
Écrire une fonction voisins(grotte, ligne, col) qui retourne la liste des positions (l, c) accessibles depuis (ligne, col) (cases valides adjacentes dans les 4 directions), accompagnées de leur poids.
Exemples
voisins(grotte, 0, 0) voisins(grotte, 7, 5) # 'D'
[((0,1), 1), ((1,0), 1)] [((6,5), 1), ((7,4), 1)]
On explore les 4 directions et on retourne les cases valides avec leur poids (poids de la case d'arrivée).
DIRECTIONS = [(-1, 0), (1, 0), (0, -1), (0, 1)]
def voisins(grotte, ligne, col):
resultat = []
for dl, dc in DIRECTIONS:
nl, nc = ligne + dl, col + dc
if est_valide(grotte, nl, nc):
case = grotte[nl][nc]
poids = POIDS.get(case, 1)
resultat.append(((nl, nc), poids))
return resultat
voisins(grotte, 0, 0) → [((0,1), 1), ((1,0), 1)] voisins(grotte, 7, 5) → [((6,5), 1), ((7,4), 1)]
Écrire une fonction construire_graphe(grotte) qui construit et retourne le graphe de la grotte sous forme de dictionnaire d'adjacence : chaque clé est une position (l, c) valide, et la valeur est la liste de tuples ((l', c'), poids) des voisins accessibles.
Exemple
construire_graphe(grotte)[(0, 0)]
[((0,1), 1), ((1,0), 1)]
def construire_graphe(grotte):
graphe = {}
N, M = len(grotte), len(grotte[0])
for l in range(N):
for c in range(M):
if grotte[l][c] != '#':
graphe[(l, c)] = voisins(grotte, l, c)
return graphe
construire_graphe(grotte)[(0, 0)] → [((0,1), 1), ((1,0), 1)]
Complexité
- Temps : O(N × M) — on parcourt toutes les cases.
- Espace : O(N × M) — taille du graphe.
Écrire une fonction localiser(grotte, symbole) qui retourne la liste de toutes les positions (l, c) contenant le symbole donné.
Exemples
localiser(grotte, 'E') localiser(grotte, 'T') localiser(grotte, 'D')
[(0, 0)] [(2, 6)] [(7, 5)]
def localiser(grotte, symbole):
positions = []
for l, ligne in enumerate(grotte):
for c, case in enumerate(ligne):
if case == symbole:
positions.append((l, c))
return positions
localiser(grotte, 'E') → [(0, 0)] localiser(grotte, 'T') → [(2, 6)] localiser(grotte, 'D') → [(7, 5)]
On implémente les deux parcours fondamentaux (BFS et DFS) pour explorer le graphe, détecter l'accessibilité entre salles et trouver un chemin en nombre minimal de tunnels.
Écrire une fonction bfs(graphe, depart) qui effectue un parcours en largeur depuis depart et retourne un dictionnaire {sommet: distance} donnant le nombre minimal de tunnels pour atteindre chaque salle accessible.
Exemple
bfs(graphe, (0, 0))[(4, 6)]
8 # indicatif — nombre minimal de tunnels
Le BFS utilise une file (deque) pour visiter les salles niveau par niveau. Chaque salle est explorée au plus une fois.
from collections import deque
def bfs(graphe, depart):
distances = {depart: 0}
file = deque([depart])
while file:
courant = file.popleft()
for (voisin, _) in graphe.get(courant, []):
if voisin not in distances:
distances[voisin] = distances[courant] + 1
file.append(voisin)
return distances
bfs(graphe, (0,0))[(4,6)] → 8 # indicatif
Complexité
- Temps : O(S + T) — S salles, T tunnels (arêtes).
- Espace : O(S) — dictionnaire des distances + file.
Écrire une fonction dfs(graphe, depart) qui effectue un parcours en profondeur depuis depart et retourne la liste des salles visitées dans l'ordre de découverte.
Exemple
dfs(graphe, (0, 0))[0]
(0, 0) # premier sommet découvert = départ
Le DFS utilise une pile. On propose ici une version itérative pour éviter les débordements de pile sur de grandes grottes.
def dfs(graphe, depart):
visites = []
vus = set()
pile = [depart]
while pile:
courant = pile.pop()
if courant not in vus:
vus.add(courant)
visites.append(courant)
for (voisin, _) in reversed(graphe.get(courant, [])):
if voisin not in vus:
pile.append(voisin)
return visites
dfs(graphe, (0,0))[0] → (0, 0)
Complexité
- Temps : O(S + T) — identique au BFS.
- Différence : le DFS explore en profondeur (un chemin jusqu'au bout), le BFS explore niveau par niveau. Le BFS garantit le chemin le plus court en nombre d'arêtes.
Écrire une fonction est_accessible(graphe, depart, arrivee) qui retourne True si la salle arrivee est atteignable depuis depart. Écrire également salles_accessibles(graphe, depart) qui retourne l'ensemble de toutes les salles atteignables.
Exemple
entree = localiser(grotte, 'E')[0] # (0, 0) sortie = localiser(grotte, 'S')[0] # (4, 6) est_accessible(graphe, entree, sortie)
True
def salles_accessibles(graphe, depart):
return set(bfs(graphe, depart).keys())
def est_accessible(graphe, depart, arrivee):
return arrivee in salles_accessibles(graphe, depart)
est_accessible(graphe, (0,0), (4,6)) → True
Écrire une fonction chemin_court_bfs(graphe, depart, arrivee) qui retourne un chemin de longueur minimale (en nombre de tunnels) de depart à arrivee, sous forme de liste de positions. Retourner [] si aucun chemin n'existe. On stockera les prédécesseurs pendant le BFS.
Exemple
chemin = chemin_court_bfs(graphe, (0,0), (4,6)) len(chemin) - 1 # nombre de tunnels
8 # indicatif
On enregistre le prédécesseur de chaque salle lors de la découverte, puis on remonte de l'arrivée vers le départ.
def chemin_court_bfs(graphe, depart, arrivee):
predecesseur = {depart: None}
file = deque([depart])
while file:
courant = file.popleft()
if courant == arrivee:
break
for (voisin, _) in graphe.get(courant, []):
if voisin not in predecesseur:
predecesseur[voisin] = courant
file.append(voisin)
# Reconstruction du chemin
if arrivee not in predecesseur:
return []
chemin = []
salle = arrivee
while salle is not None:
chemin.append(salle)
salle = predecesseur[salle]
chemin.reverse()
return chemin
chemin_court_bfs(graphe, (0,0), (4,6)) → [(0,0), (0,1), (0,2), (1,2), (2,2), (2,3), (2,4), (3,4), (4,4), ...] # chemin de longueur minimale (indicatif)
Le BFS trouve le chemin le plus court en nombre de tunnels, mais pas en coût total (certaines salles sont plus difficiles à traverser). On implémente Dijkstra pour minimiser le coût pondéré, puis on ajoute la contrainte d'évitement des zones dangereuses.
Écrire une fonction dijkstra(graphe, depart) qui implémente l'algorithme de Dijkstra en utilisant heapq, avec les poids des tunnels. Retourner le dictionnaire {sommet: cout_minimal} et le dictionnaire des prédécesseurs {sommet: predecesseur}.
Exemple
couts, pred = dijkstra(graphe, (0, 0)) couts[(4, 6)]
9 # indicatif selon les poids
On utilise un tas min (heapq). À chaque étape, on extrait la salle de coût minimal non encore finalisée et on met à jour ses voisins.
import heapq
def dijkstra(graphe, depart):
couts = {depart: 0}
pred = {depart: None}
tas = [(0, depart)] # (cout, sommet)
while tas:
cout_courant, courant = heapq.heappop(tas)
if cout_courant > couts.get(courant, float('inf')):
continue # entrée obsolète dans le tas
for (voisin, poids) in graphe.get(courant, []):
nouveau_cout = cout_courant + poids
if nouveau_cout < couts.get(voisin, float('inf')):
couts[voisin] = nouveau_cout
pred[voisin] = courant
heapq.heappush(tas, (nouveau_cout, voisin))
return couts, pred
couts, pred = dijkstra(graphe, (0,0)) couts[(4,6)] → 9 # indicatif
Complexité
- Temps : O((S + T) log S) — avec tas min binaire.
- Espace : O(S) — dictionnaires + tas.
Écrire une fonction chemin_optimal(graphe, depart, arrivee) qui utilise dijkstra pour retourner le chemin de coût minimal de depart à arrivee (liste de positions) et son coût total. Retourner ([], float('inf')) si inaccessible.
Exemple
chemin, cout = chemin_optimal(graphe, entree, sortie) cout
10 # indicatif
def chemin_optimal(graphe, depart, arrivee):
couts, pred = dijkstra(graphe, depart)
if arrivee not in couts:
return [], float('inf')
# Reconstruction du chemin via les prédécesseurs
chemin = []
salle = arrivee
while salle is not None:
chemin.append(salle)
salle = pred[salle]
chemin.reverse()
return chemin, couts[arrivee]
chemin, cout = chemin_optimal(graphe, entree, sortie) cout → 10 # indicatif
Écrire une fonction chemin_sans_danger(graphe, grotte, depart, arrivee) qui retourne le chemin de coût minimal évitant les salles dangereuses 'D'. On construira un sous-graphe excluant ces salles, puis on appliquera Dijkstra. Retourner ([], float('inf')) si aucun chemin sûr n'existe.
Exemple
chemin, cout = chemin_sans_danger(graphe, grotte, entree, sortie)
([...], cout_sans_danger) # coût potentiellement plus élevé
- On exclut également les voisins des salles dangereuses du sous-graphe, ce qui évite de traverser une case 'D' pour entrer dans une case valide.
- Le coût peut être plus élevé qu'avec le chemin optimal, car on doit contourner les zones dangereuses.
def chemin_sans_danger(graphe, grotte, depart, arrivee):
# Identifier les salles dangereuses
dangereuses = set(localiser(grotte, 'D'))
# Construire un sous-graphe sans les salles dangereuses
sous_graphe = {}
for salle, voisins_liste in graphe.items():
if salle in dangereuses:
continue
sous_graphe[salle] = [
(v, p) for (v, p) in voisins_liste
if v not in dangereuses
]
return chemin_optimal(sous_graphe, depart, arrivee)
On aborde des problèmes plus complexes : collecte de trésors (problème du voyageur par force brute), détection des points d'articulation (salles critiques dont la suppression déconnecte la grotte), et calcul de chemins alternatifs en cas de blocage.
Les spéléologues veulent récupérer tous les trésors avant de sortir. Écrire une fonction collecter_tresors(graphe, grotte, entree, sortie) qui retourne un itinéraire complet passant par tous les trésors puis atteignant la sortie, en minimisant le coût total. On utilisera une approche par force brute sur les permutations des trésors (acceptable si leur nombre est faible).
Exemple
collecter_tresors(graphe, grotte, entree, sortie)
([entree, ..., (2,6), ..., sortie], cout_total)
- On teste toutes les permutations de l'ordre de visite des trésors.
- Pour chaque permutation, on calcule le coût total : entrée → trésor₁ → trésor₂ → … → sortie.
- On retient la permutation de coût minimal.
- Complexité : O(T! × Dijkstra) — acceptable pour T ≤ 6.
from itertools import permutations
def collecter_tresors(graphe, grotte, entree, sortie):
tresors = localiser(grotte, 'T')
if not tresors:
# Pas de trésors : chemin direct
chemin, cout = chemin_optimal(graphe, entree, sortie)
return chemin, cout
meilleur_cout = float('inf')
meilleur_chemin = []
for perm in permutations(tresors):
etapes = [entree] + list(perm) + [sortie]
cout_total = 0
chemin_total = [etapes[0]]
valide = True
for i in range(len(etapes) - 1):
segment, cout = chemin_optimal(graphe, etapes[i], etapes[i+1])
if not segment:
valide = False
break
cout_total += cout
chemin_total += segment[1:] # éviter la duplication du point de départ
if valide and cout_total < meilleur_cout:
meilleur_cout = cout_total
meilleur_chemin = chemin_total
return meilleur_chemin, meilleur_cout
collecter_tresors(graphe, grotte, (0,0), (4,6)) → ([(0,0), ..., (2,6), ..., (4,6)], cout_total)
Écrire une fonction zones_coupees(graphe, grotte) qui identifie les salles critiques (points d'articulation) : une salle est critique si sa suppression du graphe déconnecte deux salles précédemment accessibles l'une depuis l'autre. On utilisera l'algorithme DFS avec les temps de découverte et les valeurs low.
Exemple
zones_coupees(graphe, grotte)
[(2, 4), (3, 4), ...]
disc[u] = temps de découverte de u. low[u] = plus petit temps de découverte atteignable depuis le sous-arbre DFS de u. Un nœud non-racine u est point d'articulation si et seulement si il possède un fils v tel que low[v] ≥ disc[u].
def zones_coupees(graphe, grotte):
sommets = list(graphe.keys())
disc = {} # temps de découverte
low = {} # low-link value
parent = {} # parent dans l'arbre DFS
points = set()
timer = [0]
def dfs_articu(u):
disc[u] = low[u] = timer[0]
timer[0] += 1
nb_enfants = 0
for (v, _) in graphe.get(u, []):
if v not in disc:
nb_enfants += 1
parent[v] = u
dfs_articu(v)
low[u] = min(low[u], low[v])
# Racine avec ≥ 2 enfants → point d'articulation
if parent.get(u) is None and nb_enfants > 1:
points.add(u)
# Nœud non-racine : low[v] >= disc[u] → point d'articulation
if parent.get(u) is not None and low[v] >= disc[u]:
points.add(u)
elif v != parent.get(u):
low[u] = min(low[u], disc[v])
for s in sommets:
if s not in disc:
parent[s] = None
dfs_articu(s)
return sorted(points)
zones_coupees(graphe, grotte) → [(2, 4), (3, 4), ...] # indicatif
Complexité
- Temps : O(S + T) — un seul DFS suffit.
Écrire une fonction plus_court_contournement(graphe, grotte, depart, arrivee, salle_bloquee) qui retourne le chemin de coût minimal de depart à arrivee en évitant une salle donnée (par exemple un tunnel effondré). Retourner ([], float('inf')) si aucun chemin alternatif n'existe.
Exemple
plus_court_contournement(graphe, grotte, entree, sortie, (2, 4))
([...], cout_alternatif)
Même principe que Q11 : on construit un sous-graphe sans la salle bloquée et on applique Dijkstra dessus.
def plus_court_contournement(graphe, grotte, depart, arrivee, salle_bloquee):
# Sous-graphe sans la salle bloquée
sous_graphe = {}
for salle, voisins_liste in graphe.items():
if salle == salle_bloquee:
continue
sous_graphe[salle] = [
(v, p) for (v, p) in voisins_liste
if v != salle_bloquee
]
return chemin_optimal(sous_graphe, depart, arrivee)
plus_court_contournement(graphe, grotte, entree, sortie, (2,4)) → ([...], cout_alternatif)
On finalise l'exploration en produisant une représentation visuelle du chemin dans la grotte et un rapport synthétisant tous les résultats obtenus.
Écrire une fonction afficher_chemin(grotte, chemin) qui retourne une copie affichable de la grotte (liste de listes de caractères) dans laquelle chaque salle du chemin (hors entrée et sortie) est marquée par '*'.
Exemple de rendu
afficher_chemin(grotte, chemin)
E * * # . . . . # * # . # . . # * * * # T . . # # * . . # . . . # * S . . # . . . # . # . . # . . . . . # . D .
'*' indiquent le chemin parcouru. L'entrée 'E' et la sortie 'S' conservent leur symbole d'origine.
def afficher_chemin(grotte, chemin):
# Copie profonde de la grotte
copie = [list(ligne) for ligne in grotte]
symboles_fixes = {'E', 'S', 'T', 'D', '#'}
for (l, c) in chemin:
if copie[l][c] not in symboles_fixes:
copie[l][c] = '*'
return copie
def print_grotte(grotte_affichee):
for ligne in grotte_affichee:
print(' '.join(ligne))
chemin, _ = chemin_optimal(graphe, entree, sortie) print_grotte(afficher_chemin(grotte, chemin)) # → # E * * # . . . # . # * # . # . # . # * * # T # . . # # * . . # # . . . # * S # ...
Écrire une fonction rapport_exploration(grotte, graphe) qui retourne un dictionnaire complet synthétisant l'exploration : statistiques des salles, chemin court (BFS), chemin optimal (Dijkstra), chemin sûr, points d'articulation, et itinéraire de collecte des trésors.
Rapport complet
rapport_exploration(grotte, graphe)
{
'nb_salles_totales' : 38,
'nb_salles_accessibles': 30,
'nb_tresors' : 1,
'nb_salles_dangereuses': 1,
'entree' : (0, 0),
'sortie' : (4, 6),
'chemin_court' : [(0,0), ..., (4,6)],
'longueur_chemin_court': 9,
'chemin_optimal' : [(0,0), ..., (4,6)],
'cout_chemin_optimal' : 10,
'chemin_sans_danger' : [(0,0), ..., (4,6)],
'cout_sans_danger' : 13,
'points_articulation' : [(2,4), (3,4)],
'tresors_collectes' : [(2,6)],
'itineraire_complet' : [(0,0), ..., (2,6), ..., (4,6)],
'cout_itineraire' : 18,
}
def rapport_exploration(grotte, graphe):
N, M = len(grotte), len(grotte[0])
entree = localiser(grotte, 'E')[0]
sortie = localiser(grotte, 'S')[0]
tresors = localiser(grotte, 'T')
dangereuses = localiser(grotte, 'D')
# Salles totales (non '#')
nb_totales = sum(
1 for l in range(N) for c in range(M)
if grotte[l][c] != '#'
)
# Salles accessibles depuis l'entrée
accessibles = salles_accessibles(graphe, entree)
# Chemins
chemin_court = chemin_court_bfs(graphe, entree, sortie)
ch_opt, c_opt = chemin_optimal(graphe, entree, sortie)
ch_sur, c_sur = chemin_sans_danger(graphe, grotte, entree, sortie)
# Points d'articulation
pa = zones_coupees(graphe, grotte)
# Itinéraire complet (avec trésors)
itin, c_itin = collecter_tresors(graphe, grotte, entree, sortie)
return {
'nb_salles_totales' : nb_totales,
'nb_salles_accessibles': len(accessibles),
'nb_tresors' : len(tresors),
'nb_salles_dangereuses': len(dangereuses),
'entree' : entree,
'sortie' : sortie,
'chemin_court' : chemin_court,
'longueur_chemin_court': len(chemin_court) - 1 if chemin_court else -1,
'chemin_optimal' : ch_opt,
'cout_chemin_optimal' : c_opt,
'chemin_sans_danger' : ch_sur,
'cout_sans_danger' : c_sur,
'points_articulation' : pa,
'tresors_collectes' : tresors,
'itineraire_complet' : itin,
'cout_itineraire' : c_itin,
}
rapport = rapport_exploration(grotte, graphe) # Toutes les fonctions des parties 0-3 sont intégrées
Récapitulatif des algorithmes
| Question | Algorithme | Complexité | Usage |
|---|---|---|---|
| Q5 | BFS | O(S + T) | Distance minimale en tunnels |
| Q6 | DFS | O(S + T) | Exploration exhaustive |
| Q8 | BFS + reconstruction | O(S + T) | Chemin le plus court |
| Q9 | Dijkstra (heapq) | O((S+T) log S) | Chemin de coût minimal |
| Q12 | Force brute (permutations) | O(T! × Dijkstra) | Collecte de trésors |
| Q13 | DFS (low-link) | O(S + T) | Points d'articulation |
Discussion (0)
Soyez le premier à laisser un commentaire !
Laisser un commentaire
Votre commentaire sera visible après modération.