Vue d'ensemble
Tu as vu avec les algorithmes gloutons qu'on peut parfois construire une solution en faisant à chaque étape le choix qui paraît le meilleur sur le moment. Mais tu as aussi vu que ce raisonnement myope se trompe parfois. La programmation dynamique est l'artillerie lourde : au lieu de parier sur un seul choix, elle explore tous les sous-problèmes utiles, mais sans jamais recalculer deux fois le même. C'est cette mémorisation qui la rend efficace là où la récursion naïve explose.
L'idée tient en une phrase : décomposer un problème en sous-problèmes qui se chevauchent, résoudre chaque sous-problème une seule fois, et retenir sa solution pour la réutiliser. Deux façons de coder cela : garder la récursion naturelle et lui coller un cache (mémoïsation, dite top-down), ou remplir un tableau des petits cas vers les grands (itératif, dit bottom-up).
Prérequis
- Récursivité : cas de base, appels récursifs, arbre des appels — indispensable pour comprendre pourquoi le Fibonacci naïf explose.
- Complexité temporelle : distinguer un coût exponentiel d'un coût linéaire .
- Manipulation des listes et tableaux Python (indexation, initialisation d'un tableau de taille fixe).
- Algorithmes gloutons : pour comparer l'approche myope et l'approche exhaustive optimale.
La prog dynamique bloque beaucoup d'élèves au concours. Reconnaître un problème « DP » et écrire la bonne récurrence, ça s'apprend par la pratique guidée avec nos mentors alumni X · Centrale · Mines.
Trouver un mentor →1. Deux définitions à connaître
La programmation dynamique est une méthode de résolution qui s'applique quand un problème possède deux propriétés :
- Sous-structure optimale : une solution optimale du problème se construit à partir de solutions optimales de sous-problèmes plus petits.
- Sous-problèmes chevauchants : les mêmes sous-problèmes reviennent de nombreuses fois quand on développe la récursion.
On résout alors chaque sous-problème une seule fois et on mémorise son résultat.
La mémoïsation est la technique consistant à conserver dans une structure (typiquement un dictionnaire) le résultat de chaque appel d'une fonction, indexé par ses arguments. Avant tout calcul, on regarde si le résultat est déjà en cache : si oui on le renvoie directement, sinon on le calcule puis on le range. C'est la mise en œuvre top-down de la programmation dynamique : on garde la récursion naturelle et on lui ajoute une mémoire.
2. Exemple canonique — Fibonacci
La suite de Fibonacci est définie par , et pour . C'est le terrain d'entraînement idéal : la récurrence est immédiate, mais la traduire naïvement en récursion est un désastre.
2.1 La version récursive naïve est exponentielle
def fib_naif(n):
if n <= 1: # cas de base : F0 = 0 et F1 = 1
return n
return fib_naif(n - 1) + fib_naif(n - 2)def fib_naif(n):on définit la fonction qui doit renvoyer le -ième terme de Fibonacci.if n <= 1: return ncas de base : pour on renvoie 0, pour on renvoie 1 (dans les deux cas c'est exactement n). Sans lui, la récursion ne s'arrête jamais.return fib_naif(n-1) + fib_naif(n-2)on applique littéralement la définition. Le problème : chaque appel en déclenche deux autres, et rien ne mémorise ce qui a déjà été calculé.Regarde ce qui se passe pour calculer : l'arbre des appels recalcule deux fois, trois fois, cinq fois… Le nombre d'appels double presque à chaque niveau : la complexité est exponentielle, en avec (grosso modo ). Dès , la machine rame.
| Sous-problème | ||||||
|---|---|---|---|---|---|---|
| Nombre de fois calculé | 1 | 1 | 2 | 3 | 5 | 3 |
Total : 15 appels pour , et cela grossit de façon explosive. Tout ce travail est gâché car on recalcule sans cesse les mêmes valeurs : c'est exactement la situation où la mémoïsation change tout.
2.2 Version mémoïsée (top-down) : on passe au linéaire
def fib_memo(n, cache=None):
if cache is None: # au premier appel, on cree le dictionnaire vide
cache = {}
if n <= 1: # cas de base inchange
return n
if n in cache: # deja calcule ? on renvoie sans refaire le travail
return cache[n]
cache[n] = fib_memo(n - 1, cache) + fib_memo(n - 2, cache)
return cache[n]def fib_memo(n, cache=None):même fonction, avec un second paramètre cache qui transporte la mémoire partagée entre tous les appels.if cache is None: cache = {}astuce : on n'écrit jamais cache={} par défaut (piège des arguments mutables). On crée le dictionnaire au tout premier appel seulement.if n <= 1: return ncas de base : les valeurs 0 et 1 ne se stockent pas, elles se renvoient directement.if n in cache: return cache[n]le cœur de la mémoïsation : si a déjà été calculé, on le récupère instantanément au lieu de relancer toute la récursion.cache[n] = fib_memo(n-1, cache) + fib_memo(n-2, cache)sinon on calcule une seule fois, puis on range le résultat dans le cache.return cache[n]on renvoie la valeur fraîchement mémorisée.Maintenant chaque n'est calculé qu'une seule fois : on remplit le cache de jusqu'à . Il y a valeurs à calculer, chacune en temps constant : la complexité tombe à en temps (et en mémoire pour le cache).
| Étape (ordre de rangement) | Clé | Calcul | cache[n] |
|---|---|---|---|
| 1 | 2 | 1 | |
| 2 | 3 | 2 | |
| 3 | 4 | 3 | |
| 4 | 5 | 5 | |
| 5 | 6 | 8 ✓ |
2.3 Version itérative (bottom-up)
On peut se passer de récursion : on remplit un tableau dp des petits indices vers les grands. C'est souvent plus rapide (pas de pile d'appels) et tout aussi simple ici.
def fib_iter(n):
if n <= 1:
return n
dp = [0] * (n + 1) # dp[i] contiendra F_i
dp[1] = 1 # on initialise les cas de base
for i in range(2, n + 1):
dp[i] = dp[i - 1] + dp[i - 2]
return dp[n]if n <= 1: return non traite d'abord les cas de base pour éviter d'indexer dp[1] quand n=0.dp = [0]*(n+1)on crée un tableau de cases (indices 0 à ), toutes à 0. La case dp[0] vaut déjà .dp[1] = 1second cas de base : .for i in range(2, n+1):on avance des petits sous-problèmes vers les grands : quand on calcule dp[i], dp[i-1] et dp[i-2] sont déjà remplis.dp[i] = dp[i-1] + dp[i-2]la récurrence, mais en lecture dans le tableau : aucun recalcul, chaque case remplie une fois.return dp[n]la dernière case contient la réponse.3. Exemple fort — le rendu de monnaie optimal
Rendre une somme avec un minimum de pièces, en piochant dans un système . L'algorithme glouton (prendre la plus grosse pièce possible) marche sur le système euro, mais échoue sur certains systèmes. La programmation dynamique, elle, donne toujours l'optimum.
3.1 La récurrence
Notons le nombre minimal de pièces pour rendre exactement la somme . Sous-structure optimale : pour rendre , la dernière pièce posée est une pièce , et il reste alors à rendre de façon optimale. On teste donc toutes les dernières pièces possibles et on garde la meilleure :
Le compte la pièce qu'on vient de poser. Le dit qu'il faut 0 pièce pour rendre 0. Les sous-problèmes se chevauchent massivement d'une somme à l'autre : c'est bien de la programmation dynamique.
3.2 Le code (bottom-up)
def rendu_optimal(pieces, s):
INF = float('inf')
dp = [0] + [INF] * s # dp[0]=0, le reste = infini (pas encore atteint)
for m in range(1, s + 1): # on remplit les sommes de 1 a s
for p in pieces: # on essaie chaque piece comme derniere posee
if p <= m and dp[m - p] + 1 < dp[m]:
dp[m] = dp[m - p] + 1
return dp[s]INF = float('inf')on représente « somme pas encore réalisable » par l'infini : ainsi tout vrai nombre de pièces sera plus petit.dp = [0] + [INF]*stableau de cases : dp[0]=0 (rendre 0 coûte 0 pièce), toutes les autres à l'infini au départ.for m in range(1, s+1):on remplit dans l'ordre croissant : quand on traite la somme m, toutes les sommes m-p plus petites sont déjà finales.for p in pieces:on envisage chaque pièce p comme étant la dernière posée pour atteindre m.if p <= m and dp[m-p]+1 < dp[m]:la pièce doit tenir dans la somme (p<=m), et on ne garde ce choix que s'il améliore le minimum courant. C'est le de la récurrence, calculé au fil de l'eau.dp[m] = dp[m-p] + 1on met à jour : une pièce p de plus que la solution optimale de m-p.return dp[s]la case dp[s] contient le nombre minimal de pièces. (Elle vaut l'infini si s est irréalisable avec ce système.)| Somme | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|---|
| Meilleure dernière pièce | — | 1 | 1 | 3 | 4 | 4 ou 1 | 3 |
| dp[m] (nb min de pièces) | 0 | 1 | 2 | 1 | 1 | 2 | 2 ✓ |
Lecture de la case : la meilleure dernière pièce est 3, donc . La programmation dynamique rend 6 avec 2 pièces (3+3), là où le glouton en utilisait 3. Détail des cases clés : (une pièce de 3), (une pièce de 4), (1+1), (4+1).
3.3 Complexité
Deux boucles imbriquées : la somme va de 1 à ( itérations), et pour chacune on parcourt les pièces. Le coût est donc en temps, où est la somme à rendre et le nombre de pièces. La mémoire est pour le tableau dp. C'est bien plus fiable qu'un glouton : on paie un peu plus cher, mais on obtient toujours l'optimum.
Glouton ou prog dynamique ? Savoir trancher au concours entre les deux, c'est un réflexe qui se travaille sur des dizaines d'exemples avec nos mentors alumni X · Centrale · Mines.
Trouver un mentor →4. Méthode — reconnaître un problème de programmation dynamique
- Repérer les deux ingrédients. Le problème a-t-il une sous-structure optimale (l'optimum global se compose d'optimums de sous-problèmes) et des sous-problèmes chevauchants (les mêmes reviennent) ? Si oui : DP.
- Définir précisément l'état. Écris ce que représente
dp[...]en une phrase (« nombre minimal de pièces pour rendre », « »). Un état mal défini = récurrence fausse. - Écrire la récurrence + les cas de base. Exprime
dp[état]en fonction d'états plus petits, et n'oublie pas les cas de base (dp[0],dp[1]…). - Choisir top-down ou bottom-up. Récursion + cache si la formulation récursive est naturelle ; tableau itératif si l'ordre de remplissage est évident (des petits vers les grands).
5. Exercices corrigés
On appelle fib_memo(7) (fonction de la section 2.2). Donne, dans l'ordre où elles sont rangées, les paires (clé, valeur) ajoutées au dictionnaire cache, et la valeur finale renvoyée.
Voir la correction détaillée
On utilise rendu_optimal (section 3.2) avec le système pour rendre . Construis le tableau dp[0..8], donne le résultat, et compare-le à ce que trouverait le glouton.
Voir la correction détaillée
dp[0]=0. dp[1]=1 (une pièce 1). dp[2]=2 (1+1). dp[3]=3 (1+1+1). dp[4]=1 (pièce 4).dp[5]=2 (4+1). dp[6]=1 (pièce 6). dp[7]=2 (6+1). dp[8] : via 4 → ; via 6 → ; via 1 → . Le min est 2.rendu_optimal([1,4,6],8)=2 (soit 4+4).Un escalier a marches. À chaque pas on monte de 1 ou 2 marches. On veut le nombre de façons différentes d'atteindre la marche . Écris la récurrence, code une version bottom-up en , et calcule le résultat pour .
Voir la correction détaillée
dp[i] = nombre de façons d'atteindre la marche . Pour arriver en , on vient de (dernier pas de 1) ou de (dernier pas de 2). Récurrence : .dp[0]=1 (une seule façon de « ne pas bouger »), dp[1]=1. On reconnaît Fibonacci décalé.def escalier(n):
dp = [0] * (n + 1)
dp[0] = 1
if n >= 1:
dp[1] = 1
for i in range(2, n + 1):
dp[i] = dp[i - 1] + dp[i - 2]
return dp[n]Récap final — Ce qu'il faut absolument retenir
La programmation dynamique = décomposer en sous-problèmes chevauchants, les résoudre une fois, mémoriser. Deux mises en œuvre équivalentes en complexité : mémoïsation top-down et tableau bottom-up.
- Sais-tu énoncer les deux propriétés qui rendent la programmation dynamique applicable (sous-structure optimale + sous-problèmes chevauchants) ?
- Sais-tu expliquer pourquoi
fib_naifest exponentiel et pourquoi la mémoïsation le ramène à ? - Sais-tu écrire une fonction mémoïsée avec un cache (dictionnaire) et le test « déjà calculé ? » ?
- Sais-tu la différence entre top-down (récursion + cache) et bottom-up (tableau rempli des petits vers les grands) ?
- Sais-tu écrire la récurrence du rendu de monnaie avec ses cas de base ?
- Sais-tu montrer, sur pour 6, que la prog dynamique (2 pièces) bat le glouton (3 pièces) ?
- Sais-tu donner la complexité du rendu de monnaie DP : pour la somme et pièces ?
- Sais-tu, face à un nouveau problème, définir l'état
dp[...]et écrire sa récurrence ?