Vous êtes sur la page 1sur 20
Accéder à Open-Campus ACCUEIL CURSUS COURS ADMISSIONS CAMPUS DOCUMENTATION ANCIENS ENTREPRISES OPEN CAMPUS
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
dynamique Précédent Advanced Algorithmics Suivant P P r r i i n n c c i

PPrriinncciippee ggéénnéérraall

LLee pprroobbllèèmmee dduu rreenndduu ddee mmoonnnnaaiiee

LLee pprroobbllèèmmee dduu ssaacc àà ddooss

Laurent GODEFROY Professeur Référent à SUPINFO International University
Laurent GODEFROY
Professeur Référent à SUPINFO International University

Le but de ce chapitre va être de présenter un paradigme de programmation 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

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 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. Python comme langage d'implémentation des algorithmes étudiés.

langage d'implémentation des algorithmes étudiés. 1 Principe général Nous allons dans cette partie pointer

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

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 :

u n =

{

0 si n = 0

1 si n = 1 si n ≥ 2

u n1 + u n2

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

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.

expliquent la complexité exponentielle de cet algorithme. Rappelons que le calcul de la complexité de la

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.

ces sous-problèmes ont des sous-sous-problèmes communs . Ce n'est évidemment pas le cas de tous les

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 .

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

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

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.

On continue jusqu'à la taille voulue.

cache . On continue jusqu'à la taille voulue. Autant le terme programmation dynamique peut sembler

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]

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

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

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.

déjà calculée, les appels suivants n'ont pas lieu. L'ordre dans lequel apparaissent les fonctions

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.

É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.Voici quelques exemples de problèmes d'optimisation : Problème du voyageur de commerce. Recherche d’un flot

Problème du voyageur de commerce.d'optimisation : Recherche de plus courts chemins. Recherche d’un flot maximum dans un réseau de transport.

Recherche d’un flot maximum dans un réseau de transport.de plus courts chemins. Problème du voyageur de commerce. etc. Il existe de nombreuses techniques pour

etc.Recherche d’un flot maximum dans un réseau de transport. Il existe de nombreuses techniques pour résoudre

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.que les sous-problèmes ne soient pas indépendants . Puisque l'on travaille sur un ensemble fini, on

sens après l'étude des problèmes des parties 2 et 3. Puisque l'on travaille sur un ensemble

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 . exponentielle ou même factorielle.

algorithmique exponentielle ou même factorielle . Si le problème s'y prête et que l'on peut utiliser

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.

Down ou Bottom Up . 4. Construire la solution optimale . Là encore des exemples clarifierons

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

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 n-uplet d'entiers naturels S = c

valeur de la pièce i.

1 , c

2 ,

.

.

.

, c

n

)

,

c i représente la

On suppose que c 1 = 1 et que c 1 < c 2 < c 1 = 1 et que c 1 < c 2 <

Une somme à rendre est un entier naturel X . entier naturel X.

Une répartition de pièces est un n-uplet d'entiers naturels x n-uplet d'entiers naturels x

< c n .

(

1

, x

2

,

.

.

. , x

n

)

, où x 1 représente le nombre de pièces c 1 , x 2 le nombre

de pièces c 2 , etc. Le nombre total de pièces d'une telle répartition est donc

n

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

(

x

1

, x

2

,

.

.

.

, x

n

)

qui minimise

n

i = 1

x i sous la contrainte

n

i = 1

x i c i = X.

i sous la contrainte n ∑ i = 1 x i c 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.

Pour rendre par exemple une somme de X = 6 euros, on doit considérer les n-uplets

9

(

x

1 , x

2 ,

.

.

.

, x

9 )

vérifiant

1 x i c i = 6.

 

i =

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)+ 5 x 3 = 6 et il y a cinq triplés qui la vérifient :

