Académique Documents
Professionnel Documents
Culture Documents
ACCUEIL CURSUS COURS ADMISSIONS CAMPUS DOCUMENTATION ANCIENS ENTREPRISES OPEN CAMPUS PUBLICATIONS
Laurent GODEFROY
Principe général
Le but de ce chapitre va être de présenter un paradigme de programmation
Le problème du rendu de monnaie
alternatif à celui diviser pour régner. Nous allons en effet mettre en
évidence l'une des faiblesses de celui-ci, en un mot des appels récursifs Le problème du sac à dos
redondants, et voir comment y remédier. Cette nouvelle technique
s'appellera programmation dynamique, et l'on verra qu'elle prend deux formes, l'une récursive et l'autre itérative.
Après en avoir exposé le principe général, nous la mettrons en application sur deux grands problèmes classiques, celui du rendu de
monnaie et celui du sac à dos.
Soyons honnête avec le lecteur, ce chapitre est délicat et demande beaucoup d'attention. Il faut en effet une
bonne maîtrise de la notion mathématique de récurrence et de son implémentation en programmation, la
récursivité.
C'est sans nul doute la raison pour laquelle certaines grandes firmes de l'I.T. (Amazon, Facebook, Microsoft ou
Google pour ne citer qu'elles) ont choisi ce thème en particulier pour tester les capacités de leurs candidats lors
d'entretiens d'embauche.
Sans surprise, l'auteur de ce cours a choisi le Python comme langage d'implémentation des algorithmes étudiés.
1 Principe général
Nous allons dans cette partie pointer du doigt l'un des grands problèmes de la récursivité, puis proposer deux solutions pour y
pallier.
Commençons par rappeler une fois de plus les trois étapes du paradigme "diviser pour régner" :
Le problème est que dans certaines situations, les sous-problèmes ne sont pas indépendants. On peut alors retrouver un même
sous-problème dans des appels récursifs différents. Et donc être amené à le résoudre plusieurs fois car dans cette approche dès
qu’un sous-problème est rencontré il est résolu.
Présentons de suite un exemple afin que chacun comprenne bien les enjeux. Ce n'est pas stricto sensu un cas "diviser pour régner",
mais il a l'avantage de la simplicité et les questions soulevées seront les mêmes.
{
0 si n = 0
un = 1 si n = 1
u n − 1 + u n − 2 si n ≥ 2
def recursiveFibo(n):
if n==0 or n==1:
return n
else:
return recursiveFibo(n-1) + recursiveFibo(n-2)
Demandons-nous maintenant quels sont les appels récursifs effectués avec une valeur initiale du paramètre n égale par
exemple à 7.
Pour que cela soit le plus clair possible, nous allons représenter ces appels sous forme d'un arbre, où chaque sommet
correspondra à un appel et contiendra la valeur du paramètre à cet instant.
On réalise ainsi un grand nombre d'opérations inutiles car redondantes. Celles-ci sont très coûteuses et expliquent la
complexité exponentielle de cet algorithme.
Rappelons que le calcul de la complexité de la fonction précédente a été effectué lors du second chapitre de ce
cours.
Pour éviter ces appels récursifs redondants et coûteux, il suffit d'avoir une idée qui après tout est relativement simple : on va
mémoriser les résultats des sous-problèmes afin de ne pas les recalculer plusieurs fois.
Cela n’est bien sûr possible que si les sous-problèmes ne sont pas indépendants. Cela signifie donc que ces sous-problèmes ont des
sous-sous-problèmes communs.
Ce n'est évidemment pas le cas de tous les algorithmes de type diviser pour régner. Le tri fusion n'est par
exemple pas concerné, puisqu'il n'y aucune raison de retrouver les mêmes valeurs à trier dans la première
moitié de la liste que dans la seconde.
Pour ce faire, et donc éviter de recalculer plusieurs fois les solutions des mêmes sous-problèmes, on va mémoriser ces solutions
dans une sorte de mémoire cache. Celle-ci sera un tableau ou liste, selon le langage d'implémentation utilisé, et possèdera une ou
deux dimensions suivant les cas.
Cette technique porte le nom de programmation dynamique, et sa mise en pratique peut prendre deux formes :
On résout d’abord les sous problèmes de la plus "petite taille", puis ceux de la taille "d’au dessus", etc. Au fur et à
mesure on stocke les résultats obtenus dans le tableau de mémoire cache.
On continue jusqu'à la taille voulue.
Autant le terme programmation dynamique peut sembler artificiel (quel est le côté dynamique de la chose ?),
autant nous espérons que l'usage des qualificatifs Top Down et Bottom up soit naturel pour le lecteur. Ils
indiquent en effet le sens de traitement, des données de grandes tailles vers celles de petites tailles, ou
l'inverse.
Nous allons maintenant reprendre l'exemple de la suite de Fibonacci et lui appliquer les deux méthodes précédentes.
Voici la fonction déclarant cette liste, nommée track, et réalisant le premier appel de la fonction récursive qui effectuera le
calcul :
def DPTDFibo(n):
track = [0]*(n+1)
return DPTDFiboRec(n,track)
def DPTDFiboRec(n,track):
if n==0 or n==1:
track[n]=n
return n
elif track[n]>0:
return track[n]
else:
track[n]=DPTDFiboRec(n-1,track) + DPTDFiboRec(n-2,track)
return track[n]
Il faut que le lecteur comprenne bien qu'il s'agit quasiment de la fonction récursive de la sous-partie 1.2. La seule différence,
mais bien sûr majeure au niveau de l'efficacité, réside dans la condition "elif track[n]>0". Elle permet de vérifier dans la
mémoire cache si le terme en question de la suite à déjà été calculé ou non. Si oui on le retourne et la fonction prend fin, sinon
on le calcule récursivement, on stocke sa valeur dans la mémoire cache et on la retourne.
Il est assez facile de voir que la complexité de cette fonction n'est plus exponentielle comme dans sa première version mais
linéaire. Moralement il faut en effet remplir chacune des n + 1 cases de la mémoire cache, et ce à coût constant.
L'ordre dans lequel apparaissent les fonctions précédentes n'est que pédagogique.
Dans un code il faudrait bien sûr les inverser. En effet, DPTDFibo appelle DPTDFiboRec, donc cette dernière
fonction doit être placée en premier afin d'être reconnue par l'interpéteur Python.
Puisqu'elle a le même rôle, il est logique que la mémoire cache soit comme dans le cas Top Down une liste à n + 1 éléments.
La différence est que cette liste ne vas plus se remplir récursivement, en partant de la valeur n et en décrémentant jusqu'à 0
ou 1, mais itérativement, en partant cette fois de 0 et 1 et en incrémentant jusqu'à n.
def DPBUFibo(n):
track = [0]*(n+1)
track[1] = 1
for i in range(2,n+1):
track[i] = track[i-1] + track[i-2]
return track[n]
Bien entendu, c'est toujours la formule de récurrence définissant la suite qui nous permet de remplir notre mémoire cache.
On verra sur des exemples plus délicats qu’une des différences entre les deux approches réside dans le fait que dans un algorithme
Bottom Up on résout tous les sous-problèmes de taille inférieure, alors que dans un algorithme Top Down on ne résout que ceux
nécessaires.
Un des principaux champs d’applications de la programmation dynamique est la résolution de problèmes d’optimisation. Il s’agit de
problèmes dont chaque solution possède une valeur. On cherche alors une solution de valeur optimale (minimale ou maximale).
Il existe de nombreuses techniques pour résoudre ce genre de problèmes et la programmation dynamique en fait partie. Des
méthodes gloutonnes peuvent également marcher comme nous le verrons dans le sixième et dernier chapitre de ce cours. Nous
pouvons également citer l'optimisation linéaire ou certains algorithmes d'analyse numérique.
Il est donc logique de se demander à quelles conditions doit-on utiliser la programmation dynamique pour résoudre un problème
d'optimisation.
Il faut premièrement que l'ensemble des éléments constituant le problème soit discret et fini. Par exemple pour la recherche de plus
courts chemins, ces éléments sont les sommets du graphe et les arêtes les reliant entre eux.
Ensuite, une solution optimale au problème global doit induire des solutions optimales aux sous-problèmes. Enfin, il est nécessaire
que les sous-problèmes ne soient pas indépendants.
Que le lecteur se rassure, ces considérations vont prendre tout leur sens après l'étude des problèmes des
parties 2 et 3.
Puisque l'on travaille sur un ensemble fini, on pourrait naïvement envisager de considérer toutes les solutions et
de prendre la meilleure. Cela donne évidemment le résultat, mais comme nous le verrons au prix d'une
complexité algorithmique exponentielle ou même factorielle.
Si le problème s'y prête et que l'on peut utiliser une technique de programmation dynamique, on retrouvera généralement quatre
étapes dans la conception d'une solution optimale :
Là encore des exemples clarifierons tout cela. Le lecteur y verra rapidement que la seconde étape est souvent la
plus délicate. C'est elle qui nécessite en effet le plus d'abstraction et de logique mathématique.
Ces quatre étapes sont celles exposées par Cormen & co dans leur livre.
Il ne faut pas confondre la valeur d'une solution optimale et la solution en elle-même. Par exemple pour une
recherche de plus court chemin entre deux points, la valeur est la distance et la solution le chemin à emprunter.
Le problème relativement simple du rendu de monnaie va nous permettre dans cette partie de bien appréhender tous les concepts
de la programmation dynamique présentés auparavant.
La question est la suivante : quel est le nombre minimal de pièces à utiliser pour rendre une somme donnée ? De plus, quelle est la
répartition des pièces correspondante ?
Pour reprendre les termes de la première partie, la valeur de la solution optimale sera ce nombre minimal de pièces, et la solution
en elle-même la liste des pièces nous permettant de rendre la somme.
( )
Le système de pièces de monnaie peut être modélisé par un n-uplet d'entiers naturels S = c 1 , c 2 , . . . , c n , où c i représente la
valeur de la pièce i.
On suppose que c 1 = 1 et que c 1 < c 2 < . . . < c n .
Une somme à rendre est un entier naturel X.
( )
Une répartition de pièces est un n-uplet d'entiers naturels x 1 , x 2 , . . . , x n , où x 1 représente le nombre de pièces c 1 , x 2 le nombre
n
de pièces c 2 , etc. Le nombre total de pièces d'une telle répartition est donc ∑ x i.
i=1
Notre question peut alors être reformulée comme suit : pour tout entier naturel X, on cherche un n-uplet d'entiers naturels
n n
L'hypothèse c 1 = 1 garantit qu’un rendu est toujours possible puisque les sommes à rendre sont entières.
Dans la zone euro, le système actuellement en circulation est S = (1, 2, 5, 10, 20, 50, 100, 200, 500). Avec les notations précédentes,
on a ainsi c 1 = 1, c 2 = 2, c 3 = 5, . . . , c 9 = 500. Et bien sûr n = 9.
9
( )
Pour rendre par exemple une somme de X = 6 euros, on doit considérer les n-uplets x 1 , x 2 , . . . , x 9 vérifiant ∑ x ic i = 6.
i=1
Il est facile de voir que nécessairement x i = 0 si i ≥ 4, car les pièces correspondantes ont une valeur plus grande que la somme
à rendre. La contrainte devient donc x 1 + 2x 2 + 5x 3 = 6 et il y a cinq triplés qui la vérifient :
(6, 0, 0)
(4, 1, 0)
(2, 2, 0)
(1, 0, 1)
(0, 3, 0)
Le triplet qui minimise x 1 + x 2 + x 3 est alors la solution, il s'agit en l'occurrence de (1, 0, 1). Il faut ainsi deux pièces pour rendre
une somme de 6 euros, une pièce de 1 et une de 5.
Pour terminer cette sous-partie, demandons-nous ce qu'un humain ferait dans une telle situation. Il commencerait sans doute par
rendre la plus grande pièce "possible", puis ferait de même avec le reste jusqu'à ce que la somme soit rendue. C'est d'ailleurs ce que
font des millions de commerçants quotidiennement.
1. Choisir la plus grande pièce du système de monnaie inférieure ou égale à la somme à rendre.
2. Déduire cette pièce de la somme.
3. Si la somme n’est pas nulle recommencer à l’étape 1.
Cet algorithme est dit glouton, voir le prochain chapitre de cours où l'on étudiera cette technique de
programmation.
Cet méthode est séduisante, car simple, mais malheureusement pas toujours optimale, comme nous allons le constater sur un
contre-exemple.
Par contre, pour rendre cette même somme avec le système (1, 3, 4) il n'y a pas optimalité. En effet on rendra d'abord une pièce
de 4, puis une pièce de 1 et enfin une autre pièce de 1, c'est-à-dire trois pièces. Or on pouvait rendre de façon plus
performante deux pièces de 3.
Il va donc falloir trouver une approche plus subtile, ce que nous allons faire dans le reste de cette partie. Comme souvent, la
récursivité va nous y aider.
Pour résoudre ce problème de façon récursive, sans parler encore de programmation dynamique, il faut commencer par établir une
formule de récurrence.
Dans un premier temps, on va s'intéresser uniquement au nombre minimal de pièces à rendre. On reconstituera la répartition
correspondante plus tard.
Qui dit récurrence ou récursivité dit diminution de la valeur du paramètre. Il faut donc se demander quelles sont les sommes
inférieures à X obtenables à partir de X.
Si l'on sait comment rendre chacune de ces sommes de façon optimale, on saura le faire également pour X. Il suffira de prendre la
meilleure de ces possibilités, i.e. celle correspondant à un plus petit nombre de pièces, et de rajouter 1. Ce + 1 correspondant au
choix de la première pièce.
La condition d'arrêt à la récursivité sera bien sûr l'obtention d'une somme nulle.
Grâce aux éléments précédents, nous pouvons maintenant présenter cette formule de récurrence
C[X] =
{ 0
1≤i≤n
1 + minc
i≤X
[
C X − c i ]
si X = 0
si X > 0
Exploiter cette formule pour implémenter un algorithme récursif est alors relativement simple :
def recursiveChange(S,X):
if X==0:
return 0
else:
mini = X+1
for i in range(len(S)):
if S[i]<=X:
nb = 1 + recursiveChange(S,X-S[i])
if nb<mini:
mini = nb
return mini
Les notations sont les mêmes que précédemment, S désigne le système de pièces (sous la forme d'un t-uple en Python) et X la
somme à rendre.
La technique pour calculer le minimum est des plus classiques. On initialise d'abord une variable arbitrairement "trop grande"
destinée à contenir in fine ce minimum. On parcourt ensuite un à un les éléments de l'ensemble sur lequel on effectue la
minimisation en mettant à jour si nécessaire (i.e. si l'on trouve une valeur inférieure à la valeur courante) notre minimum. A noter
que l'on a logiquement restreint l'ensemble des pièces à celles plus petites que la somme à rendre.
On a choisi ici de calculer le minimum "à la main" sans avoir recours à une fonction prédéfinie. Notre code n'en
sera ainsi que plus facilement transposable dans d'autres langages.
Cet algorithme est bien de type diviser pour régner, puisque l'on a ramené le calcul pour une somme X à celui pour des sommes
X - c 1 , X - c 2 , . . . , X - c n . Nous avons ainsi divisé le problème initial puis appliqué un traitement récursif.
Nous sommes de plus dans une situation où les sous-problèmes ne sont pas indépendants, comme nous allons le constater en
étudiant l'arbre des appels récursifs sur un exemple.
On peut d'ailleurs instinctivement conjecturer une complexité exponentielle. Nous reviendrons sur cette question dans la
sous-partie 2.6.
Nous avons qualifié cet algorithme de naïf, le terme brute force aurait tout aussi judicieux, car en fait on étudie
toutes les façons possibles de rendre la monnaie.
Rappelons-en brièvement le principe : au lieu de recalculer plusieurs fois les solutions des mêmes sous-problèmes, on va les
mémoriser dans une mémoire cache.
Pour une somme X, il va ainsi falloir enregistrer les résultats pour les sommes 0, 1, . . . , X - 1. La mémoire cache, que l'on notera track,
sera donc une liste undimensionnelle à X + 1 éléments.
Pour 0 ≤ x ≤ X, track[x] sera donc égal au nombre de pièces minimal que l'on doit utiliser pour rendre une somme x. La solution à
notre problème initial étant alors track[X].
Avec une approche Top Down, on va construire cette liste track de façon récursive en partant de notre somme initiale X. Cette
fonction sera donc très proche de la version naïve et peu efficace présentée dans la sous-partie précédente.
La seule différence est que lors d’un appel récursif qui n’est pas terminal, on va se demander si la valeur en question n’a pas déjà
été calculée en regardant dans notre mémoire cache. Si oui on la retourne, sinon on la calcule par récursivité et on met à jour notre
mémoire cache pour ne pas avoir à effectuer de nouveau ce calcul lors d'un appel postérieur.
Voici la fonction déclarant la liste track et faisant le premier appel de la fonction récursive :
def DPTDChange(S,X):
track = [0]*(X+1)
return DPTDChangeRec(S,X,track)
Et voici la fonction récursive, qui comme mentionné précédemment, est très proche de celle de la sous-partie 2.2 :
def DPTDChangeRec(S,X,track):
if X==0:
return 0
elif track[X]>0:
return track[X]
else:
mini = X+1
for i in range(len(S)):
if S[i]<=X:
nb=1+DPTDChangeRec(S,X-S[i],track)
if nb<mini:
mini = nb
track[X] = mini
return mini
Poursuivons l'exemple 1.6 et constatons une nette amélioration de l'arbre des appels récursifs.
Les redondances ont disparu, et c'est bien heureux puisque là était notre but.
Terminons cette sous-partie en nous concentrant un instant sur notre liste track. Avec cette approche Top Down, elle va donc se
remplir récursivement en partant de X et en décrémentant jusqu'à 0 :
Voyons cela sur un exemple.
A l'issue du programme, avec le système européen et une somme de 11, la liste track contiendra ces valeurs :
Si lecteur a quelques doutes quand à sa bonne compréhension de l'agorithme Top Down, qu'il essaie de remplir
"à la main" la liste de l'exemple précédent, en détaillant chacune des étapes.
Pour une somme de X, notre mémoire cache sera comme dans le cas Top Down une liste undimensionnelle à X + 1 éléments.
La différence est qu'avec une approche Bottom Up, on va remplir cette fois notre liste de façon itérative en partant de la plus petite
valeur possible à rendre, i.e. 0, jusqu'à X. Le calcul des différents éléments de track provenant lui toujours de la formule de
récurrence.
def DPBUChange(S,X):
track = [0]*(X+1)
for x in range(1,X+1):
mini = X
for i in range(len(S)):
if (S[i]<=x) and (1+track[x-S[i]]<mini):
mini = 1+track[x-S[i]]
track[x] = mini
return track[X]
Comme mentionné ci-dessus, on va remplir itérativement la liste track en partant de 0 et en incrémentant jusqu'à X :
Les algorithmes précédents calculent le nombre de pièces minimum à utiliser pour rendre une somme X, mais ne donnent pas la
répartition correspondante. Par répartition, on entend ici le nombre d'exemplaires de chaque pièce du système de monnaie dont il
faut se servir pour rendre X.
Pour cela, nous allons modifier quelque peu notre algorithme Bottom Up, mais nous aurions pu faire la même chose avec le Top
Down. Nous laissons ainsi généreusement un peu de travail au lecteur.
On va définir une liste de la même dimension que notre mémoire cache et nous mettrons les deux à jour simultanément.
Nous nommerons cette nouvelle liste keep, et pour 0 ≤ x ≤ X, keep[x] sera égal au numéro de la première pièce du système de
monnaie utilisée pour réaliser le change de x.
La définition et l'utilisation de cette liste peuvent paraître absconses au premier abord, mais nous allons vite voir qu'il n'en est rien.
Pour cela, nous demandons au lecteur d'admettre un instant les valeurs de l'exemple suivant, nous reviendrons sur leur calcul un
peu plus tard.
Rappelons que notre système de pièces est S = (1, 2, 5, 10, 20, 50, 100, 200, 500) et étudions comment rendre la somme X = 8.
Puisque keep[8] = 1, on doit d'abord rendre la première pièce du système. Celle-ci valant 1 il reste alors une somme de 7.
On a keep[7] = 2, il faudra donc ensuite rendre la seconde pièce. Cette dernière valant 2 il ne reste plus que 5 à rendre.
On a keep[5] = 3, donc la pièce suivante sera la troisième du système. Puisque celle-ci vaut 5, notre rendu est terminé.
La solution optimale est donc composée des trois premières pièces du système.
Reprenons le raisonnement de cet exemple de façon algorithmique. On va construire une liste L constituée des valeurs des pièces
réalisant le change de façon optimale. Cette liste sera retournée par notre fonction en plus de track[X].
1. On part de x = X.
2. Tant que x est strictement positif on ajoute à L la valeur de la pièce numérotée par keep[x] et on retranche cette valeur à x.
Il ne reste plus qu'à se demander à quel moment mettre à jour notre liste keep. La réponse est simple : en même temps que notre
mémoire cache track. Quand dans track nous mémoriserons un nombre de pièces minimum, dans keep nous garderons l'indice de
la première pièce ayant réalisé ce minimum.
Nous avons donc maintenant tous les éléments pour présenter notre algorithme Top Down avec construction de la solution optimale
:
def DPBUChange(S,X):
track = [0]*(X+1)
keep = [0]*(X+1)
for x in range(1,X+1):
mini = X+1
for i in range(len(S)):
if (S[i]<=x) and (1+track[x-S[i]]<mini):
mini = 1+track[x-S[i]]
coin = i
track[x] = mini
keep[x] = coin
x,L = X,[]
while x>0:
L.append(S[keep[x]])
x-=S[keep[x]]
return track[X],L
Pour finir cette sous-partie, appliquons notre fonction à l'exemple traité à la main précédemment :
S=(1,2,5,10,20)
print(DPBUChange(S,8))
Console
(3, [1, 2, 5])
Nous n'avons pas encore évoqué précisément la question de la complexité de nos fonctions. Nous nous sommes pour l'instant
contenter d'observer un net progrès entre l'algorithme récursif naïf et l'algorithme Top Down suite à la comparaison de leurs arbres
d'appels récursifs.
Nous allons maintenant essayer de préciser tout cela, même si nous laisserons parfois un peu de poussière sous le tapis.
Les algorithmes de rendu de monnaie étudiés dans cette partie ont deux données, à savoir la somme à rendre et le système de
monnaie. Leur complexité dépendra donc de la taille de chacune d'elles, qui sont respectivement log 2 (X) et n (avec les notations
précédentes).
Rappelons, voir premier chapitre de ce cours, que si une donnée est un nombre entier, et c'est le cas de X, sa
taille est le nombre de chiffres de son écriture binaire, i.e. log 2 (X) + 1.
Pour évaluer la complexité de l'algorithme naïf, demandons-nous quel est le nombre d'appels récursifs effectués à chaque niveau. Il
s'agit du nombre de pièces du système de monnaie puisqu'à chaque étape, sauf à la "fin" de l'arbre, on les teste toutes. L'arbre
ayant une hauteur égale à la somme à rendre, on peut donc conclure que la complexité est en O n X . ( )
On se persuade aisément que la complexité des algorithmes Top Down et Bottom Up est identique. Ceux-ci réalisent en effet
exactement les mêmes calculs, à savoir remplir chacune des cases de la liste track.
Il est facile de voir sur la version Bottom Up que cette complexité est en Θ(nX), puisque l'on a juste deux boucles imbriquées, et que
les itérations de chacune d'elles comportent le même nombre d'opérations élémentaires.
Attention bien sûr à ne pas conclure à une complexité linéaire par rapport à la somme à rendre. Comme mentionné précédemment
la taille de X est log 2 (X). Or X = 2 log 2 ( X ) , donc la complexité des algorithmes de programmation dynamique est malheureusement
exponentielle par rapport à la taille de X. Elle est toutefois bien meilleure que celle de l'agorithme naïf.
Lorsque la complexité d'un algorithme est polynomiale par rapport à la valeur de la donnée et non sa taille on le
qualifie de pseudo-polynomial. C'est le cas ici.
Nous allons maintenant augmenter la difficulté en nous confrontant dans cette dernière partie au célèbre problème du sac à dos.
Celui-ci est étudié depuis plus d'un siècle et reste un sujet très prisé de recherche.
La question est la suivante : quels objets peut-on mettre dans le sac sans dépasser sa capacité de poids, afin de maximiser la valeur
totale ?
L'ensemble des n objets peut être modélisé par un n-uplet de couples S = ((w 1, v 1 ), (w 2, v 2 ), . . . , (w n, v n )), où w i représente le
poids de l'objet i et v i sa valeur.
On note W la capacité du sac, et l'on suppose que ∀ i, 1 ≤ i ≤ n, w i ≤ W, w i > 0 et v i > 0.
n
( )
Un choix d'objets est un n-uplet d'entiers naturels x 1 , x 2 , . . . , x n , où chaque x i vaut 1 ou 0, selon que l'on prenne ou non l'objet
n n
(
Notre question peut alors être reformulée comme suit : on cherche un n-uplet de binaires x 1 , x 2 , . . . , x n qui maximise ) ∑ x iv i sous la
i=1
n
contrainte ∑ x iw i ≤ W.
i=1
La première hypothèse signifie que chaque objet peut rentrer dans le sac et a de plus un poids et une valeur non
nuls.
La seconde stipule que le sac ne peut contenir tous les objets en même temps, et donc que l'on a bien un choix à
effectuer.
En un mot, ces conditions ne sont là que pour s'assurer que le problème a bien un sens.
Etudions maintenant à la main un exemple afin de bien appréhender le problème et les notations précédentes.
On a ici n = 5 objets, et le n-uplet de couples les représentant est S = ((12, 4), (1, 2), (4, 10), (1, 1), (2, 2)).
5
( )
Puisque W = 15, on doit considérer les n-uplets binaires x 1 , x 2 , . . . , x 5 vérifiant ∑ x iw i ≤ 15, i.e. 12x 1 + x 2 + 4x 3 + x 4 + 2x 5 ≤ 15.
i=1
Chacun de ces n-uplets correspond à un choix d'objets ayant une valeur ∑ x iv i, i.e. 4x 1 + 2x 2 + 10x 3 + x 4 + 2x 5 .
i=1
Il y a quatre quintuplés qui vérifient cette inégalité, les voici avec leur valeur correspondante :
(1, 1, 0, 0, 1) pour une valeur de 8.
(1, 1, 0, 1, 0) pour une valeur de 7.
(1, 0, 0, 1, 1) pour une valeur de7.
(0, 1, 1, 1, 1) pour une valeur de 15.
Le quintuplet maximisant la valeur est donc (0, 1, 1, 1, 1). Il faut ainsi prendre les quatre derniers objets.
On rajoute parfois le qualificatif 0 / 1 quand on mentionne le problème du sac à dos avec les hypothèses
précédentes.
Comme pour le problème du rendu de monnaie, on peut se demander quelle méthode nous adopterions instinctivement pour
répondre à cette question. Vraisemblablement nous classerions les objets par densité de valeur, et nous les ajouterions dans cet
ordre tant que cela est possible.
1. Classer les objets dans une liste par ordre décroissant de leur rapport valeur/poids.
2. Parcourir cette liste. Si un objet peut être mis dans le sac sans dépasser la capacité de celui-ci, le faire. Sinon passer à l’objet
suivant.
Là aussi l'humain pense naturellement à une technique gloutonne, qui en un mot consiste à faire le meilleur
choix sur le moment. Nous renvoyons de nouveau au dernier chapitre de ce cours pour un tout d'horizon de ce
sujet.
Malheureusement cette méthode ne donne pas toujours un résultat optimal comme le contre-exemple suivant le démontre.
Nous les avons préalablement trié par ordre décroissant de leur rapport valeur/poids, ceux-ci sont respectivement
(1.11, 0.58, 0.5, 0.43, 0.4) à deux décimales près.
En suivant la méthode gloutonne, on choisirait le premier objet puis le troisième pour une valeur totale de 11, alors que la
solution optimale est constituée du premier objet et du dernier, pour une valeur totale de 12.
Commençons par établir une formule de récurrence. Cette démarche doit être de l'ordre du réflexe maintenant.
On va se focaliser pour le moment sur la valeur maximale totale des objets que l'on va pouvoir mettre dans le sac. La reconstitution
de la liste des objets correspondant à cette valeur sera effectuée dans un second temps.
La récursivité va s'effectuer ici sur les indices des objets, on va considérer ceux-ci un par un du dernier jusqu'au premier. Pour
chacun d'eux il n'y a clairement que deux possibilités, le mettre dans le sac ou pas. On prendra ensuite le maximum des valeurs
engendrées par ces deux possibilités.
A noter que si le poids de l'objet courant est supérieur à la capacité restante du sac cette question de le prendre ou non ne se pose
même pas.
Détaillons ces deux cas de figure :
Si l'on met le i-ème élément dans le sac, on est amené à calculer récursivement la valeur maximale que l’on peut mettre dans
un sac de capacité diminuée de w i avec les i - 1 premiers objets.
Si l'on ne met pas le i-ème élément dans le sac, on est amené à calculer récursivement la valeur maximale que l’on peut mettre
dans un sac de capacité inchangée avec les i - 1 premiers objets.
La condition d’arrêt à la récursivité est atteinte quand il ne reste plus qu’un objet à traiter. Si le poids de celui-ci est inférieur à la
capacité restante on le prend, sinon on le laisse.
Pour 0 ≤ i ≤ n et 0 ≤ w ≤ W, nous noterons V[i][w] la valeur maximale que l'on peut mettre dans un sac de capacité w en ne prenant en
compte que les i premiers objets. In fine la solution à notre problème étant bien sûr V[n][W].
Les considérations précédentes nous permettent à présent d'établir cette formule de récurrence
{
0 si i = 0
V[i − 1][w] si w i > w
V[i][w] =
( [
max V[i − 1][w], v i + V[i − 1] w − w i ]) si w i ≤ w
Que le lecteur comprenne bien que la condition d'arrêt de cette formule, i.e. quand i = 0, est la même que celle
présentée auparavant.
En effet, n'avoir plus qu'un objet à traiter correspond à i = 1. On a alors i - 1 = 0 ce qui implique
[ ]
V[i - 1][w] = V[i - 1] w - w i = 0. Et l'on retouve donc l'alternative de mettre ou pas cet objet selon la capacité
restante.
Si l'on implémente stricto sensu la formule précédente, nous obtenons cet algorithme récursif :
def recursiveKnapsack(S,W,i):
if i==0:
return 0
if S[i-1][0]>W:
return recursiveKnapsack(S,W,i-1)
else:
return max(recursiveKnapsack(S,W,i-1),S[i-1][1]+recursiveKnapsack(S,W-S[i-1][0],i-1))
def naiveKnapsack(S,W):
return recursiveKnapsack(S,W,len(S))
Les notations sont les mêmes que précédemment, S désigne l'ensemble des objets (sous la forme d'un t-uple de couples en Python)
et W la capacité du sac.
Comme toujours, soyons vigilant avec les indices qui commencent à 0 en Python et dans la plupart des langages.
Ainsi dans le code précédent quand i désigne un numéro d'objet entre 1 et n, "recursiveKnapsack(...,...,i-1)" fait
référence à l'objet précédent tandis que "S[i-1][...]" désigne ce i-ème objet.
Pour conclure cette sous-partie, ne manquons pas de présenter l'allure de l'arbre des appels récursifs. On y retrouve toute la
faiblesse d'un algorithme de type diviser pour régner quand il posséde des sous-problèmes non indépendants.
Les redondances sont peut-être moins flagrantes au premier regard que dans les problèmes des parties précédentes, mais
elles sont bien présentes. Constater par exemple les multiples appels avec les valeurs 7, 2 ou 7, 1. Ce phénomène s'amplifierait
bien sûr avec un plus grand nombre d'objets.
Améliorons maintenant la fonction récursive que nous venons d'implémenter en procédant par mémoïsation. L'utilisation d'une
mémoire cache va ainsi nous permettre de ne pas recalculer plusieurs fois les solutions des mêmes sous-problèmes.
Pour ce faire, nous allons stocker les valeurs V[i][w], pour 0 ≤ i ≤ n et 0 ≤ w ≤ W, dans notre mémoire cache. Celle-ci, que l'on notera
comme à l'accoutumée track, sera donc une liste à n + 1 lignes et W + 1 colonnes.
Puisque notre approche est ici Top Down, le calcul des valeurs va s'effectuer récursivement, de façon très similaire à la fonction de
la sous-partie précédente.
Comme pour les exemples de la suite de Fibonacci et du rendu de monnaie, la différence va résider dans la consultation de la
mémoire cache afin de ne pas réaliser des calculs redondants. Nous espérons que cette logique devient familière à présent.
def DPTDKnapsackRec(S,W,i,track):
if track[i][W]>0:
return track[i][W]
if i==0:
return 0
if S[i-1][0]>W:
track[i][W]=DPTDKnapsackRec(S,W,i-1,track)
return track[i][W]
else:
track[i][W]=max(DPTDKnapsackRec(S,W,i-1,track),S[i-1][1]+DPTDKnapsackRec(S,W-S[i-1][0],i-1,t
return track[i][W]
Comme toujours dans ces situations, il faut une fonction pour déclarer la liste track et réaliser le premier appel de la fonction
récursive :
def DPTDKnapsack(S,W):
track = [[0]*(W+1) for i in range(len(S)+1)]
return DPTDKnapsackRec(S,W,len(S),track)
L'arbre des appels récursifs va naturellement s'épurer, comme on peut le constater en pousuivant l'exemple 1.13.
Pour clore cette sous-partie, revenons un instant sur la construction de la liste track. Notre approche étant ici Top Down, on va la
considérer récursivement en partant du dernier objet et en remontant jusqu'au premier :
Avec une approche Bottom Up, nous changeons notre façon de calculer les valeurs de notre mémoire cache en procédant cette fois
itérativement.
Les autres éléments restent par contre les mêmes, la liste track conserve ses dimensions, son calcul provient toujours de la formule
de récurrence, et la solution à notre problème est encore donné par track[n][W].
def DPBUKnapsack(S,W):
track = [[0]*(W+1) for i in range(len(S)+1)]
for i in range(1,len(S)+1):
for w in range(W+1):
if S[i-1][0]>w:
track[i][w] = track[i-1][w]
else:
track[i][w] = max(track[i-1][w],S[i-1][1]+track[i-1][w-S[i-1][0]])
return track[len(S)][W]
Revenons un moment sur notre liste track qui va cette fois se remplir itérativement en partant du premier objet et en allant
jusqu'au dernier :
En comparant les listes track à l’issue des algorithmes Top Down et Bottom Up on comprend bien l'une des
principales différences entre ces deux approches.
En Bottom Up on calcule les solutions optimales de tous les sous-problèmes alors qu’en Top down on ne calcule
que ceux nécessaires.
Par exemple la valeur track[3][9] ne peut pas être calculée en Top Down puisque partant de i = 4, W = 10, les
appels récursifs se feront nécessairement avec i = 3, W = 7 et i = 3, W = 10 selon que l'on prenne ou non le dernier