PYTHON → ROBOTIQUE · 12

Séance 12 · Partie B · Algorithmique et maths

Récursivité.

Pour trier une liste, on trie ses deux moitiés. Pour explorer un labyrinthe, on explore chaque couloir. Une fonction qui s’appelle elle-même : l’idée la plus puissante et la plus déroutante de l’informatique.

Durée : 2 séances · Objectifs : écrire et comprendre une fonction récursive, identifier cas de base et cas récursif, raisonner par récurrence, connaître la pile d’appels, savoir quand préférer une boucle.

Ce que vous saurez faire à la fin
  • Dérouler une fonction récursive à la main et prédire son résultat.
  • Écrire factorielle, puissance, Fibonacci, Hanoï, un parcours de labyrinthe.
  • Expliquer RecursionError et la pile d’appels.
  • Reconnaître un problème « naturellement récursif ».

01 / L’idée

Se ramener à un problème plus petit

5! = 5 × 4! et 4! = 4 × 3! … jusqu’à 0! = 1.

Deux ingrédients, toujours :

  1. Un cas de base qui répond sans se rappeler.
  2. Un cas récursif qui s’appelle sur un problème strictement plus petit.

Sans cas de base, ou si le problème ne rétrécit pas : appels sans fin.

Dérouler

factorielle(3) → 3 × factorielle(2) → 3 × (2 × factorielle(1)) → 3 × (2 × (1 × factorielle(0))) → 3 × (2 × (1 × 1)) → 6. Les multiplications s’effectuent en remontant, une fois le cas de base atteint. Chaque appel en attente est stocké : c’est la pile.

01 / L’idée

Voir la pile d’appels

La pile

Chaque appel de fonction crée un « cadre » avec ses variables locales, empilé au-dessus de l’appelant. Quand la fonction renvoie, le cadre est retiré. Python limite la pile à ~1000 cadres pour éviter qu’un bug ne consomme toute la mémoire : d’où RecursionError. Une boucle n’a pas cette limite. Sur un microcontrôleur avec 2 Ko de RAM, la pile est minuscule : la récursivité profonde y est proscrite.

02 / Classiques

Puissance rapide : diviser l’exposant par deux

Pourquoi un seul appel

