backend / algorithmes-structures-donnees
Backtracking
Explication
Ce que vous allez apprendre
- Décrire le squelette "choisir, explorer, défaire" commun à tous les algorithmes de backtracking
- Expliquer pourquoi l'étape "défaire" est indispensable pour ne pas polluer l'exploration des branches suivantes
- Appliquer l'élagage (pruning) pour éviter de construire des branches condamnées à l'échec
- Résoudre un problème classique de backtracking (N-reines, sous-ensembles, permutations) en identifiant choix, contrainte et but
- Comprendre pourquoi le backtracking reste praticable malgré un espace de solutions théoriquement exponentiel
Dans quel contexte ?
Un générateur de grilles de sudoku doit trouver une combinaison de chiffres valide parmi un nombre de possibilités écrasant si on les testait toutes. Le backtracking place un chiffre, vérifie immédiatement s'il viole une règle (ligne, colonne, bloc 3x3), et si oui l'abandonne sans même explorer la suite de cette branche : c'est l'élagage. Sans cet élagage précoce, résoudre une seule grille de sudoku prendrait un temps astronomique ; avec lui, la plupart des grilles se résolvent en quelques millisecondes.
D'abord, un type de problème différent
Le backtracking répond à une catégorie de problèmes où il faut construire une solution par une série de choix successifs, sans savoir à l'avance lesquels mèneront à une solution valide.
L'idée centrale
Essayer un choix, continuer à construire à partir de ce choix, et si l'on découvre en cours de route que cette branche ne peut plus aboutir, DÉFAIRE ce choix et en essayer un autre.
| Étape | Rôle | Exemple sur les N-reines |
|---|---|---|
| Choisir | Prendre une décision partielle | Placer une reine sur une case libre |
| Explorer | Continuer récursivement à partir de ce choix | Passer à la ligne suivante |
| Vérifier / élaguer | Détecter tôt une violation | Reine qui en attaque une autre déjà posée |
| Défaire | Annuler le choix avant d'en essayer un autre | Retirer la reine, essayer la case suivante |
Le squelette commun à tous ces problèmes
Choisir, explorer récursivement, défaire. Ce triptyque revient identique que l'on génère des permutations, des sous-ensembles, ou que l'on place des reines sur un échiquier.
Une étape souvent oubliée, mais essentielle
La partie "défaire" est cruciale : sans elle, l'état construit pour explorer une branche continuerait de polluer l'exploration des branches suivantes.
Piège fréquent
Oublier l'étape "défaire" — par exemple ne pas retirer un élément d'une liste partagée entre les appels récursifs — fait fuiter l'état d'une branche vers les branches suivantes, produisant des résultats incohérents ou dupliqués sans erreur explicite.
Il reste un problème pratique : l'explosion combinatoire
En théorie, le backtracking explore un espace de solutions potentiellement exponentiel, ce qui le rendrait inutilisable sur des problèmes de taille réelle.
Astuce
Plus l'élagage intervient tôt dans la construction d'une solution partielle, plus il élimine de branches d'un coup. Vérifiez les contraintes dès qu'elles PEUVENT être violées, pas seulement une fois la solution complète construite.
La solution : l'élagage
L'élagage (pruning) consiste à détecter le PLUS TÔT possible qu'une branche partielle ne pourra jamais mener à une solution valide, et à l'abandonner immédiatement sans même la construire entièrement.
Un exemple concret de cette technique
Dans le problème des N reines, vérifier les colonnes et diagonales déjà occupées AVANT de placer une reine évite d'explorer des millions de configurations condamnées d'avance.
La différence essentielle avec la programmation dynamique
La PD évite le RECALCUL de sous-problèmes identiques. Le backtracking, lui, n'a généralement pas de sous-problèmes qui se répètent : il évite plutôt l'EXPLORATION de branches qui ne peuvent structurellement pas réussir.
Vers la suite
Cette idée de structure en arbre, où chaque mot partage un chemin avec les autres, se retrouve sous une forme différente dans la prochaine leçon, avec le Trie.
Commandes & code
Backtracking
# Le backtracking explore un espace de solutions en construisant une solution partielle,
# et REVIENT EN ARRIÈRE (backtrack) dès qu'un choix s'avère invalide ou sous-optimal.
# --- Générer toutes les permutations d'un tableau ---
def permutations(tableau: list) -> list[list]:
resultat = []
def backtrack(courant: list, restants: list):
if not restants:
resultat.append(courant[:]) # copie -- "courant" continuera d'être modifié
return
for i in range(len(restants)):
courant.append(restants[i])
backtrack(courant, restants[:i] + restants[i + 1:]) # choisir
courant.pop() # défaire le choix (backtrack)
backtrack([], tableau)
return resultat
assert len(permutations([1, 2, 3])) == 6 # 3! = 6
# --- Générer tous les sous-ensembles (powerset) ---
def sous_ensembles(tableau: list) -> list[list]:
resultat = []
def backtrack(index: int, courant: list):
resultat.append(courant[:]) # chaque état partiel EST une solution valide ici
for i in range(index, len(tableau)):
courant.append(tableau[i])
backtrack(i + 1, courant) # explorer avec cet élément inclus
courant.pop() # backtrack : l'exclure pour explorer l'autre branche
backtrack(0, [])
return resultat
assert len(sous_ensembles([1, 2, 3])) == 8 # 2^3 = 8
# --- Problème des N reines : placer N reines sur un échiquier N x N sans qu'aucune ne s'attaque ---
def n_reines(n: int) -> list[list[int]]:
solutions = []
colonnes_occupees = set()
diagonales_montantes = set() # (ligne - colonne) constant sur une diagonale
diagonales_descendantes = set() # (ligne + colonne) constant sur l'autre diagonale
def backtrack(ligne: int, positions: list[int]):
if ligne == n:
solutions.append(positions[:])
return
for colonne in range(n):
if (colonne in colonnes_occupees
or (ligne - colonne) in diagonales_montantes
or (ligne + colonne) in diagonales_descendantes):
continue # élagage (pruning) : cette branche est invalide, on ne l'explore même pas
# choisir
colonnes_occupees.add(colonne)
diagonales_montantes.add(ligne - colonne)
diagonales_descendantes.add(ligne + colonne)
positions.append(colonne)
backtrack(ligne + 1, positions)
# défaire (backtrack)
colonnes_occupees.remove(colonne)
diagonales_montantes.remove(ligne - colonne)
diagonales_descendantes.remove(ligne + colonne)
positions.pop()
backtrack(0, [])
return solutions
assert len(n_reines(4)) == 2 # 2 solutions distinctes pour un échiquier 4x4
assert len(n_reines(8)) == 92 # le résultat classique pour 8 reines
# --- Résolution de Sudoku par backtracking avec élagage ---
def resoudre_sudoku(grille: list[list[int]]) -> bool:
def trouver_case_vide():
for i in range(9):
for j in range(9):
if grille[i][j] == 0:
return i, j
return None
def valide(ligne: int, colonne: int, valeur: int) -> bool:
if any(grille[ligne][c] == valeur for c in range(9)):
return False
if any(grille[l][colonne] == valeur for l in range(9)):
return False
bloc_ligne, bloc_colonne = 3 * (ligne // 3), 3 * (colonne // 3)
for l in range(bloc_ligne, bloc_ligne + 3):
for c in range(bloc_colonne, bloc_colonne + 3):
if grille[l][c] == valeur:
return False
return True
case = trouver_case_vide()
if case is None:
return True # plus de case vide -> grille résolue
ligne, colonne = case
for valeur in range(1, 10):
if valide(ligne, colonne, valeur):
grille[ligne][colonne] = valeur # choisir
if resoudre_sudoku(grille):
return True
grille[ligne][colonne] = 0 # backtrack : ce choix n'a pas mené à une solution
return False
# --- Génération de combinaisons de parenthèses valides ---
def generer_parentheses(n: int) -> list[str]:
resultat = []
def backtrack(courant: str, ouvertes: int, fermees: int):
if len(courant) == 2 * n:
resultat.append(courant)
return
if ouvertes < n:
backtrack(courant + "(", ouvertes + 1, fermees)
if fermees < ouvertes: # élagage : ne jamais fermer plus qu'on n'a ouvert
backtrack(courant + ")", ouvertes, fermees + 1)
backtrack("", 0, 0)
return resultat
assert set(generer_parentheses(3)) == {"((()))", "(()())", "(())()", "()(())", "()()()"}Résumé
- Le backtracking = récursion + choisir/explorer/défaire (backtrack) dès qu'une branche est invalide ou épuisée.
- L'élagage (pruning) précoce -- rejeter une branche invalide avant de la construire entièrement -- est la clé de la performance.
- N-reines, Sudoku, génération de permutations/combinaisons/sous-ensembles partagent tous ce même squelette algorithmique.
- Contrairement à la programmation dynamique, le backtracking n'évite pas nécessairement de recalcul : il évite d'explorer des branches condamnées.
Exercices pratiques
Mission : réparer le générateur de grilles qui produit des doublons
Objectif : Diagnostiquer un backtracking sans étape 'défaire' et raisonner sur l'élagage dans le problème des N reines.
Contexte
Un développeur a écrit une version de sous_ensembles qui utilise une seule liste courant partagée entre tous les appels récursifs, mais a oublié d'appeler courant.pop() après chaque appel récursif interne. Sur sous_ensembles([1, 2, 3]), le résultat contient des sous-ensembles incorrects et incomplets, sans qu'aucune exception ne soit levée.