cours1 min de lecture

Implémenter une structure — l'exemple de la file FIFO

Mettre en pratique l'objet et l'interface — la même file FIFO codée de deux manières, puis comparée du point de vue de la complexité.
programme

Introduction

Le cours précédent a posé un principe : une interface est un contrat, une implémentation est un code qui le réalise — et un même contrat admet plusieurs implémentations. Le moment est venu d'en faire l'expérience sur un exemple canonique de Terminale : la file FIFO.

Vous allez voir deux classes, FileTableau et FileDeuxPiles, qui exposent la même interface — quatre méthodes au comportement strictement identique du point de vue de l'utilisateur — mais dont les performances diffèrent radicalement. C'est l'illustration parfaite de l'item : « écrire plusieurs implémentations d'une même structure de données ».

Ce cours suppose acquis le vocabulaire du cours précédent (class, __init__, self, méthode, encapsulation). Si l'un de ces termes vous échappe, retournez au cours 1.

L'interface d'une file FIFO

Une file (en anglais queue) est une structure linéaire au comportement FIFOFirst In, First Out. L'analogie est immédiate : la file d'attente à la boulangerie. On entre par la queue, on sort par la tête, on ne double pas.

Son interface comporte typiquement quatre opérations :

MéthodeSignatureEffet attendu
enfiler(x)File × Élément → FileAjoute x à la queue de la file.
defiler()File → ÉlémentRetire et renvoie l'élément de tête ; lève une exception si la file est vide.
est_vide()File → BooléenRenvoie True si la file est vide, False sinon.
taille()File → EntierRenvoie le nombre d'éléments.
Cette interface ne dit rien sur la mémoire utilisée ni sur la complexité. Elle décrit un contrat. Une implémentation qui le respecte est valable, même si elle est lente.

Dans une file FIFO, le prochain élément à sortir est…

Implémentation 1 — avec un tableau (liste Python)

La représentation la plus naturelle utilise une liste Python comme stockage interne. Les éléments sont rangés dans l'ordre d'arrivée ; on enfile à la fin, on défile au début.

class FileTableau:
    """File FIFO implémentée par une liste Python."""

    def __init__(self):
        self._data = []

    def enfiler(self, x):
        self._data.append(x)            # O(1) amorti

    def defiler(self):
        if not self._data:
            raise IndexError("file vide")
        return self._data.pop(0)        # O(n) — décalage de tous les éléments

    def est_vide(self):
        return len(self._data) == 0

    def taille(self):
        return len(self._data)

Traçons un scénario complet pour vérifier la sémantique FIFO :

f = FileTableau()
f.enfiler(1)
f.enfiler(2)
f.enfiler(3)
print(f.defiler())   # 1 — le premier entré
print(f.defiler())   # 2
print(f.taille())    # 1

Analyse de complexité :

  • enfiler repose sur list.append, qui s'exécute en O(1)\mathcal{O}(1) amorti.
  • defiler repose sur list.pop(0), qui décale tous les éléments restants vers la gauche — coût en O(n)\mathcal{O}(n)nn est la taille courante.

Cette implémentation est simple à lire mais non optimale. Sur une file de un million d'éléments, chaque defiler réalise un million d'écritures mémoire — inacceptable en production.

Implémentation 2 — avec deux piles

Une astuce classique : utiliser deux piles pour simuler une file. La pile d'entrée reçoit les enfiler ; la pile de sortie sert aux defiler. Quand la sortie est vide, on transvase tout le contenu de l'entrée dedans, ce qui inverse l'ordre — et le premier entré redevient le premier sorti.

class FileDeuxPiles:
    """File FIFO implémentée par deux piles (listes utilisées en LIFO)."""

    def __init__(self):
        self._entree = []        # pile : on append/pop en bout
        self._sortie = []

    def enfiler(self, x):
        self._entree.append(x)                       # O(1)

    def defiler(self):
        if not self._sortie:
            while self._entree:
                self._sortie.append(self._entree.pop())
        if not self._sortie:
            raise IndexError("file vide")
        return self._sortie.pop()                    # O(1) amorti

    def est_vide(self):
        return not self._entree and not self._sortie

    def taille(self):
        return len(self._entree) + len(self._sortie)

