Programmation dynamique
Cours complet · OCaml (option informatique), chapitre 13 · prépas MPSI et MP, option informatique
Travailler ce chapitre sur Adloun Exercices corrigés de ce chapitre
<i class="fa-solid fa-compass mr-2" style="color:#9A563B"></i>13.1 Introduction et motivation
Le chapitre 11 explorait tous les choix (exact mais exponentiel) ; le chapitre 12 n'en faisait qu'un, le meilleur local (rapide mais parfois faux). La programmation dynamique concilie les deux : elle obtient l'optimum sans tout réexplorer, en mémorisant les solutions des sous-problèmes pour ne les calculer qu'une fois. C'est elle qui résout proprement le rendu de monnaie sur un système quelconque, ou le sac à dos indivisible — là où le glouton se trompait.
Deux ingrédients la rendent applicable : une sous-structure optimale (la solution optimale se compose de solutions optimales de sous-problèmes) et un chevauchement des sous-problèmes (les mêmes reviennent sans cesse). On rangera alors les résultats dans une table (un tableau du chapitre 5) qu'on remplit dans le bon ordre — ou que l'on consulte par mémoïsation (la table de hachage du chapitre 8).
13.2 Le principe
Méthode : Concevoir une solution par programmation dynamique
- Définir le sous-problème : que représente
dp.(i)(oudp.(i).(j)) ? - Écrire la relation de récurrence : exprimer
dp.(i)en fonction de sous-problèmes plus petits. - Fixer les cas de base.
- Choisir l'ordre de remplissage : du plus petit sous-problème au plus grand (bas-haut), ou par récursion mémoïsée (haut-bas).
- (au besoin) Reconstruire la solution en retraçant les choix.
Deux styles équivalents : haut-bas (récursion + mémoïsation, cf. le Fibonacci du chapitre 8) et bas-haut (boucles remplissant un tableau). On privilégiera ici le bas-haut, plus explicite.
13.3 Rendu de monnaie optimal
On reprend le problème où le glouton échouait (chapitre 12). Soit m.(s) le nombre minimal de pièces pour rendre la somme s. Récurrence : m.(s) m.(s - p).
let rendu_min pieces montant =
let infini = montant + 1 in
let m = Array.make (montant + 1) infini in
m.(0) <- 0; (* cas de base : 0 pièce pour 0 *)
for s = 1 to montant do
List.iter (fun p ->
if p <= s && m.(s - p) + 1 < m.(s) then m.(s) <- m.(s - p) + 1
) pieces
done;
if m.(montant) = infini then -1 else m.(montant)
Complexité : Rendu optimal
On remplit montant + 1 cases, chacune en essayant toutes les pièces : coût . rendu_min [4; 3; 1] 6 renvoie maintenant 2 (et non ) : la programmation dynamique trouve l'optimum là où le glouton se trompait. Le sous-problème « rendre s » n'est résolu qu'une fois, puis réutilisé.
13.4 Le sac à dos 0/1
Chaque objet est pris ou non (indivisible). Soit dp.(i).(w) la valeur maximale en n'utilisant que les i premiers objets, sous une capacité w. Pour l'objet i (poids po, valeur va) : soit on ne le prend pas (dp.(i-1).(w)), soit on le prend si possible (dp.(i-1).(w - po) + va).
let sac objets capacite =
let n = Array.length objets in (* objets : (poids, valeur) array *)
let dp = Array.make_matrix (n + 1) (capacite + 1) 0 in
for i = 1 to n do
let (po, va) = objets.(i - 1) in
for w = 0 to capacite do
dp.(i).(w) <- dp.(i - 1).(w); (* sans l'objet i *)
if po <= w && dp.(i - 1).(w - po) + va > dp.(i).(w) then
dp.(i).(w) <- dp.(i - 1).(w - po) + va (* avec l'objet i *)
done
done;
dp.(n).(capacite)
Complexité : Sac à dos 0/1
Le tableau a cases, remplies en temps constant chacune : coût . C'est le problème que le glouton ne savait pas résoudre (chapitre 12) : ici l'optimum est garanti. (On parle de coût pseudo-polynomial, car il dépend de la valeur , non de sa taille en bits.)
13.5 Plus longue sous-suite commune
Une sous-suite d'une chaîne s'obtient en supprimant des caractères (sans changer l'ordre). On cherche la plus longue commune à deux chaînes a et b. Soit dp.(i).(j) la longueur de la PLSC des préfixes a[0..i-1] et b[0..j-1].
let plsc a b =
let n = String.length a and m = String.length b in
let dp = Array.make_matrix (n + 1) (m + 1) 0 in
for i = 1 to n do
for j = 1 to m do
if a.[i - 1] = b.[j - 1] then dp.(i).(j) <- dp.(i - 1).(j - 1) + 1
else dp.(i).(j) <-
(if dp.(i - 1).(j) >= dp.(i).(j - 1) then dp.(i - 1).(j) else dp.(i).(j - 1))
done
done;
dp.(n).(m)
Si les derniers caractères coïncident, ils appartiennent à la PLSC (+1 sur la diagonale) ; sinon, on retire l'un ou l'autre et l'on garde le meilleur. Coût . plsc "BATEAU" "TABLEAU" vaut 4 (par exemple « BEAU » ... ici « TEAU »/« BAU » de longueur correspondante).
13.6 Reconstruire la solution
La table donne la valeur optimale ; pour la solution elle-même, on retrace les choix qui ont mené à l'optimum, en parcourant la table à rebours.
<i class="fa-solid fa-dumbbell mr-2" style="color:#2E7559"></i>13.7 Exercices résolus
Niveau (application directe du cours)
Écrire fibo n en remplissant un tableau (bas-haut), en .
Démonstration
let fibo n =
if n <= 1 then n
else begin
let dp = Array.make (n + 1) 0 in
dp.(1) <- 1;
for i = 2 to n do dp.(i) <- dp.(i - 1) + dp.(i - 2) done;
dp.(n)
end
Chaque dp.(i) est calculé une fois à partir des deux précédents : c'est la version bas-haut de la mémoïsation du chapitre 8. Coût , contre exponentiel pour la double récursion naïve.
Écrire rendu_min et vérifier qu'il rend 2 sur [4; 3; 1] et 6.
Démonstration
let rendu_min pieces montant =
let infini = montant + 1 in
let m = Array.make (montant + 1) infini in
m.(0) <- 0;
for s = 1 to montant do
List.iter (fun p ->
if p <= s && m.(s - p) + 1 < m.(s) then m.(s) <- m.(s - p) + 1
) pieces
done;
if m.(montant) = infini then -1 else m.(montant)
rendu_min [4;3;1] 6 : m.(6) se calcule via m.(3) + 1 (et m.(3) = 1), soit 2. La DP trouve l'optimum , contrairement au glouton.
Écrire nb_facons pieces montant : le nombre de façons (à l'ordre près) de rendre le montant.
Démonstration
let nb_facons pieces montant =
let dp = Array.make (montant + 1) 0 in
dp.(0) <- 1;
List.iter (fun p ->
for s = p to montant do
dp.(s) <- dp.(s) + dp.(s - p)
done
) pieces;
dp.(montant)
On traite les pièces l'une après l'autre (boucle externe sur pieces) pour ne compter chaque combinaison qu'une fois (et non chaque permutation). dp.(0) = 1 : une seule façon de rendre (ne rien donner).
Niveau (raisonnement intermédiaire)
On monte un escalier de n marches par pas de ou . Écrire escalier n comptant le nombre de façons.
Démonstration
let escalier n =
if n <= 1 then 1
else begin
let dp = Array.make (n + 1) 0 in
dp.(0) <- 1; dp.(1) <- 1;
for i = 2 to n do dp.(i) <- dp.(i - 1) + dp.(i - 2) done;
dp.(n)
end
Pour atteindre la marche i, on vient de i-1 (pas de ) ou de i-2 (pas de ) : dp.(i) = dp.(i-1) + dp.(i-2). On retrouve… la suite de Fibonacci ! Beaucoup de problèmes de comptage s'y ramènent.
Une grille cout d'entiers ; on va de (0,0) à (n-1,m-1) par pas droite ou bas, en additionnant les cases. Écrire chemin_min cout.
Démonstration
let chemin_min cout =
let n = Array.length cout and m = Array.length cout.(0) in
let dp = Array.make_matrix n m 0 in
dp.(0).(0) <- cout.(0).(0);
for j = 1 to m - 1 do dp.(0).(j) <- dp.(0).(j - 1) + cout.(0).(j) done;
for i = 1 to n - 1 do dp.(i).(0) <- dp.(i - 1).(0) + cout.(i).(0) done;
for i = 1 to n - 1 do
for j = 1 to m - 1 do
let h = if dp.(i - 1).(j) <= dp.(i).(j - 1) then dp.(i - 1).(j) else dp.(i).(j - 1) in
dp.(i).(j) <- h + cout.(i).(j)
done
done;
dp.(n - 1).(m - 1)
On arrive en (i,j) par le haut ou par la gauche : on prend le moins coûteux, plus le coût de la case. La première ligne et la première colonne (un seul chemin possible) servent de cas de base. Coût .
Écrire plsc a b (longueur).
Démonstration
let plsc a b =
let n = String.length a and m = String.length b in
let dp = Array.make_matrix (n + 1) (m + 1) 0 in
for i = 1 to n do
for j = 1 to m do
if a.[i - 1] = b.[j - 1] then dp.(i).(j) <- dp.(i - 1).(j - 1) + 1
else dp.(i).(j) <-
(if dp.(i - 1).(j) >= dp.(i).(j - 1) then dp.(i - 1).(j) else dp.(i).(j - 1))
done
done;
dp.(n).(m)
La ligne 0 et la colonne 0 valent 0 (PLSC avec une chaîne vide). Diagonale +1 si les caractères coïncident, sinon maximum des deux voisins. Coût .
Niveau (approfondissement)
Écrire sac objets capacite (objets indivisibles, tableau de couples (poids, valeur)).
Démonstration
let sac objets capacite =
let n = Array.length objets in
let dp = Array.make_matrix (n + 1) (capacite + 1) 0 in
for i = 1 to n do
let (po, va) = objets.(i - 1) in
for w = 0 to capacite do
dp.(i).(w) <- dp.(i - 1).(w);
if po <= w && dp.(i - 1).(w - po) + va > dp.(i).(w) then
dp.(i).(w) <- dp.(i - 1).(w - po) + va
done
done;
dp.(n).(capacite)
dp.(i).(w) est la meilleure valeur avec les i premiers objets sous capacité w : on choisit, pour l'objet i, le meilleur entre « sans » et « avec ». Coût . Le glouton par ratio (chapitre 12) ne donnait pas cet optimum.
Écrire levenshtein a b : le nombre minimal d'insertions, suppressions ou substitutions de caractères pour transformer a en b.
Démonstration
let levenshtein a b =
let n = String.length a and m = String.length b in
let mini x y = if x <= y then x else y in
let dp = Array.make_matrix (n + 1) (m + 1) 0 in
for i = 0 to n do dp.(i).(0) <- i done; (* i suppressions *)
for j = 0 to m do dp.(0).(j) <- j done; (* j insertions *)
for i = 1 to n do
for j = 1 to m do
let c = if a.[i - 1] = b.[j - 1] then 0 else 1 in
dp.(i).(j) <-
mini (mini (dp.(i - 1).(j) + 1) (dp.(i).(j - 1) + 1)) (dp.(i - 1).(j - 1) + c)
done
done;
dp.(n).(m)
Les trois opérations correspondent aux trois cases voisines : suppression (i-1,j), insertion (i,j-1), substitution (i-1,j-1, gratuite si les caractères coïncident). levenshtein "chat" "chien" . Coût .
Écrire pieces_du_rendu pieces montant : int list option qui renvoie la liste des pièces d'un rendu optimal (et pas seulement leur nombre).
Démonstration
let pieces_du_rendu pieces montant =
let infini = montant + 1 in
let m = Array.make (montant + 1) infini in
let choix = Array.make (montant + 1) (-1) in (* pièce choisie pour la somme s *)
m.(0) <- 0;
for s = 1 to montant do
List.iter (fun p ->
if p <= s && m.(s - p) + 1 < m.(s) then begin
m.(s) <- m.(s - p) + 1;
choix.(s) <- p
end
) pieces
done;
if m.(montant) = infini then None
else
let rec remonte s = if s = 0 then [] else choix.(s) :: remonte (s - choix.(s)) in
Some (remonte montant)
On mémorise, pour chaque somme s, la pièce choix.(s) qui a réalisé l'optimum. La reconstruction part de montant et soustrait à chaque étape la pièce choisie, jusqu'à 0. C'est le schéma général : une table annexe des choix permet de retracer la solution.
Écrire plsc_suite a b : char list qui renvoie une plus longue sous-suite commune (et pas seulement sa longueur).
Démonstration
let plsc_suite a b =
let n = String.length a and m = String.length b in
let dp = Array.make_matrix (n + 1) (m + 1) 0 in
for i = 1 to n do
for j = 1 to m do
if a.[i - 1] = b.[j - 1] then dp.(i).(j) <- dp.(i - 1).(j - 1) + 1
else dp.(i).(j) <-
(if dp.(i - 1).(j) >= dp.(i).(j - 1) then dp.(i - 1).(j) else dp.(i).(j - 1))
done
done;
let rec remonte i j =
if i = 0 || j = 0 then []
else if a.[i - 1] = b.[j - 1] then remonte (i - 1) (j - 1) @ [a.[i - 1]]
else if dp.(i - 1).(j) >= dp.(i).(j - 1) then remonte (i - 1) j
else remonte i (j - 1)
in
remonte n m
Après avoir rempli la table, on remonte depuis (n, m) : un caractère commun se trouve sur la diagonale (on le garde et l'on remonte en diagonale) ; sinon on suit la direction qui a fourni le maximum. On renvoie une liste de caractères (et non une chaîne — la construire caractère par caractère sort du programme, cf. chapitre 6).
- Programmation dynamique : optimum exact en mémorisant les sous-problèmes. S'applique si sous-structure optimale + chevauchement des sous-problèmes.
- Recette : définir
dp.(i)(oudp.(i).(j)), la récurrence, les cas de base, l'ordre de remplissage ; reconstruire si besoin. - Deux styles équivalents : bas-haut (boucles + tableau) et haut-bas (récursion + mémoïsation, table de hachage du chapitre 8).
- Classiques 1D : rendu de monnaie optimal (), nombre de façons, montées d'escalier (= Fibonacci).
- Classiques 2D : sac à dos 0/1 (), chemin minimal dans une grille, plus longue sous-suite commune et distance d'édition ().
- Reconstruire : conserver une table de choix (ou retracer la table de valeurs) pour produire la solution, pas seulement son coût.
- La DP résout ce que le glouton ratait (rendu quelconque, sac 0/1) — sans l'explosion du backtracking.
13.8 Exercices d'entraînement
Légende : application directe, raisonnement intermédiaire, approfondissement ; signale un classique incontournable. La numérotation prolonge celle des dix exercices résolus.
Thème A — DP en dimension 1.
- [11.] Écrire
somme_max_non_adjacents t: somme maximale d'éléments d'un tableau sans en prendre deux voisins. - [12.] Écrire
plus_longue_croissante t: longueur de la plus longue sous-suite strictement croissante (DP en ). - [13.] Découpe d'une barre : valeurs
prix.(i)pour une longueuri; maximiser le gain en découpant une barre de longueurn.
Thème B — DP en dimension 2.
- [14.]
nb_chemins n m: nombre de chemins droite/bas dans une grille (DP, sans obstacle). - [15.] Variante avec obstacles (cases interdites) : adapter
nb_chemins. - [16.] Reconstruire le chemin de coût minimal de l'exercice 5 (liste de cases).
Thème C — Haut-bas vs bas-haut.
- [17.] Réécrire
rendu_minen mémoïsé (récursion + table de hachage du chapitre 8). - [18.] Comparer, sur
plsc, l'occupation mémoire des deux styles ; comment réduire la table à deux lignes ?
Thème D — Partition et comptage.
- [19.]
partition_possible t: peut-on couperten deux parts de même somme ? (DP sur les sommes atteignables, cf. sac à dos). - [20.] Triangle de Pascal :
binome n kpar DP (C(n,k) = C(n-1,k-1) + C(n-1,k)). - [21.] Distance d'édition : reconstruire la suite d'opérations transformant
aenb. - [22.] Discuter : quand préférer la DP au backtracking (chapitre 11) et au glouton (chapitre 12) ?