Les algorithmes gloutons
Cours complet · NSI (terminale), chapitre 8 · terminale, spécialité numérique et sciences informatiques
Travailler ce chapitre sur Adloun Exercices corrigés de ce chapitre
Face à un problème d'optimisation, on cherche souvent la meilleure solution parmi un très grand nombre de possibilités. Explorer toutes les combinaisons est généralement trop coûteux. Les algorithmes gloutons (en anglais greedy algorithms) proposent une stratégie simple et rapide : à chaque étape, on effectue le choix qui paraît le meilleur sur le moment, sans jamais remettre en cause les décisions déjà prises.
Cette approche est séduisante par sa simplicité, mais elle ne fournit pas toujours la solution optimale. L'enjeu de ce chapitre est double : comprendre comment construire un algorithme glouton, et surtout savoir reconnaître les situations où il est garanti de donner la meilleure réponse, par opposition à celles où il échoue. Nous étudierons le rendu de monnaie, le sac à dos fractionnaire et l'ordonnancement de tâches, avant de comparer cette méthode à la programmation dynamique.
8.1 Principe d'un algorithme glouton
Un algorithme glouton construit une solution étape par étape. À chaque étape, il sélectionne, parmi les choix possibles, celui qui optimise un critère local immédiat (le choix glouton), puis il poursuit avec le sous-problème restant. Une fois un choix effectué, il n'est jamais reconsidéré.
L'idée centrale est donc le choix localement optimal : on espère qu'en empilant des décisions individuellement bonnes, on obtiendra une solution globalement bonne.
Un problème d'optimisation consiste à trouver, parmi un ensemble de solutions dites réalisables (respectant des contraintes), une solution qui maximise ou minimise une grandeur appelée fonction objectif.
Méthode : Concevoir un algorithme glouton
Pour construire un algorithme glouton, on identifie :
- l'ensemble des candidats parmi lesquels choisir ;
- un critère de sélection (le choix glouton) qui désigne le « meilleur » candidat à chaque étape ;
- un test de faisabilité qui vérifie que l'on peut ajouter ce candidat à la solution partielle ;
- un test d'arrêt indiquant que la solution est complète.
Très souvent, le critère de sélection se traduit par un tri préalable des candidats.
Les algorithmes gloutons sont en général simples à programmer et rapides, car ils n'explorent pas l'arbre complet des possibilités. En contrepartie, ils ne garantissent pas toujours l'optimalité : un bon choix local peut conduire à une impasse globale. Il faut donc, au cas par cas, prouver que la stratégie gloutonne est correcte, ou bien accepter qu'elle ne fournisse qu'une solution approchée.
Imaginons que l'on veuille visiter le maximum de musées dans une ville en une journée. Une stratégie gloutonne pourrait être : « aller toujours au musée le plus proche non encore visité ». Ce choix local est raisonnable, mais il peut nous entraîner dans un quartier isolé et nous faire perdre du temps ensuite. C'est l'illustration typique d'un glouton qui ne garantit pas l'optimum.
8.2 Le rendu de monnaie
Le problème du rendu de monnaie est l'exemple historique des algorithmes gloutons. Un commerçant doit rendre une somme donnée en utilisant le moins de pièces possible.
Étant donné un système de pièces (un ensemble de valeurs) et une somme à rendre, on cherche à exprimer comme une somme de pièces du système, en minimisant le nombre total de pièces utilisées.
Méthode : Stratégie gloutonne du rendu de monnaie
À chaque étape, on choisit la plus grande pièce dont la valeur ne dépasse pas la somme restante à rendre. On retire cette valeur de la somme restante et on recommence, jusqu'à atteindre .
Avec le système européen , pour rendre centimes :
- on prend (reste ) ;
- on prend (reste ) ;
- on prend (reste ) ;
- on prend (reste ).
Soit pièces : . C'est bien le nombre minimal.
Voici l'implémentation Python correspondante.
def rendu_monnaie(systeme, somme):
"""Rend la somme avec le moins de pieces possible (approche gloutonne).
systeme : liste des valeurs de pieces, triee par ordre decroissant."""
pieces = []
for valeur in systeme:
while somme >= valeur:
somme -= valeur
pieces.append(valeur)
return pieces
systeme = [50, 20, 10, 5, 2, 1]
print(rendu_monnaie(systeme, 67)) # [50, 10, 5, 2]
Si le système comporte valeurs de pièces et que la plus petite pièce vaut , le rendu glouton s'effectue en où est le nombre de pièces rendues. Avec une variante utilisant la division entière (quotient et reste), on atteint .
def rendu_monnaie_rapide(systeme, somme):
"""Version utilisant la division entiere : O(p)."""
resultat = {}
for valeur in systeme:
nb = somme // valeur
if nb > 0:
resultat[valeur] = nb
somme -= nb * valeur
return resultat
print(rendu_monnaie_rapide([50, 20, 10, 5, 2, 1], 67))
# {50: 1, 10: 1, 5: 1, 2: 1}
Pour le rendu de monnaie, l'algorithme glouton fournit toujours la solution optimale lorsque le système de pièces est canonique. Les systèmes monétaires usuels (euros, dollars) sont canoniques. En revanche, pour certains systèmes, le glouton échoue.
Considérons le système et la somme .
- Le glouton prend (reste ), puis et : soit pièces ().
- La solution optimale est : seulement pièces.
Le choix localement optimal (la plus grande pièce) conduit ici à une solution non optimale. Pour ce type de système, il faut recourir à la programmation dynamique.
8.3 Le problème du sac à dos
On dispose d'un sac de capacité maximale et d'un ensemble d'objets, chacun caractérisé par un poids et une valeur . On cherche à remplir le sac de manière à maximiser la valeur totale transportée, sans dépasser la capacité .
Il existe deux variantes essentielles à bien distinguer.
- Dans le sac à dos entier (0/1 knapsack), chaque objet est pris en entier ou pas du tout.
- Dans le sac à dos fractionnaire, on peut prendre une fraction d'un objet (par exemple une portion d'un sac de farine), sa valeur étant alors proportionnelle à la fraction prise.
La version fractionnaire se résout de façon optimale par un algorithme glouton. En revanche, la version entière ne peut pas, en général, être résolue optimalement par un glouton : elle relève de la programmation dynamique.
Méthode : Glouton pour le sac à dos fractionnaire
Pour maximiser la valeur :
- Calculer pour chaque objet son rapport valeur/poids (sa « densité » de valeur).
- Trier les objets par rapport décroissant.
- Remplir le sac en prenant les objets dans cet ordre. Pour le dernier objet qui ne tient pas en entier, en prendre la fraction qui complète exactement la capacité.
Capacité . Objets (poids, valeur) : , , . Rapports : , , .
- On prend en entier (poids , valeur , reste ).
- On prend en entier (poids , valeur , reste ).
- On prend de : valeur .
Valeur totale : . C'est l'optimum.
def sac_a_dos_fractionnaire(objets, capacite):
"""objets : liste de tuples (poids, valeur).
Retourne la valeur maximale transportable."""
# Tri par rapport valeur/poids decroissant
objets = sorted(objets, key=lambda o: o[1] / o[0], reverse=True)
valeur_totale = 0.0
reste = capacite
for poids, valeur in objets:
if reste <= 0:
break
if poids <= reste:
reste -= poids
valeur_totale += valeur
else:
fraction = reste / poids
valeur_totale += valeur * fraction
reste = 0
return valeur_totale
objets = [(10, 60), (20, 100), (30, 120)]
print(sac_a_dos_fractionnaire(objets, 50)) # 240.0
L'algorithme est dominé par le tri des objets selon leur rapport valeur/poids : sa complexité est pour objets, le parcours de remplissage étant en .
8.4 L'ordonnancement de tâches
Un autre domaine d'application classique des gloutons est la planification de tâches, par exemple la sélection d'un maximum d'activités compatibles.
On dispose de activités, chacune définie par une heure de début et une heure de fin. Deux activités sont compatibles si elles ne se chevauchent pas dans le temps. On cherche à sélectionner le plus grand nombre d'activités deux à deux compatibles.
Méthode : Glouton de l'ordonnancement
La stratégie optimale consiste à trier les activités par heure de fin croissante, puis à les parcourir dans cet ordre : on sélectionne une activité si son heure de début est postérieure (ou égale) à l'heure de fin de la dernière activité retenue. Choisir l'activité qui se termine le plus tôt laisse en effet le maximum de temps disponible pour les suivantes.
Activités (début, fin) : , , , , , . Triées par fin : .
- On prend (fin ).
- commence avant : rejetée. : rejetée.
- : , prise (fin ).
- : , prise.
On retient activités : . C'est le maximum.
def selection_activites(activites):
"""activites : liste de tuples (debut, fin).
Retourne la liste maximale d'activites compatibles."""
activites = sorted(activites, key=lambda a: a[1]) # tri par fin
selection = []
fin_courante = float("-inf")
for debut, fin in activites:
if debut >= fin_courante:
selection.append((debut, fin))
fin_courante = fin
return selection
acts = [(1, 4), (3, 5), (0, 6), (5, 7), (8, 9), (5, 9)]
print(selection_activites(acts)) # [(1, 4), (5, 7), (8, 9)]
Le critère « plus petite heure de fin » garantit ici l'optimalité : on peut prouver par échange (argument d'échange) que toute solution optimale peut être transformée en celle du glouton sans réduire le nombre d'activités. La complexité est , dominée par le tri.
8.5 Comparaison avec la programmation dynamique
Lorsque le choix glouton ne garantit pas l'optimum, on recourt souvent à la programmation dynamique, étudiée dans un autre chapitre.
La programmation dynamique résout un problème en le décomposant en sous-problèmes qui se recouvrent, en mémorisant les résultats de ces sous-problèmes pour les réutiliser. Contrairement au glouton, elle explore plusieurs combinaisons et garantit l'optimum dès lors que le problème possède la propriété de sous-structure optimale.
- Le glouton fait un unique choix à chaque étape (jamais reconsidéré) : il est rapide ( ou ) mais correct seulement si le problème a la propriété du choix glouton.
- La programmation dynamique examine plusieurs choix et combine les solutions des sous-problèmes : elle est plus coûteuse (souvent pour le sac à dos) mais garantit l'optimum sur une classe plus large de problèmes.
En résumé : « tout problème résoluble par un glouton est résoluble par la programmation dynamique, mais l'inverse est faux ».
Capacité , objets , , indivisibles.
- Le glouton (par rapport décroissant) prend puis : valeur , poids , et ne tient plus.
- L'optimum est : valeur , poids .
Le glouton donne au lieu de : seule la programmation dynamique retrouve ici l'optimum.