Skip to content

Les Piles

Les piles (stacks en anglais) correspondent exactement à la notion de pile dans la vie courante. C'est une structure qui contient des éléments empilés.

  • Une pile de cartes à jouer,
  • Une pile d’assiettes…

alt text

Pour ajouter un élément on empile cet élément, il se retrouve donc au-dessus, et pour retirer un élément on ne peut retirer que l’élément se trouvant au sommet de la pile. On dit qu'on le dépile.

En anglais on dit last in, first out ou LIFO pour dire: dernier arrivé premier sorti.

Ce type de structure de données est par exemple utilisé dans:

  • les éditeurs avec la fonction Annuler (CTRL+Z) et rétablir (CTRL+Y)
  • les navigateurs pour reculer ou avancer dans l'historique.
  • La lecture d'expressions mathématiques
  • En général le parcours de structures de données comme les graphes, arbres... que nous verrons plus tard.

Pourquoi ces structures comptent

Bien implémentées, les piles et les files garantissent leurs opérations d'ajout et de retrait en temps constant, O(1) : quelle que soit la quantité de données, empiler, dépiler, enfiler et défiler prennent le même temps. C'est ce qui les rend omniprésentes en informatique : la pile d'appels de la récursivité, annuler/refaire, le parcours des graphes (en profondeur avec une pile, en largeur avec une file), l'ordonnancement des tâches. Tu les reverras toute l'année. Ce ne sera alors pas le moment de les réapprendre : il faut les avoir acquises dès maintenant.

Interface

Définition - Interface

L'interface d'une structure de données abstraite est composée des fonctionnalités théoriques que doit savoir remplir la structure de données. On appelle ces fonctionnalités des primitives.

Tu dois penser à la structure et au fonctionnement de l'interface (comme des légos) lorsque tu résous des problèmes. Pas à Python en particulier.

Une pile est définie par l’interface comprenant les primitives suivantes:

Primitive Description
CREER() → Pile Renvoie une nouvelle Pile vide
EST_VIDE(p: Pile) → Booléen Savoir si la pile p est vide
EMPILER(e: T, p: Pile) Empiler un élément e pour le mettre au sommet de la pile p
DEPILER(p: Pile) → T Dépiler un élément: le retirer du sommet de la pile et le renvoyer

Implémentation en Python

Définition - Implémentation

L'implémentation d'une structure de donnée est la traduction pratique de son interface dans un langage de programmation spécifique. Les primitives doivent trouver une implémentation la plus rapide possible. Il peut exister plusieurs façon d'implémenter une structure de données dans un langage.

