Programmation Dynamique
Rappel — Là où le glouton a échoué
Dans le cours précédent, vous avez implémenté l'algorithme glouton pour le rendu de monnaie. Il donnait de bons résultats avec le système européen, mais échouait sur certains systèmes de pièces.
Rappelez-vous :
rendu_monnaie(6, [4, 3, 1]) # → [4, 1, 1] (3 pièces)
Pourtant, [3, 3] suffit — seulement 2 pièces.
Le glouton a fait un mauvais choix en prenant 4, et il ne peut jamais revenir en arrière. C'est précisément ce que la programmation dynamique va corriger.
Le problème, reformulé
Entrée : un montant n (en centimes) et une liste de valeurs de pièces disponibles.
Sortie : le nombre minimal de pièces pour atteindre exactement n.
On va maintenant chercher à résoudre ce problème de façon toujours optimale.
Idée centrale : décomposer en sous-problèmes
Posons la question autrement.
Pour rendre n centimes en un minimum de pièces, je peux choisir n'importe quelle pièce p comme dernière pièce utilisée. Il me reste alors à rendre n - p centimes, toujours de façon optimale.
Autrement dit :
Le meilleur rendu pour
n= 1 (la pièce choisie) + le meilleur rendu pourn - p
Et on choisit la pièce p qui minimise ce total.
Formellement, si on note opt(n) le nombre minimal de pièces pour rendre n :
opt(0) = 0
opt(n) = 1 + min{ opt(n - p) | p ∈ pieces, p ≤ n }
Cette formule s'appelle une relation de récurrence (ou équation de Bellman).
Exercice 0 — Comprendre la récurrence
Avec les pièces {4, 3, 1}, calculez à la main :
opt(0),opt(1),opt(2),opt(3),opt(4)- En utilisant ces résultats, calculez
opt(5)etopt(6). - Vérifiez que
opt(6) = 2et identifiez quelle pièce a été choisie à chaque étape.
Naïf mais correct : la récursion
La récurrence se traduit presque directement en Python :
def opt_naif(n: int, pieces: list[int]) -> int:
"""
Retourne le nombre minimal de pièces pour rendre n centimes.
Approche récursive naïve (sans mémoïsation).
Préconditions :
- n >= 0
- 1 doit figurer dans pieces (garantit qu'une solution existe)
- pieces contient des entiers strictement positifs
>>> opt_naif(6, [4, 3, 1])
2
>>> opt_naif(0, [4, 3, 1])
0
>>> opt_naif(11, [10, 6, 1])
2
"""
if ... :
return ...
return ...
Exercice 1 — Tester et observer
-
Codez et testez
opt_naifavec les exemples de la docstring. -
Tracez à la main l'arbre des appels récursifs pour
opt_naif(6, [4, 3, 1]):
Combien d'appels sont effectués en tout ?
Certains calculs sont-ils répétés ? Lesquels ?
- Essayez
opt_naif(40, [25, 10, 1]). Que remarquez-vous ? (Attendez 10 secondes. Si rien ne s'affiche, interrompez avec Ctrl+C.)
Le problème : des calculs répétés
Vous avez sûrement observé que opt_naif recalcule les mêmes valeurs des dizaines, voire des milliers de fois.
Par exemple, pour opt(6) avec les pièces {4, 3, 1} :
opt(2)est calculé depuisopt(3)(via la pièce 1), mais aussi depuisopt(6)(via la pièce 4, puis pièce 1, puis...).
C'est un problème de sous-problèmes qui se chevauchent (overlapping subproblems).
La solution : mémoriser chaque résultat dès qu'on le calcule, pour ne jamais le recalculer. Cette technique s'appelle la mémoïsation.
Mémoïsation : ne jamais calculer deux fois la même chose
def opt_memo(n: int, pieces: list[int], memo: dict = None) -> int:
"""
Retourne le nombre minimal de pièces pour rendre n centimes.
Approche récursive avec mémoïsation.
>>> opt_memo(6, [4, 3, 1])
2
>>> opt_memo(0, [4, 3, 1])
0
"""
if memo is None:
...
if n == ... :
...
if n not in memo :
...
return ...
Exercice 2 — Comparer naïf et mémoïsé
-
Complétez
opt_memoet testez-la avec les mêmes exemples qu'avant. -
Comparez le nombre d'appels pour
opt_naif(6, [4, 3, 1])vsopt_memo(6, [4, 3, 1]). -
Comparez le nombre d'appels effectués pour les valeurs
5,10,15avec les méthodes naïve et mémoïsée. -
Quelle relation observez-vous entre
net le nombre d'appels mémoïsés ?
Approche ascendante : remplir un tableau
La mémoïsation part de n et descend vers 0. On peut aussi faire l'inverse : partir de 0 et monter jusqu'à n, en remplissant un tableau dp tel que dp[i] est le nombre minimal de pièces pour rendre i centimes.
C'est l'approche tabulaire (ou bottom-up), la forme la plus classique de programmation dynamique.
def rendu_monnaie_dp(montant: int, pieces: list[int]) -> int:
"""
Retourne le nombre minimal de pièces pour rendre `montant` centimes.
Approche tabulaire (bottom-up).
Préconditions :
- montant >= 0
- pieces contient des entiers strictement positifs
- 1 doit figurer dans pieces
Postconditions :
- Retourne le nombre minimal de pièces
>>> rendu_monnaie_dp(6, [4, 3, 1])
2
>>> rendu_monnaie_dp(0, [4, 3, 1])
0
>>> rendu_monnaie_dp(11, [10, 6, 1])
2
"""
memo =
memo[0] = ...
for i in range(...):
...
return ...
Exercice 3 — Comprendre le tableau
-
Exécutez
rendu_monnaie_dppourmontant = 6etpieces = [4, 3, 1]. Affichez le tableaudpcomplet après exécution. -
Avec cette stratégie, combien de pièces sont nécessaires pour les valeurs allant de 0 à 6.
-
Proposez un invariant de boucle pour la boucle externe (
for i in range(...)). -
La fonction se termine-t-elle toujours ? Justifiez avec un variant.
Exercice 4 — Analyser la complexité
-
Combien de cases le tableau
dpcontient-il ? -
Pour chaque case, combien d'opérations effectue-t-on ?
-
En déduire la complexité en notation O(...) en fonction de
montantetlen(pieces). -
Comparez avec la complexité du glouton. Lequel est plus rapide ? Lequel donne toujours la solution optimale ?
-
Pour
montant = 10 000et 6 pièces, estimez le nombre d'opérations. Est-ce raisonnable ?
Exercice 5 (pour les rapides) — Retrouver les pièces utilisées
rendu_monnaie_dp retourne seulement le nombre de pièces.
Écrivez rendu_monnaie_dp_complet qui retourne la liste des pièces utilisées.
def rendu_monnaie_dp_complet(n: int, pieces: list) -> list:
"""
>>> rendu_monnaie_dp_complet(6, [4, 3, 1])
[3, 3]
>>> rendu_monnaie_dp_complet(0, [4, 3, 1])
[]
"""
# À compléter
Aides :
- Au lieu de stocker un entier dans memo[i], stockez directement la liste des pièces.
- memo[0] vaut quoi ?
- Pour chaque montant i, comment obtenir memo[i] à partir de memo[i - piece] ?
Conclusion — Glouton vs Programmation Dynamique
Questions :
Questions :
-
Quels sont les avantages de la programmation dynamique par rapport au glouton ?
-
Quels sont ses inconvénients ?