Exercices sur la programmation dynamique

Exercice 1. QCM d’activation

Pour chaque question, une seule réponse est correcte.

Question 1. La programmation dynamique est particulièrement adaptée aux problèmes qui présentent :

  • A) des sous-problèmes indépendants (comme dans « diviser pour régner »)
  • B) des sous-problèmes chevauchants (un même sous-problème apparaît plusieurs fois)
  • C) une seule solution possible
  • D) une complexité toujours linéaire
Correction

Réponse B. La programmation dynamique s’applique lorsque les sous-problèmes se chevauchent : un même calcul est nécessaire à plusieurs reprises. On stocke alors les résultats pour éviter les recalculs.

  • A est faux : quand les sous-problèmes sont indépendants, « diviser pour régner » suffit ;
  • C est faux : il peut exister plusieurs solutions, la programmation dynamique cherche la solution optimale ;
  • D est faux : la complexité dépend du problème (souvent quadratique ou pseudo-polynomiale).

Question 2. La version récursive naïve de Fibonacci a une complexité temporelle :

  • A) $O(n)$
  • B) $O(n^2)$
  • C) $O(2^n)$
  • D) $O(n \log n)$
Correction

Réponse C. Chaque appel engendre deux appels récursifs, et les mêmes valeurs sont recalculées un nombre exponentiel de fois. L’arbre d’appels a une profondeur $n$ et sa taille croît exponentiellement.

  • A est faux : c’est la complexité de la version avec mémoïsation ou bottom-up ;
  • B est faux : la croissance est bien plus rapide que quadratique ;
  • D est faux : aucun mécanisme ne divise le problème par deux à chaque étape.

Question 3. La mémoïsation (approche top-down) consiste à :

  • A) remplir un tableau du plus petit sous-problème au plus grand
  • B) stocker les résultats des appels récursifs déjà calculés pour éviter les recalculs
  • C) utiliser un algorithme glouton avec un cache
  • D) résoudre le problème sans récursivité
Correction

Réponse B. La mémoïsation conserve la structure récursive mais ajoute un dictionnaire ou un tableau pour stocker les résultats déjà calculés. Quand un sous-problème est rencontré à nouveau, on retourne directement le résultat stocké.

  • A est faux : c’est l’approche ascendante (bottom-up), pas la mémoïsation ;
  • C est faux : la mémoïsation n’est pas un algorithme glouton ;
  • D est faux : la mémoïsation utilise la récursivité (contrairement à l’approche bottom-up).

Question 4. Dans le problème du sac à dos par programmation dynamique, le tableau contient en ligne $i$ et colonne $p$ :

  • A) le poids maximal qu’on peut porter avec $i$ objets
  • B) le nombre d’objets de poids $p$
  • C) la valeur maximale atteignable avec les $i$ premiers objets et un poids maximal de $p$
  • D) le nombre de façons de remplir le sac avec $i$ objets
Correction

Réponse C. La case $(i, p)$ du tableau contient la valeur maximale que l’on peut obtenir en considérant les $i$ premiers objets avec une capacité de $p$ kg.

  • A est faux : les colonnes représentent le poids, pas les lignes ;
  • B est faux : le tableau ne compte pas les objets d’un poids donné ;
  • D est faux : on cherche la valeur maximale, pas le nombre de combinaisons.

Question 5. Pour le système de monnaie $(1, 3, 4)$ et la somme $s = 6$, l’algorithme glouton donne :

  • A) deux pièces de 3 (solution optimale)
  • B) une pièce de 4, puis une de 1, puis une de 1 (trois pièces)
  • C) une pièce de 3, puis une de 3 (solution optimale)
  • D) six pièces de 1
Correction

Réponse B. L’algorithme glouton choisit d’abord la plus grande pièce possible : 4, puis il reste $6 - 4 = 2$, et il rend $1 + 1$. Total : trois pièces. Or la solution optimale est deux pièces de 3. Cet exemple montre que le système $(1, 3, 4)$ n’est pas canonique : l’algorithme glouton ne donne pas toujours le résultat optimal.

  • A est faux : c’est la solution optimale, mais le glouton ne la trouve pas car il commence par la pièce de 4 ;
  • C est faux : même remarque ;
  • D est faux : le glouton ne choisit jamais la plus petite pièce tant qu’une plus grande convient.

Exercice 2. Fibonacci : comparer les approches (guidé)

