Mémoisation et tabulation : deux approches de programmation dynamique
La programmation dynamique est une technique d'optimisation qui résout des problèmes complexes en les décomposant en sous-problèmes plus simples, et en stockant les résultats intermédiaires pour éviter les calculs répétitifs.
Mémoisation et tabulation sont deux approches couramment utilisées pour optimiser les solutions de programmation dynamique. Elles permettent de résoudre les problèmes plus efficacement en stockant les résultats intermédiaires pour éviter les calculs répétitifs.
Approche descendante qui part du problème principal et utilise la récursion avec mise en cache des résultats.
Approche ascendante qui résout itérativement tous les sous-problèmes du plus petit au plus grand.
La mémoisation (approche top-down)
La mémoisation est une technique d'optimisation consistant à stocker les résultats de calculs intermédiaires pour éviter de les recalculer plusieurs fois. Elle est souvent utilisée dans les approches descendantes (top-down) de la programmation dynamique.
La mémoisation fait généralement appel à des structures de données telles que des dictionnaires, des tableaux ou des matrices pour stocker les résultats intermédiaires.
On part du problème principal, on le décompose récursivement, et on stocke chaque résultat calculé. Si le même sous-problème est rencontré à nouveau, on utilise la valeur en cache plutôt que de le recalculer.
Exemple classique : La suite de Fibonacci avec mémoisation
Le problème de la suite de Fibonacci est un exemple parfait pour illustrer la mémoisation. Lors de l'utilisation de la récursion simple, le même calcul est effectué plusieurs fois pour des valeurs répétées.
Version récursive simple (sans optimisation)
def fibonacci_simple(n):
"""Version récursive naïve - très inefficace pour n > 30"""
if n < 2:
return n # F(0)=0, F(1)=1
return fibonacci_simple(n-1) + fibonacci_simple(n-2)
# Problème : de nombreux calculs redondants !
# Pour n=5, F(3) est calculé deux fois, F(2) trois fois, etc.Version avec mémoisation
def fibonacci_memo(n, cache=None):
"""
Calcule le n-ième nombre de Fibonacci avec mémoisation
Paramètres:
n (int): Indice du nombre de Fibonacci à calculer
cache (dict): Dictionnaire pour stocker les résultats intermédiaires
Retourne:
int: La valeur de F(n)
"""
# Initialisation du cache si nécessaire
if cache is None:
cache = {}
# Condition de base
if n < 2:
return n # F(0)=0, F(1)=1
# Vérification du cache
if n not in cache:
# Calcul récursif avec stockage du résultat
cache[n] = fibonacci_memo(n-1, cache) + fibonacci_memo(n-2, cache)
# Retour de la valeur en cache
return cache[n]
# Test
print(fibonacci_memo(10)) # 55
print(fibonacci_memo(100)) # 354224848179261915075 (instantané)Explication du code :
- La fonction prend deux arguments :
n(l'indice) etcache(dictionnaire pour stocker les résultats). - Condition de base :
if n < 2: return n(F(0)=0, F(1)=1). - Vérification du cache :
if n not in cachevérifie si la valeur a déjà été calculée. - Stockage : Si non, on calcule et on stocke dans
cache[n]. - Retour : On retourne la valeur (depuis le cache).
Version avec décorateur @lru_cache (Pythonique)
from functools import lru_cache
@lru_cache(maxsize=None)
def fibonacci(n):
"""Version avec décorateur de mémoisation intégré"""
if n < 2:
return n
return fibonacci(n-1) + fibonacci(n-2)
print(fibonacci(100)) # 354224848179261915075
print(fibonacci.cache_info()) # CacheInfo(hits=98, misses=101, maxsize=None, currsize=101)La tabulation (approche bottom-up)
La tabulation est une technique d'optimisation consistant à construire une table (généralement un tableau ou une matrice) pour stocker les résultats intermédiaires des sous-problèmes. Elle est souvent utilisée dans les approches ascendantes (bottom-up) de la programmation dynamique.
La tabulation résout les sous-problèmes de manière itérative, en commençant par les plus petits et en construisant la solution au problème global.
Exemple : Fibonacci avec tabulation
def fibonacci_tabu(n):
"""
Calcule le n-ième nombre de Fibonacci avec tabulation (bottom-up)
Paramètres:
n (int): Indice du nombre de Fibonacci à calculer
Retourne:
int: La valeur de F(n)
"""
if n < 2:
return n
# Création du tableau pour stocker tous les résultats
tab = [0] * (n + 1)
tab[0] = 0
tab[1] = 1
# Remplissage itératif du tableau
for i in range(2, n + 1):
tab[i] = tab[i-1] + tab[i-2]
return tab[n]
# Version optimisée en mémoire (sans tableau complet)
def fibonacci_tabu_optime(n):
"""Version avec seulement deux variables (O(1) mémoire)"""
if n < 2:
return n
a, b = 0, 1
for _ in range(2, n + 1):
a, b = b, a + b
return b
print(fibonacci_tabu(100)) # 354224848179261915075
print(fibonacci_tabu_optime(100)) # 354224848179261915075Explication :
- On construit un tableau
tabde taille n+1. - On initialise les cas de base :
tab[0] = 0,tab[1] = 1. - On remplit le tableau itérativement de 2 à n.
- Chaque valeur utilise les résultats précédents déjà calculés.
- La version optimisée ne garde que les deux dernières valeurs (mémoire constante).
Comparaison : Mémoisation vs Tabulation
| Caractéristique | Mémoisation (Top-down) | Tabulation (Bottom-up) |
|---|---|---|
| Approche | Descendante (part du problème principal) | Ascendante (part des sous-problèmes) |
| Mécanisme | Récursif avec cache | Itératif avec tableau |
| Stockage | Dictionnaire / tableau (seulement les valeurs calculées) | Tableau complet (souvent tous les sous-problèmes) |
| Ordre de calcul | À la demande (lazy evaluation) | Systématique (tous les sous-problèmes) |
| Complexité temporelle | Souvent O(n) mais avec overhead des appels | O(n) sans overhead |
| Complexité spatiale | O(n) pour le cache + pile de récursion | O(n) pour le tableau (peut être optimisé) |
| Facilité d'implémentation | Plus naturelle pour les problèmes récursifs | Parfois plus complexe à concevoir |
| Risque | Dépassement de pile (récursion profonde) | Calcul inutile de sous-problèmes non nécessaires |
Étude de cas : Problème du nombre minimum de sauts
Étant donné un tableau d'entiers non négatifs, chaque entier représentant la longueur maximale d'un saut à partir de cet indice, déterminer le nombre minimum de sauts nécessaires pour atteindre la fin du tableau à partir de l'indice 0.
Exemple : tab = [2, 3, 1, 1, 2, 4, 2, 9, 1, 1, 1, 3, 5]
Ce problème illustre parfaitement la différence entre les deux approches, car tous les sous-problèmes ne sont pas nécessairement utiles pour trouver la solution optimale.
Solution 1 : Mémoisation (approche descendante)
Minimum de sauts avec mémoisation
Dans cette approche, on commence par l'indice 0 et on explore récursivement toutes les possibilités de sauts en utilisant les valeurs du tableau. On stocke les résultats des calculs intermédiaires dans un dictionnaire pour éviter les calculs redondants.
def min_sauts_memo(tab, index=0, memo=None):
"""
Calcule le nombre minimum de sauts pour atteindre la fin du tableau
Version avec mémoisation (top-down)
Paramètres:
tab (list): Tableau des longueurs de sauts
index (int): Position actuelle
memo (dict): Cache des résultats déjà calculés
Retourne:
int: Nombre minimum de sauts, ou infini si impossible
"""
if memo is None:
memo = {}
# Cas de base : on est déjà à la fin
if index == len(tab) - 1:
return 0
# Cas de base : on dépasse la fin (solution invalide)
if index >= len(tab):
return float('inf')
# Vérification du cache
if index in memo:
return memo[index]
# Nombre maximum de pas qu'on peut faire depuis cette position
max_saut = tab[index]
# Si on ne peut pas avancer (valeur 0) et qu'on n'est pas à la fin
if max_saut == 0 and index < len(tab) - 1:
memo[index] = float('inf')
return float('inf')
# Exploration de toutes les possibilités de saut
min_sauts = float('inf')
for i in range(1, max_saut + 1):
sauts = min_sauts_memo(tab, index + i, memo)
min_sauts = min(min_sauts, sauts + 1)
# Stockage du résultat dans le cache
memo[index] = min_sauts
return min_sauts
# Test
tab = [2, 3, 1, 1, 2, 4, 2, 9, 1, 1, 1, 3, 5]
resultat = min_sauts_memo(tab)
print(f"Nombre minimum de sauts (mémoisation) : {resultat}")
# Affichage du cache pour voir quels indices ont été calculés
print("Indices calculés :", sorted(memo.keys()))Explication détaillée :
- La fonction
min_sauts_memo(tab, index, memo)est une fonction récursive auxiliaire. - Cas de base 1 : Si l'index atteint la fin → retourne 0 (plus aucun saut nécessaire).
- Cas de base 2 : Si l'index dépasse la fin → retourne l'infini (solution invalide).
- Cache : Si l'index est dans
memo, on retourne la valeur stockée. - Exploration : Pour chaque longueur de saut possible de 1 à
tab[index], on appelle récursivement la fonction sur la nouvelle position. - Optimisation : On ne calcule que les indices réellement atteignables depuis le chemin optimal.
Avantage : Certaines combinaisons de sauts ne sont pas explorées, car elles ne sont pas nécessaires pour trouver la solution optimale. Cela peut entraîner un gain de temps significatif.
Solution 2 : Tabulation (approche ascendante)
Minimum de sauts avec tabulation
Dans cette approche, on construit un tableau pour stocker les solutions des sous-problèmes en partant des indices les plus petits. On résout tous les sous-problèmes en parcourant le tableau, puis on combine ces solutions.
def min_sauts_tabu(tab):
"""
Calcule le nombre minimum de sauts pour atteindre la fin du tableau
Version avec tabulation (bottom-up)
Paramètres:
tab (list): Tableau des longueurs de sauts
Retourne:
int: Nombre minimum de sauts, ou -1 si impossible
"""
n = len(tab)
# Tableau pour stocker le nombre minimum de sauts pour atteindre chaque position
# Initialisé à l'infini
sauts = [float('inf')] * n
sauts[0] = 0 # 0 saut pour atteindre la première position
# Parcours de toutes les positions
for i in range(1, n):
# Pour chaque position i, on regarde toutes les positions précédentes j
for j in range(i):
# Si on peut atteindre i depuis j (j + tab[j] >= i)
if j + tab[j] >= i:
sauts[i] = min(sauts[i], sauts[j] + 1)
print("Tableau des sauts minimum par position:", sauts)
# Retourne le nombre de sauts pour atteindre la dernière position
return sauts[-1] if sauts[-1] != float('inf') else -1
# Test
tab = [2, 3, 1, 1, 2, 4, 2, 9, 1, 1, 1, 3, 5]
resultat = min_sauts_tabu(tab)
print(f"Nombre minimum de sauts (tabulation) : {resultat}")Explication détaillée :
- On crée un tableau
sautsde même longueur quetab, initialisé à l'infini. sauts[i]représente le nombre minimum de sauts pour atteindre l'indice i.sauts[0] = 0car on est déjà à la position 0.- Double boucle :
- Boucle externe : pour chaque position i de 1 à n-1
- Boucle interne : pour chaque position j avant i
- Si on peut atteindre i depuis j (
j + tab[j] >= i), on met à joursauts[i]
- À la fin,
sauts[-1]contient le nombre minimum de sauts pour atteindre la fin.
Inconvénient : On résout tous les sous-problèmes, même ceux qui ne sont pas nécessaires pour obtenir la solution optimale. Cela peut entraîner une perte de temps pour les problèmes où tous les sous-problèmes ne sont pas pertinents.
Comparaison des deux approches sur ce problème
- Avantage : Ne calcule que les sous-problèmes nécessaires.
- Exemple : Pour
tab = [2, 3, 1, 1, 2, 4, 2, 9, 1, 1, 1, 3, 5], certains indices peuvent ne jamais être explorés. - Complexité : O(n × m) dans le pire cas, mais souvent meilleur en pratique.
- Mémoire : Cache + pile de récursion.
- Inconvénient : Calcule tous les sous-problèmes systématiquement.
- Exemple : Pour le même tableau, on calcule la solution pour chaque indice, même si certains ne sont pas sur le chemin optimal.
- Complexité : O(n²) dans cette implémentation (double boucle).
- Mémoire : Tableau de taille n (peut être optimisé).
- Mémoisationest préférable quand :
- Tous les sous-problèmes ne sont pas nécessaires à la solution.
- La structure du problème est naturellement récursive.
- La profondeur de récursion reste raisonnable.
- Tabulationest préférable quand :
- Tous les sous-problèmes doivent être résolus de toute façon.
- On veut éviter la récursion (risque de stack overflow).
- On peut optimiser l'espace mémoire (ne garder que les dernières valeurs).
Autre exemple : Le problème du sac à dos (Knapsack)
Étant donné une liste d'objets avec un poids et une valeur, et un sac à dos de capacité maximale W, déterminer la valeur maximale qu'on peut emporter sans dépasser la capacité.
La tabulation est particulièrement efficace pour ce problème :
def knapsack_tabu(poids, valeurs, capacite):
"""
Résout le problème du sac à dos par tabulation
Paramètres:
poids (list): Poids des objets
valeurs (list): Valeurs des objets
capacite (int): Capacité maximale du sac
Retourne:
int: Valeur maximale atteignable
"""
n = len(poids)
# Création d'une matrice (n+1) x (capacite+1)
dp = [[0] * (capacite + 1) for _ in range(n + 1)]
# Remplissage de la matrice
for i in range(1, n + 1):
for w in range(1, capacite + 1):
if poids[i-1] <= w:
# On peut prendre l'objet i-1
dp[i][w] = max(
valeurs[i-1] + dp[i-1][w - poids[i-1]], # On le prend
dp[i-1][w] # On ne le prend pas
)
else:
# L'objet est trop lourd
dp[i][w] = dp[i-1][w]
return dp[n][capacite]
# Exemple
poids = [2, 3, 4, 5]
valeurs = [3, 4, 5, 6]
capacite = 5
print(f"Valeur maximale: {knapsack_tabu(poids, valeurs, capacite)}") # 7Implémenter les deux versions
Pour le problème du nombre de chemins uniques dans une grille (de (0,0) à (m,n) en se déplaçant uniquement vers la droite ou vers le bas), implémentez :
- Une version avec mémoisation (top-down)
- Une version avec tabulation (bottom-up)
Rappel : Le nombre de chemins pour aller de (0,0) à (m,n) est donné par C(m+n, m).
from functools import lru_cache
# Version 1 : Mémoisation (top-down)
@lru_cache(maxsize=None)
def chemins_memo(m, n):
"""Nombre de chemins uniques de (0,0) à (m,n)"""
# Cas de base : une seule ligne ou une seule colonne
if m == 0 or n == 0:
return 1
# Relation de récurrence : chemins(m,n) = chemins(m-1,n) + chemins(m,n-1)
return chemins_memo(m-1, n) + chemins_memo(m, n-1)
# Version 2 : Tabulation (bottom-up)
def chemins_tabu(m, n):
"""Nombre de chemins uniques de (0,0) à (m,n) - version itérative"""
# Création d'une matrice (m+1) x (n+1)
dp = [[0] * (n + 1) for _ in range(m + 1)]
# Initialisation : première ligne et première colonne = 1
for i in range(m + 1):
dp[i][0] = 1
for j in range(n + 1):
dp[0][j] = 1
# Remplissage de la matrice
for i in range(1, m + 1):
for j in range(1, n + 1):
dp[i][j] = dp[i-1][j] + dp[i][j-1]
return dp[m][n]
# Version optimisée en mémoire (une seule ligne)
def chemins_tabu_optime(m, n):
"""Version avec seulement O(n) mémoire"""
# On ne garde qu'une ligne
ligne = [1] * (n + 1)
for i in range(1, m + 1):
for j in range(1, n + 1):
ligne[j] += ligne[j-1]
return ligne[n]
# Tests
m, n = 10, 10
print(f"Mémoisation : {chemins_memo(m, n)}")
print(f"Tabulation : {chemins_tabu(m, n)}")
print(f"Tabulation optimisée : {chemins_tabu_optime(m, n)}")- Mémoisation : O(m×n) temps, O(m×n) mémoire pour le cache + pile.
- Tabulation : O(m×n) temps, O(m×n) mémoire pour la matrice.
- Tabulation optimisée : O(m×n) temps, O(n) mémoire (meilleur compromis).
Pour m=n=20, la version optimisée est nettement plus efficace en mémoire.
- La mémoisation (top-down) part du problème principal et utilise la récursion avec cache.
- La tabulation (bottom-up) résout tous les sous-problèmes itérativement du plus petit au plus grand.
- La mémoisation ne calcule que les sous-problèmes nécessaires, ce qui peut être plus efficace si tous les sous-problèmes ne sont pas utiles.
- La tabulation calcule systématiquement tous les sous-problèmes, mais évite la récursion et l'overhead des appels de fonction.
- La tabulation permet souvent des optimisations de mémoire (ne garder que les dernières valeurs).
- Le choix entre les deux dépend du problème : nature récursive, nécessité de tous les sous-problèmes, profondeur de récursion, contraintes mémoire.
- En Python,
@lru_cacheest un outil pratique pour la mémoisation.
Discussion (0)
Soyez le premier à laisser un commentaire !
Laisser un commentaire
Votre commentaire sera visible après modération.