Adloun

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) (ou dp.(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 &quot;BATEAU&quot; &quot;TABLEAU&quot; 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)

Exercice 1 : Fibonacci par table

É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.

Exercice 2 : Rendu de monnaie optimal

É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.

Exercice 3 : Nombre de façons de rendre

É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)

Exercice 4 : Montées d'escalier

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.

Exercice 5 : Chemin de coût minimal dans une grille

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 .

Exercice 6 : Plus longue sous-suite commune

É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)

Exercice 7 : Sac à dos 0/1

É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.

Exercice 8 : Distance d'édition

É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 &quot;chat&quot; &quot;chien&quot; . Coût .

Exercice 9 : Reconstruire les pièces du rendu

É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.

Exercice 10 : Reconstruire la sous-suite commune

É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).

Synthèse du chapitre (à retenir)
  • 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) (ou dp.(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.
Thème B — DP en dimension 2.
Thème C — Haut-bas vs bas-haut.
Thème D — Partition et comptage.

Continuer sur Adloun : animation, QCM, fiches, exercices