(4, 1, 0)= 6 et il y a cinq triplés qui la vérifient : (6, 0, 0) (2,

(2, 2, 0)il y a cinq triplés qui la vérifient : (6, 0, 0) (4, 1, 0) (1,

(1, 0, 1)triplés qui la vérifient : (6, 0, 0) (4, 1, 0) (2, 2, 0) (0, 3,

(0, 3, 0)qui la vérifient : (6, 0, 0) (4, 1, 0) (2, 2, 0) (1, 0, 1)

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

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.

Si la somme n’est pas nulle recommencer à l’étape 1. Cet algorithme est dit glouton, voir
Cet algorithme est dit glouton, voir le prochain chapitre de cours où l'on étudiera cette
Cet
algorithme
est
dit
glouton,
voir
le
prochain
chapitre
de
cours
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 +   min c 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:

return mini

mini = nb

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.

des pièces à celles plus petites que la somme à rendre. On a choisi ici de

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

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 reviendrons sur cette question dans la sous-partie 2.6. Nous avons qualifié cet algorithme de naïf

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, sera donc une liste undimensionnelle à X + 1 éléments.

, X - 1. La mémoire cache, que l'on notera track,

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 :

(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

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 :

A l'issue du programme, avec le système européen et une somme de 11 , la liste
une somme de 11 , la liste track contiendra ces valeurs : Si lecteur a quelques

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 :

en partant de 0 et en incrémentant jusqu'à X : Là encore, un petit exemple pour

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 :

Up A l'issue du programme, avec le système européen et une somme de 11 , la
A l'issue du programme, avec le système européen et une somme de 11 , la liste
Les deux approches Top Down et Bottom Up ont ici produit la même mémoire cache

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 à :

et la somme de 8 la liste keep soit égale à : Rappelons que notre système

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

log 2 ( X ) et n (avec les notations précédentes). Rappelons, voir premier chapitre de

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

de programmation dynamique est malheureusement

la taille de X est log 2 (X). Or X = 2 log 2 ( X ) , donc la complexité des algorithmes

exponentielle par rapport à la taille de X. Elle est toutefois bien meilleure que celle de l'agorithme naïf.

toutefois bien meilleure que celle de l'agorithme naïf. Lorsque la complexité d'un algorithme est polynomiale

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 = n objets peut être modélisé par un n-uplet de couples S =

poids de l'objet i et v i sa valeur.

((

w

1 , v

1

)

,

On note W la capacité du sac, et l'on suppose que W la capacité du sac, et l'on suppose que

On émet également l'hypothèse que, On note W la capacité du sac, et l'on suppose que n ∑ w i

n

w i > W.

i = 1

i, 1 ≤ i n, w i W, w i > 0 et v i > 0.

(

w

2 , v

2 )

,

.

.

.

,

(

w

n , v

n ))

, où w i représente le

Un choix d'objets est un n-uplet d'entiers naturels 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

i. Le poids d'une telle sélection est donc

n n

i = 1

x i w i et sa valeur

i = 1

x i v i .

Notre question peut alors être reformulée comme suit : on cherche un n-uplet de binaires

contrainte

n

i = 1

x i w i W.

(

x

1

, x

2

,

.

.

.

,

x

n

)

qui maximise

n

i = 1

x i v i sous la

. , x n ) qui maximise n ∑ i = 1 x i v i

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 :

est le choix optimal d'objets dans le cas ci-dessous : On a ici n = 5

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

Puisque W = 15, on doit considérer les n-uplets binaires

(

x

1 , x

2 ,

.

.

.

,

x

5 )

vérifiant

5

x i w 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

5

x i v 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 . pour une valeur de 8.

(1, 1, 0, 1, 0) pour une valeur de 7 . pour une valeur de 7.

(1, 0, 0, 1, 1) pour une valeur de 7 . pour une valeur de7.

(0, 1, 1, 1, 1) pour une valeur de 15 . 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.

1, 1, 1) . Il faut ainsi prendre les quatre derniers objets. On rajoute parfois le
On rajoute parfois le qualificatif 0/1 quand on mentionne le problème du sac à dos
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.

de celui-ci, le faire. Sinon passer à l’objet suivant. Là aussi l'humain pense naturellement à une

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

V[i][w] =

{

0

V[i − 1][w]

max V[i − 1][w], v + V[i − 1] w w

i

(

[

i

])

si i = 0 si w i > w

si w i w

i ]) si i = 0 si  w i > w si  w i ≤ w
Que le lecteur comprenne bien que la condition d'arrêt de cette formule, i.e. quand i
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.

t-uple de couples en Python) et W la capacité du sac. Comme toujours, soyons vigilant avec

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(

]"

référence à l'objet précédent tandis que "S[i-1][

désigne ce i-ème objet.

,

,i-1)"

fait

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.

Sur chaque sommet figure d'abord la valeur de w puis celle de i . Les

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,

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 :

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

d'objets S = ((5, 7), (3, 1), (3, 4), (3, 2)) : L'utilisation d'une mémoire cache

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 :

du dernier objet et en remontant jusqu'au premier : Clarifions nos idées grâce à un petit

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 :

S = ((5, 10), (4, 40), (6, 30), (3, 50)) . A l'issue du programme, la
S = ((5, 10), (4, 40), (6, 30), (3, 50)) . A l'issue du programme, la
S = ((5, 10), (4, 40), (6, 30), (3, 50)) . A l'issue du programme, la

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 :

partant du premier objet et en allant jusqu'au dernier : Finissons cette sous-partie avec un exemple

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 :

S = ((5, 10), (4, 40), (6, 30), (3, 50)) . A l'issue du programme, la
du programme, la liste track contiendra ces valeurs : En comparant les listes track à l’issue

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