backend / algorithmes-structures-donnees
Programmation dynamique : fondations et mémoïsation
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.
| Approche | Sens de construction | Structure | Avantage principal |
|---|---|---|---|
| Mémoïsation (top-down) | Du problème global vers les sous-problèmes | Récursion + cache | Garde l'écriture récursive naturelle |
| Tabulation (bottom-up) | Des plus petits sous-problèmes vers le global | Boucle + tableau | Pas 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
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
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.