Programme

Capacités attendues et commentaires

Capacités attendues

Commentaires

Programmation dynamique

Fibonnacci naif

Définition

La suite de Fibonacci est définie par :

Implémentation naïve

def fibonacci(n):
    if n == 0:
        return 0
    if n == 1:
        return 1
    return fibonacci(n-1) + fibonacci(n-2)

Arbre d’appels

fibonacci(4)
    fibonacci(3)
        fibonacci(2)
            fibonacci(1)
            fibonacci(0)
        fibonacci(1)
    fibonacci(2)
        fibonacci(1)
        fibonacci(0)

Problème

Fibonnacci avec mémoïsation

Mémoïsation

def fibm(n, memo=None):
    if memo is None:
        memo = {0: 0, 1: 1}
    if n not in memo:
        memo[n] = fibm(n-1, memo) + fibm(n-2, memo)
    return memo[n]

Complexité avec mémoïsation

Programmation dynamique

Concept

Principe

La programmation dynamique s’applique aux problèmes qui vérifient deux propriétés :

La méthode consiste à résoudre chaque sous-problème une seule fois et à mémoriser son résultat pour ne pas le recalculer.

Deux approches

Les deux approches ont la même complexité temporelle. La tabulation évite les risques liés à la récursion et permet parfois d’optimiser la mémoire.

Le terme programmation ne signifie pas écrire un programme mais planifier une solution. C’est une méthode algorithmique, introduite par Richard Bellman dans les années 1950.

Applications

Applications

Approche top-down

Rendu de monnaie glouton

Algorithmes gloutons

Rappel : un algorithme glouton est un algorithme qui résout un problème en faisant des choix locaux optimaux. Il ne revient pas sur ses choix.

Implémentation

def rendu_monnaie_glouton(sar, pieces):
    pieces = sorted(pieces, reverse=True)
    rendu = []
    for p in pieces:
        while sar >= p:
            rendu.append(p)
            sar -= p
    return rendu

Problème

Il existe des cas où l’algorithme glouton ne donne pas la solution optimale, ou ne donne pas de solution du tout, alors qu’une solution existe (ici : 1 pièce de 5 cts, et 3 pièces de 2 cts).

Rendu de monnaie programmation dynamique

Principe

On peut donc écrire un algorithme récursif pour résoudre ce problème.

Attention : il faut prévoir le cas où on ne peut pas rendre la somme. CF code après.

Implémentation sans mémoïsation

def rendu_dyn(sar, pieces): # renvoie bool, liste = trouvé ? et liste pces
    # Cas de base, on a trouvé youpi
    if sar == 0:
        return True, []
    # On recherche la meilleure (= la plus courte) liste de pieces
    best_liste, best_size = None, None
    # On parcours toutes les pieces disponibles
    for p in pieces:
        # Si la piece est > a la somme a rendre = piece inutile, next
        if p > sar:
            continue
        # On demande a rendu_dyn de renvoyer la solution optimale 
        # pour sar-p
        # Il renvoie une liste `liste` de pieces si il a trouvé `trouve`
        trouve, liste = rendu_dyn(sar-p, pieces)
        # Si rendu_dyn n'a pas trouvé, on passe a la piéce suivante
        if not trouve:
            continue
        # Sinon on check si la solution avec cette piece est 
        # la meilleure (plus courte)
        if (best_size is None            # c le premier, on prend de ttes façons
            or len(liste) < best_size):  # c une meilleure solution
            best_size = len(liste) + 1   # on enregistre la taille (= la liste + la piece)
            best_liste = [p] + liste     # on enregistre la liste (+ la piece)
    # si on a pas trouvé, on renvoie False
    if best_size is None:
        return False, []
    # On a trouvé, on renvoie la liste
    return True, best_liste

rendu_dyn(11, [10,2,5])

Implémentation avec mémoïsation

def rendu_dyn_mem(sar, pieces, memo=None): # renvoie bool, liste = trouvé ? et liste pces
    if memo is None:
        memo = {0: (True,[])}
    if sar in memo:
        return memo[sar]
    # On recherche la meilleure (= la plus courte) liste de pieces
    best_liste, best_size = None, None
    # On parcours toutes les pieces disponibles
    for p in pieces:
        # Si la piece est > a la somme a rendre = piece inutile, next
        if p > sar:
            continue
        # On demande a rendu_dyn_mem de renvoyer la solution optimale 
        # pour sar-p
        # Il renvoie une liste `liste` de pieces si il a trouvé `trouve`
        trouve, liste = rendu_dyn_mem(sar-p, pieces, memo)
        # Si rendu_dyn_mem n'a pas trouvé, on passe a la piéce suivante
        if not trouve:
            continue
        # Sinon on check si la solution avec cette piece est 
        # la meilleure (plus courte)
        if (best_size is None            # c le premier, on prend de ttes façons
            or len(liste) < best_size):  # c une meilleure solution
            best_size = len(liste) + 1   # on enregistre la taille (= la liste + la piece)
            best_liste = [p] + liste     # on enregistre la liste (+ la piece)
    # si on a pas trouvé, on renvoie False
    if best_size is None:
        # On enregistre la solution dans le memo
        memo[sar] = False, []
        return False, []
    # On enregistre la solution dans le memo
    memo[sar] = True, best_liste
    # On a trouvé, on renvoie la liste
    return True, best_liste

