Les piles
Qu'est-ce qu'une pile ?
Une pile est une structure de données qui stocke des éléments selon le principe LIFO (Last In, First Out), c'est-à-dire :
- le dernier élément ajouté est le premier à être retiré ;
- les opérations se font toujours au sommet de la pile.
On peut imaginer une pile d'assiettes : on dépose une nouvelle assiette sur le dessus, puis on retire aussi celle du dessus.
Sommet
|
v
+-----+
| 30 |
+-----+
| 20 |
+-----+
| 10 |
+-----+
Dans cet exemple, si on retire un élément, on obtiendra d'abord 30, puis
20, puis 10.
Pourquoi les piles existent-elles ?
Les piles sont utiles lorsqu'on veut traiter les éléments dans l'ordre inverse de leur arrivée.
Voici quelques situations courantes :
- Historique d'actions : annuler la dernière action dans un éditeur.
- Navigation : revenir à la page précédente dans un navigateur.
- Parcours de structures : certains algorithmes utilisent une pile pour se souvenir des étapes à reprendre plus tard.
La pile existe donc parce qu'elle modélise très bien les problèmes où l'on doit revenir en arrière sur les opérations les plus récentes.
Les opérations principales
Une pile propose généralement les opérations suivantes :
push(element): ajoute un élément au sommet.pop(): retire et retourne l'élément au sommet.peek()outop(): retourne l'élément au sommet sans le retirer.is_empty(): indique si la pile est vide.
Exemple d'évolution d'une pile :
pile = []
pile.append(10) # push
pile.append(20) # push
pile.append(30) # push
print(pile) # [10, 20, 30]
print(pile[-1]) # 30 -> sommet (peek)
valeur = pile.pop()
print(valeur) # 30
print(pile) # [10, 20]
Dans cet exemple, 30 est retiré en premier car c'est le dernier élément qui a
été ajouté.
Utiliser une pile en Python
Comme vu précédemment, en Python, la façon la plus simple de représenter une pile est d'utiliser une liste.
pile = []
# Empiler des éléments
pile.append("a")
pile.append("b")
pile.append("c")
print(pile) # ['a', 'b', 'c']
# Consulter le sommet
print(pile[-1]) # 'c'
# Dépiler
print(pile.pop()) # 'c'
print(pile.pop()) # 'b'
print(pile) # ['a']
Ici :
- la fin de la liste représente le sommet de la pile ;
append()ajoute un élément au sommet ;pop()retire l'élément du sommet.
Ce choix est important : travailler à la fin de la liste est naturel et efficace en Python.
Exemple concret : vérifier des parenthèses
Les piles sont très utiles pour vérifier qu'une expression contient des parenthèses bien placées.
Principe :
- on empile chaque parenthèse ouvrante
(; - lorsqu'on rencontre une parenthèse fermante
), on retire une ouvrante ; - si on veut retirer alors que la pile est vide, l'expression est invalide ;
- à la fin, la pile doit être vide.
def parentheses_equilibrees(expression):
pile = []
for caractere in expression:
if caractere == '(':
pile.append(caractere)
elif caractere == ')':
if len(pile) == 0:
return False
pile.pop()
return len(pile) == 0
print(parentheses_equilibrees("(a + b) * (c - d)")) # True
print(parentheses_equilibrees("((a + b)")) # False
print(parentheses_equilibrees(")a + b(")) # False
Si append() et pop() n'existaient pas
La liste en Python est déjà en soi une structure de données qui fait office de
pile. Toutefois, pour mieux comprendre ce qui se passe plus bas niveau,
imaginons que Python ne fournit pas append() ni pop().
Dans un langage comme le C, on doit gérer nous-mêmes :
- un tableau de taille fixe (le buffer) ;
- un pointeur vers le sommet qui indique combien d'éléments sont réellement utilisés ;
- le redimensionnement du tableau lorsqu'il est plein ou trop vide.
Construisons cette pile étape par étape.
Étape 1 : la structure de base
On a besoin de trois informations : le tableau, sa capacité totale et le nombre d'éléments qu'il contient réellement (le sommet).
class Pile:
def __init__(self, capacite_initiale=4):
self.data = [None] * capacite_initiale
self.capacite = capacite_initiale
self.sommet = 0 # pointe vers la prochaine case libre
Au départ, le tableau contient capacite_initiale cases toutes à None, et
sommet vaut 0 (la pile est vide).
Étape 2 : redimensionner le tableau
Quand on doit changer la taille du tableau, on crée un nouveau tableau, on recopie les éléments un par un, puis on remplace l'ancien.
def _redimensionner(self, nouvelle_capacite):
nouveau = [None] * nouvelle_capacite
for i in range(self.sommet):
nouveau[i] = self.data[i]
self.data = nouveau
self.capacite = nouvelle_capacite
Étape 3 : empiler (push)
On place l'élément à la position sommet, puis on avance le pointeur. Si le
tableau est plein, on double la capacité avant d'insérer.
def push(self, element):
if self.sommet == self.capacite:
self._redimensionner(self.capacite * 2)
self.data[self.sommet] = element
self.sommet += 1
Étape 4 : dépiler (pop)
On recule le pointeur, on récupère l'élément et on nettoie la case. Si le tableau devient trop vide (le quart de sa capacité), on le divise par 2 pour ne pas gaspiller de mémoire.
def pop(self):
if self.sommet == 0:
raise IndexError("pop sur une pile vide")
self.sommet -= 1
element = self.data[self.sommet]
self.data[self.sommet] = None
if self.sommet > 0 and self.sommet <= self.capacite // 4:
self._redimensionner(self.capacite // 2)
return element
Étape 5 : consulter le sommet (peek)
def peek(self):
if self.sommet == 0:
raise IndexError("peek sur une pile vide")
return self.data[self.sommet - 1]
La classe au complet
class Pile:
def __init__(self, capacite_initiale=4):
self.data = [None] * capacite_initiale
self.capacite = capacite_initiale
self.sommet = 0
def _redimensionner(self, nouvelle_capacite):
nouveau = [None] * nouvelle_capacite
for i in range(self.sommet):
nouveau[i] = self.data[i]
self.data = nouveau
self.capacite = nouvelle_capacite
def push(self, element):
if self.sommet == self.capacite:
self._redimensionner(self.capacite * 2)
self.data[self.sommet] = element
self.sommet += 1
def pop(self):
if self.sommet == 0:
raise IndexError("pop sur une pile vide")
self.sommet -= 1
element = self.data[self.sommet]
self.data[self.sommet] = None
if self.sommet > 0 and self.sommet <= self.capacite // 4:
self._redimensionner(self.capacite // 2)
return element
def peek(self):
if self.sommet == 0:
raise IndexError("peek sur une pile vide")
return self.data[self.sommet - 1]
def est_vide(self):
return self.sommet == 0
def __len__(self):
return self.sommet
def __repr__(self):
return str(self.data[:self.sommet])
Exemple d'utilisation
pile = Pile(capacite_initiale=2)
pile.push(10)
pile.push(20)
print(pile) # [10, 20]
print(pile.capacite) # 2
pile.push(30) # le tableau est plein -> capacité doublée (2 -> 4)
print(pile) # [10, 20, 30]
print(pile.capacite) # 4
print(pile.pop()) # 30
print(pile.pop()) # 20
print(pile) # [10]
print(pile.capacite) # 2 (réduit de moitié car 1 <= 4 // 4)
Pourquoi doubler plutôt que +1 ?
Si on agrandit de +1 à chaque push, on recopie tout le tableau à chaque
insertion. En doublant la capacité, les recopies deviennent de plus en plus rares : on
copie n éléments, puis on a n places libres avant la prochaine recopie.
La même logique s'applique pour la réduction : on divise par 2 seulement quand la pile est remplie au quart. Cela évite l'effet « yo-yo » où l'on agrandirait et réduirait sans cesse autour de la même taille.