Retour au cours

backend / algorithmes-structures-donnees

Programmation dynamique : fondations et mémoïsation

Leçon 171 exercice

Explication

Ce que vous allez apprendre

  • Identifier les sous-problèmes qui se chevauchent dans une récursion naïve (l'exemple de Fibonacci)
  • Différencier mémoïsation (top-down) et tabulation (bottom-up) et savoir écrire chacune
  • Vérifier qu'un problème admet une "sous-structure optimale" avant d'y appliquer la programmation dynamique
  • Réduire l'espace mémoire d'une solution de PD en ne gardant que les résultats réellement nécessaires
  • Mesurer le gain concret de la PD en comparant la complexité avant et après (exponentielle vers linéaire)

Dans quel contexte ?

Un développeur doit calculer, pour un système de recommandation e-commerce, le nombre de façons de décomposer un budget de 500€ selon différentes offres promotionnelles disponibles — un calcul naturellement récursif. Une implémentation récursive naïve explore un nombre exponentiel de combinaisons et prend plusieurs minutes à répondre, rendant la fonctionnalité inutilisable en temps réel. En ajoutant un simple cache (mémoïsation) qui évite de recalculer deux fois le même sous-montant, le même calcul passe de plusieurs minutes à quelques millisecondes, sans changer la logique métier.

D'abord, un gaspillage caché dans la récursion naïve

Le calcul récursif naïf de Fibonacci recalcule inlassablement les mêmes sous-problèmes : fib(5) appelle fib(3), qui est aussi appelé indirectement par fib(4). L'arbre d'appels explose exponentiellement, alors qu'il n'existe en réalité que n sous-problèmes vraiment distincts.

L'observation qui change tout

Si un sous-problème a déjà été résolu une fois, pourquoi le recalculer ? C'est exactement l'idée de la programmation dynamique (PD) : mémoriser un résultat déjà obtenu au lieu de le recalculer.

Une condition à vérifier avant d'appliquer cette idée

La PD ne s'applique pas à tout problème récursif : il faut que les mêmes sous-problèmes réapparaissent plusieurs fois dans l'arbre récursif naïf. Sans ce chevauchement, mémoriser ne sert à rien, ce n'est que de la récursion classique.

Piège fréquent

La programmation dynamique ne s'applique QUE si les sous-problèmes se chevauchent réellement. L'appliquer à une récursion dont chaque appel est unique (comme la génération de toutes les permutations) n'apporte aucun gain : le cache reste vide de doublons et n'accélère rien.

Étape 1 : la mémoïsation, la version la plus naturelle

La mémoïsation (top-down) garde l'écriture récursive naturelle du problème, mais consulte un cache avant tout calcul et y stocke chaque résultat après l'avoir obtenu. Chaque sous-problème n'est ainsi résolu qu'une seule fois, jamais deux.

Astuce

En Python, le décorateur @functools.lru_cache transforme n'importe quelle fonction récursive pure en version mémoïsée, sans avoir à gérer le cache à la main.

Étape 2 : la tabulation, la perspective inverse

La tabulation (bottom-up) construit au contraire la solution en partant des plus petits sous-problèmes vers les plus grands, en remplissant une table dans un ordre où chaque case ne dépend que de cases déjà remplies avant elle.

ApprocheSens de constructionStructureAvantage principal
Mémoïsation (top-down)Du problème global vers les sous-problèmesRécursion + cacheGarde l'écriture récursive naturelle
Tabulation (bottom-up)Des plus petits sous-problèmes vers le globalBoucle + tableauPas de limite de récursion, souvent plus rapide

Un pas de plus : réduire la mémoire utilisée

Une fois la récurrence identifiée, par exemple que fib(n) ne dépend que de fib(n-1) et fib(n-2), on réalise souvent qu'il est inutile de garder toute la table : ne conserver que les quelques derniers résultats nécessaires suffit.

Un exemple concret de cette réduction

L'algorithme de Kadane (somme maximale d'un sous-tableau) illustre cette PD "minimale", où chaque étape ne dépend que de l'étape immédiatement précédente, réduisant l'espace de O(n) à O(1).

Vers la suite

Ces exemples ne dépendaient que d'un seul paramètre. La prochaine leçon montre comment la même idée s'étend à des problèmes à deux dimensions de décision, comme le sac à dos.

Commandes & code

Programmation dynamique : fondations et mémoïsation

python
from functools import lru_cache

# La PD s'applique quand un problème a : (1) sous-structure optimale, (2) sous-problèmes qui se chevauchent

# --- Fibonacci : de l'exponentiel au linéaire ---
def fib_naif(n: int) -> int:
    # O(2^n) : recalcule fib(3) des dizaines de fois pour n grand
    if n <= 1:
        return n
    return fib_naif(n - 1) + fib_naif(n - 2)

