Exercice 1 — QCM : vocabulaire des listes chaînées
Pour chaque question, une seule réponse est correcte.
1. Dans une liste chaînée, chaque nœud contient :
- A. uniquement une valeur
- B. une valeur et une référence vers le nœud suivant
- C. une valeur, une référence vers le suivant et une référence vers le précédent
- D. une valeur et son indice dans la liste
Correction
B. Dans une liste simplement chaînée, chaque nœud contient une valeur (element) et une référence vers le nœud suivant (suivant). A est incomplet (il manque la référence). C décrit une liste doublement chaînée, ce qui n’est pas la structure étudiée ici. D décrit un tableau indexé, pas une liste chaînée.
2. Pour représenter une liste vide, on utilise :
- A. un nœud dont la valeur est 0
- B. un nœud dont la valeur est
None - C. un objet
ListeChaineedont l’attributpremiervautNone - D. la valeur
[]
Correction
C. La classe ListeChainee gère le cas de la liste vide grâce à son attribut premier qui vaut None. A et B créent un nœud, donc une liste à un élément. D utilise le type list de Python (tableau dynamique), pas une liste chaînée.
3. Quelle est la complexité de l’ajout en tête d’une liste chaînée ?
- A. \(O(n)\) car il faut décaler tous les éléments
- B. \(O(1)\) car on crée un nœud et on met à jour une référence
- C. \(O(n)\) car il faut parcourir la liste pour trouver la tête
- D. \(O(\log n)\) car on utilise une recherche dichotomique
Correction
B. L’ajout en tête se fait en temps constant : on crée un nouveau nœud, on le fait pointer vers l’ancien premier, et on met à jour self.premier. Il n’y a pas de décalage (A est le cas d’un tableau), pas de parcours (C est faux : la tête est directement accessible via self.premier), et la dichotomie (D) ne s’applique pas ici.
4. On considère la liste chaînée 5 → 3 → 8 → None. Quelle expression Python permet d’accéder à la valeur 8 ?
- A.
ma_liste.premier.element - B.
ma_liste.premier.suivant.element - C.
ma_liste.premier.suivant.suivant.element - D.
ma_liste.ieme_element(3)
Correction
C. Le premier nœud contient 5, premier.suivant contient 3, premier.suivant.suivant contient 8. A donne 5 (premier élément). B donne 3 (deuxième élément). D provoque une erreur car les indices vont de 0 à 2 (il n’y a pas d’indice 3).
5. Par rapport au type list de Python (tableau dynamique), la liste chaînée est plus performante pour :
- A. l’accès à un élément par son indice
- B. les insertions et suppressions en tête
- C. la recherche d’un élément
- D. le calcul de la longueur
Correction
B. Les insertions et suppressions en tête sont en \(O(1)\) pour une liste chaînée, contre \(O(n)\) pour un tableau dynamique (qui doit décaler tous les éléments). L’accès par indice (A) est en \(O(1)\) pour un tableau contre \(O(n)\) pour une liste chaînée. La recherche (C) et le calcul de longueur (D) sont en \(O(n)\) dans les deux cas.
Exercice 2 — Exercice guidé : manipuler une liste chaînée pas à pas
On rappelle les classes vues en cours :
class Noeud:
def __init__(self, element, suivant=None):
self.element = element
self.suivant = suivant
class ListeChainee:
def __init__(self):
self.premier = None
def ajout_en_tete(self, element):
premier = Noeud(element)
premier.suivant = self.premier
self.premier = premier
On exécute les instructions suivantes :
ma_liste = ListeChainee()
ma_liste.ajout_en_tete(7)
ma_liste.ajout_en_tete(3)
ma_liste.ajout_en_tete(9)
- Dessiner l’état de la liste chaînée après chaque instruction
ajout_en_tete. On représentera chaque nœud par une case contenant la valeur et une flèche vers le suivant. - Quelle est la valeur de
ma_liste.premier.element? - Quelle est la valeur de
ma_liste.premier.suivant.suivant.element? - On ajoute maintenant
ma_liste.ajout_en_tete(1). Dessiner le nouvel état de la liste.
Correction
Après chaque instruction :
ajout_en_tete(7):[7] → Noneajout_en_tete(3):[3] → [7] → Noneajout_en_tete(9):[9] → [3] → [7] → None
L’ajout en tête place le nouvel élément avant l’ancien premier. L’ordre final est donc l’inverse de l’ordre d’insertion.
ma_liste.premier.elementvaut 9 (le dernier élément ajouté en tête).ma_liste.premierest le nœud contenant 9,.suivantest le nœud contenant 3,.suivantest le nœud contenant 7. Donc la valeur est 7.Après
ajout_en_tete(1):[1] → [9] → [3] → [7] → None.
Exercice 3 — Exercice guidé : compléter des méthodes à trous
On reprend les classes Noeud et ListeChainee. Compléter les méthodes suivantes en remplaçant les ... par le code approprié.
a) Méthode __len__ qui renvoie le nombre d’éléments de la liste :
def __len__(self):
courant = ... # partir du premier nœud
cpt = 0
while courant is not None:
courant = ... # passer au nœud suivant
cpt += 1
return ... # renvoyer le compteur
b) Méthode __repr__ qui renvoie une représentation sous la forme "9 → 3 → 7" :
def __repr__(self):
elements = []
courant = self.premier
while courant is not None:
elements.append(str(...)) # ajouter la valeur du nœud
courant = ... # passer au suivant
return " → ".join(elements)
c) Méthode contient qui renvoie True si element est dans la liste, False sinon :
def contient(self, element):
courant = self.premier
while courant is not None:
if ... == element: # comparer avec la valeur du nœud
return True
courant = ... # passer au suivant
return ... # élément non trouvé
Correction
a)
def __len__(self):
courant = self.premier # partir du premier nœud
cpt = 0
while courant is not None:
courant = courant.suivant # passer au nœud suivant
cpt += 1
return cpt # renvoyer le compteur
Le parcours suit le schéma classique : on part du premier nœud, on avance de nœud en nœud avec courant = courant.suivant, et on s’arrête quand courant vaut None (fin de la chaîne).
b)
def __repr__(self):
elements = []
courant = self.premier
while courant is not None:
elements.append(str(courant.element)) # la valeur est dans courant.element
courant = courant.suivant # passer au suivant
return " → ".join(elements)
On collecte les valeurs dans une liste Python puis on les joint avec la flèche.
c)
def contient(self, element):
courant = self.premier
while courant is not None:
if courant.element == element: # comparer avec la valeur du nœud
return True
courant = courant.suivant # passer au suivant
return False # élément non trouvé
Si on atteint la fin de la chaîne sans avoir trouvé l’élément, c’est qu’il est absent.
Exercice 4 — Accès et suppression en tête
Écrire la méthode
supprime_en_tete(self)qui supprime le premier élément de la liste et renvoie sa valeur. Si la liste est vide, la méthode doit lever une exceptionValueError("liste vide").Tester votre méthode sur la liste
9 → 3 → 7:- appeler
supprime_en_tete()une première fois : quelle valeur est renvoyée ? Quel est l’état de la liste ? - appeler
supprime_en_tete()deux fois de plus : quelles valeurs sont renvoyées ? - un dernier appel : que se passe-t-il ?
- appeler
Correction
def supprime_en_tete(self):
if self.premier is None:
raise ValueError("liste vide")
element = self.premier.element
self.premier = self.premier.suivant
return element
Principe : on sauvegarde la valeur du premier nœud, puis on fait pointer self.premier vers le deuxième nœud (le suivant du premier). L’ancien premier nœud n’est plus référencé et sera récupéré par le ramasse-miettes de Python.
- Premier appel : renvoie 9, la liste devient
3 → 7. - Deuxième appel : renvoie 3, la liste devient
7. - Troisième appel : renvoie 7, la liste est maintenant vide (
premier = None). - Quatrième appel : lève
ValueError("liste vide")carself.premiervautNone.
Exercice 5 — Insertion en position quelconque
Écrire la méthode inserer(self, element, i) qui insère element en position i dans la liste (les positions sont comptées à partir de 0).
Indications :
- si
i == 0, il suffit d’utiliserajout_en_tete; - sinon, parcourir la liste jusqu’au nœud de position
i - 1(le futur prédécesseur) ; - créer un nouveau nœud dont le suivant est l’ancien nœud en position
i; - mettre à jour le suivant du nœud en position
i - 1.
Tester avec la liste 9 → 3 → 7 :
inserer(5, 0)doit donner5 → 9 → 3 → 7;inserer(1, 2)doit donner5 → 9 → 1 → 3 → 7.
Correction
def inserer(self, element, i):
if i == 0:
self.ajout_en_tete(element)
else:
courant = self.premier
for _ in range(i - 1):
courant = courant.suivant
nouveau = Noeud(element)
nouveau.suivant = courant.suivant
courant.suivant = nouveau
Explication pas à pas pour inserer(1, 2) sur la liste 5 → 9 → 3 → 7 :
i = 2, donc on ne passe pas parajout_en_tete.- On avance d’un pas (
i - 1 = 1) :courantpointe sur le nœud contenant 9 (position 1). - On crée un nouveau nœud contenant 1.
- On fait pointer
nouveau.suivantverscourant.suivant, c’est-à-dire le nœud contenant 3. - On fait pointer
courant.suivantvers le nouveau nœud. - Résultat :
5 → 9 → 1 → 3 → 7.
Vérification : l’ajout en position 0 redonne bien ajout_en_tete, et l’ajout en dernière position (i = longueur) revient à un ajout en queue.
Exercice 6 — Comparaison de complexité
Compléter le tableau suivant en indiquant la complexité de chaque opération pour les deux structures :
| Opération | list Python (tableau) | Liste chaînée |
|---|---|---|
Accès à l’élément d’indice i | ||
| Ajout en tête | ||
| Ajout en queue | ||
| Suppression en tête | ||
| Suppression en queue | ||
| Recherche d’un élément |
Correction
| Opération | list Python (tableau) | Liste chaînée |
|---|---|---|
Accès à l’élément d’indice i | \(O(1)\) — accès direct par adresse | \(O(n)\) — parcours nœud par nœud |
| Ajout en tête | \(O(n)\) — décalage de tous les éléments | \(O(1)\) — création d’un nœud + mise à jour d’une référence |
| Ajout en queue | \(O(1)\) amorti — tableau dynamique | \(O(n)\) — parcours jusqu’au dernier nœud |
| Suppression en tête | \(O(n)\) — décalage de tous les éléments | \(O(1)\) — mise à jour de premier |
| Suppression en queue | \(O(1)\) — le tableau connaît sa taille | \(O(n)\) — parcours jusqu’à l’avant-dernier nœud |
| Recherche d’un élément | \(O(n)\) — parcours séquentiel | \(O(n)\) — parcours séquentiel |
Conclusion : la liste chaînée est particulièrement adaptée lorsque les opérations se concentrent en tête de la structure (ce qui en fait une brique idéale pour les piles). Le tableau dynamique est plus polyvalent grâce à l’accès direct par indice.
Exercice 7 — Synthèse : implémenter une file avec une liste chaînée
Une file est une structure de données en mode FIFO (premier entré, premier sorti). On veut implémenter une file en utilisant la classe ListeChainee.
Quelles opérations de la liste chaînée correspondent à
enfiler(ajouter un élément en fin de file) etdefiler(retirer l’élément en début de file) ?Écrire une classe
FileutilisantListeChaineeavec les méthodes suivantes :enfiler(self, element): ajoute un élément en fin de file ;defiler(self): retire et renvoie l’élément en début de file ;est_vide(self): renvoieTruesi la file est vide ;__repr__(self): affichage de la file.
Tester votre classe en simulant une file d’attente au secrétariat : trois élèves arrivent (Alice, Bob, Charlie), puis deux sont servis, puis un nouvel élève arrive (Diana). Afficher l’état de la file après chaque opération.
Quelle est la complexité de
enfileret dedefileravec cette implémentation ? Proposer une amélioration pour rendreenfileren \(O(1)\).
Correction
enfilercorrespond àajout_en_queue(ajout à la fin) etdefilercorrespond àsupprime_en_tete(retrait au début). C’est bien le comportement FIFO : le premier entré (en queue) est le premier à sortir (en tête).
class File:
def __init__(self):
self.contenu = ListeChainee()
def enfiler(self, element):
self.contenu.ajout_en_queue(element)
def defiler(self):
if self.est_vide():
raise ValueError("file vide")
return self.contenu.supprime_en_tete()
def est_vide(self):
return self.contenu.premier is None
def __repr__(self):
elements = []
courant = self.contenu.premier
while courant is not None:
elements.append(str(courant.element))
courant = courant.suivant
return " ← ".join(elements) if elements else "(file vide)"
f = File()
f.enfiler("Alice") # Alice
f.enfiler("Bob") # Alice ← Bob
f.enfiler("Charlie") # Alice ← Bob ← Charlie
f.defiler() # renvoie "Alice" → Bob ← Charlie
f.defiler() # renvoie "Bob" → Charlie
f.enfiler("Diana") # Charlie ← Diana
Avec cette implémentation,
defilerest en \(O(1)\) (suppression en tête) maisenfilerest en \(O(n)\) (parcours jusqu’en queue).Amélioration : ajouter un attribut
dernierdans la classeListeChainee(ou dansFile) qui pointe toujours vers le dernier nœud. Lors de l’ajout en queue, on accède directement au dernier nœud sans parcourir la liste. L’ajout en queue devient alors \(O(1)\).def enfiler(self, element): nouveau = Noeud(element) if self.dernier is not None: self.dernier.suivant = nouveau else: self.contenu.premier = nouveau self.dernier = nouveauIl faut aussi remettre
dernieràNonequand la file se vide, sinon le prochainenfileraccroche le nouveau nœud à un nœud déjà retiré de la liste :def defiler(self): element = self.contenu.supprime_en_tete() if self.contenu.premier is None: self.dernier = None return element