La programmation dynamique a mauvaise réputation : elle passe pour une astuce obscure réservée aux compétiteurs. En réalité, c'est une seule idée, simple et redoutable : ne jamais résoudre deux fois le même sous-problème. Dès qu'un calcul se répète — et il se répète souvent — on le mémorise et on le réutilise. C'est ce qui fait passer un algorithme d'un temps exponentiel à un temps linéaire.
Cet article te donne tout ce qu'il faut pour la maîtriser en prépa : le principe (sous-problèmes qui se chevauchent + sous-structure optimale), les deux implémentations (mémoïsation top-down et table bottom-up), les quatre exemples canoniques attendus en MPI — Fibonacci, rendu de monnaie, plus longue sous-séquence commune et sac à dos — avec code Python commenté, et 3 exercices corrigés. Nos profs Hadamard, anciens de MP2I et de MPSI option info passés par l'X, l'ENS et CentraleSupélec, voient chaque année les mêmes blocages : ils les listent pour que tu les évites.
Le principe : deux conditions à réunir
La programmation dynamique s'applique à un problème d'optimisation (ou de comptage) lorsque deux propriétés sont réunies. Ce sont les deux cases à cocher avant de dérouler la méthode.
Sous-problèmes qui se chevauchent. Le problème se découpe en sous-problèmes plus petits, mais ces sous-problèmes reviennent plusieurs fois dans le calcul. C'est ce chevauchement qui rend la mémorisation rentable.
Sous-structure optimale. La solution optimale du problème se reconstruit à partir des solutions optimales des sous-problèmes. Autrement dit, on peut écrire une relation de récurrence qui exprime la réponse d'une instance en fonction de réponses d'instances plus petites.
Toute la difficulté d'un exercice de programmation dynamique tient dans une seule étape : trouver la relation de récurrence et bien définir ce que représente chaque sous-problème. Une fois cette relation posée, le code s'écrit presque mécaniquement, dans un sens ou dans l'autre.
À ne pas confondre. « Diviser pour régner » (tri fusion, dichotomie) découpe aussi en sous-problèmes, mais ceux-ci sont indépendants et ne se chevauchent pas — inutile d'y mémoriser quoi que ce soit. La programmation dynamique est précisément l'outil du cas inverse : quand les sous-problèmes se répètent.
Mémoïsation (top-down) ou table (bottom-up)
Une fois la récurrence trouvée, il reste à la calculer sans redondance. Deux stratégies, exactement la même complexité, deux styles de code.
La mémoïsation (top-down, « du haut vers le bas ») garde l'écriture récursive naturelle. On part du problème complet, on descend vers les sous-problèmes, et on range chaque résultat dans un cache (dictionnaire ou tableau). Avant chaque calcul, on regarde si la réponse est déjà connue : si oui, on la relit ; sinon, on la calcule puis on la stocke.
La table (bottom-up, « du bas vers le haut ») renverse le sens. On part des plus petits sous-problèmes, on remplit un tableau de proche en proche selon la récurrence, jusqu'à atteindre le problème demandé. Pas de récursion, donc aucun risque de débordement de pile.
| Critère | Mémoïsation (top-down) | Table (bottom-up) |
|---|---|---|
| Écriture | Récursive + cache | Itérative (boucles) |
| Sous-problèmes calculés | Seulement les utiles | Tous, dans l'ordre |
| Risque de pile | Oui (profondeur des appels) | Non |
| Complexité | Identique | Identique |
Les deux approches reposent sur la récursivité et sur l'analyse de complexité ; garde les deux en tête, un même exercice se traite souvent des deux manières.
Cours particuliers
Maîtrisez l'informatique avec un prof expert
Python, algorithmique, structures de données... Un accompagnement adapté.
Fibonacci : de l'exponentiel au linéaire
La suite de Fibonacci est l'exemple fondateur. Elle est définie par , et . La traduction récursive naïve est immédiate :
def fib(n):
# Version naive : traduit directement la recurrence.
if n <= 1:
return n
return fib(n - 1) + fib(n - 2)
Le problème n'est pas la justesse mais le coût. Pour calculer fib(n), on relance fib(n-1) et fib(n-2), qui relancent à leur tour deux appels chacun. Le même sous-calcul est refait un nombre gigantesque de fois : fib(5) recalcule fib(2) trois fois. Le nombre d'appels croît comme une exponentielle, en environ (plus précisément en où est le nombre d'or). Dès , la machine peine.
C'est le cas d'école de sous-problèmes qui se chevauchent. On mémoïse : chaque n'est calculé qu'une fois, puis relu dans un cache.
def fib_memo(n, memo=None):
# Version top-down : recursion + cache.
if memo is None:
memo = {}
if n <= 1:
return n
if n in memo: # deja calcule : on relit
return memo[n]
memo[n] = fib_memo(n - 1, memo) + fib_memo(n - 2, memo)
return memo[n]
Chaque valeur de à est désormais calculée une seule fois : le temps devient linéaire, en , pour un coût mémoire . La version table (bottom-up) donne le même résultat sans récursion :
def fib_table(n):
# Version bottom-up : on remplit un tableau des petits vers les grands.
if n <= 1:
return n
dp = [0] * (n + 1)
dp[1] = 1
for i in range(2, n + 1):
dp[i] = dp[i - 1] + dp[i - 2] # recurrence appliquee dans l'ordre
return dp[n]
Le gain à retenir. Passer de à n'est pas une optimisation cosmétique : pour , la version naïve demanderait plus d'un milliard de milliards d'opérations, la version dynamique en fait quatre-vingt-dix. C'est toute la puissance de la méthode, sur un problème d'une ligne de récurrence.
Rendu de monnaie : le nombre minimal de pièces
Problème. On dispose d'un système de pièces (par exemple , , ) en quantité illimitée. Pour un montant donné, quel est le nombre minimal de pièces pour l'atteindre exactement ?
L'algorithme glouton (prendre à chaque fois la plus grosse pièce possible) semble naturel, mais il échoue sur certains systèmes. La sous-structure optimale, elle, est nette : si est le nombre minimal de pièces pour le montant , alors en essayant chaque pièce en dernière position :
On remplit une table de jusqu'au montant visé :
def rendu_monnaie(pieces, montant):
# dp[s] = nombre minimal de pieces pour rendre exactement s.
INF = float('inf')
dp = [0] + [INF] * montant # dp[0] = 0, le reste inconnu
for s in range(1, montant + 1):
for p in pieces:
if p <= s and dp[s - p] + 1 < dp[s]:
dp[s] = dp[s - p] + 1 # une piece p de plus que dp[s - p]
return dp[montant] if dp[montant] != INF else -1 # -1 = impossible
La complexité est : chaque case du tableau teste chaque pièce. Sur l'exemple pièces et montant , le glouton propose pièces, alors que la table trouve pièces — l'optimum. On le vérifie en exercice.
Trouvez le prof qu'il vous faut
Échangez avec notre équipe pour trouver le professeur idéal selon vos besoins.
Plus longue sous-séquence commune
Problème. Étant donné deux chaînes, quelle est la longueur de leur plus longue sous-séquence commune (PLSSC, ou LCS) ? Une sous-séquence garde l'ordre des caractères mais autorise des trous : ACE est une sous-séquence de ABCDE. C'est le principe des outils de comparaison de fichiers et de séquences ADN.
Tester toutes les sous-séquences serait exponentiel, en . La récurrence dynamique compare les chaînes et caractère par caractère. On note la PLSSC des premiers caractères de et des premiers de :
def plssc(a, b):
n, m = len(a), len(b)
# dp[i][j] = longueur de la PLSSC de a[:i] et b[:j].
dp = [[0] * (m + 1) for _ in range(n + 1)]
for i in range(1, n + 1):
for j in range(1, m + 1):
if a[i - 1] == b[j - 1]: # dernier caractere commun
dp[i][j] = dp[i - 1][j - 1] + 1
else: # on ecarte l'un des deux
dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])
return dp[n][m]
On remplit un tableau à deux dimensions de taille : la complexité est , à comparer à l'explosion exponentielle de la recherche naïve. La réponse se lit dans la dernière case, dp[n][m].
Le sac à dos (knapsack)
Problème. On a objets, chacun de poids et de valeur , et un sac de capacité . Quelle est la valeur maximale qu'on peut emporter sans dépasser ? Ici, chaque objet se prend ou se laisse (sac à dos 0/1) : on ne coupe pas un objet en deux.
Essayer toutes les parties des objets, c'est possibilités. La récurrence dynamique raisonne objet par objet. On note la valeur maximale atteignable avec les premiers objets et une capacité . Pour l'objet , deux choix : le laisser, ou le prendre s'il tient :
def sac_a_dos(poids, valeurs, capacite):
n = len(poids)
# dp[i][c] = valeur max avec les i premiers objets et capacite c.
dp = [[0] * (capacite + 1) for _ in range(n + 1)]
for i in range(1, n + 1):
for c in range(capacite + 1):
dp[i][c] = dp[i - 1][c] # objet i-1 laisse
if poids[i - 1] <= c: # objet i-1 pris s'il tient
pris = dp[i - 1][c - poids[i - 1]] + valeurs[i - 1]
dp[i][c] = max(dp[i][c], pris)
return dp[n][capacite]
Complexité pseudo-polynomiale. Le tableau a cases, d'où un temps . Attention : ce n'est pas polynomial en la taille de l'entrée, car peut être énorme par rapport au nombre de bits qui le codent. Le sac à dos 0/1 est d'ailleurs un problème NP-difficile ; la programmation dynamique le résout efficacement seulement quand reste modéré.
3 exercices corrigés
Exercice 1, monter un escalier
Énoncé. Un escalier a marches. À chaque pas, on monte de ou marches. Combien y a-t-il de façons différentes d'atteindre la marche ? Donner la récurrence, puis une version en .
Solution. Pour atteindre la marche , le dernier pas vient soit de la marche (pas de ), soit de la marche (pas de ). Le nombre de chemins vérifie donc :
C'est exactement la suite de Fibonacci (décalée) : les sous-problèmes se chevauchent, on remplit une table plutôt que de recalculer.
def escalier(n):
# c[k] = nombre de facons d'atteindre la marche k.
if n <= 1:
return 1
c = [0] * (n + 1)
c[0], c[1] = 1, 1
for k in range(2, n + 1):
c[k] = c[k - 1] + c[k - 2]
return c[n]
La version naïve récursive serait en ; celle-ci est en temps et mémoire (réductible à avec deux variables). Pour : façons.
Exercice 2, rendu de monnaie et échec du glouton
Énoncé. Avec les pièces , rendre la monnaie sur . Montrer que l'algorithme glouton n'est pas optimal, puis dérouler la table dynamique.
Solution. Le glouton prend d'abord la plus grosse pièce , soit , puis , puis : total pièces. Or on peut faire pièces. Le glouton se trompe. On remplit :
Lecture : (une pièce de ), . La table confirme le minimum de pièces, là où le glouton en donnait .
Exercice 3, remplir la table du sac à dos
Énoncé. Trois objets de poids et de valeurs , un sac de capacité . Remplir la table et donner la valeur maximale.
Solution. On applique ligne par ligne (l'objet ayant poids et valeur ). La ligne est nulle.
| objet 1 | ||||||
| objet 2 | ||||||
| objet 3 |
La valeur maximale est , obtenue en prenant les objets et (poids , valeur ). On retrouve ce résultat en remontant la table : vient de .
Ce qu'il faut retenir
- Deux conditions : sous-problèmes qui se chevauchent + sous-structure optimale. Sans chevauchement, c'est du « diviser pour régner », pas de la programmation dynamique.
- Le vrai travail est de trouver la relation de récurrence et de bien définir chaque sous-problème ; le code suit presque mécaniquement.
- Deux implémentations : mémoïsation (top-down, récursif + cache) et table (bottom-up, itératif). Même complexité ; la table évite le débordement de pile.
- Le gain type : Fibonacci passe de à . Rendu de monnaie en , PLSSC en , sac à dos en .
- Le réflexe glouton ne donne pas toujours l'optimum : le rendu de monnaie le prouve. La programmation dynamique, elle, garantit l'optimum sous sous-structure optimale.
La programmation dynamique s'appuie directement sur la récursivité et sur la notation de complexité ; elle prolonge naturellement les algorithmes classiques de tri et de recherche vus en début de prépa.
C'est l'un des chapitres phares de l'informatique de deuxième année, en particulier pour ceux qui poussent l'option info. Pour situer sa place dans le cursus, vois pourquoi choisir l'option informatique en MPSI, une porte d'entrée vers la filière MPI où la programmation dynamique est centrale.


