Files
1ereNSI/gloutons/draft/TP_draft_correction.ipynb
T

22 KiB

TP — Draft d'Arène : corrigé

Dans le mode Arène d'un jeu de cartes, on ne joue pas avec sa collection : on drafte son deck. On t'ouvre un booster de 3 cartes, tu en gardes une seule, et on passe au booster suivant.

Une carte écartée est perdue pour toujours. On ne revient jamais en arrière.

Cette règle du jeu est exactement la définition d'un algorithme glouton :

  • on procède par étapes (un booster à la fois) ;
  • à chaque étape on fait le meilleur choix possible ;
  • on ne revient jamais sur un choix déjà fait.

Ce TP a un seul but : voir de nos propres yeux ce que le glouton fait bien, et ce qu'il rate.


Les données

Une carte est un tuple (nom, puissance, cout) :

  • la puissance mesure la force de la carte (plus c'est haut, mieux c'est) ;
  • le coût est le nombre de manas nécessaires pour la poser sur le plateau.

Règle de jouabilité. Une carte est dite chère si elle coûte 5 manas ou plus. Un deck n'est jouable que s'il contient au plus 3 cartes chères. Sinon, on n'a rien à jouer pendant les premiers tours : on perd la partie avant même d'avoir pu poser ses grosses cartes.

Objectif : obtenir le deck jouable de puissance totale maximale.

In [ ]:
# ---------------------------------------------------------------
# Données du TP — ne pas modifier
# ---------------------------------------------------------------

# Une carte = (nom, puissance, cout)
BOOSTERS = [
    [("Gnome bricoleur", 3, 1), ("Yéti des roches", 5, 4), ("Golem de siège", 6, 5)],
    [("Écuyer fidèle", 2, 1), ("Loup argenté", 4, 3), ("Chevalier du néant", 5, 5)],
    [("Apprenti mage", 3, 2), ("Sorcière du marais", 4, 4), ("Dragon de bronze", 5, 5)],
    [("Recrue de la garde", 2, 1), ("Berserker orc", 4, 3), ("Titan de pierre", 9, 7)],
    [("Novice de l'ombre", 1, 1), ("Archer elfe", 3, 2), ("Léviathan des abysses", 10, 8)],
    [("Sanglier tenace", 2, 2), ("Garde royal", 5, 4), ("Ogre lourdaud", 4, 6)],
    [("Voleur agile", 3, 2), ("Prêtresse de la lune", 4, 3), ("Colosse runique", 7, 6)],
    [("Gobelin ferrailleur", 2, 1), ("Chaman du totem", 4, 4), ("Hydre des marais", 6, 6)],
    [("Éclaireur nain", 3, 2), ("Paladin d'argent", 5, 4), ("Behemoth ancien", 6, 7)],
    [("Rat des égouts", 1, 1), ("Duelliste vétéran", 5, 3), ("Ver des sables", 6, 5)],
]

COUT_CHER = 5        # une carte est "chère" à partir de 5 manas
MAX_CHERES = 3       # au plus 3 cartes chères dans un deck jouable


def afficher_deck(deck):
    """Affiche un deck sous forme de tableau."""
    for nom, puissance, cout in deck:
        marque = " (chère)" if cout >= COUT_CHER else ""
        print(f"  {nom:<24} puissance {puissance:>2}   coût {cout}{marque}")


print(f"{len(BOOSTERS)} boosters de 3 cartes.")
print("Booster 1 :")
afficher_deck(BOOSTERS[0])

Partie 1 — Savoir juger un deck

Avant de drafter, il faut savoir répondre à deux questions sur un deck : combien vaut-il ? et est-il seulement jouable ?

In [ ]:
def puissance_totale(deck):
    """Renvoie la somme des puissances des cartes du deck."""
    total = 0
    for nom, puissance, cout in deck:
        total = total + puissance
    return total


# Vérification
test = [("A", 3, 1), ("B", 5, 6), ("C", 2, 2)]
assert puissance_totale(test) == 10
assert puissance_totale([]) == 0
print("Exercice 1 : OK")
In [ ]:
def nb_cartes_cheres(deck):
    """Renvoie le nombre de cartes coûtant COUT_CHER manas ou plus."""
    n = 0
    for nom, puissance, cout in deck:
        if cout >= COUT_CHER:
            n = n + 1
    return n


