xxxxxxxxxx
# Programmation dynamique
## I : le problème des courses hippiques en version récursive «classique»
Le propriétaire d'un (très bon) cheval de course se penche sur le calendrier des courses hippiques de la saison à venir :
- chaque jour il y a une course hippique à laquelle il peut participer;
- chacune des courses hippiques offre un prix pour le vainqueur;
- après avoir participé à une course, le cheval doit se reposer et ne peut participer à une nouvelle course que quatre jours plus tard.
Le propriétaire cherche à choisir les courses auxquelles il participera en maximisant le montant total des prix qu'il pourra récolter s'il gagne (en réalité il est certain de gagner : son cheval est le meilleur !).
### Exemple :
On suppose que le calendrier des courses est implémenté par un tableau indexé `cal` pour lequel la valeur d'indice `i` est le prix offert pour la course du `i`ème jour de la saison (en commençant à zéro).
Par exemple avec comme calendrier des courses `cal = [2, 1, 10, 6, 8, 20, 22, 10, 4, 20, 2, 4, 14, 2, 6]` on sait que la course du jour 0 offre un prix égal à 2, la course du jour 1 offre un prix égal à 1, la course du jour 2 offre un prix égal à 10, la course du jour 3 offre un prix égal à 6 etc.
Le propriétaire peut choisir comme planning `[0, 4, 8, 12]` auquel cas son cheval participera aux jours 0, 4, 8 et 12, dans ce cas le montant total des prix est 2 + 8 + 4 + 14 = 28. Ce planning n'est clairement pas optimal.
Le propriétaire peut choisir comme planning `[0, 5, 9, 14]` auquel cas le montant total des prix est 2 + 20 + 20 + 6 = 48. Ce planning est meilleur que le précédent, **c'est même le meilleur de tous les plannings : il est optimal**.
En revanche le propriétaire ne peut pas choisir comme planning `[2, 6, 9, 12]` car il n'y a pas quatre jours d'écarts entre les jours d'indice 6 et 9 ni entre les jours d'indices 9 et 12.
### Notations utilisées :
Lorsqu'on considère **uniquement** les jours d'indices `0` à `n` du calendrier `cal`, on note `total_max(cal, n)` le montant total des prix correspondant au planning *optimal*.
Par exemple avec l'exemple `cal = [2, 1, 10, 6, 8, 20, 22, 10, 4, 20, 2, 4, 14, 2, 6]` précédent on a :
```
total_max(cal, 0) = 2
total_max(cal, 1) = 2
total_max(cal, 2) = 10
total_max(cal, 3) = 10
total_max(cal, 4) = 10
total_max(cal, 5) = 2 + 20 = 22
total_max(cal, 6) = 10 + 22 = 32
...
total_max(cal, 12) = 2 + 20 + 20 + 6 = 48
```
### Récursivité
Pour calculer `total_max(cal, n)`, on peut utiliser la relation de récursivité suivante (démontrée dans le notebook «planning hippique» du chapitre récursivité) :
```
si n < 4 (cas de base) :
total_max(cal, n) = max(cal[i] for i in range(n + 1))
sinon (cas récursif) :
total_max(cal, n) = max(total_max(cal, n - 1), total_max(cal, n - 4) + cal[n])
```
xxxxxxxxxx
*Question 1 :*
On suppose que le calcul de `total_max` est effectué grâce à l'implémentation récursive comme indiqué ci-dessus.
Pour un certain calendrier `cal_x`, on effectue l'appel de fonction `total_max(cal_x, 73)`. Quels sont les deux appels récursifs que cela va entraîner ?
Question 1 :
On suppose que le calcul de total_max
est effectué grâce à l'implémentation récursive comme indiqué ci-dessus.
Pour un certain calendrier cal_x
, on effectue l'appel de fonction total_max(cal_x, 73)
. Quels sont les deux appels récursifs que cela va entraîner ?
xxxxxxxxxx
xxxxxxxxxx
<details>
<summary style="border:1pt solid slateblue; border-radius:5pt; width:15%; color:slateblue; padding:3px; background-color: lightcyan"> Solution </summary>
<div style="border:1pt solid slateblue; border-radius:5pt; color:slateblue; padding:3px; background-color: lightcyan">
Cet appel va entraîner les appels récursifs à `total_max(cal_x, 72)` et à `total_max(cal_x, 69)`.
</div>
</details>
Cet appel va entraîner les appels récursifs à total_max(cal_x, 72)
et à total_max(cal_x, 69)
.
xxxxxxxxxx
*Question 2 :*
On suppose que le calcul de `total_max` est effectué grâce à l'implémentation récursive comme indiqué ci-dessus.
Pour un certain calendrier `cal_x`, on effectue l'appel de fonction `total_max(cal_x, 10)`. Recopier puis compléter l'arbre des appels récursifs ci-dessous (seule est indiquée la valeur de `n` passée en paramètre).
```
n = 10
/ \
/ \
/ \
n = 9 n = 6
/ \ / \
/ \ / \
.. .. .. ..
```
xxxxxxxxxx
<details>
<summary style="border:1pt solid slateblue; border-radius:5pt; width:15%; color:slateblue; padding:3px; background-color: lightcyan"> Solution </summary>
<div style="border:1pt solid slateblue; border-radius:5pt; color:slateblue; padding:3px; background-color: lightcyan">
<img style="width:400px;" src="https://progalgo.fr/images/prog_dyn_cours_0.png"/>
</div>
</details>
xxxxxxxxxx
Voici maintenant l'arbre des appels récursifs pour `n = 14` : on constate que certains appels récursifs identiques sont effectués plusieurs fois. Autrement dit, les problèmes de taille plus petite sont résolus plusieurs fois, ce qui explique le coût exorbitant de cet algorithme. Par exemple le problème du calcul `total_max(cal, 6)` est résolu sept fois ! On refait donc sept fois la même chose.
<img style="width:800px;" src="https://progalgo.fr/images/prog_dyn_cours_1.png"/>
Ce qu'on vient de dire serait encore pire si on résolvait `total_max(cal, 100)`. On peut ainsi montrer que l'appel de fonction `total_max(cal, 100)` nécessite `108 654 474 368 651` appels récursifs en tout, ce qui signifie que ce n'est pas exécutable en pratique (temps de calcul de plusieurs dizaines d'heures).
Voici maintenant l'arbre des appels récursifs pour n = 14
: on constate que certains appels récursifs identiques sont effectués plusieurs fois. Autrement dit, les problèmes de taille plus petite sont résolus plusieurs fois, ce qui explique le coût exorbitant de cet algorithme. Par exemple le problème du calcul total_max(cal, 6)
est résolu sept fois ! On refait donc sept fois la même chose.
Ce qu'on vient de dire serait encore pire si on résolvait total_max(cal, 100)
. On peut ainsi montrer que l'appel de fonction total_max(cal, 100)
nécessite 108 654 474 368 651
appels récursifs en tout, ce qui signifie que ce n'est pas exécutable en pratique (temps de calcul de plusieurs dizaines d'heures).
xxxxxxxxxx
*Question 3 :*
En augmentant **petit à petit** la taille `TAILLE CALENDRIER` du calendrier aléatoire, déterminer à partir de quelle taille de calendrier l'implémentation récursive de la fonction `total_max` n'est plus pertinente.
Question 3 :
En augmentant petit à petit la taille TAILLE CALENDRIER
du calendrier aléatoire, déterminer à partir de quelle taille de calendrier l'implémentation récursive de la fonction total_max
n'est plus pertinente.
xxxxxxxxxx
def total_max(cal, n):
'''
Renvoie le plus grand montant total de gains possible dans le caklendrier cal
pour les indices de 0 à n.
'''
if n < 4:
prix_max = max(cal[i] for i in range(n + 1))
else:
prix_max = max(total_max(cal, n - 1), total_max(cal, n - 4) + cal[n])
return prix_max
xxxxxxxxxx
from random import randint
TAILLE_CALENDRIER = 15
calendrier = [randint(1, 30) for _ in range(TAILLE_CALENDRIER)]
total_max(calendrier, TAILLE_CALENDRIER - 1)
xxxxxxxxxx
<details>
<summary style="border:1pt solid slateblue; border-radius:5pt; width:15%; color:slateblue; padding:3px; background-color: lightcyan"> Solution </summary>
<div style="border:1pt solid slateblue; border-radius:5pt; color:slateblue; padding:3px; background-color: lightcyan">
On constate qu'à partir d'une taille de calendrier égale à 48 environ, le temps de calcul est de l'ordre de quelques secondes. Puis si la taille de calendrier augmente encore (par exemple 55), le temps de calcul devient rapidement problématique.
</div>
</details>
On constate qu'à partir d'une taille de calendrier égale à 48 environ, le temps de calcul est de l'ordre de quelques secondes. Puis si la taille de calendrier augmente encore (par exemple 55), le temps de calcul devient rapidement problématique.
xxxxxxxxxx
<div style="border:3pt solid black; border-radius:5pt; padding:5px;">
Lorsqu'on cherche à résoudre un problème de taille n :
- en résolvant récursivement des sous-problèmes de taille inférieure,
- pour lequel les sous-problèmes sont présents de nombreuses fois dans l'arbre des appels récursifs,
la programmation dynamique consiste à modifier l'algorithme récursif pour ne pas résoudre pusieurs fois un même sous-problème.
On utilise pour cela une structure de données qui mémorise les résultats des sous-problèmes au fur et à mesure : cela évite de résoudre à nouveau un sous-problème déjà rencontré.
*Remarque : le problème initial a parfois une taille en deux dimensions, par exemple (nb_lignes, nb_colonnes), voir par exemple l'exercice de la cueillette de fraises ou l'alignement de séquences.*
</div>
Lorsqu'on cherche à résoudre un problème de taille n :
la programmation dynamique consiste à modifier l'algorithme récursif pour ne pas résoudre pusieurs fois un même sous-problème.
On utilise pour cela une structure de données qui mémorise les résultats des sous-problèmes au fur et à mesure : cela évite de résoudre à nouveau un sous-problème déjà rencontré.
Remarque : le problème initial a parfois une taille en deux dimensions, par exemple (nb_lignes, nb_colonnes), voir par exemple l'exercice de la cueillette de fraises ou l'alignement de séquences.
xxxxxxxxxx
*Question 4 :*
Si on utilise la programmation dynamique, l'arbre des appels récursifs se transforme en un graphe beaucoup plus petit puisque chaque étiquette n'est alors présente qu'une seule fois. Le graphe associé à `total_max(cal, 14)` a été commencé ci-dessous. Le recopier puis compléter les arcs manquants.
<img style="width:800px;" src="https://progalgo.fr/images/prog_dyn_cours_2.png"/>
Question 4 :
Si on utilise la programmation dynamique, l'arbre des appels récursifs se transforme en un graphe beaucoup plus petit puisque chaque étiquette n'est alors présente qu'une seule fois. Le graphe associé à total_max(cal, 14)
a été commencé ci-dessous. Le recopier puis compléter les arcs manquants.
xxxxxxxxxx
<details>
<summary style="border:1pt solid slateblue; border-radius:5pt; width:15%; color:slateblue; padding:3px; background-color: lightcyan"> Solution </summary>
<div style="border:1pt solid slateblue; border-radius:5pt; color:slateblue; padding:3px; background-color: lightcyan">
<img style="width:800px;" src="https://progalgo.fr/images/prog_dyn_cours_3.png"/>
</div>
</details>
xxxxxxxxxx
## II : programmation dynamique – version récursive + mémoïsation
La version récursive avec mémoïsation consiste à résoudre les sous-problèmes à partir de l'algorithme récursif en rajoutant une structure de données (tableau ou dictionnaire) qui va mémoriser les résultats des différents appels au fur et à mesure de leurs calculs.
Plus précisément, lorsqu'on traite un appel récursif :
- si le résultat est déjà mémorisé :
- on le lit dans la mémoire,
- si le résultat n'est pas déjà mémorisé :
- on le calcule en utilisant la relation de récursivité ou le cas de base,
- une fois calculé on le sauvegarde dans la mémoire,
- on renvoie le résultat.
Sur notre exemple, les sous-problèmes sont indexés par le nombre entier `n` : nous pouvons donc choisir comme structure de données un tableau `memoire` indexé par `n`. Ainsi `memoire[n]` contiendra le `total_max(cal, n)`.
Enfin, pour savoir si un résultat est mémorisé, nous initialiserons ce tableau `memoire` avec des valeurs égales à `None` :
- si `memoire[n] is None`, on sait que le résultat correspondant à `n` n'a pas encore été calculé (ni mémorisé).
- si `memoire[n] is not None`, on sait que le résultat correspondant à `n` a déjà été calculé (et mémorisé).
La version récursive avec mémoïsation consiste à résoudre les sous-problèmes à partir de l'algorithme récursif en rajoutant une structure de données (tableau ou dictionnaire) qui va mémoriser les résultats des différents appels au fur et à mesure de leurs calculs.
Plus précisément, lorsqu'on traite un appel récursif :
si le résultat est déjà mémorisé :
si le résultat n'est pas déjà mémorisé :
on renvoie le résultat.
Sur notre exemple, les sous-problèmes sont indexés par le nombre entier n
: nous pouvons donc choisir comme structure de données un tableau memoire
indexé par n
. Ainsi memoire[n]
contiendra le total_max(cal, n)
.
Enfin, pour savoir si un résultat est mémorisé, nous initialiserons ce tableau memoire
avec des valeurs égales à None
:
memoire[n] is None
, on sait que le résultat correspondant à n
n'a pas encore été calculé (ni mémorisé).memoire[n] is not None
, on sait que le résultat correspondant à n
a déjà été calculé (et mémorisé).xxxxxxxxxx
*Question 5 :*
Compléter le code de la fonction `total_max` ci-dessous pour qu'elle corresponde à une implémentation récursive avec mémoïsation.
Question 5 :
Compléter le code de la fonction total_max
ci-dessous pour qu'elle corresponde à une implémentation récursive avec mémoïsation.
xxxxxxxxxx
def total_max(cal, n, memoire):
if memoire[n] is not None:
...
else:
if n < 4:
prix_max = max(cal[i] for i in range(n + 1))
else:
prix_max = max(total_max(cal, n - 1, memoire), total_max(cal, n - 4, memoire) + cal[n])
...
return ...
xxxxxxxxxx
# test sur un calendrier de longueur 20
calendrier = [4, 7, 9, 4, 15, 25, 25, 8, 10, 20, 15, 8, 3, 5, 10, 5, 3, 6, 25, 30]
memoire = [None for _ in range(20)]
assert total_max(calendrier, 19, memoire) == 92
# test sur un calendrier de longueur 100
calendrier = [8, 10, 4, 5, 10, 25, 3, 9, 4, 7, 9, 4, 15, 25, 10, 8, 9, 10, 8, 4, 3, 25, 25, 8, 10, 20, 15, 8, 3, 5, 4, 25, 7, 25, 5, 15, 9, 86, 4, 25, 20, 25, 8, 3, 15, 3, 7, 4, 8, 30, 5, 25, 4, 4, 4, 10, 9, 8, 10, 5, 5, 3, 5, 10, 3, 7, 15, 5, 10, 5, 9, 3, 10, 5, 3, 4, 7, 9, 4, 15, 25, 25, 86, 25, 30, 5, 8, 3, 5, 10, 5, 3, 5, 10, 5, 3, 4, 7, 9, 4]
memoire = [None for _ in range(100)]
assert total_max(calendrier, 99, memoire) == 465
xxxxxxxxxx
<details>
<summary style="border:1pt solid slateblue; border-radius:5pt; width:15%; color:slateblue; padding:3px; background-color: lightcyan"> Solution </summary>
<div style="border:1pt solid slateblue; border-radius:5pt; color:slateblue; padding:3px; background-color: lightcyan">
```python
def total_max(cal, n, memoire):
if memoire[n] is not None:
prix_max = memoire[n]
return prix_max
if memoire[n] is None:
if n < 4:
prix_max = max(cal[i] for i in range(n + 1))
else:
prix_max = max(total_max(cal, n - 1, memoire), total_max(cal, n - 4, memoire) + cal[n])
memoire[n] = prix_max
return prix_max
```
</div>
</details>
xxxxxxxxxx
*Points de vigilance concernant l'implémentation :*
- Le tableau `memoire` est passé en argument de la fonction récursive pour être accessible depuis chacun des appels récursifs.
- Le tableau `memoire` est initialisé à l'extérieur de la fonction récursive, avant l'appel principal, sinon (placée à l'intérieur de la fonction total_prix) elle serait ré-initialisée à chaque appel récursif.
- Le tableau `memoire` est complété au fur et à mesure car les tableaux python (type `list`) sont mutables et une mutation à l'intérieur d'une fonction se répercute à l'extérieur.
Points de vigilance concernant l'implémentation :
Le tableau memoire
est passé en argument de la fonction récursive pour être accessible depuis chacun des appels récursifs.
Le tableau memoire
est initialisé à l'extérieur de la fonction récursive, avant l'appel principal, sinon (placée à l'intérieur de la fonction total_prix) elle serait ré-initialisée à chaque appel récursif.
Le tableau memoire
est complété au fur et à mesure car les tableaux python (type list
) sont mutables et une mutation à l'intérieur d'une fonction se répercute à l'extérieur.
xxxxxxxxxx
*Question 6 :*
Vérifier qu'on peut désormais appeler la fonction `total_max` avec de grands calendriers : le temps d'exécution reste rapide.
Question 6 :
Vérifier qu'on peut désormais appeler la fonction total_max
avec de grands calendriers : le temps d'exécution reste rapide.
xxxxxxxxxx
from random import randint
TAILLE_CALENDRIER = 15
calendrier = [randint(1, 30) for _ in range(TAILLE_CALENDRIER)]
memoire = [None for _ in range(TAILLE_CALENDRIER)]
total_max(calendrier, TAILLE_CALENDRIER - 1, memoire)
xxxxxxxxxx
<details>
<summary style="border:1pt solid slateblue; border-radius:5pt; width:15%; color:slateblue; padding:3px; background-color: lightcyan"> Solution </summary>
<div style="border:1pt solid slateblue; border-radius:5pt; color:slateblue; padding:3px; background-color: lightcyan">
Pour des calendriers de taille 1000 ou 2000, le calcul est quasiment instantané ...
</div>
</details>
Pour des calendriers de taille 1000 ou 2000, le calcul est quasiment instantané ...
xxxxxxxxxx
# III : programmation dynamique – version itérative (bottom-up)
La version itérative (bottom-up) consiste à d'abord partir des sous-problèmes situés en bas de l'arbre des appels récursifs pour remonter vers le problème situé à la racine. Autrement dit on résout d'abord les sous-problèmes de taille plus petite avant de résoudre ceux de taille plus grande.
Plus précisément on effectue les calculs pour `n = 0`, puis `n = 1`, puis `n = 2`, puis `n = 3` etc. Il est donc naturel d'utiliser un algorithme itératif pour remplir le tableau `memoire`.
On va donc initialiser un tableau `memoire` avec des `None` puis on va le parcourir de gauche à droite. Pour chaque indice `i`, on pourra - grâce aux valeurs déjà mémorisées `memoire[i - 1]` et `memoire[i - 4]` - calculer la valeur de `total_prix(cal, i)` puis la memoriser à son tour dans `memoire[i]`.
Par ailleurs, pour éviter des détails techniques, on se restreint au cas où on cherche à résoudre le problème pour `n > 3` (pour `n <= 3` le problème est trivial).
*Question 7 :*
Compléter le code de la fonction `total_max` ci-dessous pour qu'elle corresponde à une implémentation itérative.
La version itérative (bottom-up) consiste à d'abord partir des sous-problèmes situés en bas de l'arbre des appels récursifs pour remonter vers le problème situé à la racine. Autrement dit on résout d'abord les sous-problèmes de taille plus petite avant de résoudre ceux de taille plus grande.
Plus précisément on effectue les calculs pour n = 0
, puis n = 1
, puis n = 2
, puis n = 3
etc. Il est donc naturel d'utiliser un algorithme itératif pour remplir le tableau memoire
.
On va donc initialiser un tableau memoire
avec des None
puis on va le parcourir de gauche à droite. Pour chaque indice i
, on pourra - grâce aux valeurs déjà mémorisées memoire[i - 1]
et memoire[i - 4]
- calculer la valeur de total_prix(cal, i)
puis la memoriser à son tour dans memoire[i]
.
Par ailleurs, pour éviter des détails techniques, on se restreint au cas où on cherche à résoudre le problème pour n > 3
(pour n <= 3
le problème est trivial).
Question 7 :
Compléter le code de la fonction total_max
ci-dessous pour qu'elle corresponde à une implémentation itérative.
xxxxxxxxxx
def total_max(cal, n):
assert n > 3, "n doit être supérieur ou égal à 4 pour éviter des détails techniques"
memoire = [None for i in range(...)]
# cas de base
for i in range(4):
memoire[i] = max( [cal[j] for j in range(i+1)] )
# cas général
for i in range(4, ...):
memoire[i] = max(... , ... + cal[i])
return ...
xxxxxxxxxx
# test sur un calendrier de longueur 20
calendrier = [4, 7, 9, 4, 15, 25, 25, 8, 10, 20, 15, 8, 3, 5, 10, 5, 3, 6, 25, 30]
assert total_max(calendrier, 19) == 92
# test sur un calendrier de longueur 100
calendrier = [8, 10, 4, 5, 10, 25, 3, 9, 4, 7, 9, 4, 15, 25, 10, 8, 9, 10, 8, 4, 3, 25, 25, 8, 10, 20, 15, 8, 3, 5, 4, 25, 7, 25, 5, 15, 9, 86, 4, 25, 20, 25, 8, 3, 15, 3, 7, 4, 8, 30, 5, 25, 4, 4, 4, 10, 9, 8, 10, 5, 5, 3, 5, 10, 3, 7, 15, 5, 10, 5, 9, 3, 10, 5, 3, 4, 7, 9, 4, 15, 25, 25, 86, 25, 30, 5, 8, 3, 5, 10, 5, 3, 5, 10, 5, 3, 4, 7, 9, 4]
assert total_max(calendrier, 99) == 465
xxxxxxxxxx
<details>
<summary style="border:1pt solid slateblue; border-radius:5pt; width:15%; color:slateblue; padding:3px; background-color: lightcyan"> Solution </summary>
<div style="border:1pt solid slateblue; border-radius:5pt; color:slateblue; padding:3px; background-color: lightcyan">
```python
def total_max(cal, n):
assert n > 3, "n doit être supérieur ou égal à 4 pour éviter des détails techniques"
memoire = [None for i in range(n+1)]
# cas de base
for i in range(4):
memoire[i] = max( [cal[j] for j in range(i+1)] )
# cas général
for i in range(4, n+1):
memoire[i] = max( memoire[i-4] + cal[i], memoire[i-1] )
return memoire[n]
```
</div>
</details>
xxxxxxxxxx
*Question 8 :*
Vérifier qu'avec cette implémentation on peut également appeler la fonction `total_max` avec de grands calendriers : le temps d'exécution reste rapide.
Question 8 :
Vérifier qu'avec cette implémentation on peut également appeler la fonction total_max
avec de grands calendriers : le temps d'exécution reste rapide.
xxxxxxxxxx
from random import randint
TAILLE_CALENDRIER = 15
calendrier = [randint(1, 30) for _ in range(TAILLE_CALENDRIER)]
total_max(calendrier, TAILLE_CALENDRIER - 1)
xxxxxxxxxx
<details>
<summary style="border:1pt solid slateblue; border-radius:5pt; width:15%; color:slateblue; padding:3px; background-color: lightcyan"> Solution </summary>
<div style="border:1pt solid slateblue; border-radius:5pt; color:slateblue; padding:3px; background-color: lightcyan">
Pour des calendriers de taille 1000 ou 2000, le calcul est quasiment instantané ...
</div>
</details>
Pour des calendriers de taille 1000 ou 2000, le calcul est quasiment instantané ...