def fib_memoise(n: int, cache: dict | None = None) -> int:
    # Top-down (mémoïsation) : récursif, mais chaque sous-problème résolu UNE SEULE fois
    if cache is None:
        cache = {}
    if n in cache:
        return cache[n]           # O(1) : résultat déjà calculé
    if n <= 1:
        return n
    cache[n] = fib_memoise(n - 1, cache) + fib_memoise(n - 2, cache)
    return cache[n]
# O(n) temps, O(n) espace (cache + pile de récursion)

@lru_cache(maxsize=None)
def fib_lru(n: int) -> int:
    # Même idée, mémoïsation automatique fournie par la bibliothèque standard
    if n <= 1:
        return n
    return fib_lru(n - 1) + fib_lru(n - 2)

def fib_bottom_up(n: int) -> int:
    # Bottom-up (tabulation) : itératif, construit la solution du plus petit au plus grand sous-problème
    if n <= 1:
        return n
    table = [0] * (n + 1)
    table[1] = 1
    for i in range(2, n + 1):
        table[i] = table[i - 1] + table[i - 2]
    return table[n]

def fib_espace_optimise(n: int) -> int:
    # O(1) espace : on n'a besoin que des DEUX derniers résultats, pas de toute la table
    if n <= 1:
        return n
    precedent, courant = 0, 1
    for _ in range(2, n + 1):
        precedent, courant = courant, precedent + courant
    return courant

assert fib_naif(10) == fib_memoise(10) == fib_lru(10) == fib_bottom_up(10) == fib_espace_optimise(10) == 55

# --- Chemins dans une grille : illustration du passage récursif -> tabulation ---
def chemins_uniques_naif(m: int, n: int) -> int:
    # Nombre de chemins pour aller du coin (0,0) au coin (m-1,n-1) en ne se déplaçant qu'à droite/bas
    if m == 1 or n == 1:
        return 1
    return chemins_uniques_naif(m - 1, n) + chemins_uniques_naif(m, n - 1)   # O(2^(m+n)) -- très lent

def chemins_uniques_dp(m: int, n: int) -> int:
    # O(m*n) : table[i][j] = nombre de chemins pour atteindre la case (i, j)
    table = [[1] * n for _ in range(m)]
    for i in range(1, m):
        for j in range(1, n):
            table[i][j] = table[i - 1][j] + table[i][j - 1]   # vient soit d'en haut, soit de la gauche
    return table[m - 1][n - 1]

assert chemins_uniques_dp(3, 7) == chemins_uniques_naif(3, 7) == 28

# --- Somme maximale d'un sous-tableau contigu (Kadane) : DP en O(n), O(1) espace ---
def sous_tableau_max(tableau: list[int]) -> int:
    max_courant = max_global = tableau[0]
    for x in tableau[1:]:
        # à chaque étape : soit on étend le sous-tableau précédent, soit on recommence à x
        max_courant = max(x, max_courant + x)
        max_global = max(max_global, max_courant)
    return max_global

assert sous_tableau_max([-2, 1, -3, 4, -1, 2, 1, -5, 4]) == 6   # [4, -1, 2, 1]

# --- Nombre de façons de monter un escalier (variante de Fibonacci) ---
def compter_facons_escalier(n: int, pas_autorises: list[int] = [1, 2]) -> int:
    table = [0] * (n + 1)
    table[0] = 1   # une seule façon d'être déjà arrivé : ne rien faire
    for i in range(1, n + 1):
        for pas in pas_autorises:
            if i - pas >= 0:
                table[i] += table[i - pas]
    return table[n]

assert compter_facons_escalier(4) == 5   # (1,1,1,1)(1,1,2)(1,2,1)(2,1,1)(2,2)

Résumé

  • Deux approches équivalentes : top-down (mémoïsation, récursif + cache) et bottom-up (tabulation, itératif).
  • La PD s'applique quand un problème présente une sous-structure optimale ET des sous-problèmes qui se chevauchent.
  • L'optimisation d'espace (garder seulement les 2-3 derniers états au lieu de toute une table) est fréquente une fois la récurrence identifiée.
  • L'algorithme de Kadane résout la somme maximale d'un sous-tableau en O(n) temps et O(1) espace, un cas particulier élégant de PD.

Exercices pratiques

1 disponible
1

Mission : sauver le calcul de rentabilité qui explose en production

Objectif : Diagnostiquer une récursion sans mémoïsation, la corriger, puis reconnaître un cas où la PD n'apporte rien.

Contexte

Un système de recommandation calcule récursivement le nombre de façons de décomposer un panier de n articles selon des règles de compatibilité, avec une formule identique à Fibonacci. Pour n=35, le calcul prend plusieurs minutes ; pour n=45, le service timeout complètement, sans qu'aucune boucle infinie ne soit en cause.

Résoudre l’exercice →