def deck_jouable(deck):
    """Renvoie True si le deck respecte la règle de jouabilité."""
    return nb_cartes_cheres(deck) <= MAX_CHERES


# Vérification
test = [("A", 3, 1), ("B", 5, 6), ("C", 2, 7), ("D", 4, 5), ("E", 1, 8)]
assert nb_cartes_cheres(test) == 4
assert deck_jouable(test) is False
assert deck_jouable(test[:3]) is True
print("Exercice 2 : OK")

Partie 2 — Le glouton naïf

La stratégie qui vient spontanément à l'esprit :

À chaque booster, je prends la carte la plus puissante.

C'est bien un algorithme glouton : un choix par étape, le meilleur sur le moment, jamais de retour en arrière. Codons-la.

In [ ]:
def carte_la_plus_puissante(booster):
    """Renvoie la carte de plus grande puissance du booster."""
    meilleure = booster[0]
    for carte in booster:
        if carte[1] > meilleure[1]:
            meilleure = carte
    return meilleure


# Vérification
assert carte_la_plus_puissante(BOOSTERS[0]) == ("Golem de siège", 6, 5)
assert carte_la_plus_puissante(BOOSTERS[4]) == ("Léviathan des abysses", 10, 8)
print("Exercice 3 : OK")
In [ ]:
def draft_naif(boosters):
    """Renvoie le deck obtenu en prenant à chaque fois la carte la plus puissante."""
    deck = []
    for booster in boosters:
        deck.append(carte_la_plus_puissante(booster))
    return deck


deck_naif = draft_naif(BOOSTERS)
afficher_deck(deck_naif)
print()
print("Puissance totale :", puissance_totale(deck_naif))
print("Cartes chères    :", nb_cartes_cheres(deck_naif), "( maximum autorisé :", MAX_CHERES, ")")
print("Deck jouable ?   :", deck_jouable(deck_naif))

Question 1 — correction

L'algorithme n'a regardé qu'un seul critère, la puissance, et il l'a optimisé booster par booster. Or les cartes les plus puissantes sont justement les plus chères : en maximisant la puissance à chaque étape, il accumule mécaniquement des cartes chères.

La contrainte de jouabilité, elle, est globale : elle porte sur le deck entier, pas sur un choix isolé. Un algorithme qui décide en regardant uniquement le booster courant n'a aucun moyen de la voir venir.

C'est le premier enseignement du TP : un glouton optimise le critère qu'on lui donne, rien d'autre.


Partie 3 — Le glouton contraint

Réparons l'algorithme sans changer sa nature. Il reste glouton — un choix par étape, pas de retour en arrière — mais il compte au fur et à mesure les cartes chères déjà prises :

À chaque booster, je prends la carte la plus puissante parmi celles que j'ai encore le droit de prendre.

Tant que le quota de cartes chères n'est pas atteint, toutes les cartes sont autorisées. Une fois le quota plein, seules les cartes à moins de COUT_CHER manas le sont.

(Les boosters sont construits pour qu'il y ait toujours au moins une carte à moins de 5 manas : l'algorithme ne peut donc jamais se retrouver bloqué.)

In [ ]:
def draft_contraint(boosters):
    """Renvoie le deck obtenu par un glouton qui respecte le quota de cartes chères."""
    deck = []
    cheres_prises = 0
    for booster in boosters:
        # Cartes que l'on a encore le droit de prendre
        autorisees = []
        for carte in booster:
            if carte[2] < COUT_CHER or cheres_prises < MAX_CHERES:
                autorisees.append(carte)
        # Meilleur choix parmi elles
        choisie = carte_la_plus_puissante(autorisees)
        if choisie[2] >= COUT_CHER:
            cheres_prises = cheres_prises + 1
        deck.append(choisie)
    return deck


deck_glouton = draft_contraint(BOOSTERS)
afficher_deck(deck_glouton)
print()
print("Puissance totale :", puissance_totale(deck_glouton))
print("Deck jouable ?   :", deck_jouable(deck_glouton))

Question 2

Cette fois le deck est jouable, pour une puissance de 46.

Notre algorithme fonctionne. Mais une question reste ouverte, et c'est la question du chapitre : 46, est-ce le maximum ?

Le glouton, lui, en est incapable de le dire — il n'a jamais comparé son deck à un autre.


Partie 4 — La force brute : quel était le vrai maximum ?

