Stages de Pré-Rentrée — Inscriptions ouvertes, places très limitées ! S'inscrire

Aller au contenu principal
Programmation dynamique : principe et exemples
Méthode
12 min

Programmation dynamique : principe et exemples

Mohamed K.

Mohamed K.

Centralien · MPSI puis MP · Recherche ML santé

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 MPIFibonacci, 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èreMémoïsation (top-down)Table (bottom-up)
ÉcritureRécursive + cacheItérative (boucles)
Sous-problèmes calculésSeulement les utilesTous, dans l'ordre
Risque de pileOui (profondeur des appels)Non
ComplexitéIdentiqueIdentique

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

Exercices pratiquesProfs expérimentés
Voir les cours d'info

Fibonacci : de l'exponentiel au linéaire

La suite de Fibonacci est l'exemple fondateur. Elle est définie par F0=0F_0 = 0, F1=1F_1 = 1 et Fn=Fn1+Fn2F_n = F_{n-1} + F_{n-2}. 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 O(2n)O(2^n) environ (plus précisément en O(φn)O(\varphi^n)φ\varphi est le nombre d'or). Dès n=40n = 40, la machine peine.

C'est le cas d'école de sous-problèmes qui se chevauchent. On mémoïse : chaque FkF_k 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 00 à nn est désormais calculée une seule fois : le temps devient linéaire, en O(n)O(n), pour un coût mémoire O(n)O(n). 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 O(2n)O(2^n) à O(n)O(n) n'est pas une optimisation cosmétique : pour n=90n = 90, 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 11, 33, 44) 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 m(s)m(s) est le nombre minimal de pièces pour le montant ss, alors en essayant chaque pièce pp en dernière position :

m(s)  =  1+minpsm(sp),m(0)=0.m(s) \;=\; 1 + \min_{p \le s}\, m(s - p), \qquad m(0) = 0.

On remplit une table de 00 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 O(montant×nombre de pieˋces)O(\text{montant} \times \text{nombre de pièces}) : chaque case du tableau teste chaque pièce. Sur l'exemple pièces {1,3,4}\lbrace 1, 3, 4 \rbrace et montant 66, le glouton propose 4+1+1=34 + 1 + 1 = 3 pièces, alors que la table trouve 3+3=23 + 3 = 2 pièces — l'optimum. On le vérifie en exercice.

RDV gratuit de 15 min

Trouvez le prof qu'il vous faut

Échangez avec notre équipe pour trouver le professeur idéal selon vos besoins.

Matching avec le bon prof
Programme sur-mesure
Premier cours d'essai

Sans engagement • Réponse sous 24h

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 O(2n)O(2^n). La récurrence dynamique compare les chaînes AA et BB caractère par caractère. On note L(i,j)L(i, j) la PLSSC des ii premiers caractères de AA et des jj premiers de BB :