Cet exercice compare les trois approches vues en cours pour calculer les termes de la suite de Fibonacci.

Partie A. Approche récursive naïve

  1. Écrire la fonction récursive fibo(n) (cas de base : $F_0 = 0$, $F_1 = 1$).

  2. Dessiner l’arbre des appels pour fibo(5). Combien de fois fibo(2) est-il calculé ?

  3. Tester fibo(35). Combien de temps cela prend-il approximativement ?

Partie B. Mémoïsation (top-down)

  1. Compléter la fonction suivante qui utilise un dictionnaire pour stocker les résultats :
def fibo_memo(n, memo=None):
    if memo is None:
        memo = {}
    if n in ________:
        return ________
    if n == 0 or n == 1:
        return n
    memo[n] = fibo_memo(________, memo) + fibo_memo(________, memo)
    return ________
  1. Tester avec fibo_memo(100). Comparer le temps d’exécution avec la version naïve.

Partie C. Approche ascendante (bottom-up)

  1. Compléter la fonction suivante qui remplit un tableau de gauche à droite :
def fibo_dyn(n):
    t = [0] * (n + 1)
    t[1] = ________
    for i in range(________, ________):
        t[i] = ________ + ________
    return t[n]
  1. Quelle est la complexité en temps et en espace de cette fonction ? Peut-on réduire l’espace mémoire utilisé ?
Correction

Partie A.

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

Arbre des appels pour fibo(5) :

                    fibo(5)
                   /       \
              fibo(4)       fibo(3)
             /     \        /     \
         fibo(3)  fibo(2) fibo(2) fibo(1)
        /    \    /    \   /    \
    fibo(2) fibo(1) fibo(1) fibo(0) fibo(1) fibo(0)
    /    \
fibo(1) fibo(0)

fibo(2) est calculé trois fois. Plus $n$ augmente, plus les recalculs explosent.

Pour fibo(35), le temps est de l’ordre de la seconde. Pour fibo(40), cela prend déjà une dizaine de secondes.

Partie B.

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

fibo_memo(100) retourne 354224848179261915075 quasi instantanément. Chaque valeur est calculée une seule fois puis stockée dans le dictionnaire.

Partie C.

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

La complexité est $O(n)$ en temps et $O(n)$ en espace (le tableau de taille $n + 1$).

On peut réduire l’espace à $O(1)$ en ne conservant que les deux dernières valeurs :

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

Exercice 3. Rendu de monnaie (guidé)

On dispose du système de monnaie monnaie = [1, 3, 4] et on souhaite rendre la somme $s = 6$ avec le minimum de pièces.

Partie A. Algorithme glouton

  1. Programmer la fonction rendu_glouton(monnaie, s) qui applique l’algorithme glouton : on rend toujours la plus grande pièce possible.

  2. Tester avec rendu_glouton([1, 3, 4], 6). La solution est-elle optimale ?

Partie B. Programmation dynamique

  1. Remplir à la main le tableau suivant, où chaque case contient le nombre minimal de pièces pour atteindre la somme en colonne avec les pièces disponibles en ligne :
monnaie \ somme0123456
{} (aucune)0
{1}
{1, 3}
{1, 3, 4}
  1. Quelle est la relation de récurrence permettant de remplir la case $(i, j)$ du tableau ?

  2. Programmer la fonction rendu_dynamique(monnaie, s) qui retourne le nombre minimal de pièces pour rendre la somme s.

  3. Modifier la fonction pour qu’elle retourne également la décomposition en pièces (par exemple [3, 3] pour rendre 6 avec le système [1, 3, 4]).

Correction

Partie A.

def rendu_glouton(monnaie, s):
    resultat = []
    for piece in reversed(monnaie):
        while s >= piece:
            resultat.append(piece)
            s -= piece
    return resultat

Test : rendu_glouton([1, 3, 4], 6) retourne [4, 1, 1] (trois pièces). La solution optimale est [3, 3] (deux pièces). L’algorithme glouton n’est pas optimal ici.

Partie B.

  1. Tableau rempli :
monnaie \ somme0123456
{} (aucune)0
{1}0123456
{1, 3}0121232
{1, 3, 4}0121122

Pour la ligne {1, 3} : la somme 3 peut être rendue avec une seule pièce de 3, la somme 6 avec deux pièces de 3. Pour la ligne {1, 3, 4} : la somme 4 peut être rendue avec une seule pièce de 4, la somme 5 avec une pièce de 4 et une de 1.

  1. Relation de récurrence : soit $T[i][j]$ le nombre minimal de pièces pour rendre la somme $j$ avec les $i$ premières pièces. Si la $i$-ème pièce a pour valeur $v_i$ :