Pour le savoir, il n'y a qu'une méthode certaine : essayer tous les decks possibles et garder le meilleur deck jouable.

À chaque booster on a 3 choix, et il y a 10 boosters : cela fait

3^{10} = 59\,049 \text{ decks possibles.}

C'est beaucoup pour un humain, très peu pour une machine. Le code ci-dessous est fourni : product(*boosters) fabrique toutes les combinaisons possibles, une par une.

Lis-le, exécute-le, mais ne cherche pas à le réécrire : ce n'est pas l'objet du TP.

In [ ]:
# ---------------------------------------------------------------
# Force brute — code fourni
# ---------------------------------------------------------------
from itertools import product
from time import perf_counter


def meilleur_deck_force_brute(boosters):
    """Essaie TOUS les decks possibles et renvoie le meilleur deck jouable."""
    meilleur = None
    meilleure_puissance = -1
    for deck in product(*boosters):
        if deck_jouable(deck) and puissance_totale(deck) > meilleure_puissance:
            meilleur = list(deck)
            meilleure_puissance = puissance_totale(deck)
    return meilleur, meilleure_puissance


debut = perf_counter()
deck_optimal, puissance_optimale = meilleur_deck_force_brute(BOOSTERS)
duree = perf_counter() - debut

afficher_deck(deck_optimal)
print()
print("Puissance optimale :", puissance_optimale)
print(f"Calculé en {duree:.2f} s après avoir testé {3 ** len(BOOSTERS)} decks.")
print()
print("Glouton :", puissance_totale(deck_glouton), " |  Optimal :", puissance_optimale)

Question 3

Le glouton obtient 46, l'optimum est 58. Il s'est fait piéger — reste à comprendre où.

Regarde les 3 cartes chères que chacun a retenues :

  • glouton : Golem de siège, Chevalier du néant, Dragon de bronze (les boosters 1, 2 et 3) ;
  • optimum : Titan de pierre, Léviathan des abysses, Colosse runique (les boosters 4, 5 et 7).

Le tableau ci-dessous calcule, pour chaque booster, ce que rapporte le fait d'y « dépenser » une place de carte chère plutôt que de prendre la meilleure carte bon marché.

Exécute-le, puis explique en quelques lignes l'erreur du glouton.

In [ ]:
# Ce que rapporte réellement chaque carte chère, comparée à la meilleure carte pas chère du même booster
print(f"{'Booster':<9}{'meilleure pas chère':<27}{'meilleure chère':<29}{'gain'}")
for i, booster in enumerate(BOOSTERS):
    pas_cheres = [c for c in booster if c[2] < COUT_CHER]
    cheres = [c for c in booster if c[2] >= COUT_CHER]
    if cheres == []:
        continue
    meilleure_pas_chere = carte_la_plus_puissante(pas_cheres)
    meilleure_chere = carte_la_plus_puissante(cheres)
    gain = meilleure_chere[1] - meilleure_pas_chere[1]
    print(f"{i + 1:<9}{meilleure_pas_chere[0] + f' ({meilleure_pas_chere[1]})':<27}"
          f"{meilleure_chere[0] + f' ({meilleure_chere[1]})':<29}{gain:+d}")

Question 3 — correction

Les gains sont : +1, +1, +1, +5, +7, −1, +3, +2, +1, +1.

Le glouton a dépensé ses trois places de cartes chères dans les boosters 1, 2 et 3, pour un gain de +1 à chaque fois. Quand il arrive au booster 4 (Titan de pierre, +5) puis au booster 5 (Léviathan des abysses, +7), son quota est plein : il doit se rabattre sur le Berserker orc et l'Archer elfe.

L'optimum fait exactement l'inverse : il renonce aux trois petits gains du début pour garder ses places aux boosters 4, 5 et 7 (+7, +5, +3).

46 + 7 + 5 + 3 - 1 - 1 - 1 = 58

Et c'est là tout le problème : au booster 1, l'algorithme n'a aucun moyen de savoir qu'un Léviathan des abysses l'attend au booster 5. Il décide avec une vision purement locale, et la règle du draft lui interdit de revenir corriger son choix quand l'information arrive.

Le glouton n'a pas fait d'erreur de calcul. Il a fait, trois fois de suite, le meilleur choix du moment — et c'est précisément ce qui l'a ruiné.


Partie 5 — Alors pourquoi ne pas toujours faire de la force brute ?

