Programmation dynamique
Cours complet · informatique (MP2I/MPI), chapitre 17 · MP2I et MPI
Travailler ce chapitre sur Adloun Exercices corrigés de ce chapitre
17.1 Ne pas recalculer deux fois la même chose
Le chapitre chap:recursivite montrait l'arbre des appels de fib 4 : fib 2 y était calculé deux fois, fib 1 trois fois. Sur fib 40, ce gaspillage atteint appels — comptés, pas estimés — pour quarante valeurs distinctes.
La programmation dynamique est la méthode qui supprime ce gaspillage. Elle s'applique dès que deux conditions sont réunies — et le programme les nomme toutes les deux.
- Sous-structure optimale : une solution optimale du problème contient des solutions optimales de ses sous-problèmes. C'est ce qui autorise à résoudre les sous-problèmes indépendamment.
- Chevauchement des sous-problèmes : les mêmes sous-problèmes reviennent plusieurs fois. C'est ce qui rend la mémorisation rentable.
Le chapitre chap:diviser découpait en sous-problèmes disjoints — les deux moitiés d'un tableau ne se recoupent pas. Il n'y a alors rien à mémoriser.
Ici, les sous-problèmes se chevauchent. Mémoriser leurs solutions transforme un arbre d'appels exponentiel en un tableau de taille polynomiale. C'est le chevauchement qui fait toute la différence, et c'est la première chose à vérifier.
17.2 Deux manières de faire, une seule idée
Méthode : Par mémoïsation, ou de haut en bas
On garde la formulation récursive, et l'on retient chaque valeur calculée.
(* n-ième terme de Fibonacci. Précondition : n >= 0. Complexité : Theta(n). *)
let fib n =
let memo = Hashtbl.create 97 in
let rec aux k =
if k < 2 then k
else match Hashtbl.find_opt memo k with
| Some v -> v (* déjà calculé : on rend *)
| None ->
let v = aux (k - 1) + aux (k - 2) in
Hashtbl.add memo k v; (* on retient AVANT de rendre *)
v
in
aux n
Avantage : le code reste celui de la définition, et l'on ne calcule que les sous-problèmes effectivement atteints.
Méthode : De bas en haut, par remplissage de table
On renverse l'ordre : on calcule les petits sous-problèmes d'abord, dans un tableau, jusqu'à celui qu'on veut.
/* n-ième terme de Fibonacci. Précondition : 0 <= n < TAILLE. */
long long fib(int n) {
assert(0 <= n && n < TAILLE);
long long t[TAILLE];
t[0] = 0;
t[1] = 1;
for (int k = 2; k <= n; k = k + 1) { t[k] = t[k - 1] + t[k - 2]; }
return t[n];
}
Avantage : aucune récursion, donc aucune pile à faire déborder, et des accès mémoire contigus — donc rapides.
Bonne pratique (Réduire la mémoire quand on ne remonte pas)
Le programme demande de « souligner les enjeux de complexité en mémoire ». La version ci-dessus occupe pour ne consulter que deux cases à la fois :
long long a = 0, b = 1;
for (int k = 2; k <= n; k = k + 1) { long long c = a + b; a = b; b = c; }
/* b vaut fib(n) ; mémoire : Theta(1) */
Cette réduction est fréquente et gratuite — mais attention : elle interdit la reconstruction de la solution, dont il est question plus bas. On ne réduit la mémoire qu'après avoir décidé qu'on n'aura pas besoin du chemin.
17.3 Le sac à dos
C'est l'exemple canonique, déjà rencontré en exhaustif au chapitre chap:exploration et en glouton approché au chapitre chap:gloutons.
objets de poids et valeur , capacité . Notons la valeur maximale atteignable avec les premiers objets et une capacité . Alors
La troisième ligne est le cœur : on compare ne pas prendre l'objet et le prendre, et l'on garde le meilleur.
/* Valeur maximale transportable. Préconditions : n >= 0, C >= 0,
p[i] >= 0. Complexité : Theta(nC) en temps et en mémoire. */
int sac(const int p[], const int v[], int n, int C) {
int m[n + 1][C + 1];
for (int c = 0; c <= C; c = c + 1) { m[0][c] = 0; }
for (int i = 1; i <= n; i = i + 1) {
for (int c = 0; c <= C; c = c + 1) {
m[i][c] = m[i - 1][c]; /* on ne prend pas */
if (p[i - 1] <= c) {
int avec = v[i - 1] + m[i - 1][c - p[i - 1]];
if (avec > m[i][c]) { m[i][c] = avec; } /* on prend */
}
}
}
return m[n][C];
}
La confusion est classique et le chapitre chap:decidabilite y reviendra. La capacité s'écrit avec bits : un nombre de chiffres binaires vaut un milliard. Un algorithme en est donc exponentiel en la taille de son entrée, même s'il paraît polynomial. On dit qu'il est pseudo-polynomial.
Concrètement : et donnent cases, instantané. et donnent cases — et le tableau ne tient pas en mémoire.
17.4 Reconstruire la solution
Le programme l'exige explicitement : « reconstruction d'une solution optimale à partir de l'information calculée ». Connaître la valeur de l'optimum ne suffit pas ; il faut savoir quels objets prendre.
Méthode : Remonter la table
On repart de la case finale et l'on rejoue les décisions à l'envers : si , c'est que l'objet a été pris.
/* Affiche les indices des objets d'une solution optimale.
Précondition : m est la table remplie par sac(). */
void reconstruire(int m[][C_MAX + 1], const int p[], int n, int C) {
int c = C;
for (int i = n; i >= 1; i = i - 1) {
if (m[i][c] != m[i - 1][c]) { /* la valeur a changé : objet PRIS */
printf("%d ", i - 1);
c = c - p[i - 1];
}
}
}
Le coût est — une remontée, pas un nouveau calcul. En revanche il faut avoir gardé toute la table : c'est là qu'on paie l'optimisation mémoire écartée plus haut.
17.5 Deux problèmes sur les mots
17.5.1 La plus longue sous-suite commune
Une sous-suite s'obtient en supprimant des caractères sans changer l'ordre des autres. Pour et de longueurs et , notons la longueur de la plus longue sous-suite commune à et :
(* Longueur de la plus longue sous-suite commune. Complexité : Theta(nm). *)
let plsc u v =
let n = String.length u and m = String.length v in
let l = Array.make_matrix (n + 1) (m + 1) 0 in
for i = 1 to n do
for j = 1 to m do
l.(i).(j) <-
if u.[i-1] = v.[j-1] then 1 + l.(i-1).(j-1)
else max l.(i-1).(j) l.(i).(j-1)
done
done;
l.(n).(m)
C'est l'algorithme au cœur de la commande diff et du calcul des différences entre deux versions d'un fichier.
17.5.2 La distance d'édition
Le nombre minimal d'insertions, suppressions et substitutions transformant en :
Les trois termes du minimum sont les trois opérations : supprimer , insérer , substituer l'un à l'autre.
| `c` | `h` | `a` | `t` | ||
|---|---|---|---|---|---|
| 0 | 1 | 2 | 3 | 4 | |
| `c` | 1 | 0 | 1 | 2 | 3 |
| `h` | 2 | 1 | 0 | 1 | 2 |
| `a` | 3 | 2 | 1 | 0 | 1 |
| `u` | 4 | 3 | 2 | 1 | 1 |
| `d` | 5 | 4 | 3 | 2 | 2 |
De chaud à chat : distance — substituer u en t, supprimer d. La diagonale en gras est le chemin optimal, et c'est elle qu'on remonte pour reconstruire les opérations.
17.6 Un problème sur les graphes : Floyd-Warshall
L'algorithme de Floyd-Warshall, que le chapitre chap:parcours détaillera, est un pur exercice de programmation dynamique. Le sous-problème est :
et la récurrence tient en une ligne :
Soit le plus court chemin passe par , soit il n'y passe pas. Trois boucles imbriquées, , et la sous-structure optimale y est parfaitement visible : un sous-chemin d'un plus court chemin est un plus court chemin.
Elle est vraie pour les plus courts chemins ; elle est fausse pour les plus longs chemins simples. Un sous-chemin d'un plus long chemin simple n'est pas nécessairement le plus long entre ses extrémités — et la programmation dynamique ne s'applique pas. Ce n'est pas un hasard : le problème du plus long chemin simple est np-difficile, alors que celui du plus court est polynomial.
Toujours vérifier la sous-structure optimale avant d'écrire la récurrence. Une récurrence bâtie sans elle produit un résultat faux, sans le moindre signe.
17.7 Ce qu'il faut retenir
- Vérifier la sous-structure optimale. Sans elle, tout le reste est faux.
- Définir le sous-problème — c'est l'étape difficile, et elle décide de tout. « = le meilleur avec les premiers objets et la capacité ».
- Écrire la récurrence et ses cas de base.
- Choisir le sens : mémoïsation si les sous-problèmes atteints sont rares, table de bas en haut sinon.
- Reconstruire la solution en remontant la table — ce qui interdit de réduire la mémoire.
Et un piège à ne pas oublier : est pseudo-polynomial, pas polynomial. Le chapitre chap:decidabilite dira pourquoi cette distinction est celle qui sépare le faisable de l'infaisable.