$$T[i][j] = \min(T[i-1][j],; T[i][j - v_i] + 1) \text{ si } j \geq v_i$$

Le premier terme correspond au cas où on n’utilise pas la pièce $v_i$. Le second correspond au cas où on utilise au moins une pièce $v_i$ (et on peut en utiliser d’autres ensuite, d’où $T[i]$ et non $T[i-1]$).

  1. Implémentation :
def rendu_dynamique(monnaie, s):
    n = len(monnaie)
    INF = float("inf")
    t = [[INF] * (s + 1) for _ in range(n + 1)]
    for i in range(n + 1):
        t[i][0] = 0
    for i in range(1, n + 1):
        v = monnaie[i - 1]
        for j in range(1, s + 1):
            t[i][j] = t[i - 1][j]  # on n'utilise pas la pièce v
            if j >= v and t[i][j - v] + 1 < t[i][j]:
                t[i][j] = t[i][j - v] + 1
    return t[n][s]

print(rendu_dynamique([1, 3, 4], 6))  # 2
  1. Version avec décomposition :
def rendu_dynamique_complet(monnaie, s):
    n = len(monnaie)
    INF = float("inf")
    t = [[INF] * (s + 1) for _ in range(n + 1)]
    for i in range(n + 1):
        t[i][0] = 0
    for i in range(1, n + 1):
        v = monnaie[i - 1]
        for j in range(1, s + 1):
            t[i][j] = t[i - 1][j]
            if j >= v and t[i][j - v] + 1 < t[i][j]:
                t[i][j] = t[i][j - v] + 1

    # Reconstruction de la solution
    pieces = []
    i, j = n, s
    while j > 0:
        if i > 0 and t[i][j] == t[i - 1][j]:
            i -= 1  # on n'a pas utilisé la pièce i
        else:
            pieces.append(monnaie[i - 1])
            j -= monnaie[i - 1]
    return t[n][s], pieces

nb, decomposition = rendu_dynamique_complet([1, 3, 4], 6)
print(nb, decomposition)  # 2 [3, 3]

La complexité est $O(n \times s)$ en temps et en espace, où $n$ est le nombre de types de pièces et $s$ la somme à rendre.

Exercice 4. Sac à dos

On dispose des objets suivants et d’un sac pouvant porter au maximum $w = 7$ kg :

ObjetValeurPoids
A53
B84
C42
D61
  1. Appliquer l’algorithme glouton « meilleur rapport valeur/poids » et donner la solution obtenue. Est-elle optimale ?

  2. Construire le tableau de programmation dynamique de taille $5 \times 8$ (quatre objets + ligne 0, poids de 0 à 7).

  3. En déduire la valeur maximale et les objets sélectionnés en remontant dans le tableau.

  4. Programmer la fonction sac_a_dos(objets, w)objets est une liste de tuples (valeur, poids).

Correction
  1. Rapports valeur/poids : A = 5/3 ≈ 1.67, B = 8/4 = 2, C = 4/2 = 2, D = 6/1 = 6.

Tri par rapport décroissant : D (6), B (2), C (2), A (1.67). On prend D (poids 1, reste 6), B (poids 4, reste 2), C (poids 2, reste 0). Valeur totale : $6 + 8 + 4 = 18$. Poids total : 7 kg.

  1. Tableau de programmation dynamique :
objets \ poids01234567
0 (aucun)00000000
A (5, 3)00055555
B (8, 4)000588813
C (4, 2)0045891213
D (6, 1)0661011141518

Explication de quelques cases :

  • Case (B, 7) : $\max(T[\text{A}][7],; T[\text{A}][7-4] + 8) = \max(5,; 5 + 8) = 13$.
  • Case (D, 5) : $\max(T[\text{C}][5],; T[\text{C}][5-1] + 6) = \max(9,; 8 + 6) = 14$.
  1. Valeur maximale : $18$ (case en bas à droite).

Remontée : $T[\text{D}][7] = 18 \neq T[\text{C}][7] = 13$, donc D est pris (reste poids $7 - 1 = 6$). $T[\text{C}][6] = 12 \neq T[\text{B}][6] = 8$, donc C est pris (reste $6 - 2 = 4$). $T[\text{B}][4] = 8 \neq T[\text{A}][4] = 5$, donc B est pris (reste $4 - 4 = 0$). Plus de poids disponible. Objets sélectionnés : B, C, D. Valeur : $8 + 4 + 6 = 18$.