Puisque la force brute donne toujours le bon résultat, autant l'utiliser partout, non ?

La cellule suivante mesure le temps de calcul de la force brute pour un nombre croissant de boosters. Observe la colonne « temps ».

In [ ]:
print(f"{'boosters':<11}{'decks testés':<16}{'temps (s)'}")
for n in range(4, len(BOOSTERS) + 1):
    debut = perf_counter()
    meilleur_deck_force_brute(BOOSTERS[:n])
    duree_n = perf_counter() - debut
    print(f"{n:<11}{3 ** n:<16}{duree_n:.3f}")

# Extrapolation à un vrai draft d'Arène (30 boosters), à partir du temps mesuré ci-dessus
vitesse = 3 ** len(BOOSTERS) / duree_n          # decks testés par seconde
secondes = 3 ** 30 / vitesse
print()
print(f"Vitesse mesurée : environ {vitesse:.0f} decks testés par seconde.")
print(f"Pour 30 boosters : {3 ** 30} decks à tester,")
print(f"soit environ {secondes / (365 * 24 * 3600):.0f} années de calcul.")

Question 4 — correction

1. Chaque booster ajouté multiplie le nombre de decks par 3 — et le temps de calcul également. On parle de coût exponentiel.

2. Pour 30 boosters : 3^{30} \approx 2 \times 10^{14} decks, soit environ 200 000 milliards.

Le calcul se fait sans connaître la machine : passer de 10 à 30 boosters, c'est multiplier le travail par 3^{20} \approx 3,5 milliards. Le temps mesuré pour 10 boosters (quelques centièmes de seconde) devient donc plusieurs années de calcul — la cellule précédente affiche l'estimation exacte pour ta machine.

Et gagner un facteur 100 sur la vitesse de la machine ne ferait pas gagner grand-chose : il suffirait d'ajouter 5 boosters pour tout reperdre. La force brute n'est pas « lente », elle est impraticable : aucune optimisation de code ne rattrape une croissance exponentielle.

3. Le glouton est le bon choix dès que le nombre de possibilités explose, ou qu'il faut une réponse immédiate. Il donne une solution correcte en un temps proportionnel au nombre d'étapes (ici : 10 boosters → 10 tours de boucle) sans jamais garantir l'optimum.

Un algorithme de ce type — rapide, raisonnable, sans garantie d'optimalité — s'appelle une heuristique.


Ce qu'il faut retenir

Glouton Force brute
Résultat 46 (pas l'optimum) 58 (l'optimum)
Nombre d'opérations 10 (une par booster) 59 049
Avec 30 boosters 30 opérations plusieurs années
Garantie aucune optimum certain

Un algorithme glouton construit une solution par étapes, en faisant à chaque étape le meilleur choix selon un critère local, et sans jamais revenir en arrière.

Il est rapide. Il n'est pas toujours optimal. Quand ce n'est pas le cas, on parle d'heuristique.


Pour aller plus loin — correction

1. Les gains sont +1, +1, +1, +5, +7, −1, +3, +2, +1, +1. Les trois plus gros sont +7 (booster 5), +5 (booster 4) et +3 (booster 7) : exactement les cartes chères du deck optimal. Cette stratégie donne bien 58.

Ce n'est pas un hasard : le choix fait dans un booster n'influence les autres que par le quota de cartes chères. Le problème revient donc à choisir les 3 boosters où dépenser une place, et prendre les 3 meilleurs gains est ici optimal.

Un glouton peut donc être optimal — encore faut-il l'appliquer au bon critère. Le mauvais glouton ne triait pas sur la puissance de la carte, mais sur ce qu'elle rapporte vraiment par rapport à l'alternative.

2. Parce que ce tableau exige de connaître les 10 boosters à l'avance. Or dans un vrai draft, on découvre les boosters un par un : au moment de choisir dans le premier, les neuf autres n'existent pas encore.

C'est la distinction entre un problème hors ligne (toutes les données sont connues au départ — rendu de monnaie, sac à dos) et un problème en ligne (les données arrivent au fur et à mesure — le draft). Dans un problème en ligne, même le meilleur critère du monde ne peut pas compenser une information qu'on n'a pas encore.


Auteur : Florian Mathieu

Licence CC BY-SA

Licence Creative Commons
Ce cours est mis à disposition selon les termes de la Licence Creative Commons Attribution - Partage dans les Mêmes Conditions 4.0 International