Вы находитесь на странице: 1из 20

     Accéder à Open-Campus

ACCUEIL CURSUS COURS ADMISSIONS CAMPUS DOCUMENTATION ANCIENS ENTREPRISES OPEN CAMPUS PUBLICATIONS

Chapitre 05 - Programmation dynamique


Précédent Advanced Algorithmics Suivant

Laurent GODEFROY

Professeur Référent à SUPINFO International University

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.

1.1 La récursivité a ses limites

Commençons par rappeler une fois de plus les trois étapes du paradigme "diviser pour régner" :

1. Diviser : on divise les données initiales en plusieurs sous-parties.


2. Régner : on résout récursivement chacun des sous-problèmes associés (ou on les résout directement si leur taille est assez
petite).
3. Combiner : on combine les différents résultats obtenus pour obtenir une solution au problème initial.

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.

Il s'agit de la suite de Fibonacci, qui doit commencer à être familière au lecteur.

Example 1.1. Calcul récursif de la suite de Fibonacci

Rappelons la formule de récurrence définissant cette suite :

{
0 si n = 0
un = 1 si n = 1
u n − 1 + u n − 2 si n ≥ 2

Ses premiers termes sont donc 0, 1, 1, 2, 3, 5, 8, 13, 21, etc.

Voici l'implémentation récursive d'une fonction calculant le terme d'indice n de la suite :

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.

Nous obtenons cela :


On peut alors que constater que plusieurs appels sont réalisés avec une même valeur du paramètre. On fait par exemple ici
cinq appels à la fonction avec un paramètre égal à 3. Il est de plus évident que cette situation s'empirerait avec une valeur plus
grande du paramètre initial.

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.

1.2 Deux types de solutions "dynamiques"

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 :

1. Une forme récursive "Top down" dite de mémoïsation :

On utilise directement la formule de récurrence.


Lors d’un appel récursif, avant d’effectuer un calcul on regarde dans le tableau de mémoire cache si ce travail n’a pas
déjà été effectué.
2. Une forme itérative "Bottom Up" :

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.

Example 1.2. Un algorithme Top Down pour la suite de Fibonacci


Notre mémoire cache sera ici une liste unidimensionnelle. Rappelons que son rôle va être de mémoriser les résultats des sous-
problèmes de tailles inférieures à celui du problème à résoudre. Pour la suite de Fibonacci, si l'on veut calculer le terme
d'indice n, il nous faudra ainsi mémoriser les termes d'indices 0, 1, . . . , n - 1. Cette liste aura donc n + 1 éléments.

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)

A suivre à présent la fonction récursive en elle-même :

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.

Cette complexité s'estime également en étudiant l'arbre des appels récursifs :


La différence avec le premier arbre est flagrante, et heureusement d'ailleurs puisque l'on a tout fait pour. Dès qu'un appel
récursif se fait avec une valeur déjà calculée, les appels suivants n'ont pas lieu.

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.

Example 1.3. Un algorithme Bottom Up pour la suite de Fibonacci

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.

Voici la fonction correspondante :

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.

Là aussi il est facile de voir que la complexité est linéaire.


Évidemment dans ce dernier exemple on pourrait faire mieux pour réduire la complexité en espace. Mais ce n'est
pas le propos.

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.

En contrepartie, on prend le risque d’un débordement de la pile de récursion.

1.3 Problèmes d’optimisation

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

Voici quelques exemples de problèmes d'optimisation :

Recherche de plus courts chemins.


Problème du voyageur de commerce.
Recherche d’un flot maximum dans un réseau de transport.
etc.

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 :

1. Caractériser la structure d’une solution optimale.


2. Définir par récurrence la valeur d’une solution optimale.
3. Calculer la valeur d’une solution optimale par une méthode Top Down ou Bottom Up.
4. Construire la 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.

2 Le problème du rendu de monnaie

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.

2.1 Position du problème

On considère un système de pièces de monnaie.

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.

Formalisons un peu ce problème avec quelques notations mathématiques :

( )
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

(x 1, x 2, . . . , x n ) qui minimise i∑= 1 x i sous la contrainte i∑= 1 x ic i = X.

L'hypothèse c 1 = 1 garantit qu’un rendu est toujours possible puisque les sommes à rendre sont entières.

L'égalité de la contrainte signifie juste que l'on rend la bonne somme.

Présentons un petit exemple dans le but de bien fixer les idées.

Example 1.4. Système de monnaie européen

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.

D'un point de vue algorithmique cela donne :

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.

Example 1.5. L'humain rend mal la monnaie


Si l'on doit rendre la somme de 6 avec le système (1, 2, 5), la méthode précédente fournit un résultat optimal à savoir une pièce
de 5 puis une pièce de 1, i.e. deux pièces.

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.

2.2 Approche récursive naïve de type diviser pour régner

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.

Pour une somme X, on va noter C[X] ce nombre minimal de pièces.

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.