L(i,j)={0si i=0 ou j=0,L(i1,j1)+1si Ai=Bj,max(L(i1,j),L(i,j1))sinon.L(i, j) = \begin{cases} 0 & \text{si } i = 0 \text{ ou } j = 0,\\[2pt] L(i-1, j-1) + 1 & \text{si } A_i = B_j,\\[2pt] \max\big(L(i-1, j),\, L(i, j-1)\big) & \text{sinon.} \end{cases}
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 (n+1)×(m+1)(n+1) \times (m+1) : la complexité est O(n×m)O(n \times m), à 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 nn objets, chacun de poids wiw_i et de valeur viv_i, et un sac de capacité CC. Quelle est la valeur maximale qu'on peut emporter sans dépasser CC ? 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 2n2^n possibilités. La récurrence dynamique raisonne objet par objet. On note K(i,c)K(i, c) la valeur maximale atteignable avec les ii premiers objets et une capacité cc. Pour l'objet ii, deux choix : le laisser, ou le prendre s'il tient :

K(i,c)={K(i1,c)si wi>c,max(K(i1,c),  K(i1,cwi)+vi)sinon.K(i, c) = \begin{cases} K(i-1, c) & \text{si } w_i > c,\\[2pt] \max\big(K(i-1, c),\; K(i-1,\, c - w_i) + v_i\big) & \text{sinon.} \end{cases}
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 (n+1)(C+1)(n+1)(C+1) cases, d'où un temps O(n×C)O(n \times C). Attention : ce n'est pas polynomial en la taille de l'entrée, car CC 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 CC reste modéré.

3 exercices corrigés

Exercice 1, monter un escalier

Énoncé. Un escalier a nn marches. À chaque pas, on monte de 11 ou 22 marches. Combien y a-t-il de façons différentes d'atteindre la marche nn ? Donner la récurrence, puis une version en O(n)O(n).

Solution. Pour atteindre la marche nn, le dernier pas vient soit de la marche n1n-1 (pas de 11), soit de la marche n2n-2 (pas de 22). Le nombre de chemins c(n)c(n) vérifie donc :

c(n)=c(n1)+c(n2),c(0)=1, c(1)=1.c(n) = c(n-1) + c(n-2), \qquad c(0) = 1,\ c(1) = 1.

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 O(2n)O(2^n) ; celle-ci est en O(n)O(n) temps et O(n)O(n) mémoire (réductible à O(1)O(1) avec deux variables). Pour n=5n = 5 : c(5)=8c(5) = 8 façons.

Exercice 2, rendu de monnaie et échec du glouton

Énoncé. Avec les pièces {1,3,4}\lbrace 1, 3, 4 \rbrace, rendre la monnaie sur 66. 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 6\le 6, soit 44, puis 11, puis 11 : total 4+1+1=34 + 1 + 1 = 3 pièces. Or on peut faire 3+3=23 + 3 = 2 pièces. Le glouton se trompe. On remplit dp[s]=1+minpsdp[sp]dp[s] = 1 + \min_{p \le s} dp[s-p] :

ss00112233445566
dp[s]dp[s]00112211112222

Lecture : dp[3]=1+dp[0]=1dp[3] = 1 + dp[0] = 1 (une pièce de 33), dp[6]=1+min(dp[5],dp[3],dp[2])=1+dp[3]=2dp[6] = 1 + \min(dp[5], dp[3], dp[2]) = 1 + dp[3] = 2. La table confirme le minimum de 22 pièces, là où le glouton en donnait 33.

Exercice 3, remplir la table du sac à dos

Énoncé. Trois objets de poids {1,2,3}\lbrace 1, 2, 3 \rbrace et de valeurs {6,10,12}\lbrace 6, 10, 12 \rbrace, un sac de capacité C=5C = 5. Remplir la table K(i,c)K(i, c) et donner la valeur maximale.

Solution. On applique K(i,c)=max(K(i1,c), K(i1,cwi)+vi)K(i, c) = \max\big(K(i-1, c),\ K(i-1, c - w_i) + v_i\big) ligne par ligne (l'objet ii ayant poids wiw_i et valeur viv_i). La ligne 00 est nulle.

K(i,c)K(i, c)c=0c=0c=1c=1c=2c=2c=3c=3c=4c=4c=5c=5
objet 1 (1,6)(1, 6)006666666666
objet 2 (2,10)(2, 10)00661010161616161616
objet 3 (3,12)(3, 12)00661010161618182222

La valeur maximale est K(3,5)=22K(3, 5) = 22, obtenue en prenant les objets 22 et 33 (poids 2+3=52 + 3 = 5, valeur 10+12=2210 + 12 = 22). On retrouve ce résultat en remontant la table : K(3,5)K(3,5) vient de K(2,53)+12=K(2,2)+12=10+12=22K(2, 5-3) + 12 = K(2,2) + 12 = 10 + 12 = 22.

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 O(2n)O(2^n) à O(n)O(n). Rendu de monnaie en O(montant×pieˋces)O(\text{montant} \times \text{pièces}), PLSSC en O(n×m)O(n \times m), sac à dos en O(n×C)O(n \times C).
  • 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.

Partager

FAQ

Questions fréquentes

Accompagnement personnalisé

Besoin d'aide pour réussir votre prépa ?

Nos professeurs, issus de Polytechnique et Centrale, vous accompagnent dans votre réussite avec un suivi sur-mesure.