Programmation dynamique
Principe de la programmation dynamique⚓︎
En informatique, la programmation dynamique est une méthode algorithmique pour résoudre des problèmes d'optimisation. Le concept a été introduit au début des années 1950 par Richard Bellman.
La programmation dynamique consiste à résoudre un problème en le décomposant en sous-problèmes, puis à résoudre les sous-problèmes, des plus petits aux plus grands en stockant les résultats intermédiaires.
La programmation dynamique est utilisée pour résoudre des problèmes d'optimisation. La conception d’un algorithme de programmation dynamique est typiquement découpée en 4 étapes :
- 1. Caractériser la structure d’une solution optimale.
- 2. Définir (souvent de manière récursive) la valeur d’une solution optimale.
- 3. Calculer la valeur d’une solution optimale.
- 4. Construire une solution optimale à partir des informations calculées.
La dernière étape est utile pour calculer une solution optimale, et pas seulement la valeur optimale. Un problème d'optimisation peut avoir de nombreuses solutions. Chaque solution a une valeur, et on souhaite trouver une solution ayant la valeur optimale. Une telle solution optimale au problème n'est pas forcément unique, c'est sa valeur qui l'est.
En pratique, il s’agit de stocker les résultats des sous-problèmes au fur et à mesure pour éviter d’avoir à les résoudre plusieurs fois et les réutiliser facilement. Ce principe de stockage s’appelle la « mémoïsation ».
L’un des avantages de la programmation dynamique sur la méthode « diviser pour régner » vient de la mémoïsation. Chaque sous-problème n’est traité qu’une seule fois avec la programmation dynamique. Ce n’est pas forcément le cas avec la méthode « diviser pour régner ».
Application à la suite de Fibonacci⚓︎
Le graphe de dépendance ci-dessus permet d'illustrer que les sous-problèmes se chevauchent.
Par exemple, pour calculer
Mais pour calculer
La programmation dynamique consiste alors à stocker les valeurs des sous-problèmes pour éviter les recalculs. Il existe alors deux méthodes pour calculer effectivement une solution : la méthode ascendante et la méthode descendante.
Suite de Fibonacci et programmation dynamique ascendante
Dans la méthode ascendante (« bottom-up dynamic programming »), on commence par calculer des solutions pour les sous-problèmes les plus petits, puis, de proche en proche, on calcule les solutions des problèmes en utilisant le principe d'optimalité et on mémorise les résultats dans un tableau F
.
def fibonacci(n: int) -> int:
"""programmation dynamique ascendante"""
F = [0]*(n+1)
F[0] = 0
F[1] = 1
for i in range(2, n+1):
F[i] = F[i-1] + F[i-2]
return F[n]
#------------------------------------- TEST ------------------------------------
from time import perf_counter_ns
ti = perf_counter_ns()
f100 = fibonacci(100)
tf = perf_counter_ns()
print("fibonacci(100) = ", f"{f100:_}".replace('_', ' '),\
"\ncalculé en", (tf-ti)/1e3, "ms")
Suite de Fibonacci et programmation dynamique descendante
Dans la méthode descendante (« top-down dynamic programming »), on écrit un algorithme récursif mais on utilise la mémoïsation pour ne pas résoudre plusieurs fois le même problème, c'est-à-dire que l'on stocke dans un dictionnaire F
les résultats des appels récursifs :
def fibonacci(n: int, F: dict={0: 0, 1: 1}) -> int:
"""programmation dynamique descendante"""
if n not in F:
F[n] = fibonacci(n-1, F) + fibonacci(n-2, F)
return F[n]
#------------------------------------- TEST ------------------------------------
from time import perf_counter_ns
ti = perf_counter_ns()
f100 = fibonacci(100)
tf = perf_counter_ns()
print("fibonacci(100) = ", f"{f100:_}".replace('_', ' '),\
"\ncalculé en", (tf-ti)/1e3, "ms")
Application au rendu de monnaie⚓︎
Rappel de l'énoncé du problème
Étant donné un système de monnaie (pièces et billets), comment rendre une somme donnée de façon optimale, c'est-à-dire avec le nombre minimal de pièces et billets ?
Le système de monnaie peut être identifié à un tableau croissant de
monnaie = [1, 2, 5, 10, 20, 50, 100, 200, 500]
On souhaite écrire une fonction
rendu
prenant en argument la liste monnaie
et un entier s
, correspondant à la somme d’argent à rendre, et renvoyant un tableau de longueur Ainsi,
rendu([1, 2, 5], 9)
renverra [0, 2, 1]
.
Algorithme glouton
Le problème du rendu de la monnaie a déjà été abordé en 1ère avec un algorithme glouton. On a vu que cet algorithme ne conduit pas nécessairement à une solution optimale mais que le coût temporel restait acceptable pour obtenir une valeur approchée de la solution.
Ainsi, rendu([1, 3, 4], 6)
renvoie [2, 0, 1]
(3 pièces) au lieu de [20, 2, 0]
(2 pièces).
def rendu(monnaie: list, somme: int, index: int=None) -> list:
"""Rendu de monnaie récursif glouton"""
if index is None:
index = len(monnaie) - 1
if somme == 0:
return [0] * len(monnaie)
if index < 0 or somme < 0:
return None
if somme >= monnaie[index]:
restant = rendu(monnaie, somme-monnaie[index], index)
# Solution trouvée pour le reste de la somme
if restant is not None:
# Incrémente le nombre de pièces/billets pour la valeur actuelle
restant[index] += 1
return restant
# Si aucune solution, explore avec la valeur monétaire précédente
return rendu(monnaie, somme, index-1)
Solution optimale
Il est possible de déterminer la solution optimale avec un coût temporel raisonnable grâce à la programmation dynamique.
Rendu de monnaie et programmation dynamique ascendante
Exemple : monnaie = (1, 2, 5)
et s = 13
On trouve une solution optimale pour chaque somme d’argent de 0
à s
.
En effet, si on connaît les façons optimales de rendre toute somme d’argent strictement inférieure à s = 13
, alors pour rendre une somme s
, on prend une pièce, à choisir dans le n-uplet monnaie
, et une somme strictement inférieure à s
, ce qui correspond à un problème déjà résolu. Ainsi, on trouve la solution optimale avec une somme s
en comparant les monnaie
.
On remplit le tableau par colonne. On peut noter directement les solutions dans le tableau.
Pour obtenir s = 13
, il y a trois possibilités :
Possibilité n°1 : en ajoutant
[0, 1, 2]
+ [1, 0, 0]
→ [1, 1, 2]
)
Possibilité n°2 : en ajoutant
[1, 0, 2]
+ [0, 1, 0]
→ [1, 1, 2]
)
Possibilité n°3 : en ajoutant
[1, 1, 1]
+ [0, 0, 1]
→ [1, 1, 2]
)
Dans tous les cas, la solution est [1, 1, 2]
, i.e. avec :
•
•
•
soient
def rendu_dyn_bottom_up(monnaie: list, somme: int) -> list:
"""Rendu de monnaie dynamique bottom-up"""
nb_pieces = [0] + [float('inf')] * somme
# Stocker les pièces rendues pour chaque somme
pieces_rendues = [[0] * len(monnaie) for _ in range(somme + 1)]
for s in range(1, somme + 1):
for j in range(len(monnaie)):
p = monnaie[j]
if p <= s and 1 + nb_pieces[s-p] < nb_pieces[s]:
nb_pieces[s] = 1 + nb_pieces[s-p]
# Copie de pieces_rendues[s-p] pour éviter un effet de bord
pieces_rendues[s] = list(pieces_rendues[s-p])
# Mise à jour des pièces rendues
pieces_rendues[s][j] += 1
return pieces_rendues[somme]
rendu_dyn_bottom_up([1, 2, 5], 13)
>>> [1, 1, 2]
rendu_dyn_bottom_up([1, 3, 4], 6)
[0, 2, 0]
Rendu de monnaie et programmation dynamique descendante
def rendu_dyn_top_down(monnaie, somme, memo=None):
# Utilisation d'un dictionnaire pour mémoriser les résultats
if memo is None:
# Initialisation avec une liste de 0 pour chaque pièce de monnaie
memo = {0: [0] * len(monnaie)}
# Principe de la mémoïsation :
# si le résultat a déjà été calculé, on ne le recalcule pas
if somme in memo:
return memo[somme]
possibilites = []
for i in range(len(monnaie)):
p = monnaie[i]
if somme - p >= 0:
precedent = rendu_dyn_top_down(monnaie, somme - p, memo)
nouveau = precedent.copy() # Copie du résultat précédent
nouveau[i] += 1 # Ajout de 1 à la pièce actuelle
possibilites.append(nouveau)
choix_optimal = min(possibilites, key=lambda x: sum(x))
memo[somme] = choix_optimal
return memo[somme]