Ici, l’algorithme glouton trouvait déjà la solution optimale. Ce n’est pas toujours le cas.

  1. Implémentation :
def sac_a_dos(objets, w):
    n = len(objets)
    t = [[0] * (w + 1) for _ in range(n + 1)]

    for i in range(1, n + 1):
        valeur, poids = objets[i - 1]
        for p in range(w + 1):
            t[i][p] = t[i - 1][p]  # on ne prend pas l'objet i
            if p >= poids and t[i - 1][p - poids] + valeur > t[i][p]:
                t[i][p] = t[i - 1][p - poids] + valeur

    # Reconstruction de la solution
    selection = []
    p = w
    for i in range(n, 0, -1):
        if t[i][p] != t[i - 1][p]:
            selection.append(i - 1)  # indice de l'objet
            p -= objets[i - 1][1]

    return t[n][w], selection

objets = [(5, 3), (8, 4), (4, 2), (6, 1)]
valeur_max, indices = sac_a_dos(objets, 7)
print(f"Valeur maximale : {valeur_max}")
print(f"Objets sélectionnés : {indices}")

Résultat : valeur maximale 18, objets d’indices [3, 2, 1] (D, C, B).

La complexité est $O(n \times w)$ en temps et en espace, où $n$ est le nombre d’objets et $w$ la capacité du sac. C’est une complexité pseudo-polynomiale : elle dépend de la valeur de $w$ et non de la taille de son encodage.

Exercice 5. Distance d’édition

La distance d’édition (ou distance de Levenshtein) entre deux chaînes est le nombre minimal d’opérations (insertion, suppression, substitution) pour transformer l’une en l’autre. Ce problème est fondamental en bio-informatique pour comparer des séquences ADN.

  1. Construire à la main le tableau de programmation dynamique pour transformer "BAIN" en "BRIN".

    Rappel : la case $(i, j)$ contient la distance d’édition entre les $i$ premiers caractères de la première chaîne et les $j$ premiers de la seconde.

  2. En déduire la distance d’édition et les opérations effectuées.

  3. Programmer la fonction distance_edition(a, b) qui retourne la distance d’édition entre deux chaînes a et b.

  4. Tester avec les paires suivantes : ("CHAT", "CHATS"), ("NICHE", "CHIEN"), ("ALGORITHME", "ALTRUISME").

Correction
  1. Tableau pour "BAIN" et "BRIN" :
BRIN
01234
B10123
A21123
I32212
N43321

Explication de la case (A, R) = 1 : on compare "BA" et "BR". Le minimum entre :

  • suppression : $d(\text{“B”}, \text{“BR”}) + 1 = 1 + 1 = 2$ ;
  • insertion : $d(\text{“BA”}, \text{“B”}) + 1 = 1 + 1 = 2$ ;
  • substitution : $d(\text{“B”}, \text{“B”}) + 1 = 0 + 1 = 1$ (car A ≠ R).

Résultat : $\min(2, 2, 1) = 1$.

  1. La distance d’édition est 1 (case en bas à droite). L’unique opération est une substitution du A en R (transformer "BAIN" en "BRIN").

  2. Implémentation :

def distance_edition(a, b):
    n, m = len(a), len(b)
    t = [[0] * (m + 1) for _ in range(n + 1)]
    for i in range(n + 1):
        t[i][0] = i
    for j in range(m + 1):
        t[0][j] = j
    for i in range(1, n + 1):
        for j in range(1, m + 1):
            cout = 0 if a[i - 1] == b[j - 1] else 1
            t[i][j] = min(
                t[i - 1][j] + 1,       # suppression
                t[i][j - 1] + 1,       # insertion
                t[i - 1][j - 1] + cout  # substitution (ou identité)
            )
    return t[n][m]
  1. Tests :
print(distance_edition("CHAT", "CHATS"))        # 1
print(distance_edition("NICHE", "CHIEN"))        # 4
print(distance_edition("ALGORITHME", "ALTRUISME"))  # 5
print(distance_edition("BAIN", "BRIN"))          # 1

Pour "CHAT""CHATS" : une insertion du S à la fin. Distance = 1.