x¹⁰⁰ = (x⁵⁰)². Si on écrivait puissance_rapide(x, n//2) * puissance_rapide(x, n//2), on ferait le même calcul deux fois et on perdrait tout le gain. On le fait une fois et on le stocke. Complexité : O(log n) au lieu de O(n). Cet algorithme (exponentiation rapide) est au cœur du chiffrement RSA, où l’on calcule des puissances avec des exposants de 600 chiffres : impossible sans lui. Séance 13.

02 / Classiques

Fibonacci : le piège de la récursivité naïve

Arbre d’appels

fib(30) appelle fib(29) et fib(28) ; fib(29) appelle fib(28) et fib(27)… fib(28) est calculé deux fois, fib(27) trois fois, fib(20) plus de 10 000 fois. Nombre total d’appels ≈ 1,6ⁿ : exponentiel. La mémoïsation (un dictionnaire des résultats connus) ramène à n appels utiles. Python fournit le décorateur @functools.lru_cache qui fait exactement cela. Leçon : la récursivité est élégante, mais il faut compter les appels comme on compte les tours de boucle.

02 / Classiques

Les tours de Hanoï : impossible sans récursivité ?

Compter les coups

C(n) = 2·C(n−1) + 1, C(0) = 0, donc C(n) = 2ⁿ − 1. Prouvez-le par récurrence : c’est exactement la structure de la fonction. Pour 64 disques (la légende) : 1,8 × 10¹⁹ coups, soit 585 milliards d’années à un coup par seconde. Le raisonnement « je suppose que je sais faire pour n−1, j’en déduis n » est le raisonnement par récurrence des mathématiques : la récursivité en est la traduction directe en code. Une version itérative existe mais elle est bien moins lisible.

03 / Structures

Récursivité sur les listes et les chaînes

Quand la récursivité est le bon outil

somme et inverser se font mieux en boucle (et liste[1:] recopie la liste : O(n²) au total). Mais aplatir traite une structure dont la profondeur est inconnue : une boucle ne suffit pas, il faudrait gérer une pile à la main. Règle : la récursivité s’impose quand les données sont elles-mêmes récursives (listes imbriquées, arbres, dossiers contenant des dossiers, expressions mathématiques).

03 / Structures

Explorer un labyrinthe : le retour sur trace

Backtracking

À chaque case, on essaie les quatre directions. Si l’une mène à la sortie, on remonte True. Si aucune ne marche, on retire la case du chemin (pop) et on renvoie False : l’appelant essaiera une autre direction. Ce schéma « essayer, et défaire si ça échoue » s’appelle le retour sur trace (backtracking). Il résout le sudoku, les huit reines, la planification d’un robot… Le marquage « visité » est indispensable : sans lui, on tournerait en rond. Le chemin trouvé n’est pas forcément le plus court : pour cela, séance 18 (BFS).

03 / Structures

Dessiner avec la récursivité : le flocon de Koch

Une longueur infinie dans une boîte finie

À chaque niveau, le nombre de segments est multiplié par 4 et la longueur par 4/3. À la limite, la courbe a une longueur infinie mais tient dans un rectangle. C’est une fractale, de dimension log 4 / log 3 ≈ 1,26 — ni une ligne, ni une surface. Sur votre PC, dessinez les segments avec le module turtle (livré avec Python) ou avec matplotlib (séance 19) : le résultat est spectaculaire. Les fractales modélisent les côtes, les poumons, les antennes de téléphone.

04 / Défis

Défi ★ — Les classiques, version récursive

Consigne

Écrire en récursif, avec des tests : somme_chiffres(n) (472 → 13), compte(x, liste) (occurrences de x), pgcd(a, b) (Euclide : pgcd(a, b) = pgcd(b, a % b), cas de base b = 0), binaire(n) qui renvoie l’écriture binaire sous forme de chaîne (13 → "1101").

Correction
def somme_chiffres(n):
    if n < 10:
        return n
    return n % 10 + somme_chiffres(n // 10)

def compte(x, liste):
    if not liste:
        return 0
    return (1 if liste[0] == x else 0) + compte(x, liste[1:])

def pgcd(a, b):
    if b == 0:
        return a
    return pgcd(b, a % b)

def binaire(n):
    if n < 2:
        return str(n)
    return binaire(n // 2) + str(n % 2)

Pour binaire, le dernier bit est n % 2 et les autres sont l’écriture de n // 2 : la récursivité place naturellement les bits dans le bon ordre. Séance 13 approfondit.

04 / Défis

Défi ★★ — Toutes les combinaisons

Consigne

1. sous_ensembles(liste) : la liste de tous les sous-ensembles (2ⁿ). Idée : les sous-ensembles de [a, reste] sont ceux de reste, plus ceux de reste auxquels on ajoute a.
2. permutations(liste) : toutes les façons d’ordonner (n!). Idée : pour chaque élément, le mettre en tête puis permuter le reste.
3. Application : un robot doit visiter 5 points ; parmi les 120 ordres possibles, trouver celui qui minimise la distance totale (points aux coordonnées de votre choix).

Correction
def sous_ensembles(liste):
    if not liste:
        return [[]]
    reste = sous_ensembles(liste[1:])
    return reste + [[liste[0]] + s for s in reste]

def permutations(liste):
    if len(liste) <= 1:
        return [liste]
    resultat = []
    for i, x in enumerate(liste):
        for p in permutations(liste[:i] + liste[i + 1:]):
            resultat.append([x] + p)
    return resultat

import math
points = [(0, 0), (5, 2), (1, 7), (8, 8), (3, 4)]
def longueur(ordre):
    return sum(math.dist(ordre[i], ordre[i + 1]) for i in range(len(ordre) - 1))
meilleur = min(permutations(points), key=longueur)
print(meilleur, round(longueur(meilleur), 2))

C’est la force brute du voyageur de commerce (séance 10). 5 points : 120 essais, instantané. 10 points : 3,6 millions, quelques secondes. 15 points : 1,3 × 10¹², impossible. La récursivité rend l’énumération facile à écrire ; elle ne la rend pas rapide.

04 / Défis

Défi ★★★ — Esprit prépa : les huit reines

Consigne

Placer 8 reines sur un échiquier 8×8 sans qu’aucune n’en menace une autre (même ligne, colonne ou diagonale). Par backtracking : placer une reine par ligne, colonne après colonne ; si aucune colonne n’est possible, revenir à la ligne précédente.

Écrire reines(n) qui renvoie le nombre de solutions pour un échiquier n×n. Vérifier : 4 → 2, 6 → 4, 8 → 92. Afficher une solution pour n = 8. Jusqu’à quel n votre programme répond-il en moins de 10 s ?

Correction et ouverture
def reines(n, afficher_une=False):
    colonnes = []                      # colonnes[i] = colonne de la reine de la ligne i
    compte = 0

    def compatible(col):
        ligne = len(colonnes)
        for l, c in enumerate(colonnes):
            if c == col or abs(c - col) == abs(l - ligne):     # colonne ou diagonale
                return False
        return True

    def placer():
        nonlocal compte
        if len(colonnes) == n:
            compte += 1
            if afficher_une and compte == 1:
                for c in colonnes:
                    print("." * c + "Q" + "." * (n - c - 1))
            return
        for col in range(n):
            if compatible(col):
                colonnes.append(col)
                placer()
                colonnes.pop()          # défaire

    placer()
    return compte

reines(8, afficher_une=True)
for n in (4, 5, 6, 8, 10):
    print(n, reines(n))

Les diagonales : deux cases (l₁, c₁) et (l₂, c₂) sont sur une diagonale si |l₁ − l₂| = |c₁ − c₂|. Le pop() après l’appel récursif est le geste du backtracking : on essaie, on explore, on défait. nonlocal permet à la fonction interne de modifier compte. Pour n = 12, on trouve 14 200 solutions en quelques secondes ; n = 14 demande des minutes. Ce problème est un banc d’essai classique ; Dijkstra l’a utilisé en 1972 pour enseigner la programmation structurée. Ouverture : et si on voulait seulement une solution pour n = 1000 ? Il existe une formule directe — la force brute n’est pas toujours nécessaire.

05 / Vérification

Que fait def f(n): return f(n - 1) appelée avec f(5) ?

Deux questions supplémentaires

1. Pourquoi fib naïf est-il exponentiel ? Parce que chaque appel en fait deux, et que les mêmes valeurs sont recalculées un nombre exponentiel de fois.

2. Toute récursivité peut-elle s’écrire en boucle ? Oui, en gérant soi-même une pile. Ce n’est pas toujours plus simple.

Référence

Les mots à retenir

MotDéfinition
Cas de baseCas où la fonction répond sans se rappeler.
Cas récursifAppel sur un problème strictement plus petit.
Pile d’appelsCadres des fonctions en attente ; limitée en taille.
MémoïsationStocker les résultats déjà calculés.
BacktrackingEssayer, explorer, défaire si échec.
Diviser pour régnerCouper en sous-problèmes indépendants et recombiner.
RécurrenceLe raisonnement mathématique correspondant.

Pour continuer

Vous pensez en récursif

Séance suivante : retour aux nombres. Le binaire et l’arithmétique qui font tourner les processeurs et le chiffrement.

À faire chez soi

← Séance 11SommaireSéance 13 : Binaire et arithmétique →