2-01. Structures de données linéaires
Dans ce chapitre, on aborde certaines structures de données linéaires : listes, files et piles. Tout ça s’implémente avec les lists en Python, mais le monde du développement est bien plus vaste que Python. 😏
Implémentation et interface
- Spécifier une structure de données par son interface.
- Distinguer interface et implémentation.
L’implémentation d’un type de données, c’est la manière dont ces données sont gérées et stockées en mémoire.
Par exemple, une liste peut être implémentée sous forme de liste chaînée ou de tableau. Dans Python, les listes sont implémentée sous forme de tableau dynamique.
Dans le premier langage qui a implémenté les listes (LISP, 1958), il s’agissait de listes chainées.
J’en dirai plus sur ces deux concepts (tableaux et listes chaînées) dans le prochain paragraphe.
L’interface d’un type de données, c’est l’ensemble des opérations qu’on peut réaliser dans un langage de programmation donné sur ce type de données.
En gardant toujours les listes comme exemple, dans Python, on dispose d’un ensemble de méthodes (pop
, append
, extend
, remove
…) associées aux listes.
Dans LISP, par contre, l’interface était plus réduite : on pouvait obtenir le premier élément de la liste (car
), obtenir toute la liste sauf le premier élément (cdr
) et construire une nouvelle liste à partir d’un élément et d’une autre liste – éventuellement vide (cons
).
La même interface peut avoir différentes implémentations, et l’utilisateur n’a généralement pas besoin de connaître l’implémentation sous-jacente pour utiliser l’interface.
L’utilisateur peut également définir l’implémentation lui-même. Nous en avons vu un exemple avec les graphes. On peut choisir de les implémenter avec des listes d’adjacences ou des matrices d’adjacence. Et grâce à la POO, on peut même aller plus loin en implémentant la classe Graphe, fournissant des méthodes spécifiques.
Les listes
- Choisir une structure de données adaptée à la situation à modéliser
- Écrire plusieurs implémentations d’une même structure de données
Une liste est une suite d’éléments rangés dans un certain ordre. Par exemple, la séquence des nombres {14, 2, -5, 20}. Mais il existe de nombreuse façon d’implémenter une liste.
Listes ou tableaux ?
Les listes et les tableaux sont des types abstraits de données linéaires qui permettent de stocker des éléments de façon ordonnée, mais qui diffèrent par leur mode d’accès et leurs opérations caractéristiques: accès direct pour les tableaux, séquentiel pour les listes.
Tableaux (arrays)
Les tableaux sont des structures de données caractérisées par :
- Contiguïté en mémoire : tous les éléments sont stockés côte à côte dans la mémoire
- Taille fixe définie à la création (pour les tableaux statiques)
- Accès direct : l’accès à n’importe quel élément se fait en temps constant O(1) via son index
- Efficacité en lecture : très performants pour l’accès aléatoire aux données
Listes chaînées
Les listes chaînées, en revanche, présentent des caractéristiques différentes :
- Éléments dispersés : chaque élément (nœud) peut être placé n’importe où en mémoire
- Structure dynamique : peut grandir ou rétrécir facilement pendant l’exécution
- Accès séquentiel : pour accéder au nème élément, il faut parcourir tous les précédents
- Efficacité en modification : insertions et suppressions efficaces une fois la position connue
Comparaison des opérations
Opération | Tableau | Liste chaînée |
---|---|---|
Accès à un élément | O(1) | O(n) |
Insertion au début | O(n) | O(1) |
Insertion à la fin | O(1)* ou O(n) | O(n) ou O(1)** |
Insertion au milieu | O(n) | O(1)*** |
Suppression | O(n) | O(1)*** |
* Pour un tableau dynamique avec espace supplémentaire réservé
** Si on maintient un pointeur vers la fin
*** Une fois la position connue, sinon O(n) pour trouver la position
Tableaux dynamiques
Les tableaux dynamiques (comme les listes Python) combinent les avantages :
- Structure contiguë en mémoire comme un tableau
- Capacité à s’agrandir dynamiquement comme une liste
- Implémentés en allouant un tableau plus grand quand nécessaire
Implémentations des « vraies » listes
Même si on exclue les tableaux, il reste plusieurs manières possibles d’implémenter des listes (c’est-à-dire une suite de valeurs ordonnées réparties de manière non contigüe en mémoire).
Liste chaînée simple
La forme la plus basique, où chaque élément contient :
- Une valeur
- Un pointeur vers l’élément suivant
Caractéristiques : Parcours unidirectionnel, insertion facile en tête (O(1)), mais recherche séquentielle (O(n)).
Liste doublement chaînée
Chaque élément contient :
- Une valeur
- Un pointeur vers l’élément suivant
- Un pointeur vers l’élément précédent
Caractéristiques : Navigation bidirectionnelle, facilite la suppression et l’insertion, coût mémoire supplémentaire.
Et d’autres variantes existent : liste circulaire, liste avec sentinelle…
Les listes Python ne sont pas des listes !
Bien que Python appelle cette structure « list », les listes Python ne sont pas des listes chaînées mais des tableaux dynamiques. Cette implémentation :
- Stocke les éléments de manière contiguë en mémoire
- Permet un accès direct par index en O(1)
- Redimensionne automatiquement l’espace mémoire quand nécessaire
Conséquences sur les performances
Cette implémentation influence directement l’efficacité des opérations :
- Ajout à la fin : généralement O(1), mais occasionnellement O(n) lors d’un redimensionnement
- Insertion/suppression au début ou milieu : O(n) car tous les éléments suivants doivent être déplacés
- Recherche par valeur : O(n)
Comportement lors du redimensionnement
Lorsqu’une liste Python atteint sa capacité maximale et qu’un nouvel élément est ajouté :
- Python alloue un nouveau tableau plus grand en mémoire (généralement 1,125× la taille précédente)
- Copie tous les éléments existants dans le nouveau tableau
- Ajoute le nouvel élément
- Libère l’ancien espace mémoire
Ce compromis entre mémoire et vitesse permet un bon équilibre pour la plupart des usages courants.
Mais pourquoi tant de listes ?
Quand on dispose de ressources de calculs ou de mémoire très restreintes, on doit optimiser son usage. Et donc il faut une implémentation adaptée.
En informatique embarquée ou en domotique (par exemple avec les arduinos), on peut se trouver dans ce cas de figure.
Arduino Uno
- RAM : 2 ko
- Cadence processeur : 16 MHz
Apollo XI (1969)
- RAM : 4 ko
- Cadence processeur : 1 MHz
Voyager 1 & 2 (1977)
- Mémoire totale : 72 ko
Dans ces environnements, le choix d'implémentation n'est pas une question d'élégance théorique mais de survie de la mission :
- Les listes chaînées peuvent être préférables quand la mémoire est très fragmentée
- Les tableaux sont privilégiés quand l'accès rapide aux données est critique
- Des structures hybrides peuvent être développées spécifiquement pour ces contraintes
Ces considérations, quoique moins critiques dans la programmation quotidienne, restent pertinentes aujourd'hui dans l'IoT, les systèmes embarqués et les applications temps réel.
Implémentation d’une liste chaînée en Python
On souhaite implémenter des listes chaînées en Python.
class Node:
"""Représente un nœud dans une liste chaînée."""
def __init__(self, data=None):
self.data = data # Donnée stockée
self.next = None # Nœud suivant (class Node ou None)
def __str__(self):
return str(self.data)
class ChainList:
"""Implémentation d'une liste chaînée en Python."""
def __init__(self):
self.head = None # Premier élément = None à l’instantiation
self.size = 0 # Nombre d'éléments dans la liste
def is_empty(self):
"""Vérifie si la liste est vide."""
return self.head is None
def _navigate_to(self, index):
"""méthode "privée" renvoyant l’élément se trouvant à l’index spécifié"""
if index < 0 or index > self.size-1:
raise IndexError("Index hors limites")
current = self.head # on part du 1er nœud
for _ in range (index):
current = current.next # on navigue jusqu’au nœud cible
return current
def append(self, data):
"""Ajoute un élément à la fin de la liste."""
new_node = Node(data)
if self.is_empty():
self.head = new_node
else:
last_node = self._navigate_to(self.size-1)
last_node.next = new_node
self.size += 1
def prepend(self, data):
"""Ajoute un élément au début de la liste."""
new_node = Node(data)
new_node.next = self.head
self.head = new_node
self.size += 1
def insert_at(self, index, data):
"""Insère un élément à une position donnée."""
if index == 0:
self.prepend(data)
else:
new_node = Node(data)
node_before_insertion = self._navigate_to(index-1)
new_node.next = node_before_insertion.next
node_before_insertion.next = new_node
self.size += 1
def remove_at(self, index):
"""Supprime l'élément à la position donnée."""
# À compléter ... ☺️
def get_at(self, index):
target_node = self._navigate_to(index)
return target_node.data
def __len__(self):
"""Retourne la taille de la liste."""
return self.size
def __str__(self):
"""Retourne une représentation en string de la liste."""
if self.is_empty():
return "[]"
result = []
current = self.head
while current:
result.append(str(current.data))
current = current.next
return "[" + ", ".join(result) + "]"
1. Expliquer comment fonctionne la méthode _navigate_to
. En quoi cela entraîne une complexité en O(n) ?
2. Compléter la méthode remove_at
en vous inspirant de la méthode insert_at
.
3. Quel peut être l’intérêt de cette implémentation par rapport à la gestion de la mémoire ?
Correction
Les files
- Distinguer des structures par le jeu des méthodes qui les caractérisent.
Une file, c’est une liste, mais dont l’usage est restreint : on ne peut qu’ajouter un élément à la fin, et obtenir l’élément au début tout en le retirant de la file. C’est ce qu’on appelle le FIFO – First In First Out.
Ne pas confondre FIFO et PIPO – parler intarissablement pour occulter, qui est le langage des politiciens 😏
Pensez à une file à un guichet. C’est le premier de la file qui est reçu. Si une nouvelle personne arrive, elle doit (théoriquement) se placer à la fin de la file, et toutes les personnes devant seront servies par ordre d’arrivée.
Souvenez-vous, on s’est servi des files lorsqu’on a étudié le parcours en largeur d’un graphe.
En python, on ne va pas réinventer la roue. On va simplement utiliser la classe native list
et se limiter aux méthodes pop(0)
et append
. Et bien sûr, on a toujours accès à la fonction len
pour savoir combien d’éléments elle contient.
file = [5, -2, 14, 20]
first = file.pop(0)
print(first) # 5
print(file) # [-2, 14, 20]
file.append(33)
print(file) # [-2, 14, 20, 33]
Bien sûr, on pourrait créer une classe File
dont les méthodes soient limitées aux méthodes autorisées, mais ça serait stupide et inutile.
Gestion des files de clients dans un supermarché
On veut écrire un script permettant de simuler la gestion des files de clients aux caisses d’un supermarché.
Les règles sont les suivantes :
- Il y a toujours au moins une caisse ouverte.
- Il y a au plus trois caisses ouvertes en même temps. Chaque caisse a sa propre file d’attente.
- Si la caisse n a plus de 10 clients, la caisse n+1, si elle existe, ouvre. Les clients ne changent pas de file pour simplifier le problème.
- Les clients arrivant en caisse choisissent toujours la caisse ouvert avec la file la plus petite.
- Chaque client est traité par une caisse en 1 à 3 minutes.
- Si une caisse a une file de 2 clients ou moins et que la caisse précédente a moins de 6 clients, elle ne prend plus de nouveau client. Elle traite les clients en cours puis ferme quand elle n’en a plus. La caisse n°1 reste cependant toujours ouverte.
- Le flux de clients arrivant chaque minute n’est pas nécessairement un entier. S’il y a par exemple un flux de 3,2 on considère que 3 clients arrivent chaque minute, avec une probabilité de 20 % d’en avoir un supplémentaire.
1. Écrire une fonction prenant en paramètre le nombre de clients arrivant en caisse chaque minute et la durée de la simulation, en minutes. Cette simulation doit montrer, minute par minute, l’évolution des clients en attente, caisse par caisse.
2. Quel doit être le flux de client pour qu’une caisse puisse suffire indéfiniment ?
3. Le magasin peut-il faire face indéfiniment à un flux de 4 clients par minutes ? Si non, combien de temps peut-il gérer un tel afflux, si on considère que plus de 30 clients en attente est inacceptable ?
Libre à vous de vous aider de la trame de code ci-dessous.
import random
import time
def meilleur_choix(caisses):
"""renvoie la première caisse ouverte avec la file la plus courte"""
# À compléter
def traitement_clients(caisses):
"""pour chaque caisse, si elle est occupée, décroît le temps d’attente de 1, sinon prend un nouveau client"""
for caisse in caisses.values():
if caisse["attente"] > 0:
...
elif len(caisse["file"]) > 0:
caisse["attente"] = random.randint(0,2)
...
def evolution(caisses):
"""gère les ouvertures et fermetures selon la taille des files"""
for key, caisse in caisses.items():
# si la caisse n a plus de 10 clients en attente, ouvrir la caisse suivante
...
# si la dernière caisse a plus de 10 clients, les caisses sont débordées !
...
# si la caisse a moins de 3 clients et la caisse précédente a moins de 6 clients
# elle ferme. Elle traite les clients en cours mais n’en accepte plus de nouveau
...
def simulation_file_attente(duree_simulation = 60, flux_clients = 2.5):
caisses = {
1: {"file": [], "ouverte": True, "attente": 0},
2: {"file": [], "ouverte": False, "attente": 0},
3: {"file": [], "ouverte": False, "attente": 0}
}
temps = 0
id_client = 1
while temps < duree_simulation:
# Nombre de clients arrivant durant cette minute
nb_nouveaux_clients = int(flux_clients) + (1 if random.random() < flux_clients % 1 else 0)
for _ in range(nb_nouveaux_clients):
# caisse attirant le nouveau client
...
# ouverture ou fermeture de caisses
...
id_client += 1
# ouverture et fermeture de caisses même si aucun nouveau client n’arrive
...
# Nombre de clients traités durant cette minutes
traitement_clients(caisses)
print(caisses)
temps += 1
time.sleep(0.15) # Pour ralentir la simulation et la rendre lisible
Correction
import random
import time
def meilleur_choix(caisses):
"""renvoie la première caisse ouverte avec la file la plus courte"""
min = 1000
index = 0
for i, c in caisses.items():
if len(c["file"]) < min and c["ouverte"]:
min = len(c["file"])
index = i
return caisses[index]
def traitement_clients(caisses):
"""pour chaque caisse, si elle est occupée, décroît le temps d’attente de 1, sinon prend un nouveau client"""
for caisse in caisses.values():
if caisse["attente"] > 0:
caisse["attente"] -= 1
elif len(caisse["file"]) > 0:
caisse["attente"] = random.randint(0,2)
caisse["file"].pop(0)
def evolution(caisses):
"""gère les ouvertures et fermetures selon la taille des files"""
for key, caisse in caisses.items():
# si la caisse n a plus de 10 clients en attente, ouvrir la caisse suivante
if len(caisse["file"]) > 10 and caisses.get(key+1):
caisses.get(key+1)["ouverte"] = True
# si la dernière caisse a plus de 10 clients, les caisses sont débordées !
elif len(caisse["file"]) > 10 and not caisses.get(key+1):
print("caisses débordées ! 😣")
# si la caisse a moins de 3 clients et la caisse précédente a moins de 6 clients
# la caisse n’accepte plus de nouveau client
elif len(caisse["file"]) < 3 and key != 1 and len(caisses.get(key-1)["file"]) < 6:
caisse["ouverte"] = False
def simulation_file_attente(duree_simulation = 60, flux_clients = 2.5):
caisses = {
1: {"file": [], "ouverte": True, "attente": 0},
2: {"file": [], "ouverte": False, "attente": 0},
3: {"file": [], "ouverte": False, "attente": 0}
}
temps = 0
id_client = 1
while temps < duree_simulation:
# Nombre de clients arrivant durant cette minute
if temps > 30 : flux_clients = 0
nb_nouveaux_clients = int(flux_clients) + (1 if random.random() < flux_clients % 1 else 0)
for _ in range(nb_nouveaux_clients):
# caisse attirant le nouveau client
meilleur_choix(caisses)["file"].append(id_client)
# ouverture ou fermeture de caisses
evolution(caisses)
id_client += 1
evolution(caisses)
# Nombre de clients traités durant cette minutes
traitement_clients(caisses)
print(caisses)
temps += 1
time.sleep(0.15) # Pour ralentir la simulation et la rendre lisible
simulation_file_attente()
Les piles
Une pile, c’est également une liste, mais dont l’usage est légèrement différent. Il suit le principe de LIFO – Last In First Out.
On se limite donc aux méthodes append
et pop
(qui, par défaut, retire et renvoie le dernier élément d’une liste)
Pensez à quand vous remettez votre copie sur mon bureau. Vous la placez sur le dessus de la pile. Et vos copies seront corrigées dans l’ordre LIFO : je corrigerai en premier la dernière copie qui a été déposée. 😏
Là encore, on va utiliser les list
de python.
pile = [5, 2, -4, 13, 18]
last = pile.pop()
print(last) # 18
print(pile) # [5, 2, -4, 13]
pile.append(33)
print(pile) # [5, 2, -4, 13, 33]
Vérificateur de parenthèses équilibrées
Objectif : Vérifier si une expression mathématique ou un code source a ses parenthèses, crochets et accolades correctement équilibrés.
Principe de l’algorithme : on passe à l’algorithme une chaîne de caractères. Celui-ci doit vérifier si la succession de parenthèse (), de crochets [] et d’accolades {} éventuellement présente est cohérente. C’est-à-dire que chaque signe ouvrant "(", "[" et "{" possède bien sa contrepartie fermante. Et bien sûr il ne peut pas y avoir de sections croisées.
Chaque fois qu’on trouve un signe ouvrant, on l’ajoute à une pile. Chaque fois qu’on trouve un signe fermant, on vérifie que le signe ouvrant équivalent est au sommet de la pile et on le dépile.
Exemples d’expressions
"{a + (b -c)} and [1, 2]" |
valide |
"{[((a et b) et c)], 3} and {[1, 2]}" |
valide |
"{[1, 2}" |
non valide (manque un "]") |
"{(1, 2} 4)" |
non valide (sections croisées) |
Proposez une implémentation de la fonction est_equilibree
remplissant l’objectif demandé.
Correction
def est_equilibre(expression):
"""Vérifie si les parenthèses, crochets et accolades sont équilibrés"""
pile = []
correspondances = {')': '(', ']': '[', '}': '{'}
for caractere in expression:
if caractere in '([{':
pile.append(caractere)
elif caractere in ')]}':
if not pile or pile.pop() != correspondances[caractere]:
return False
return len(pile) == 0