Retour au cours

backend / algorithmes-structures-donnees

Backtracking

Leçon 201 exercice

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.

ÉtapeRôleExemple sur les N-reines
ChoisirPrendre une décision partiellePlacer une reine sur une case libre
ExplorerContinuer récursivement à partir de ce choixPasser à la ligne suivante
Vérifier / élaguerDétecter tôt une violationReine qui en attaque une autre déjà posée
DéfaireAnnuler le choix avant d'en essayer un autreRetirer 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

python
# 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

1 disponible
1

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.

Résoudre l’exercice →