Partie II - Structures de donnees · Chapitre 10

Piles Et Files

3 sections de cours · fiche d'exercices corrigee

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.

Definition — Pile (stack), LIFO

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.

Definition — File (queue), FIFO

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
Ne jamais travailler par le debut

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.

Solution 1 — 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.

Solution 2 — deux piles

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

StructureImplementationInsertionSuppression
Pilelist + append/pop \(\Theta(1)\) amorti\(\Theta(1)\)
Filedeque\(\Theta(1)\)\(\Theta(1)\)
Filelist + pop(0)\(\Theta(1)\) \(\Theta(n)\) — a proscrire
Filedeux piles\(\Theta(1)\)\(\Theta(1)\) amorti

2Comparaison interactive et applications

Pile et file : meme suite d'operations, ordres de sortie differents
Inserer quelques valeurs, puis les retirer et comparer.

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

Definition

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
L'ordre de depilement

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']
Propriete fondamentale du BFS

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.

Methode — derecursiver avec une pile explicite

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)

Definition

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
StructureInsertionExtractionElement 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/pop sur une liste : \(\Theta(1)\).
  • Une file s'implemente par collections.deque ; jamais par pop(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.
Fiche d'exercices — chapitre 10Enonces gradues, du calcul d'application au niveau concours, avec corriges detailles.
Ouvrir la fiche →