Niveau : licence, du facile au difficile. Durée estimée : 90 min. 10 exercices avec corrections détaillées, en Python.
Rappel des notions clés
Une pile se remplit et se vide par le même bout, le sommet : le dernier entré sort le premier (LIFO, « last in, first out »). Une file se remplit par la fin et se vide par la tête : le premier entré sort le premier (FIFO, « first in, first out »). Tu ajoutes 1, 2 puis 3 : la pile te rend d'abord 3, la file d'abord 1.
Une liste chaînée range chaque valeur dans un nœud, avec un lien vers le nœud suivant (None pour le dernier). Tu ne tiens que la tête, le premier nœud : pour atteindre un élément, tu suis les liens un par un. Le coût d'une opération, noté $O(\dots)$, dit comment son temps grandit avec le nombre $n$ d'éléments : $O(1)$ ne dépend pas de $n$, $O(n)$ grandit comme lui.
Coûts à retenir :
- Pile sur une liste Python :
append(x)empile en $O(1)$ amorti, c'est-à-dire en moyenne sur une suite d'opérations (un agrandissement de la liste coûte parfois $O(n)$) ;pop()dépile en $O(1)$. - File :
collections.deque, avecappend(x)pour enfiler etpopleft()pour défiler, tous deux en $O(1)$. - Liste chaînée simple : atteindre le $k$-ième nœud coûte $O(k)$ ; insérer ou supprimer juste après un nœud déjà atteint coûte $O(1)$.
Piège classique : une liste Python fait une bonne pile mais une mauvaise file. D'après la documentation de Python, pop(0) décale d'une case tous les éléments restants : $O(n)$ à chaque défilement.
Exercices : niveau facile
Exercice 1 : Pour chaque situation, choisis une pile, une file ou une liste chaînée, et justifie. a) Le bouton « Annuler » d'un éditeur de texte. b) Les documents envoyés à une imprimante partagée. c) Une liste de lecture où tu insères souvent un morceau juste après celui en cours.
Correction :
Regarde par où les éléments entrent et sortent. a) On annule d'abord la dernière action : pile. b) Le premier document envoyé est imprimé le premier : file. c) Tu tiens déjà le nœud du morceau en cours, et insérer après lui coûte $O(1)$ sans rien décaler : liste chaînée.
Exercice 2 : Une pile est vide. Tu fais dans l'ordre : empiler 4, empiler 7, dépiler, empiler 2, empiler 9, dépiler, dépiler. Donne les valeurs dépilées et le contenu final de la pile, du fond au sommet.
Correction :
On dépile toujours le dernier arrivé. Après deux empilements, [4, 7] : on dépile 7. Puis [4, 2, 9] : on dépile 9, puis 2.
Valeurs dépilées : 7, 9, 2. Pile finale : [4].
Exercice 3 : Même suite sur une file vide : enfiler 4, enfiler 7, défiler, enfiler 2, enfiler 9, défiler, défiler. Donne les valeurs défilées et le contenu final, de la tête à la fin.
Correction :
On défile toujours le premier arrivé. [4, 7] : on défile 4. Puis [7, 2, 9] : on défile 7, puis 2.
Valeurs défilées : 4, 7, 2. File finale : [9].
Erreur à éviter : répondre 7, 9, 2, comme pour la pile.
Exercices : niveau moyen
Exercice 4 : Un nœud a deux attributs : valeur et suiv, le nœud suivant (None pour le dernier). Écris longueur(tete), qui renvoie le nombre de nœuds de la liste qui commence à tete (None si elle est vide). Donne son coût et ce qu'elle renvoie pour 10 -> 20 -> 30 -> 40 -> None.
Correction :
On suit les liens depuis la tête jusqu'à None, en comptant.
def longueur(tete):
n = 0
courant = tete
while courant is not None:
n += 1
courant = courant.suiv
return n
Chaque nœud est visité une fois : coût $O(n)$, elle renvoie 4.
Erreur à éviter : oublier courant = courant.suiv, et la boucle ne s'arrête plus.
Exercice 5 : Écris une classe Pile qui range ses éléments dans une liste Python, avec empiler(x), depiler() et est_vide(). Que doit faire depiler() sur une pile vide ? Donne le coût de chaque méthode.
Correction :
Place le sommet en fin de liste, là où append et pop() ne décalent rien.
class Pile:
def __init__(self):
self.elements = []
def est_vide(self):
return len(self.elements) == 0
def empiler(self, x):
self.elements.append(x)
def depiler(self):
if self.est_vide():
raise IndexError("pile vide")
return self.elements.pop()
Sur une pile vide, on lève une exception : renvoyer None se confondrait avec un None empilé. Coût : $O(1)$ partout, $O(1)$ amorti pour empiler.
Erreur à éviter : un sommet en début de liste, avec insert(0, x) et pop(0), passe chaque opération en $O(n)$.
Exercice 6 : Un camarade code une file avec une liste Python : append(x) pour enfiler, pop(0) pour défiler. Chaque pop(0) décale d'une case tous les éléments restants. Combien de décalages faut-il pour vider une file de $n$ éléments ? Calcule-le pour $n = 10\,000$, puis propose mieux.
Correction :
Défiler une file de $k$ éléments décale les $k-1$ autres. Pour la vider, on additionne :
$$\sum_{k=1}^{n}(k-1) = \frac{n(n-1)}{2}$$
C'est $O(n^2)$ au total. Pour $n = 10\,000$ : $\frac{10\,000 \times 9\,999}{2}$, soit $49\,995\,000$ décalages.
Mieux : collections.deque et popleft(), en $O(1)$ ; vider la file coûte alors $O(n)$.
Exercice 7 : Écris bien_parenthesee(s), qui renvoie True si les parenthèses, crochets et accolades de la chaîne s se ferment dans le bon ordre, False sinon. Utilise une pile. Teste-la sur "([{}])", "[(])" et "(()".
Correction :
On empile chaque ouvrant ; chaque fermant doit trouver au sommet l'ouvrant qui lui correspond.
def bien_parenthesee(s):
paires = {")": "(", "]": "[", "}": "{"}
pile = []
for c in s:
if c in "([{":
pile.append(c)
elif c in paires:
if not pile or pile.pop() != paires[c]:
return False
return not pile
"[(])" échoue car ] trouve ( au sommet ; "(()" échoue car un ( reste dans la pile. Résultats : True, False, False.
Erreur à éviter : oublier le test final not pile, qui rejette "(()".
Exercices : niveau difficile
Exercice 8 : Dans un petit réseau social (données fictives), voici les amis de chacun, dans l'ordre où le programme les lit. A : C, B. B : A, E. C : A, D. D : C, F. E : B. F : D.
Tu fais un parcours en largeur depuis A, marqué au départ : on défile une personne, puis on enfile ses amis pas encore marqués, en les marquant aussitôt. Donne l'ordre de visite et la distance de chacun à A, en nombre de liens. Pourquoi une file, et pas une pile ?
Correction :
Suis la file pas à pas : la distance d'une personne vaut celle de qui l'a enfilée, plus 1.
file défilé enfilés
[A] A C, B
[C, B] C D
[B, D] B E
[D, E] D F
[E, F] E aucun
[F] F aucun
Ordre : A, C, B, D, E, F. Distances : $0$ pour A, $1$ pour C et B, $2$ pour D et E, $3$ pour F.
Avec une pile : A, B, E, C, D, F ; E (distance 2) passerait avant C (distance 1).
Exercice 9 : Avec seulement p.empiler(x), p.depiler() et p.est_vide(), et aucune autre structure de données, écris deux fonctions récursives : inserer_au_fond(p, x), qui place x sous tous les éléments de la pile p, et inverser(p). Combien de dépilements fait inverser sur une pile de $n$ éléments ?
Correction :
On retire le sommet, on inverse le reste, puis on glisse l'ancien sommet au fond.
def inserer_au_fond(p, x):
if p.est_vide():
p.empiler(x)
else:
haut = p.depiler()
inserer_au_fond(p, x)
p.empiler(haut)
def inverser(p):
if not p.est_vide():
haut = p.depiler()
inverser(p)
inserer_au_fond(p, haut)
Les éléments retirés attendent dans les appels en cours : la pile d'appels sert de pile cachée. Sur $k$ éléments, inserer_au_fond dépile $k$ fois. Le nombre $D(n)$ de dépilements de inverser vérifie donc $D(n) = D(n-1) + 1 + (n-1)$, avec $D(0) = 0$, d'où $D(n) = \frac{n(n+1)}{2}$ dépilements, soit $O(n^2)$.
Exercice 10 : Dans une liste doublement chaînée, un nœud a trois attributs : valeur, prec et suiv (nœuds précédent et suivant) ; Noeud(v) en crée un, ses liens à None. liste.tete et liste.queue désignent le premier et le dernier nœud. Écris inserer_apres(liste, n, v), qui insère v juste après le nœud n. Combien de liens faut-il écrire, quel cas traiter à part, et pour quel coût ?
Correction :
Le nouveau nœud reçoit ses deux liens, puis ses deux voisins pointent vers lui.
def inserer_apres(liste, n, v):
nouveau = Noeud(v)
nouveau.prec = n
nouveau.suiv = n.suiv
if n.suiv is None:
liste.queue = nouveau
else:
n.suiv.prec = nouveau
n.suiv = nouveau
Cas à part : n est la queue ; il n'a pas de suivant, c'est liste.queue qui change. Aucun parcours : 4 liens à écrire, en $O(1)$.
Erreur à éviter : écrire n.suiv = nouveau en premier. nouveau.suiv recevrait alors nouveau lui-même, et la fin de la liste serait perdue.
Bilan et conseils
Ce qu'il faut retenir : avant de coder, demande-toi par où les éléments entrent et sortent : par le même bout, c'est une pile ; par deux bouts opposés, une file. Donne toujours le coût de tes opérations, il départage deux codes justes. Dans une liste chaînée, dessine les liens avant de les modifier.
Sources
- Documentation Python, « Time complexity of operations on built-in types » : docs.python.org.
- Documentation Python, module
collections, classedeque: docs.python.org.