rendu_dyn_mem(11, [10,2,5])

Approche bottom-up

Limites de la mémoïsation

Rappel

La mémoïsation est une approche descendante (top-down) :

Problème

import sys
sys.setrecursionlimit(10000)  # contournement, pas une vraie solution

Solution

L’approche ascendante (bottom-up) résout d’abord les petits sous-problèmes, puis construit la solution au problème de taille nn à partir de ceux-ci. Elle n’utilise pas de récursion.

Fibonacci ascendant

Principe

Au lieu de partir de F(n)F(n) et de descendre, on part des cas de base et on remonte :

Implémentation

def fibt(n):
    if n == 0:
        return 0
    dp = [0] * (n + 1)
    dp[0] = 0
    dp[1] = 1
    for i in range(2, n + 1):
        dp[i] = dp[i-1] + dp[i-2]
    return dp[n]

Tableau de calcul pour n=6n = 6

i 0 1 2 3 4 5 6
dp 0 1 1 2 3 5 8

On remplit le tableau de gauche à droite. Chaque valeur ne dépend que des deux cases précédentes.

Complexité

Optimisation mémoire

Observation

Pour calculer dp[i], on n’a besoin que de dp[i-1] et dp[i-2]. Inutile de conserver tout le tableau.

Implémentation avec O(1)O(1) en mémoire

def fibt_opt(n):
    if n == 0:
        return 0
    a, b = 0, 1
    for _ in range(2, n + 1):
        a, b = b, a + b
    return b

Cette optimisation est caractéristique de l’approche ascendante : elle n’est pas possible avec la mémoïsation, qui doit conserver tous les appels en attente sur la pile.

Rendu de monnaie ascendant

Principe

On construit la solution pour toutes les sommes de 00 à ss, dans l’ordre croissant.

Implémentation

def rendu_topdown(sar, pieces):
    # dp[m] = (trouvé, liste) : solution optimale pour rendre la somme m
    # Cas de base : rendre 0 est toujours possible, avec une liste vide
    dp = {0: (True, [])}

    # On remplit dp pour toutes les sommes de 1 à sar (ordre croissant)
    for m in range(1, sar + 1):
        best_liste, best_size = None, None
        # On parcourt toutes les pièces disponibles
        for p in pieces:
            # Si la pièce est > à la somme courante = pièce inutile, next
            if p > m:
                continue
            # dp[m-p] est forcément déjà calculé (m-p < m)
            # On récupère la solution optimale pour m-p
            trouve, liste = dp[m - p]
            # Si on n'a pas pu rendre m-p, cette pièce ne mène à rien
            if not trouve:
                continue
            # Sinon on vérifie si la solution avec cette pièce est
            # la meilleure (= la plus courte) trouvée jusqu'ici
            if (best_size is None           # c'est la première, on prend de toutes façons
                or len(liste) + 1 < best_size): # c'est une strictement meilleure solution
                best_size = len(liste) + 1  # on enregistre la taille (liste + la pièce)
                best_liste = [p] + liste    # on enregistre la liste (+ la pièce)
        # Si aucune pièce n'a fonctionné, m est impossible à rendre
        if best_size is None:
            dp[m] = False, []
        else:
            dp[m] = True, best_liste

    return dp[sar]

rendu_topdown(11, [10, 2, 5])

Tableau de calcul pour s=11s = 11, pièces =[10,2,5]= [10, 2, 5]

mm trouvé liste optimale
0 Oui []
1 Non
2 Oui [2]
3 Non
4 Oui [2, 2]
5 Oui [5]
6 Oui [2, 2, 2]
7 Oui [2, 5]
8 Oui [2, 2, 2, 2]
9 Oui [2, 2, 5]
10 Oui [10]
11 Oui [2, 2, 2, 5]

Les cases à Non correspondent à des sommes impossibles à rendre avec les pièces disponibles (ici : 1 et 3).

Cas impossible

Si dp[s] vaut (False, []) à la fin, c’est qu’il n’existe aucune façon de rendre la somme ss avec les pièces disponibles.

trouve, lst = rendu_bas(3, [2, 5, 10])
if not trouve:
    print("Impossible de rendre cette somme.")
else:
    print(f"{len(lst)} pièce(s) : {lst}")

Complexité

Comparaison des trois approches

Sur Fibonacci

Approche Direction Récursion Temps Mémoire
Naïve Top-down Oui O(φn)O(\varphi^n) O(n)O(n) (pile)
Mémoïsation Top-down Oui O(n)O(n) O(n)O(n) (pile + dict)
Tabulaire Bottom-up Non O(n)O(n) O(n)O(n) (tableau)
Tabulaire opt. Bottom-up Non O(n)O(n) O(1)O(1)

Sur le rendu de monnaie

Approche Direction Récursion Temps Mémoire
Glouton Non O(slog|p|)O(s \log |\text{p}|) O(1)O(1)
Dynamique naïf Top-down Oui O(s|p|)O(s^{|\text{p}|}) exponentiel O(s)O(s) (pile)
Mémoïsation Top-down Oui O(s×|p|)O(s \times |\text{p}|) O(s)O(s) (pile + dict)
Tabulaire Bottom-up Non O(s×|p|)O(s \times |\text{p}|) O(s)O(s)

Bilan