(on trouvera aussi parfois les termes implanter/implantation à la place d'implémenter/implémentation)

Le type list en Python présente deux méthodes rapides qui lui permettent d’implémenter la Pile:

  • list.append(e): ajoute l’élément en fin de liste en O(1).
  • list.pop(): supprime le dernier élément de la liste et le renvoie en O(1).

(voir la Documentation de python)

Implémentation minimaliste

On introduit d'abord un nouveau type Pile[T], construit à partir de list (une pile de T est une liste de T), puis les quatre primitives.

type Pile[T] = list[T]

def creer[T]() -> Pile[T]:
    return []

def est_vide[T](p: Pile[T]) -> bool:
    return len(p) == 0

def empiler[T](e: T, p: Pile[T]) -> None:
    p.append(e)

def depiler[T](p: Pile[T]) -> T:
    assert not est_vide(p), "La pile est vide"
    return p.pop()

Implémentation avancée

Ici on considère que:

Une Pile d'éléments d'un type quelconque T est une liste d'éléments de type T

On ajoute aussi des docstrings qui intègrent les tests unitaires de chaque fonction.

Voici le fichier pile.py

# Python 3.13

type Pile[T] = list[T]

def creer[T]() -> Pile[T]:
    """
    Crée une pile vide.

    >>> p: Pile[int] = creer()
    >>> p
    []
    """
    return []

def est_vide[T](p: Pile[T]) -> bool:
    """
    Indique si la pile est vide.

    >>> p: Pile[str] = creer()
    >>> est_vide(p)  #? True pour une pile vide
    True
    >>> empiler("test", p)
    >>> est_vide(p)  #? False sinon
    False
    """
    return len(p) == 0

def empiler[T](e: T, p: Pile[T]) -> None:
    """
    Empile l'élément e au sommet de la pile p (modifie p sur place).

    >>> p = creer()
    >>> empiler(10, p)  #? pas de valeur de retour (None)
    >>> p
    [10]
    >>> empiler(5, p)
    >>> p
    [10, 5]
    """
    p.append(e)


def depiler[T](p: Pile[T]) -> T:
    """
    Dépile et retourne l'élément au sommet de la pile p.

    >>> p = creer()
    >>> empiler(1, p)
    >>> empiler(2, p)
    >>> depiler(p)
    2
    >>> p
    [1]

    Dépile sur pile vide -> AssertionError :

    >>> depiler(p)
    1
    >>> depiler(p)  # doctest: +ELLIPSIS
    Traceback (most recent call last):
    ...
    AssertionError: La pile est vide
    """
    assert not est_vide(p), "La pile est vide"
    return p.pop()


if __name__ == "__main__":
    import doctest
    doctest.testmod()

Piège : empiler et depiler modifient la pile

Ces deux primitives ont un effet de bord : elles changent l'état de la pile passée en argument. Après depiler(p), l'élément a disparu de p. Conséquence directe : calculer la taille d'une pile en la dépilant la vide. Une fonction censée laisser la pile intacte doit remettre les éléments en place (voir les exercices « destructif / non destructif »).

L'état d'une pile est caché : seul le sommet est visible

On n'accède jamais au milieu d'une pile, seulement à son sommet. Le reste de l'état n'apparaît nulle part dans le code. Pour comprendre ce que font vraiment empiler et depiler, le bon réflexe est de tracer l'état à la main, comme dans l'exercice « Sans exécuter le code » plus bas.

Ce que l'IA ne change pas

Une IA écrit empiler et depiler en une seconde, et elle sait aussi rédiger la spécification et les tests. Ce n'est pas une question de capacité de la machine.

C'est une question de dépendance. Décider ce que doit faire chaque primitive (que se passe-t-il si on dépile une pile vide ? renvoie-t-on une erreur, None, autre chose ?) est un choix de conception que tu assumes, et dont tu réponds. Si tu ne sais pas énoncer ce contrat, tu ne peux pas juger si l'implémentation qu'on te propose le respecte. D'où la règle, qui vaut que tu écrives le code ou non : signature typée et tests d'abord, corps ensuite. L'épreuve pratique, elle, se passe sans IA.

Exercices

Préparation

Les chemins sont donnés relativement à ton répertoire prog_term

Les commandes sont lancées dans ce même répertoire.

Préparation des fichiers:

  • Crée le répertoire structures. Ajoutes-y un fichier vide __init__.py
  • Crée le répertoire structures/lineaires. Ajoutes-y un fichier vide __init__.py
  • Reporter le code de création de la structure de Pile dans le fichier structures/lineaires/pile.py
  • Crée le fichier exos/exospiles.py et ajoute ce code:
from structures.lineaires import pile

Tu travailleras dans le fichier exospiles.py

  • Tu lanceras ton programme à l'aide de la commande uv run -m exos.exospiles

Exercice 1

Créer une fonction pile_exemple qui renvoie la pile suivante:

| 'jaune' |
| 'rouge' |
| 'jaune' |
| 'vert'  |
| 'rouge' |
-----------

Sans exécuter le code - papier

Dessine la pile p à chacune de ses modifications, dans un tableau à trois colonnes : l'opération, l'état de la pile, et la valeur rendue par l'opération.

La troisième colonne est celle qui piège : empiler ne rend rien, depiler rend l'élément et le retire.

p: Pile[int] = creer()
for v in [2, 4, 3, 6, 8, 5, 77, 10, 1]:
    if v % 2 == 0:
        empiler(v, p)
    else:
        depiler(p)

Sommet d'une pile

Écrire une fonction sommet qui renvoie le sommet d'une pile sans qu'elle soit modifiée à la sortie de la fonction. (on peut donc la modifier, mais on remet tout bien en place avant de sortir de la fonction)

def sommet[T](p: Pile[T]) -> T:
    """
    Compléter la docstring

    >>> P = pile_exemple()
    >>> sommet(P)
    'jaune'
    """

Taille d'une pile - version destructive

Créer une fonction taille_pile[T](p: Pile[T]) -> int qui renvoie la taille de p de manière destructive. (la pile est vide si on l'affiche après un appel de fonction)

Taille d'une pile - version non destructive

Créer une fonction taille_pile[T](p: Pile[T]) -> int qui renvoie la taille de p de manière non destructive. (la pile est intacte si on l'affiche après un appel de fonction)

On pourra utiliser une pile temporaire.

Renverser une pile

Créer et tester une fonction renverse qui prend une pile \(p\) en paramètre et renvoie une pile contenant les éléments de \(p\) dans l'ordre inverse.

On commencera bien évidemment par travailler la signature de la fonction d'après l'énoncé.

Renverser une pile en place

Créer une fonction renverse_inplace qui prend une pile en paramètre et la renverse en place. Cette fonction ne retourne rien.

Indice léger

Transférer une pile dans une autre, élément par élément, inverse son ordre. Combien de transferts te faut-il, sachant que le résultat doit se retrouver dans p elle-même ?

Indice plus précis

Un transfert inverse l'ordre, deux transferts le rétablissent. Il t'en faut donc un nombre impair, et le dernier doit arriver dans p. Trois transferts, donc deux piles temporaires. Aucune autre structure n'est nécessaire, et tu n'as le droit d'utiliser que les quatre primitives.

Avant d'ouvrir la solution

En une phrase, sur ton cahier : qu'est-ce que l'indice t'a appris sur ce qui n'allait pas dans ton code ?

Solution
def renverse_inplace[T](p: Pile[T]) -> None:
    t1: Pile[T] = creer()
    t2: Pile[T] = creer()
    while not est_vide(p):
        empiler(depiler(p), t1)    # p vers t1 : ordre inverse
    while not est_vide(t1):
        empiler(depiler(t1), t2)   # t1 vers t2 : ordre rétabli
    while not est_vide(t2):
        empiler(depiler(t2), p)    # t2 vers p : ordre inverse de l'original

La pile p n'est jamais remplacée, seulement vidée puis remplie : c'est ce que veut dire « en place ».

Sujet épreuve pratique (30 minutes grand maximum)

Ne te grille pas immédiatement cet exercice. Il faut le faire une fois que tu es à l'aise avec les autres, et pas le même jour.

On dispose de chaînes de caractères contenant uniquement des parenthèses ouvrantes et fermantes. Un parenthésage est correct si :

  • le nombre de parenthèses ouvrantes de la chaîne est égal au nombre de parenthèses fermantes.
  • en parcourant la chaîne de gauche à droite, le nombre de parenthèses déjà ouvertes doit être, à tout moment, supérieur ou égal au nombre de parenthèses déjà fermées.

Ainsi, "((()())(()))" est un parenthésage correct. Les parenthésages "())(()" et "(())(()" sont, eux, incorrects.

On souhaite programmer une fonction parenthesage qui prend en paramètre une chaîne de caractères ch formée de parenthèses et renvoie True si la chaîne ch est bien parenthésée et False sinon.

Cette fonction utilise une pile et suit le principe suivant : en parcourant la chaîne de gauche à droite, si on trouve une parenthèse ouvrante, on l’empile au sommet de la pile et si on trouve une parenthèse fermante, on dépile (si possible) la parenthèse ouvrante stockée au sommet de la pile.

La chaîne est alors bien parenthésée si, à la fin du parcours, la pile est vide. Elle est, par contre, mal parenthésée :

  • si dans le parcours, on trouve une parenthèse fermante, alors que la pile est vide ;
  • ou si, à la fin du parcours, la pile n’est pas vide.

Compléter le code de parenthesage et le tester.

Exemples :

>>> parenthesage("((()())(()))")
True
>>> parenthesage("())(()")
False
>>> parenthesage("(())(()")
False

Code:

def parenthesage(ch: str) -> bool:
    """
    Renvoie True si la chaîne ch est bien parenthésée
    et False sinon
    """
    p: Pile[str] = creer()
    for c in ch:
        if c == ...:
            empiler(c, p)
        elif c == ...:
            if est_vide(p):
                return ...
            else:
                ...
    return est_vide(p)