Méthodes de la programmation dynamique : Mémoisation et tabulation

24 Mar 2023 24 Mar 2023 1384 vues ESSADDOUKI Mostafa 16 min de lecture
Complexité algorithmique
1 Introduction à l'analyse des algorithmes 2 Notations asymptotiques 3 Opérations élémentaires et modèles de coût 4 Complexité temporelle et spatiale 5 Méthode de comptage des pas 6 Méthode de comptage des pas pour les boucles
Diviser pour régner & algorithmes de tri
7 Rappel sur l'approche récursive 8 Diviser pour régner 9 la recherche dichotomique 10 Tri par fusion 11 Tri rapide 12 Analyse des fonctions récursives
Les arbres binaires
13 Introduction aux arbres binaires 14 Définitions récursives des arbres 15 Propriétés des arbres binaires 16 Types d'arbre binaire 17 Parcours en profondeur des arbres binaires 18 Parcours en largeur des arbres binaires 19 Exercices corrigés sur les arbres - TD 1 20 Exercices corrigés sur les arbres - TD 2 21 Exercices corrigés sur les arbres - TD 3 22 DS - Arbres binaires de recherche équilibrés (AVL) 23 DS - Codage de Huffman - Compression de données
Algorithmes gloutons
24 Introduction aux algorithmes gloutons 25 Problème de la sélection d'activités 26 Problème de séquencement des tâches 27 Problème du Sac à Dos fraction
Programmation dynamique
28 Introduction à la programmation dynamique 29 Le concept de sous-structure optimale 30 Le concept de sous-problèmes superposés 31 Méthodes de la programmation dynamique 32 Différence entre la programmation dynamique, l'approche diviser pour régner, et les algorithmes gloutons 33 Calculer les nombres de catalan en C++ et Python 34 Calculer le coefficient binomial 35 Le nombre de façons pour construire un mur de dimension 4*N 36 Défi de conversion de mots 37 Décomposition de phrases à partir d'un dictionnaire 38 La collection de pièces dans un labyrinthe 39 Nombre de façons de regrouper les étudiants 40 Compter tous les chemins possibles dans une grille MxN
Méta heuristique
41 Algorithmes heuristiques et métaheuristiques 42 Algorithme de Recuit simulé 43 Algorithme Colonies de fourmis
Théorie des graphes
44 Introduction et notions fondamentales 45 Chemins, cycles et connexité 46 Représentations des graphes 47 Parcours de graphes 48 Algorithme de Dijkstra 49 Algorithme A* 50 Algorithme de Bellman-Ford 51 Algorithme Floyd-Warshall 52 Coloration des graphes
Base de données et SQL
53 Introduction au langage SQL 54 Concepts de base de SGBDR 55 Syntaxes de différentes instructions SQL 56 Création et suppression d'une base de données 57 Opérateurs SQL 58 Les contraintes en SQL 59 Création et suppression des tables en SQL 60 Insertion et modifications des enregistrements 61 Extraction des données - SELECT 62 Filtrer les données - WHERE 63 Recherche de motifs - LIKE 64 Trier les données - ORDER BY 65 Les jointures en SQL - JOIN 66 Fonctions d'agrégation en SQL - SUM, COUNT, AVG, MIN et MAX 67 Organiser des données identiques en groupes - GROUP BY et HAVING 68 Les sous-requêtes en SQL 69 Les fonctions SQL de manipulation de date 70 DS - langage SQL 71 Exercices corrigés de langage SQL
Introduction à l'intelligence artificielle
Introduction à la théorie des jeux
Préparation aux concours
72 Réseau de distribution d'eau 73 Arbre d'expression arithmétique 74 Exploration d'une grotte souterraine

Mémoisation et tabulation : deux approches de programmation dynamique

Définition : 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.

Mémoisation (Top-down)

Approche descendante qui part du problème principal et utilise la récursion avec mise en cache des résultats.

Tabulation (Bottom-up)

Approche ascendante qui résout itérativement tous les sous-problèmes du plus petit au plus grand.

La mémoisation (approche top-down)

Définition : Mémoisation

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.

Principe

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) et cache (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 cache vé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)

Définition : Tabulation

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)) # 354224848179261915075

Explication :

  • On construit un tableau tab de 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éristiqueMémoisation (Top-down)Tabulation (Bottom-up)
ApprocheDescendante (part du problème principal)Ascendante (part des sous-problèmes)
MécanismeRécursif avec cacheItératif avec tableau
StockageDictionnaire / 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é temporelleSouvent O(n) mais avec overhead des appelsO(n) sans overhead
Complexité spatialeO(n) pour le cache + pile de récursionO(n) pour le tableau (peut être optimisé)
Facilité d'implémentationPlus naturelle pour les problèmes récursifsParfois plus complexe à concevoir
RisqueDé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

Énoncé du problème

É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)

 Top-down

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)

 Bottom-up

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 sauts de même longueur que tab, initialisé à l'infini.
  • sauts[i] représente le nombre minimum de sauts pour atteindre l'indice i.
  • sauts[0] = 0 car 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 à jour sauts[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

Mémoisation
  • 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.
Tabulation
  • 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é).
Quand choisir quelle approche ?
  • 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)

Problème du sac à dos

É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)}")  # 7
 Exercice pratique

Implémenter les deux versions

 Niveau : Intermédiaire

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 :

  1. Une version avec mémoisation (top-down)
  2. 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).

Points clés à retenir
  • 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_cache est 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.