Différence entre mémoïsation et programmation dynamique
Update : Merci à Marc de Falco qui a trouvé une erreur dans le code initial. J’élabore là-dessus ci-dessous.
L’autre jour, en jury de l’agrégation d’informatique où j’ai eu la chance de participer de 2022 à 2025 en compagnie de collègues et candidats aussi brillants que passionnés, le candidat choisit la leçon sur la programmation dynamique et fait son développement sur le célèbre algorithme de sac à dos. Une collègue pose la question : “Qu’est-ce qui se passe si les capacités des poids sont négatives ?”
Cette question permet d’illustrer une différence entre mémoïsation et programmation dynamique.
On commence par la façon classique de résoudre le problème dans le cas des capacités positives $c_i \geqslant 0$. La relation de récurrence (équation de Bellman) est la suivante.
Si $maxval[i][c]$ est la capacité maximale que l’on peut atteindre avec les $i \geqslant 0$ premiers objets et une capacité limite de $c$, alors $maxval[0][c] = 0$ pour toute capacité $c$ (pas d’objet) et quand $i \geqslant 1$,
\[maxval[i][c] = \max \begin{cases}maxval[i - 1][c] \textrm{ (si on ne prend pas le $i$-ème objet)}\\maxval[i - 1][c - c_i] + v_i \textrm{ si $c \geqslant c_i$ (si on prend le $i$-ème objet)}\end{cases}\]On peut faire un balayage avec une double boucle pour aller en nombre d’objets croissant et capacité croissante (bottom-up). Complexité temps et mémoire : $O(nC)$ où $n$ est le nombre d’objets et $C$ est la capacité du sac à dos.
capacities = [2, 3, 5]
values = [6, 4, 2]
cmax = 9
def knapsack():
n = len(capacities)
max_value = [[0] * (1 + cmax) for _ in range(n + 1)]
candidates = []
for i in range(1, n + 1):
for c in range(0, cmax + 1):
candidates = [max_value[i - 1][c]]
if c >= capacities[i - 1]:
candidates.append(values[i - 1] + max_value[i - 1][c - capacities[i - 1]])
max_value[i][c] = max(candidates)
return max_value[n][cmax]
knapsack()
En effet, on peut obtenir une valeur de 10 avec les 2 premiers objets (capacité totale 2 + 3 = 5 en dessous de la limite 9).
Cette méthode nous permet de répondre à toutes les questions possibles : que vaut $maxval$ pour les $i$ premiers objets et une capacité limite $c$ ? On visite tous les états possibles $(i, c)$.
Regardons à présent à quoi ressemblerait une solution qui mémoïserait (i.e. stockerait les appels récursifs pour ne pas recalculer la même quantité plusieurs fois). On peut s’appuyer sur l’astuce Python du décorateur @cache
. (On est plusieurs profs à penser que la mémoïsation devrait être enseignée avant la programmation dynamique.) Notez au passage à quel point ce code est similaire au précédent. En particulier, si on faisait du Haskell, on n’aurait pas besoin du décorateur.
from functools import cache
capacities = [2, 3, 5]
values = [6, 4, 2]
cmax = 9
@cache
def max_value(i, c):
candidates = []
if i == 0:
return 0
candidates.append(max_value(i - 1, c))
if c >= capacities[i - 1]:
candidates.append(values[i - 1] + max_value(i - 1, c - capacities[i - 1]))
return max(candidates)
n = len(capacities)
max_value(n, cmax)
Complexité : autant d’appels que strictement nécessaire, $O(nC)$ au pire. Mais soyons plus précis.
Soit $C_M$ le nombre d’états uniques (cases mémoire) parcourus par l’algorithme. On a d’une part $C_M \leq (n + 1)(C + 1)$ car c’est le nombre total d’états possibles, et d’autre part $C_M \leq 2^n$ car c’est le nombre total de combinaisons possibles (et d’appels récursifs si on ne faisait pas de mémoïsation).
On voit bien que selon le régime, par exemple si $n$ est petit et $C$ grand, il vaut mieux tout tester, même avec l’algorithme naïf, plutôt que de remplir tous les états possibles. Si $C$ est petit on a plutôt envie d’exploiter la redondance. Dans tous les cas, l’algorithme de mémoïsation est optimal en temps (mais pas en mémoire). Le gain par rapport au naïf est $C_M / 2^n$ et par rapport à l’algo de programmation dynamique est $C_M / ((n + 1)(C + 1))$. Faudrait que je fasse un dessin sur une instance avec les cases visitées pour chaque algorithme.
À présent, regardons ce qui se passe dans le cas de capacités négatives. (Veillez à bien exécuter les cellules précédentes avant celle-ci.)
La mémoïsation fonctionne et reste optimale. Update : en fait non, le code ci-dessus est faux. Cherchez un contre-exemple, en voici un.
capacities = [2, -1]
values = [1, 1]
cmax = 1
n = len(capacities)
max_value.cache_clear() # Sinon les appels sont encore dans la mémoire
max_value(n, cmax)
Ici, la réponse est bonne : 2, on prend tous les objets. Mais si on met les objets dans l’autre ordre :
capacities = [-1, 2]
values = [1, 1]
cmax = 1
n = len(capacities)
max_value.cache_clear()
max_value(n, cmax)
On rate l’objet de poids 2 car on ne s’autorise pas à passer par des états de capacités négatives qu’on pourrait rattraper plus tard. Autorisons donc les capacités négatives tant que l’état final convient. La relation de récurrence est donc :
\[maxval[0][c] = \begin{cases}0 \textrm{ si $c \geqslant 0$ (il reste de la capacité)}\\ -\infty \textrm{ sinon}\end{cases}\\ maxval[i][c] = \max \begin{cases}maxval[i - 1][c] \textrm{ si on ne prend pas le $i$-ème objet}\\maxval[i - 1][c - c_i] + v_i \textrm{ si on prend le $i$-ème objet}\end{cases}\]Code modifié :
from functools import cache
capacities = [-1, 2]
values = [1, 1]
cmax = 1
@cache
def max_value_neg(i, c):
candidates = []
if i == 0:
return 0 if c >= 0 else float('-inf') # Valeur admissible s'il reste de la capacité
return max(
max_value_neg(i - 1, c),
values[i - 1] + max_value_neg(i - 1, c - capacities[i - 1])
)
n = len(capacities)
max_value_neg(n, cmax)
Quelle est sa complexité ? On pourrait l’améliorer en éliminant les branches non prometteuses (s’il ne reste plus d’objets pour aboutir à un état positif). Qu’en est-il de l’algorithme itératif de programmation dynamique au début de ce billet ?
capacities = [-1, 2]
values = [1, 1]
cmax = 1
knapsack()
Exercice au lecteur : comment modifier le code précédent de knapsack
pour qu’il fonctionne dans le cas de capacités négatives ? Quel est le nombre d’états maximaux à considérer ? Quelle est la structure de données adaptée ? Faites un dessin sur papier pour voir ce qui se passe.
Une autre solution d’un étudiant est de partir d’un état où on a pris toutes les capacités négatives et considérer des actions “enlever l’objet” afin de se ramener au sac à dos à capacités positives. C’est également une solution correcte.
Méta : si ça vous intéresse de voir comment j’ai chargé pyodide sur ce blog post Jekyll, vous pouvez regarder en bas du source de cette page. Vous pouvez faire une PR pour remplacer le premier bloc par un textarea (avec coloration syntaxique svp) pour modifier le code.