Traçons le même scénario qu'avant — l'observation extérieure est identique :

f = FileDeuxPiles()
f.enfiler(1)
f.enfiler(2)
f.enfiler(3)
print(f.defiler())   # 1
print(f.defiler())   # 2
print(f.taille())    # 1
File à deux piles : transvasement entrée vers sortie _entrée ← sommet 3 2 1 TRANSVASE _sortie vide PUIS → [3, 2, 1] 1 au sommet, sort en 1er
Deux piles côte à côte : pile entrée à gauche avec [1, 2, 3] du bas vers le haut, pile sortie vide à droite. Le transvasement depuis le sommet de l'entrée produit [3, 2, 1] dans la sortie — le 1 se retrouve au sommet, prêt à sortir.

Pour défiler avec deux piles, on transvase l'entrée vers la sortie uniquement quand…

Pourquoi est-ce plus rapide ?

À première vue, le transvasement coûte cher : il faut bien kk opérations pour basculer kk éléments. Mais regardez ce qui se passe sur une longue suite d'opérations.

Analyse amortie

Chaque élément, sur toute sa durée de vie, subit exactement quatre manipulations de pile :

  1. un append dans _entree à son enfiler ;
  2. un pop de _entree lors du transvasement ;
  3. un append dans _sortie lors du transvasement ;
  4. un pop de _sortie à son defiler.

Sur une suite de nn opérations, le coût total est en O(n)\mathcal{O}(n) — soit O(1)\mathcal{O}(1) amorti par opération.

C'est la beauté de la complexité amortie : on ne raisonne pas sur le pire cas d'une opération isolée (qui peut être un gros transvasement), mais sur le coût moyen observé sur une longue séquence d'opérations.

La promesse de l'abstraction

Les deux classes FileTableau et FileDeuxPiles exposent exactement la même interface. Un code qui utilise f.enfiler(x) puis f.defiler() fonctionne sans modification, quelle que soit la classe choisie. C'est la promesse de l'abstraction : on peut changer le moteur sans changer la carrosserie.

# Ce code fonctionne identiquement avec FileTableau OU FileDeuxPiles
def traiter_taches(file_de_taches):
    while not file_de_taches.est_vide():
        tache = file_de_taches.defiler()
        # ... traiter la tâche ...
Règle de l'abstraction : un programme client doit dépendre de l'interface, jamais de l'implémentation. Si vous écrivez f._data[0] au lieu de f.defiler(), vous brisez ce contrat — et vous perdez la liberté de changer d'implémentation sans casser le programme.

Récapitulatif comparatif

CritèreFileTableauFileDeuxPiles
Stockage interneune listedeux listes (piles)
enfilerO(1)\mathcal{O}(1) amortiO(1)\mathcal{O}(1)
defilerO(n)\mathcal{O}(n)O(1)\mathcal{O}(1) amorti
Pire cas d'un defiler isoléO(n)\mathcal{O}(n)O(k)\mathcal{O}(k) avec kk = taille de l'entrée au moment du transvasement
Lisibilité du codetrès simpledemande l'astuce du transvasement

Les deux respectent le contrat ; la deuxième est asymptotiquement meilleure. Le choix d'implémentation dépend du contexte : si la file reste petite et que la simplicité prime, FileTableau suffit ; si la performance compte, FileDeuxPiles (ou mieux, collections.deque — voir ci-dessous).

Pour aller plus loin

Le module standard collections.deque fournit une file double — O(1)\mathcal{O}(1) à chaque bout — implémentée en C. C'est ce que vous utiliserez en pratique :

from collections import deque
f = deque()
f.append(1)         # enfiler
f.append(2)
print(f.popleft())  # defiler → 1

Mais savoir réimplémenter une file à partir de structures plus primitives est exigible au baccalauréat — c'est précisément ce que vous venez de faire, deux fois.