2-03. Programmation dynamique
Il existe de nombreuses approches algorithmiques pour résoudre un problème donné en se basant sur la résolutions de ses sous-problèmes. En Première, nous avons parlé de l'approche gloutonne et nous avons déjà vu cette année l'approche basée sur la méthode « Diviser pour régner ».
Dans ce chapitre, nous abordons une nouvelle approche algorithmique : la programmation dynamique. Celle-ci consiste en une mémoïsation des résultats déjà calculés afin de ne pas avoir à recalculer plusieurs fois les mêmes données.
Activité : rendu de monnaie
On suppose donné un système monétaire. Un commerçant a à sa disposition un nombre illimité de pièces de ce système monétaire. Il doit rendre la monnaie à un client en utilisant un nombre minimal de pièces. Pour simplifier les choses, on suppose que les valeurs d'un système monétaire ainsi que la somme à rendre sont des entiers positifs.
Dans toute cette activité, on considèrera trois systèmes monétaires : le système européen, l'ancien système impérial britannique et un système imaginaire. Chacun de ces systèmes est représenté par une liste d'entiers, chacun de ces entiers correspondant à une valeur du système monétaire :
- euro =
[50, 20, 10, 5, 2, 1] - imperial =
[30, 24, 12, 6, 3, 1] - imaginaire =
[9, 3, 2]
1. Approche gloutonne
On rappelle qu'une approche gloutonne une stratégie algorithmique qui consiste à trouver une solution à un problème en prenant le meilleur choix à chaque étape et sans revenir sur les choix précédents.
Dans le cas de l'algorithme du rendu de monnaie, on utilise la stratégie gloutonne suivante :
- on utilise la pièce qui a la plus grande valeur sans dépasser la somme à rendre,
- on actualise la somme à rendre en lui soustrayant cette valeur,
- on recommence ainsi jusqu'à ce que la somme à rendre soit égale à 0.
Implémentation
La fonction rendu_monnaie_glouton ci-dessous prend en paramètre un entier strictement positif somme_a_rendre correspondant à la somme à rendre et une liste systeme correspondant au système monétaire à utiliser pour rendre la monnaie. Elle renvoie une liste contenant la liste des valeurs à rendre en utilisant la stratégie gloutonne précédente.
def rendu_monnaie_glouton(somme_a_rendre, systeme):
rendu = [] # initialisation : la liste est vide
for elem in systeme: # parcours de la liste système
while somme_a_rendre >= ...:
somme_a_rendre = ...
rendu.append(...)
return rendu
Utiliser la fonction test_rendu_monnaie_glouton ci-dessous pour tester la validité de votre fonction.
def test_rendu_monnaie_glouton():
euro = [50, 20, 10, 5, 2, 1]
imperial = [30, 24, 12, 6, 3, 1]
imaginaire = [9, 3, 2]
assert rendu_monnaie_glouton(49, euro) == [20, 20, 5, 2, 2]
assert rendu_monnaie_glouton(49, imperial) == [30, 12, 6, 1]
assert rendu_monnaie_glouton(49, imaginaire) == [9, 9, 9, 9, 9, 3]
print("Tous les tests ont été réussis")
Analyse
On souhaite rendre la somme de 49 €.
1.1. La solution produite par l'algorithme glouton avec le système européen permet-elle de rendre cette somme ? Est-elle optimale ?
1.2. La solution produite par l'algorithme glouton avec l'ancien système impérial britannique permet-elle de rendre cette somme ? Est-elle optimale ?
1.3. La solution produite par l'algorithme glouton avec le système imaginaire permet-elle de rendre cette somme ? Est-elle optimale ?
2. Diviser pour régner
Pour obtenir la solution optimale, on peut utiliser un algorithme récursif qui explore toutes les sommes inférieures ou égales au rendu réalisables avec un ensemble de pièces donné et renvoie le nombre minimal de pièces (renvoie ∞ quand il n’y a pas de solution) :
fonction rendu_monnaie_rec(somme_à_rendre, système)
si somme_à_rendre = 0 : renvoyer 0
nb ← +∞
pour toute pièce p de système:
| si valeur de p ≤ somme_à_rendre:
| | nb ← minimum( nb, 1 + rendu_monnaie_rec(somme_à_rendre - p, système) )
renvoyer nb
2.1. Décrire en quoi cette approche est une application de la méthode « diviser pour régner ».
Étude d’un exemple
La méthode proposée dans cette approche fonctionne en effectuant toutes les combinaisons possibles de pièces pour une certaine somme et à choisir celle qui comporte le moins de pièces. Examinons plus en détails les appels récursifs réalisés.
On considère le système de pièces imaginaire [9,3,2] et on fait fonctionner l'algorithme récursif précédent sur la somme 10.
2.2. Compléter l'arbre des appels récursifs de la fonction rendu_monnaie_rec ci-dessous.
rendu_monnaie_rec2.3. Que constate-t-on en examinant cet arbre ?
3. Programmation dynamique
Comme on a pu le voir précédemment, la programmation récursive de la résolution du problème du rendu de monnaie permet d'obtenir la solution optimale mais elle nécessite de nombreux appels identiques. L'une des clés de la programmation dynamique est d'éviter ces calculs redondants.
Une idée pour éviter de recalculer plusieurs fois le même résultat est de mémoriser les résultats calculés dans un tableau dédié. On choisit de calculer tous les résultats des sous-problèmes, en commençant par les plus simples et en finissant par les plus compliqués, ce qui permet de supprimer la récursivité.
Dans notre exemple du rendu de monnaie pour la somme de 49 euros, il s'agit de calculer dans cet ordre toutes les solutions optimales pour les sommes allant de 0 à 49 € inclus.
On donne l'algorithme suivant :
fonction rendu_monnaie_dyna(somme_à_rendre, système)
nb ← tableau contenant les entiers de 0 à somme_à_rendre
pour s allant de 1 à somme_à_rendre:
| pour toutes les pièces p du système:
| | si p ≤ s:
| | | nb[s] ← minimum( nb[s], 1 + nb[s-p] )
renvoyer nb[somme_à_rendre]
Un exemple « à la main »
On exécute l'instruction rendu_monnaie_dyna(5, [2, 1]).
3.1. Quel est la somme à rendre et quel est le système monétaire utilisé ?
3.2. Décrire les différentes étapes lors de l'exécution de cette instruction.
Implémentation en Python
3.3. Implémenter l’algorithme précédent en Python.
3.4. Exécuter l'instruction rendu_monnaie_dyna(49, [9, 3, 2]). La réponse est-elle correcte ?
3.5. Exécuter l'instruction rendu_monnaie_dyna(10, [9, 3, 2]). La réponse est-elle correcte ? Pourquoi cela se produit-il ? Comment y remédier ?
Remarque : dans cette fonction, on a deux boucles emboitées qui contiennent une opération en temps constant (calcul d'un minimum). Le temps d'exécution est alors proportionnel au produit du nombre de pièces du système par la somme. C'est plus long que pour l'algorithme glouton mais on obtient une solution optimale. Par contre, cette amélioration nécessite un plus grand espace mémoire avec le tableau.
Pour aller plus loin
La fonction précédente calcule le nombre de pièces correspondant à la solution optimale quand c’est possible, mais ne précise pas comment cette solution a été obtenue.
On propose ci-dessous une fonction permettant, non seulement de calculer le nombre de pièces utiles, mais aussi la combinaison des pièces à rendre et on gère également le cas où le rendu est impossible.
def rendu_monnaie_dyna_combi(somme, systeme):
'''
Renvoie une liste minimale de pièces constituant la combinaison
des pièces à rendre pour le rendu de la somme donnée avec le
système de pièces donné.
La fonction renvoie [-1] quand le rendu est impossible.
'''
combi = [[0 for k in range(s)] for s in range(somme+1)]
for s in range(1,somme+1):
for p in systeme:
if p <= s:
if len(combi[s]) > 1+len(combi[s-p]) or 0 in combi[s]:
combi[s] = combi[s-p] + [p]
if 0 in combi[somme]: # somme impossible à réaliser
return [-1]
return combi[somme]
3.5. Expliquer ce que fait la ligne 8.
3.6. Expliquer le test de la ligne 12.
3.7. Que renvoie la fonction quand on l’exécute avec le système imaginaire et la somme 10 ? Expliquer.
Éléments de réponses
rendu_monnaie_rec
def rmd(somme, systeme):
nb = [float('inf') for i in range(somme+1)]
nb[0] = 0
for s in range(1, somme+1):
for p in systeme:
if p <= s:
nb[s] = min(nb[s], 1+nb[s-p])
return nb[somme]
Principe
- Utiliser la programmation dynamique pour écrire un algorithme.
La programmation dynamique est une implémentation améliorée de la version récursive d’un algorithme naif. Au lieu de faire des appels récursifs, on utilise la mémorisation qui économise ainsi des calculs (et du temps) au détriment de l’espace mémoire. On utilise un tableau où les valeurs sont ainsi calculées « dynamiquement » en fonction des précédentes.
Fibonacci par récursivité
On a vu, dans le chapitre sur les fonctions récurrentes, qu’il était possible de calculer les termes de la suite de Fibonacci de cette manière, même si ce n’était pas efficace.
Rappel : la suite de Fibonnaci est définie par $*u_{n+2} = u_{n+1} + u_n*$ avec $*u_0*$ = 0 et $*u_1*$ = 1.
def fib(n):
if n in [0, 1]: return n
return fib(n-1) + fib(n-2)
Cet arbre montre bien que certains calculs sont faits plusieurs fois : fib(3) est effectué deux fois (avec tous les appels récurrents que ça implique !) et fib(2) est effectué trois fois.
En programmation dynamique, on va mémoriser les calculs déjà effectués dans un tableau, pour ne pas avoir à les faire plusieurs fois.
Approche descendante
def fib_md(n, mem = None):
if mem is None : mem = [0,1] + [None] * (n - 1)
if mem[n] is None: mem[n] = fib_md(n-1, mem) + fib_md(n-2, mem)
return mem[n]
Au départ, si le tableau contenant les résultats n’existe pas, on l’initialise avec n+1 valeurs, les deux premières valant 0 et 1 et les autres étant initialisées à None.
Ensuite, si la valeur du terme de rang n n’a pas encore été calculée, on la calcule et on la stocke dans ce tableau. Si elle a déjà été calculée, on ne fait que lire la valeur dans le tableau. Le calcul n’est pas refait.
Dans cette version, on garde la structure récursive. Il s’agit d’une approche descendante : on part de n pour calculer ensuite les valeurs précédentes.
Approche ascendante
On peut même faire encore mieux et se passer complètement de la récursivité (et donc s’affranchir de ses limites en termes de nombre d’appels) en adoptant l’approche ascendante. On part de n = 1 et on calcule ensuite les termes successifs jusqu’à arriver au terme voulu.
def fib_ma(n):
mem = [0,1]
while len(mem) < n+1: mem.append(mem[-1] + mem[-2])
return mem[n]
Performances
On souhaite comparer les performances de ces trois approches (récursivité, dynamique descendante et dynamique ascendante). Pour cela, on va mesurer le temps de calcul de ces trois approches pour une valeur de n de l’ordre de 35.
Vous allez utiliser le module time de la manière suivante :
import time
t1 = time.time() # t1 contient le timestamp au moment de l’exécution de la ligne
my_function()
t2 = time.time()
print(t2-t1) # différence en secondes (float) entre les deux timestamps
Vous devrez améliorer ce code de manière à afficher un nombre entier de millisecondes écoulées.
Comparer le temps d’exécution des trois fonctions pour n= 35.
Correction
import time
def get_duration_since(t):
return int(round(time.time()-t,3)*1000)
n=35
t1 = time.time()
test1 = fib(n)
print(test1, f"en {get_duration_since(t1)} ms")
t2 = time.time()
test2 = fib_md(n)
print(test2, f"en {get_duration_since(t2)} ms")
t3 = time.time()
test3 = fib_ma(n)
print(test3, f"en {get_duration_since(t3)} ms")
Révision & entraînement
Optimisation d’un prix
Une entreprise produit et vend des barres d'acier de différentes longueurs. Voici le prix de vente de ces barres d'acier en fonction de leurs longueurs :
| longueur | prix | longueur | prix |
|---|---|---|---|
| 1 | 1 | 6 | 17 |
| 2 | 5 | 7 | 17 |
| 3 | 8 | 8 | 20 |
| 4 | 9 | 9 | 24 |
| 5 | 10 | 10 | 30 |
Quand l'entreprise produit une barre de longueur n, elle a 2 possibilités :
- vendre la barre telle quelle
- découper la barre afin d'obtenir plusieurs barres plus petites
Par exemple pour une barre de 4 m de longueur l'entreprise peut :
- vendre la barre de 4 → gain = 9
- découper la barre en 2 + 2 → gain = 5+5 = 10
- découper la barre en 1 + 3 → gain = 8+1 = 9
- découper la barre en 1 + 1 + 2 → gain = 1+1+5 = 7
- …
1. Continuez la liste ci-dessus afin d'obtenir toutes les possibilités pour une barre de 4 m. Quel est le gain maximum possible ?
Généralisons le problème pour une barre de n mètres (n entier compris entre 1 et 10). Pour chaque longueur n on peut déterminer le revenu maximum possible (que l'on notera rn). Par exemple :
n=1 |
n=2 |
n=3 |
n=4 |
|---|---|---|---|
| Pas de découpe → gain = 1 |
Pas de découpe → gain = 5 Découpe 1+1 → gain = 2 |
Pas de découpe → gain = 8 Découpe 1+2 → gain = 6 Découpe 1+1+1+ → gain = 3 |
Voir question 1 |
r1 = 1 |
r2 = 5 |
r3 = 8 |
r4 = 10 |
Considérons une barre de longueur 5. Appelons p5 son prix non découpée (p5 = 10)
2.a. Quel est le gain maximal si l’un des morceaux mesure 1 ?
2.b. Quel est le gain maximal si l’un des morceaux mesure 2 ?
2.c. Quel est le gain maximal si l’un des morceaux mesure 3 ?
2.d. Quel est le gain maximal si l’un des morceaux mesure 4 ?
2.e. En déduire comment calculer r5.
3. Compléter la fonction suivante permettant de calculer le gain maximal d’un barre de longueur n.
def revenu(n):
prix = [0,1,5,8,9,10,17,17,20,24,30]
if n == 0: ...
r = float('-inf')
for i in range(1, n+1):
r = max(r, prix[i] + revenu(...))
return r
4. Construire l’arbre des appels recursifs de cette fonction pour n = 4. Que constate-t-on ?
Comme on l’a vu dans cette situation, la programmation dynamique pourra nous éviter de refaire un grand nombre de fois les mêmes calculs.
5. En vous inspirant de ce qui a été fait dans le cours et en utilisant la programmation dynamique, complétez la fonction revenu_d(n) ci-dessous permettant de calculer rn en utilisant une approche descendante.
def revenu_d(n, mem=None):
prix = [0,1,5,8,9,10,17,17,20,24,30]
# initialisation de la liste des résultats mémorisés
if mem is None: mem = [0] + [float('-inf')] * n
# si pas calculé → faire le calcul et le stocker dans mem
if mem[n] ... :
for i in range(...):
mem[n] = ...
# renvoyer le résultat attendu
return mem[n]
6. Écrivez une fonction revenu_d(n) permettant de calculer rn en utilisant la programmation dynamique avec une approche ascendante.
def revenu_a(n):
prix = [0,1,5,8,9,10,17,17,20,24,30]
# initialisation de la liste des résultats mémorisés
mem = [0]
# on calcule les prix par ordre croissant de longueur i
for i in range(...):
mem.append(float('-inf'))
# on détermine le meilleur prix pour la longueur i
for k in range(...):
mem[i] = ...
# renvoyer le résultat attendu
return mem[n]
Correction
1. Il manque la découpe 1+1+1+1 pour un gain de 4. Le gain maximum possible vaut donc 10.
2.a. p1 + r4
2.b. p2 + r3
2.c. p3 + r2
2.d. p4 + r1
2.e. r5 = max (p5, p1 + r4, p2 + r3, p3 + r2, p4 + p1)
3. Fonction revenu
def revenu(n):
prix = [0,1,5,8,9,10,17,17,20,24,30]
if n == 0: return 0
r = float('-inf')
for i in range(1, n+1):
r = max(r, prix[i] + revenu(n-i))
return r
4. Arbre des appels récursifs de la fonction revenus
On constate que de nombreux calculs sont répétés.
5. Fonction revenu_d
def revenu_d(n, mem=None):
prix = [0,1,5,8,9,10,17,17,20,24,30]
# initialisation de la liste des résultats mémorisés
if mem is None: mem = [0] + [float('-inf')] * n
# si pas calculé → faire le calcul et le stocker dans mem
if mem[n] == float('-inf'):
for i in range(1, n+1):
mem[n] = max(mem[n], prix[i] + revenu_d(n-i, mem))
# renvoyer le résultat attendu
return mem[n]
6. Fonction revenu_a
def revenu_a(n):
prix = [0,1,5,8,9,10,17,17,20,24,30]
# initialisation de la liste des résultats mémorisés
mem = [0]
# on calcule les prix par ordre croissant de longueur
for i in range(1, n+1):
mem.append(float('-inf'))
# on détermine le meilleur prix pour la longueur i
for k in range(1, i+1):
mem[i] = max(mem[i], prix[k] + mem[i-k])
# renvoyer le résultat attendu
return mem[n]