Exercices : listes chaînées

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 ListeChainee dont l’attribut premier vaut None
  • 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)
  1. 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.
  2. Quelle est la valeur de ma_liste.premier.element ?
  3. Quelle est la valeur de ma_liste.premier.suivant.suivant.element ?
  4. On ajoute maintenant ma_liste.ajout_en_tete(1). Dessiner le nouvel état de la liste.
Correction
  1. Après chaque instruction :

    • ajout_en_tete(7) : [7] → None
    • ajout_en_tete(3) : [3] → [7] → None
    • ajout_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.

  2. ma_liste.premier.element vaut 9 (le dernier élément ajouté en tête).

  3. ma_liste.premier est le nœud contenant 9, .suivant est le nœud contenant 3, .suivant est le nœud contenant 7. Donc la valeur est 7.

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

  1. É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 exception ValueError("liste vide").

  2. 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 ?
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") car self.premier vaut None.

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’utiliser ajout_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 donner 5 → 9 → 3 → 7 ;
  • inserer(1, 2) doit donner 5 → 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 :

  1. i = 2, donc on ne passe pas par ajout_en_tete.
  2. On avance d’un pas (i - 1 = 1) : courant pointe sur le nœud contenant 9 (position 1).
  3. On crée un nouveau nœud contenant 1.
  4. On fait pointer nouveau.suivant vers courant.suivant, c’est-à-dire le nœud contenant 3.
  5. On fait pointer courant.suivant vers le nouveau nœud.
  6. 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érationlist 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érationlist 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.

  1. Quelles opérations de la liste chaînée correspondent à enfiler (ajouter un élément en fin de file) et defiler (retirer l’élément en début de file) ?

  2. Écrire une classe File utilisant ListeChainee avec 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) : renvoie True si la file est vide ;
    • __repr__(self) : affichage de la file.
  3. 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.

  4. Quelle est la complexité de enfiler et de defiler avec cette implémentation ? Proposer une amélioration pour rendre enfiler en \(O(1)\).

Correction
  1. enfiler correspond à ajout_en_queue (ajout à la fin) et defiler correspond à 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
  1. Avec cette implémentation, defiler est en \(O(1)\) (suppression en tête) mais enfiler est en \(O(n)\) (parcours jusqu’en queue).

    Amélioration : ajouter un attribut dernier dans la classe ListeChainee (ou dans File) 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 = nouveau
    

    Il faut aussi remettre dernier à None quand la file se vide, sinon le prochain enfiler accroche 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