Solutions — série 03-5 (Sets et choisir la bonne structure)

Rappel de la règle d'or : on n'ouvre ce fichier qu'après avoir terminé (ou sérieusement séché sur) la série. Compare le raisonnement, pas seulement le code : une solution différente qui marche est souvent valable. Pour 9-b, compare surtout les JUSTIFICATIONS.


Exercice facile a — les emails en double

Raisonnement — Total = len de la liste (les doublons comptent) ; uniques = len du set (ils fondent) ; doublons = la différence des deux. Affichage trié : sorted(...) — jamais l'ordre brut d'un set.

Solution

# ex-03-5-a.py
emails = ["zoe@x.fr", "ada@x.fr", "zoe@x.fr", "bob@x.fr", "ada@x.fr"]

unique = set(emails)
print(f"Inscriptions : {len(emails)}")
print(f"Adresses uniques : {len(unique)}")
print(f"Doublons : {len(emails) - len(unique)}")
for email in sorted(unique):
    print(email)

Sortie :

Inscriptions : 5
Adresses uniques : 3
Doublons : 2
ada@x.fr
bob@x.fr
zoe@x.fr

Pourquoi ça marcheset(emails) convertit et dédoublonne en un geste. La soustraction des len compte les inscriptions EXCÉDENTAIRES (zoe en trop + ada en trop = 2). sorted(unique) renvoie une liste triée : l'affichage est stable d'une exécution à l'autre, contrairement à print(unique).

Erreur classique sur cet exercice — Afficher print(unique) tel quel : ça « marche », mais l'ordre est arbitraire et peut changer à chaque lancement — un affichage non reproductible est un bug d'interface. Autre piège : compter les doublons en parcourant la liste avec count() — correct mais O(n²) et trois fois plus long à écrire.

Variante plus difficile — Affiche la liste des adresses EN DOUBLE uniquement ({'ada@x.fr', 'zoe@x.fr'}) — c'est l'exercice difficile b, mais essaie dès maintenant avec emails.count(e).


Exercice facile b — prédire l'algèbre

Raisonnement — On applique les définitions : | réunit, & garde le commun, - retire (non symétrique), ^ garde ce qui n'est que d'un côté.

Solution — Avec a = {1, 2, 3, 4} et b = {3, 4, 5} :

{1, 2, 3, 4, 5}      a | b : union
{3, 4}               a & b : intersection
{1, 2}               a - b : dans a, pas dans b
{5}                  b - a : dans b, pas dans a — PAS pareil !
3                    len(a ^ b) : {1, 2, 5} → 3 éléments

Pourquoi ça marche — Chaque opérateur répond à une question de vocabulaire courant (« au moins un », « les deux », « sauf », « un seul des deux »). ^ est l'union moins l'intersection : {1, 2, 5}.

Erreur classique sur cet exercice — Prédire la même chose pour a - b et b - a : la différence n'est PAS symétrique (comme la soustraction des nombres). Et lire a ^ b comme une puissance : sur des sets, ^ est la différence symétrique — le contexte des types change le sens des opérateurs.

Variante plus difficile — Prédis a | b == b | a, a - b == b - a et (a - b) | (b - a) == a ^ b. (Réponses : True, False, True — la dernière égalité est la DÉFINITION de ^.)


Exercice facile c — le faux set vide

Raisonnementbasket = {} crée un dict (les accolades vides lui appartiennent), qui n'a pas de méthode add : AttributeError. Deux caractères à changer : {}set().

Solution

Le code fourni plante ainsi :

AttributeError: 'dict' object has no attribute 'add'
# ex-03-5-c.py — version corrigée
basket = set()          # LE set vide ; {} aurait cree un dict
basket.add("pomme")
print(basket)           # {'pomme'}
# piege : les accolades VIDES appartiennent historiquement au dict.
# {1, 2} est bien un set, mais {} n'en sera jamais un.

Pourquoi ça marcheset() appelle le constructeur du type, sans ambiguïté. La leçon en profite pour rappeler le meilleur réflexe de diagnostic : print(type(basket)) — quand une méthode « n'existe pas », c'est presque toujours que l'objet n'est pas du type que tu crois.

Erreur classique sur cet exercice — Corriger en basket = {""} ou {None} pour « créer un set avec quelque chose dedans » : le set n'est plus vide, le programme ment. Il n'y a pas d'alternative : le set vide, c'est set(), point.

