1Types abstraits pile et file, et leurs implementations
Piles et files sont deux types abstraits qui ne different que par l'ordre de sortie des elements. Ce detail apparemment mineur change radicalement le comportement des algorithmes qui les utilisent : le meme parcours de graphe devient un parcours en profondeur avec une pile, en largeur avec une file.
Une pile est une structure ou le dernier element insere est le premier retire : Last In, First Out. Operations :
empiler(p, x)(push) : ajoute \(x\) au sommet ;depiler(p)(pop) : retire et renvoie le sommet ;sommet(p)(peek) : consulte sans retirer ;est_vide(p),taille(p).
Image mentale : une pile d'assiettes. On ne peut prendre que celle du dessus.
Une file est une structure ou le premier element insere est le premier retire : First In, First Out. Operations :
enfiler(f, x): ajoute \(x\) en queue ;defiler(f): retire et renvoie l'element de tete ;tete(f),est_vide(f),taille(f).
Image mentale : une file d'attente a un guichet.
Implementation d'une pile par une liste
La liste Python offre exactement les operations d'une pile en \(\Theta(1)\), a condition de travailler par la fin.
def pile_vide():
"""Cree une pile vide."""
return []
def empiler(p, x):
"""Ajoute x au sommet. Theta(1) amorti."""
p.append(x)
def depiler(p):
"""Retire et renvoie le sommet. Theta(1). Precondition : pile non vide."""
return p.pop()
def sommet(p):
"""Consulte le sommet sans le retirer."""
return p[-1]
def est_vide(p):
return len(p) == 0
p = pile_vide()
for x in [1, 2, 3]:
empiler(p, x)
assert depiler(p) == 3 # dernier entre, premier sorti
assert sommet(p) == 2
p.insert(0, x) et p.pop(0) coutent \(\Theta(n)\) car ils decalent
toute la liste. Une pile implementee ainsi ferait passer un algorithme lineaire en quadratique.
Le sommet de la pile doit etre la fin de la liste.
Implementation d'une file
Ici la liste ne convient pas : pop(0) est en \(\Theta(n)\). Deux solutions.
collections.deque
from collections import deque
f = deque()
f.append(1) # enfiler en queue Theta(1)
f.append(2)
x = f.popleft() # defiler en tete Theta(1)
assert x == 1
Une deque (double-ended queue) est une liste doublement chainee par
blocs : les deux extremites sont accessibles en \(\Theta(1)\). C'est l'implementation a utiliser
en pratique.
Si l'on ne dispose que de piles, on simule une file par deux piles : une pile entree et une pile sortie.
def file_vide():
return ([], []) # (entree, sortie)
def enfiler(f, x):
"""Theta(1)."""
f[0].append(x)
def defiler(f):
"""Theta(1) amorti."""
entree, sortie = f
if not sortie: # la pile de sortie est vide : on transvase
while entree:
sortie.append(entree.pop())
return sortie.pop()
f = file_vide()
for x in [1, 2, 3]:
enfiler(f, x)
assert defiler(f) == 1 and defiler(f) == 2
Analyse amortie : chaque element est empile dans entree, transvase
une fois, puis depile de sortie. Il subit donc au plus 4 operations elementaires au
total, quel que soit le nombre de defiler : le cout amorti par operation est
\(\Theta(1)\), meme si un defiler isole peut couter \(\Theta(n)\).
Recapitulatif des couts
| Structure | Implementation | Insertion | Suppression |
|---|---|---|---|
| Pile | list + append/pop |
\(\Theta(1)\) amorti | \(\Theta(1)\) |
| File | deque | \(\Theta(1)\) | \(\Theta(1)\) |
| File | list + pop(0) | \(\Theta(1)\) | \(\Theta(n)\) — a proscrire |
| File | deux piles | \(\Theta(1)\) | \(\Theta(1)\) amorti |
2Comparaison interactive et applications
Application 1 — expressions bien parenthesees
def bien_parenthesee(s):
"""L'expression est-elle correctement parenthesee ? Theta(n)."""
correspond = {")": "(", "]": "[", "}": "{"}
pile = []
for c in s:
if c in "([{":
pile.append(c)
elif c in correspond:
if not pile or pile.pop() != correspond[c]:
return False
return not pile
assert bien_parenthesee("{[()]}")
assert not bien_parenthesee("([)]")
La pile memorise les delimiteurs ouverts non encore fermes ; sa discipline LIFO correspond exactement a la regle d'imbrication.
Application 2 — notation polonaise inverse
En notation postfixee (ou polonaise inverse), l'operateur suit ses operandes :
3 4 + vaut 7, et 3 4 + 2 * vaut 14. Cette notation ne necessite
aucune parenthese et aucune regle de priorite.
def evalue_postfixe(expression):
"""Evalue une expression postfixee donnee sous forme de chaine. Theta(n)."""
pile = []
for jeton in expression.split():
if jeton in "+-*/":
b = pile.pop() # ATTENTION a l'ordre : b est depile en premier
a = pile.pop()
if jeton == "+": pile.append(a + b)
elif jeton == "-": pile.append(a - b)
elif jeton == "*": pile.append(a * b)
else: pile.append(a / b)
else:
pile.append(float(jeton))
if len(pile) != 1:
raise ValueError("expression mal formee")
return pile.pop()
assert evalue_postfixe("3 4 +") == 7
assert evalue_postfixe("3 4 + 2 *") == 14
assert evalue_postfixe("5 1 2 + 4 * + 3 -") == 14
Pour a - b, c'est b qui sort en premier de la pile. Inverser les deux
lignes donnerait \(b - a\) : erreur invisible sur l'addition, fatale sur la soustraction et la
division.
Application 3 — parcours de graphe
Le meme algorithme, au conteneur pres, donne deux parcours de natures tres differentes.
from collections import deque
def parcours_profondeur(G, depart):
"""Parcours en profondeur (DFS) avec une PILE."""
vus, ordre, a_traiter = {depart}, [], [depart]
while a_traiter:
s = a_traiter.pop() # LIFO : on repart du dernier decouvert
ordre.append(s)
for v in sorted(G[s], reverse=True):
if v not in vus:
vus.add(v)
a_traiter.append(v)
return ordre
def parcours_largeur(G, depart):
"""Parcours en largeur (BFS) avec une FILE."""
vus, ordre, a_traiter = {depart}, [], deque([depart])
while a_traiter:
s = a_traiter.popleft() # FIFO : on explore par couches
ordre.append(s)
for v in sorted(G[s]):
if v not in vus:
vus.add(v)
a_traiter.append(v)
return ordre
G = {"A": ["B", "C"], "B": ["D", "E"], "C": ["F"],
"D": [], "E": ["F"], "F": []}
print(parcours_profondeur(G, "A")) # ['A', 'B', 'D', 'E', 'F', 'C']
print(parcours_largeur(G, "A")) # ['A', 'B', 'C', 'D', 'E', 'F']
Le parcours en largeur visite les sommets par distance croissante au depart : il calcule donc le plus court chemin en nombre d'aretes. Le parcours en profondeur, lui, ne le garantit pas, mais il detecte naturellement les cycles et sert au tri topologique.
3Pile d'execution, derecursivation et files de priorite
Pile d'execution et recursivite
La pile n'est pas seulement une structure de donnees : c'est le mecanisme meme de l'appel de
fonction. A chaque appel, un bloc d'activation (parametres, variables locales, adresse
de retour) est empile ; au return, il est depile.
import sys
print(sys.getrecursionlimit()) # 1000 par defaut
def profondeur(n):
if n == 0:
return 0
return 1 + profondeur(n - 1)
profondeur(10000) # RecursionError: maximum recursion depth exceeded
La limite protege contre le debordement de la pile systeme. On peut la relever
(sys.setrecursionlimit), mais c'est generalement le signe qu'il faut
derecursiver.
Tout algorithme recursif se transforme en algorithme iteratif en gerant soi-meme la pile des appels en attente.
def somme_recursive(t, i=0):
if i == len(t):
return 0
return t[i] + somme_recursive(t, i + 1)
def parcours_arbre_recursif(a):
"""a = None ou (valeur, gauche, droite)."""
if a is None:
return []
v, g, d = a
return parcours_arbre_recursif(g) + [v] + parcours_arbre_recursif(d)
def parcours_arbre_iteratif(a):
"""Meme parcours infixe, avec une pile explicite. Pas de RecursionError."""
resultat, pile, courant = [], [], a
while pile or courant is not None:
while courant is not None: # descendre a gauche en empilant
pile.append(courant)
courant = courant[1]
courant = pile.pop() # remonter
resultat.append(courant[0])
courant = courant[2] # partir a droite
return resultat
arbre = (2, (1, None, None), (3, None, None))
assert parcours_arbre_recursif(arbre) == [1, 2, 3]
assert parcours_arbre_iteratif(arbre) == [1, 2, 3]File de priorite (tas)
Une file de priorite sert l'element de plus petite (ou plus grande) priorite, et non le plus ancien. Implementee par un tas binaire, elle offre l'insertion et l'extraction du minimum en \(\Theta(\log n)\).
import heapq
tas = []
heapq.heappush(tas, (3, "reparer"))
heapq.heappush(tas, (1, "urgent"))
heapq.heappush(tas, (2, "important"))
assert heapq.heappop(tas) == (1, "urgent") # priorite minimale d'abord
# transformer une liste en tas sur place, en Theta(n)
t = [5, 3, 8, 1]
heapq.heapify(t)
assert heapq.heappop(t) == 1
| Structure | Insertion | Extraction | Element servi |
|---|---|---|---|
| Pile | \(\Theta(1)\) | \(\Theta(1)\) | le plus recent |
| File | \(\Theta(1)\) | \(\Theta(1)\) | le plus ancien |
| File de priorite | \(\Theta(\log n)\) | \(\Theta(\log n)\) | le plus prioritaire |
Cette structure est au coeur de l'algorithme de Dijkstra (plus court chemin pondere) et du tri par tas.
L'essentiel du chapitre
- Pile = LIFO, file = FIFO ; elles ne different que par l'ordre de sortie.
- Une pile s'implemente par
append/popsur une liste : \(\Theta(1)\). - Une file s'implemente par
collections.deque; jamais parpop(0)sur une liste. - Deux piles simulent une file avec un cout amorti \(\Theta(1)\).
- Parenthesage, notation postfixee, DFS : applications directes de la pile.
- BFS (file) donne le plus court chemin en nombre d'aretes ; DFS (pile) ne le garantit pas.
- La recursivite utilise la pile d'execution ; la derecursivation consiste a la gerer explicitement.