La programmation dynamique
Cours complet · NSI (terminale), chapitre 7 · terminale, spécialité numérique et sciences informatiques
Travailler ce chapitre sur Adloun Exercices corrigés de ce chapitre
La programmation dynamique est une technique algorithmique puissante permettant de résoudre efficacement des problèmes d'optimisation et de dénombrement. Son principe fondamental consiste à décomposer un problème complexe en sous-problèmes plus simples, à résoudre chacun d'eux une seule fois, puis à mémoriser leurs solutions afin de les réutiliser. Cette approche évite de recalculer plusieurs fois les mêmes valeurs, ce qui transforme souvent un algorithme exponentiel en un algorithme polynomial.
Dans ce chapitre, nous étudierons les deux conditions qui rendent un problème soluble par programmation dynamique : la présence de sous-problèmes recouvrants et celle d'une sous-structure optimale. Nous présenterons les deux grandes stratégies de mise en œuvre, la mémoïsation descendante (top-down) et l'approche ascendante (bottom-up). De nombreux exemples emblématiques illustreront ces idées : la suite de Fibonacci optimisée, le rendu de monnaie, la plus longue sous-séquence commune et le problème du sac à dos. Enfin, nous comparerons la programmation dynamique avec les méthodes diviser-pour-régner et gloutonnes étudiées par ailleurs.
7.1 Principes fondamentaux
7.1.1 Sous-problèmes recouvrants et sous-structure optimale
Avant d'appliquer la programmation dynamique, il faut s'assurer que le problème possède deux propriétés essentielles. Sans elles, la technique n'apporte aucun gain, voire ne s'applique pas.
Un problème présente des sous-problèmes recouvrants (overlapping subproblems) lorsque sa résolution récursive amène à résoudre plusieurs fois exactement les mêmes sous-problèmes. La programmation dynamique tire parti de cette redondance en mémorisant chaque solution pour ne la calculer qu'une seule fois.
Le calcul récursif naïf de nécessite de calculer et ; mais recalcule à son tour et , etc. Ainsi est calculé deux fois, trois fois, et le nombre total d'appels croît de manière exponentielle. Les sous-problèmes se chevauchent massivement : c'est le signe qu'une mémorisation sera très bénéfique.
Un problème possède une sous-structure optimale (optimal substructure) lorsqu'une solution optimale du problème peut être construite à partir des solutions optimales de ses sous-problèmes. Cette propriété permet d'écrire une relation de récurrence reliant la solution globale aux solutions partielles.
La programmation dynamique est adaptée à un problème si et seulement si celui-ci présente à la fois :
- des sous-problèmes recouvrants (justifiant la mémorisation) ;
- une sous-structure optimale (justifiant la décomposition récursive).
Si les sous-problèmes sont indépendants (non recouvrants), la méthode diviser-pour-régner suffit. Si la sous-structure optimale est absente, aucune récurrence simple ne peut être établie.
7.1.2 Mémoïsation et approche ascendante
Une fois la relation de récurrence établie, deux stratégies permettent de la calculer efficacement.
La mémoïsation consiste à conserver la structure récursive naturelle du problème, mais à stocker dans une table (dictionnaire ou tableau) chaque résultat dès qu'il est calculé. Avant tout calcul, on consulte la table : si la valeur y figure déjà, on la renvoie directement. C'est une approche descendante (top-down) car on part du problème global vers les sous-problèmes.
L'approche ascendante (bottom-up) abandonne la récursivité. On identifie l'ordre dans lequel les sous-problèmes dépendent les uns des autres, puis on les résout itérativement du plus petit au plus grand, en remplissant un tableau. La solution du problème global se trouve à la fin du remplissage.
Les deux approches ont la même complexité temporelle asymptotique, mais diffèrent par :
- Lisibilité : la mémoïsation reste proche de la définition récursive, donc souvent plus naturelle à écrire.
- Pile d'appels : l'approche ascendante évite la récursivité et donc tout risque de débordement de pile pour de grandes entrées.
- Sous-problèmes calculés : la mémoïsation ne calcule que les sous-problèmes réellement nécessaires, tandis que l'approche ascendante les calcule tous.
- Optimisation mémoire : l'approche ascendante permet parfois de ne conserver que les dernières lignes du tableau.
Méthode : Concevoir une solution par programmation dynamique
Pour résoudre un problème par programmation dynamique, on suit les étapes :
- Caractériser la structure d'une solution optimale et vérifier la sous-structure optimale.
- Définir récursivement la valeur d'une solution optimale (la relation de récurrence) ainsi que les cas de base.
- Choisir entre mémoïsation (top-down) et table ascendante (bottom-up).
- Calculer la valeur optimale, par mémorisation ou remplissage de table.
- Reconstruire si besoin la solution elle-même (et pas seulement sa valeur) à partir de la table.
7.2 Exemples classiques
7.2.1 Fibonacci optimisé
La suite de Fibonacci est l'exemple introductif canonique. La version récursive naïve a une complexité (croissance exponentielle), alors que la programmation dynamique la ramène à .
La version naïve recalcule un nombre exponentiel de fois les mêmes valeurs.
def fibo_naif(n):
if n <= 1:
return n
return fibo_naif(n - 1) + fibo_naif(n - 2)
La version mémoïsée (top-down) stocke chaque résultat dans un dictionnaire :
def fibo_memo(n, memo=None):
if memo is None:
memo = {}
if n <= 1:
return n
if n in memo:
return memo[n]
memo[n] = fibo_memo(n - 1, memo) + fibo_memo(n - 2, memo)
return memo[n]
La version ascendante (bottom-up) remplit un tableau de bas en haut :
def fibo_bottom_up(n):
if n <= 1:
return n
table = [0] * (n + 1)
table[1] = 1
for i in range(2, n + 1):
table[i] = table[i - 1] + table[i - 2]
return table[n]
On peut même optimiser la mémoire en ne gardant que les deux dernières valeurs, réduisant l'espace de à :
def fibo_optimise(n):
a, b = 0, 1
for _ in range(n):
a, b = b, a + b
return a
Les trois dernières versions ont une complexité temporelle , contre une complexité exponentielle pour la version naïve.
7.2.2 Le rendu de monnaie
On dispose d'un système de pièces (par exemple [1, 2, 5, 10]) et l'on veut rendre une somme avec le nombre minimal de pièces. Soit ce nombre minimal pour la somme . La relation de récurrence est :
La solution ascendante remplit un tableau de à :
def rendu_monnaie(pieces, somme):
INFINI = float('inf')
table = [0] + [INFINI] * somme
for s in range(1, somme + 1):
for p in pieces:
if p <= s and table[s - p] + 1 < table[s]:
table[s] = table[s - p] + 1
return table[somme] if table[somme] != INFINI else -1
La complexité est où est le nombre de types de pièces. Contrairement à l'algorithme glouton, cette méthode trouve toujours la solution optimale, même pour des systèmes de pièces « non canoniques ».
Pour connaître les pièces effectivement utilisées (et pas seulement leur nombre), on mémorise la dernière pièce ajoutée pour chaque somme :
def rendu_monnaie_detail(pieces, somme):
INFINI = float('inf')
table = [0] + [INFINI] * somme
choix = [None] * (somme + 1)
for s in range(1, somme + 1):
for p in pieces:
if p <= s and table[s - p] + 1 < table[s]:
table[s] = table[s - p] + 1
choix[s] = p
if table[somme] == INFINI:
return None
resultat = []
s = somme
while s > 0:
resultat.append(choix[s])
s -= choix[s]
return resultat
7.2.3 La plus longue sous-séquence commune
Une sous-séquence d'une chaîne est obtenue en supprimant zéro ou plusieurs caractères sans modifier l'ordre des caractères restants. Par exemple, "ACE" est une sous-séquence de "ABCDE". Contrairement à une sous-chaîne, les caractères d'une sous-séquence ne sont pas nécessairement contigus.
Étant données deux chaînes et , on cherche la longueur de la plus longue sous-séquence commune aux deux. Soit la longueur de la PLSC des préfixes et . La récurrence est :
def plsc(X, Y):
n, m = len(X), len(Y)
table = [[0] * (m + 1) for _ in range(n + 1)]
for i in range(1, n + 1):
for j in range(1, m + 1):
if X[i - 1] == Y[j - 1]:
table[i][j] = table[i - 1][j - 1] + 1
else:
table[i][j] = max(table[i - 1][j], table[i][j - 1])
return table[n][m]
La complexité temporelle et spatiale est .
7.2.4 Le problème du sac à dos
On dispose de objets, chacun de poids et de valeur , et d'un sac de capacité . On veut maximiser la valeur totale emportée sans dépasser la capacité, chaque objet étant pris ou laissé (0/1). Soit la valeur maximale en considérant les premiers objets avec une capacité :
def sac_a_dos(poids, valeurs, capacite):
n = len(poids)
table = [[0] * (capacite + 1) for _ in range(n + 1)]
for i in range(1, n + 1):
for c in range(capacite + 1):
if poids[i - 1] > c:
table[i][c] = table[i - 1][c]
else:
table[i][c] = max(
table[i - 1][c],
valeurs[i - 1] + table[i - 1][c - poids[i - 1]]
)
return table[n][capacite]
La complexité est : on parle de complexité pseudo-polynomiale car elle dépend de la valeur numérique et non seulement de la taille des données.
7.3 Comparaison avec d'autres paradigmes
La méthode diviser-pour-régner découpe également le problème en sous-problèmes, mais ces sous-problèmes sont indépendants (ils ne se chevauchent pas), comme dans le tri fusion. Il n'y a donc rien à mémoriser. La programmation dynamique s'applique précisément lorsque les sous-problèmes se recouvrent, rendant la mémorisation indispensable pour l'efficacité.
Un algorithme glouton fait à chaque étape le choix qui semble localement optimal, sans jamais revenir en arrière ; il est rapide mais ne garantit pas toujours l'optimum global. La programmation dynamique explore l'ensemble des combinaisons pertinentes des sous-problèmes et garantit l'optimum, au prix d'un coût en temps et en mémoire plus élevé. Le rendu de monnaie illustre bien cette différence : l'algorithme glouton échoue sur certains systèmes de pièces là où la programmation dynamique reste correcte.