Variante plus difficile — Dans le même esprit, prédis type(()), type((1)) et type((1,)) (⏪ 03-3). (Réponses : tuple, int, tuple — chaque structure a son piège de création, collectionne-les.)


Exercice moyen a — les deux phrases

Raisonnement — « Communs », « uniquement dans p1 », « vocabulaire total » : traduction littérale en &, -, |. On convertit chaque phrase en set de mots UNE fois, puis tout est opérateurs.

Solution

# ex-03-5-d.py
p1 = "le chat dort sur le tapis"
p2 = "le chien dort dans le jardin"

words1 = set(p1.split())
words2 = set(p2.split())

print(f"Communs : {sorted(words1 & words2)}")
print(f"Seulement p1 : {sorted(words1 - words2)}")
print(f"Vocabulaire total : {len(words1 | words2)} mots")

Sortie :

Communs : ['dort', 'le']
Seulement p1 : ['chat', 'sur', 'tapis']
Vocabulaire total : 8 mots

Pourquoi ça marcheset(p1.split()) enchaîne découpage (liste de 6 mots, « le » en double) et dédoublonnage (5 mots uniques). Les opérateurs travaillent ensuite sur des ensembles propres. Le vocabulaire total (8) est bien inférieur à la somme des tailles (5 + 5) : le et dort ne comptent qu'une fois.

Erreur classique sur cet exercice — Chercher les communs avec une double boucle (for w1 in ... for w2 in ... if w1 == w2) : ça marche, mais c'est dix lignes, des doublons dans le résultat, et O(n²). Quand le mot « commun » ou « unique » apparaît dans un énoncé, le set EST la réponse.