On peut choisir de rendre d'abord c 1 , ou c 2 , ou c 3 , etc. Ces sommes sont donc X - c 1 , X - c 2 , . . . , X - c n .

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.

Example 1.6. Arbre des appels récursifs pour l'algorithme diviser pour régner


Considérons le système européen S = (1, 2, 5, 10, 20, 50, 100, 200, 500) et une somme X = 6 :
On remarque donc de multiples appels redondants, et ce même si notre paramètre initial était petit.

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.

2.3 Approche dynamique Top Down

On va adopter dans cette sous-partie une technique de mémoïsation.

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.

Example 1.7. Arbre des appels récursifs pour l'algorithme Top Down


Reprenons le système européen S = (1, 2, 5, 10, 20, 50, 100, 200, 500) et la somme X = 6 :

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.

Example 1.8. Remplissage de la mémoire cache avec un algorithme Top Down

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.

2.4 Approche dynamique Bottom Up

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.

Comme précédemment, la solution à notre problème initial sera track[X].

Voici la fonction adoptant cette approche Bottom Up :

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 :

Là encore, un petit exemple pour expliciter cela.

Example 1.9. Remplissage de la mémoire cache avec un algorithme Bottom Up


A l'issue du programme, avec le système européen et une somme de 11, la liste track contiendra ces valeurs :
Les deux approches Top Down et Bottom Up ont ici produit la même mémoire cache. Ca ne sera pas toujours le
cas, comme nous le verrons avec l'exemple du sac à dos dans la dernière partie de ce chapitre.

2.5 Construction de la solution optimale

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.

Example 1.10. Utilisation de la liste keep


Supposons que pour le système européen et la somme de 8 la liste keep soit égale à :

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

Le principe est le même que celui exposé ci-dessus :

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])

2.6 Considérations de complexité

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.

3 Le problème du sac à dos

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.

3.1 Position du problème


On dispose d’un sac à dos ne pouvant supporter qu’un certain poids, et l'on considère un ensemble d’objets ayant chacun un poids
et une valeur.

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 ?

Introduisons tout d'abord quelques notations mathématiques afin de formaliser ce problème :

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

On émet également l'hypothèse que ∑ w i > W.


i=1

( )
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

i. Le poids d'une telle sélection est donc ∑ x iw i et sa valeur ∑ x iv i.


i=1 i=1

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

Example 1.11. Un cas simple du problème du sac à dos


Quelle est le choix optimal d'objets dans le cas ci-dessous :

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.

Il en existe en effet de nombreuses variantes :

Variante fractionnaire, i.e. on peut prendre des “morceaux” de chaque objet.


Variante non bornée, i.e. on peut prendre chaque objet autant de fois que l’on veut.
Variante bornée, i.e. on peut prendre chaque objet jusqu’à un certain nombre d’exemplaires.

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.

L'algorithme correspondant à cette idée est le suivant :

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.

Example 1.12. Non optimalité de la méthode gloutonne


Considérons un sac de capacité W = 15 et le n-uplet d'objets S = ((9, 10), (12, 7), (2, 1), (7, 3), (5, 2)).

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.

De nouveau, aborder cette question de façon récursive va nous apporter la solution.

3.2 Approche récursive naïve de type diviser pour régner

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))

Et voici la fonction réalisant le premier appel de la fonction récursive ci-dessus :

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.

Example 1.13. Arbre des appels récursifs pour l'algorithme diviser pour régner


Considérons un sac de capacité W = 10 et le n-uplet d'objets S = ((5, 7), (3, 1), (3, 4), (3, 2)) :
Sur chaque sommet figure d'abord la valeur de w puis celle de i.

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.

3.3 Approche dynamique Top Down

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.

La solution à notre problème initial sera alors track[n][W].

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.

La fonction récursive Top Down pour ce problème est donc :

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.

Example 1.14. Arbre des appels récursifs pour l'algorithme Top Down


Reprenons notre sac de capacité W = 10 et le n-uplet d'objets S = ((5, 7), (3, 1), (3, 4), (3, 2)) :

L'utilisation d'une mémoire cache nous a permis de supprimer les redondances,

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 :

Clarifions nos idées grâce à un petit exemple.

Example 1.15. Remplissage de la mémoire cache avec un algorithme Top Down


Considérons un sac de capacité W = 10 et la liste d'objets S = ((5, 10), (4, 40), (6, 30), (3, 50)). A l'issue du programme, la liste track
contiendra ces valeurs :
Là encore, ne pas hésiter à remplir cette liste manuellement afin de s'assurer que tout est bien compris.

3.4 Approche dynamique Bottom Up

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

La fonction correspondante à cette technique est donc :

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 :

Finissons cette sous-partie avec un exemple de calcul de la liste track.

Example 1.16. Remplissage de la mémoire cache avec un algorithme Bottom Up


Considérons de nouveau un sac de capacité W = 10 et la liste d'objets S = ((5, 10), (4, 40), (6, 30), (3, 50)). A l'issue du programme, la
liste track contiendra ces valeurs :

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

Вам также может понравиться