Pour "NICHE""CHIEN" : distance = 4 (par exemple : supprimer N, supprimer I, insérer I après CH, insérer N ; d’autres suites de quatre opérations existent).

La complexité est $O(n \times m)$ en temps et en espace, où $n$ et $m$ sont les longueurs des deux chaînes.

Exercice 6. Montée d’escalier (synthèse)

Un escalier comporte $n$ marches numérotées de 1 à $n$. Un marcheur part du sol (marche 0) et peut monter d’une ou deux marches à chaque pas. On note $f(n)$ le nombre de façons d’atteindre la marche $n$.

  1. Calculer à la main $f(1)$, $f(2)$, $f(3)$, $f(4)$ et $f(5)$.

  2. Justifier que $f(n) = f(n-1) + f(n-2)$ pour $n \geq 3$, avec $f(1) = 1$ et $f(2) = 2$. Quelle suite connue reconnaît-on ?

  3. Écrire une version récursive naïve montee_recursive(n), puis la tester pour $n = 30$. Que constate-t-on ?

  4. Écrire une version avec mémoïsation montee_memo(n).

  5. Écrire une version bottom-up montee_dyn(n).

  6. Extension : chaque marche a maintenant un coût $c_i$ (l’énergie dépensée pour y poser le pied). On cherche à atteindre la marche $n$ en minimisant le coût total. On note $C(i)$ le coût minimal pour atteindre la marche $i$. Donner la relation de récurrence, puis programmer la fonction cout_minimal(couts)couts est la liste des coûts des marches de 1 à $n$.

Correction
  1. Calcul à la main :
  • $f(1) = 1$ : la seule façon est (1).
  • $f(2) = 2$ : soit (1, 1), soit (2).
  • $f(3) = 3$ : (1, 1, 1), (1, 2), (2, 1).
  • $f(4) = 5$ : (1, 1, 1, 1), (1, 1, 2), (1, 2, 1), (2, 1, 1), (2, 2).
  • $f(5) = 8$ : on les énumère ou on utilise la relation.
  1. Pour atteindre la marche $n$, le dernier pas vient soit de la marche $n - 1$ (un pas de 1), soit de la marche $n - 2$ (un pas de 2). Donc $f(n) = f(n-1) + f(n-2)$. Avec $f(1) = 1$ et $f(2) = 2$, on reconnaît la suite de Fibonacci décalée : $f(n) = F_{n+1}$ (où $F$ est la suite de Fibonacci classique).

  2. Version récursive naïve :

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

Pour $n = 30$, le calcul est très lent (complexité exponentielle $O(2^n)$), exactement comme pour Fibonacci.

  1. Version avec mémoïsation :
def montee_memo(n, memo=None):
    if memo is None:
        memo = {}
    if n in memo:
        return memo[n]
    if n == 1:
        return 1
    if n == 2:
        return 2
    memo[n] = montee_memo(n - 1, memo) + montee_memo(n - 2, memo)
    return memo[n]
  1. Version bottom-up :
def montee_dyn(n):
    if n == 1:
        return 1
    t = [0] * (n + 1)
    t[1] = 1
    t[2] = 2
    for i in range(3, n + 1):
        t[i] = t[i - 1] + t[i - 2]
    return t[n]

montee_dyn(30) retourne 1346269 instantanément.

  1. Extension avec coûts : la relation de récurrence est :

$$C(i) = c_i + \min(C(i-1),; C(i-2))$$

avec $C(1) = c_1$ et $C(2) = c_2$.

def cout_minimal(couts):
    n = len(couts)
    if n == 0:
        return 0
    if n == 1:
        return couts[0]
    t = [0] * n
    t[0] = couts[0]
    t[1] = couts[1]
    for i in range(2, n):
        t[i] = couts[i] + min(t[i - 1], t[i - 2])
    return t[n - 1]

# Exemple : marches de coût [2, 5, 1, 8, 3, 6]
print(cout_minimal([2, 5, 1, 8, 3, 6]))  # 12

Vérification : le chemin optimal est marche 1 (coût 2) → marche 3 (coût 1) → marche 5 (coût 3) → marche 6 (coût 6). Total : $2 + 1 + 3 + 6 = 12$. L’alternative marche 2 → marche 4 → marche 6 donne $5 + 8 + 6 = 19$, ce qui est plus coûteux.

Ce problème de la montée d’escalier avec coûts est un classique de la programmation dynamique qui montre comment adapter la récurrence de Fibonacci pour résoudre un problème d’optimisation.