Variante plus difficile — Rends la comparaison insensible à la casse (.lower() avant split) et calcule le « taux de similarité » des deux phrases : len(communs) / len(union) (indice de Jaccard — oui, c'est une vraie mesure utilisée en analyse de texte).


Exercice moyen b — choisir et justifier

Raisonnement — Pour chaque situation, on pose LA question du tableau de décision : comment retrouve-t-on la donnée, l'ordre compte-t-il, les doublons ont-ils un sens, ça évolue ou c'est figé ?

Solution

# ex-03-5-e.py

# 1. Pseudos connectés : pas de doublon possible, on teste "qui est là"
#    → SET (appartenance + unicité, l'ordre n'a aucun sens)
online = {"ada", "linus", "guido"}

# 2. Les 10 derniers coups d'échecs : l'ORDRE est la donnée même,
#    un même coup peut se rejouer → LISTE
moves = ["e4", "e5", "Nf3", "Nc6", "e4"]

# 3. Fiche produit : champs hétérogènes retrouvés PAR NOM → DICT
#    (un tuple perdrait les noms ; produit["prix"] se relit seul)
product = {"name": "clavier", "price": 49.9, "stock": 12}

# 4. Position (colonne, ligne) : deux champs figés qui vont ensemble,
#    et qui doivent pouvoir servir de clé de dict → TUPLE
position = (3, 5)

# 5. Prix par produit : retrouver une valeur PAR CLÉ → DICT
#    (clé = nom du produit, valeur = prix)
prices = {"clavier": 49.9, "souris": 19.9}

Pourquoi ça marche — Chaque justification cite un besoin d'accès, pas une préférence : c'est le besoin qui décide. Remarque la frontière fine entre 3 et 4 : la fiche produit serait possible en tuple ("clavier", 49.9, 12), mais l'accès par nom de champ (product["price"]) bat l'accès par position dès que la fiche a plus de deux champs — et la position (4), elle, DOIT rester un tuple si elle sert de clé (⏪ 03-3, exemple 6).

Erreur classique sur cet exercice — Tout modéliser en liste « parce que ça marche » : une liste de pseudos avec doublons possibles, un test in lent, et pas de garantie d'unicité — le code fonctionne au début puis pourrit. Le coût d'un mauvais choix de structure n'apparaît jamais le premier jour.

Variante plus difficile — Modélise « le carnet de commandes d'un restaurant : pour chaque table (numéro), la liste ordonnée des plats commandés ». (Réponse type : dict int → liste de strorders = {12: ["soupe", "steak"], 3: ["salade"]} : une COMBINAISON, comme presque toujours en vrai.)


Exercice moyen c — participants en double

Raisonnement — Un seul parcours et une mémoire des « déjà vus » : le set, encore lui. Chaque tuple (nom, réponse) se déballe directement dans le for ; seule la partie nom nous intéresse pour la détection.

Solution

# ex-03-5-f.py
answers = [("Ada", "oui"), ("Bob", "non"), ("Ada", "non")]

seen = set()
for name, answer in answers:      # déballage du tuple (⏪ 03-3)
    if name in seen:
        print(f"Doublon : {name}")
    else:
        seen.add(name)

Sortie :

Doublon : Ada

Pourquoi ça marche — L'invariant : avant de traiter une réponse, seen contient exactement les prénoms des réponses déjà traitées. Le test in sur un set reste quasi instantané même avec 100 000 réponses — la version « chercher dans une liste des noms précédents » ralentirait au carré.

Erreur classique sur cet exerciceseen.add(name) placé AVANT le test (ou hors du else) : le prénom se trouve toujours « déjà vu », tout le monde est un doublon. L'ordre test-puis-mémorise est le squelette du motif — écris-le dans cet ordre, toujours.

Variante plus difficile — Signale chaque doublon UNE seule fois même s'il vote quatre fois (il faut un deuxième set reported), puis conserve pour chaque doublon sa PREMIÈRE réponse seulement (le set ne suffit plus : dict nom → réponse, rempli seulement si la clé est absente).


Exercice difficile a — dédoublonner en gardant l'ordre

Raisonnement — Deux rôles, deux structures : le résultat doit préserver l'ordre de première apparition → liste ; la mémoire des déjà-vus doit répondre vite à in → set. On parcourt une fois : inconnu → on l'ajoute AUX DEUX ; connu → on passe.

Solution

# ex-03-5-g.py
values = ["b", "a", "b", "c", "a"]

result = []
seen = set()
for v in values:
    if v not in seen:       # test sur le SET (rapide), jamais sur result
        result.append(v)    # l'ordre de première apparition est préservé
        seen.add(v)

print(result)               # ['b', 'a', 'c']

Pourquoi ça marcheresult et seen contiennent toujours les mêmes éléments — mais chacun apporte ce que l'autre ne sait pas faire : l'ordre pour la liste, le test rapide pour le set. C'est LE motif « chaque structure son rôle » qui conclut le niveau : on ne choisit pas une structure, on en compose.

Erreur classique sur cet exercice — Tester if v not in result: (sur la liste) : résultat identique, performance catastrophique sur une grosse entrée (chaque test re-parcourt la liste). L'énoncé l'interdisait explicitement : le respect de cette contrainte EST l'exercice. Autre classique : list(set(values)) — doublons partis, ordre parti, consigne ratée.

Variante plus difficile — Même exercice sur des paires [("Ada", 1), ("Bob", 2), ("Ada", 3)] où le doublon se juge sur le PRÉNOM seul. (Le set mémorise name, la liste accumule le tuple entier — décompose bien qui stocke quoi.)


Exercice difficile b — l'ensemble des doublons

Raisonnement — Deux sets aux rôles distincts : seen (tout ce qui est passé) et dupes (ce qui est passé au moins deux fois). Pour chaque valeur : déjà dans seen → c'est un doublon, direction dupes ; sinon → première rencontre, direction seen.

Solution

# ex-03-5-h.py
values = [3, 7, 3, 1, 7, 3, 9]

seen = set()
dupes = set()
for v in values:
    if v in seen:
        dupes.add(v)        # 2e, 3e... rencontre : add ignore les redites
    else:
        seen.add(v)

print(sorted(dupes))        # [3, 7]

# pourquoi un SET pour dupes ? le 3 apparait TROIS fois : il passerait
# deux fois dans la branche "if v in seen" — une liste contiendrait
# [3, 7, 3] : le doublon... aurait des doublons. L'unicite du set
# absorbe les rencontres multiples gratuitement.

Pourquoi ça marche — Le 3 déclenche dupes.add(3) à sa deuxième ET sa troisième rencontre — mais add sur un élément déjà présent ne fait rien : dupes reste {3, 7} sans aucun code supplémentaire. La propriété d'unicité n'est pas une contrainte ici, c'est la solution.

Erreur classique sur cet exercicedupes = [] avec append : sortie [3, 7, 3], il faut alors re-dédoublonner le résultat — preuve qu'on a choisi la mauvaise structure au départ. Ou tenter le tout-en-un values.count(v) > 1 dans la boucle : correct mais re-parcourt la liste à chaque tour (O(n²)) — le duo de sets fait tout en un seul passage.

Variante plus difficile — Produis en une passe TROIS ensembles : uniques (vus exactement une fois), dupes, et vérifie uniques | dupes == seen et uniques & dupes == set(). (Piste : quand une valeur entre dans dupes, elle doit SORTIR d'uniquesdiscard.)


Mini-projet — « annuaire simple »

Raisonnement — Modélisation (le cœur du projet) : les contacts se retrouvent PAR NOM → dict ; la fiche (ville, année) est un enregistrement figé → tuple ; les noms recherchés pendant la session sont uniques et servent à un simple « qui a-t-on cherché ? » → set. Le squelette menu-boucle vient des mini-projets précédents — seule la couche données change.

Solution

# directory.py
contacts = {}        # nom -> (ville, annee) : acces PAR CLE -> dict
searched = set()     # noms deja recherches : unicite seule -> set

while True:
    print("\n1. Ajouter  2. Chercher  3. Supprimer  4. Lister  q. Quitter")
    choice = input("> ")

    if choice == "1":
        name = input("Nom : ")
        if name in contacts:                 # refus des doublons de nom
            print(f"{name} existe deja.")
        else:
            city = input("Ville : ")
            year = int(input("Annee de naissance : "))
            contacts[name] = (city, year)    # la fiche : un TUPLE fige
            print(f"{name} ajoutee.")
    elif choice == "2":
        name = input("Nom cherche : ")
        searched.add(name)                   # memorise meme les echecs
        info = contacts.get(name)            # .get : pas de KeyError
        if info is None:
            print("Inconnu au bataillon.")
        else:
            city, year = info                # deballage du tuple
            print(f"{name} — {city}, {year}")
    elif choice == "3":
        name = input("Nom a supprimer : ")
        if name in contacts:
            del contacts[name]
            print(f"{name} supprimee.")
        else:
            print(f"{name} n'est pas dans l'annuaire.")
    elif choice == "4":
        if len(contacts) == 0:
            print("Annuaire vide.")
        else:
            for name in sorted(contacts):    # tri par nom a l'affichage
                city, year = contacts[name]
                print(f"- {name} — {city}, {year}")
    elif choice == "q":
        if len(searched) > 0:
            print(f"Recherches de la session : {sorted(searched)}")
        print("Au revoir !")
        break
    else:
        print("Choix invalide.")

Exemple de session :

1. Ajouter  2. Chercher  3. Supprimer  4. Lister  q. Quitter
> 1
Nom : Ada
Ville : Londres
Annee de naissance : 1815
Ada ajoutee.

1. Ajouter  2. Chercher  3. Supprimer  4. Lister  q. Quitter
> 2
Nom cherche : Bob
Inconnu au bataillon.

1. Ajouter  2. Chercher  3. Supprimer  4. Lister  q. Quitter
> 4
- Ada — Londres, 1815

1. Ajouter  2. Chercher  3. Supprimer  4. Lister  q. Quitter
> q
Recherches de la session : ['Bob']
Au revoir !

Pourquoi ça marche — Chaque structure fait exactement son métier : le dict refuse naturellement deux « Ada » (une clé est unique — le test in rend le refus explicite et poli) ; le tuple garantit qu'aucun code ne modifiera une fiche par accident (pour corriger une ville, on REMPLACE la fiche entière : contacts[name] = (new_city, year)) ; le set des recherches absorbe les recherches répétées sans compter double. sorted(contacts) trie les clés pour le listing sans toucher au dict.

Erreur classique sur cet exercice — Utiliser une liste de fiches [["Ada", "Londres", 1815], ...] : la recherche par nom devient une boucle, le refus de doublon une autre, la suppression une troisième — trois boucles pour ce qu'un dict fait gratuitement. Si tu as écrit ça : refais-le en dict, chronomètre-toi, compare les longueurs — c'est la leçon du niveau en une expérience. Autre piège : searched.add(name) seulement quand le contact EXISTE — l'énoncé voulait toutes les recherches, relis-toi.

Variante plus difficile — (1) À la suppression, propose une confirmation o/n (⏪ validation de saisie, niveau 02). (2) Ajoute 5. Doublons de villes : affiche les villes qui apparaissent dans plusieurs fiches (c'est l'exercice difficile b appliqué aux contacts.values()). (3) Passe aux vrais projets du niveau : projects/mini-projects/06-gestionnaire-de-taches.md (v1 — liste de dicts + mutabilité) puis l'extension « vocabulaire unique » de projects/mini-projects/05-analyseur-de-texte.md.