1-04. Structures de données linéaires
Dans ce chapitre, on aborde certaines structures de données linéaires : listes, files et piles.
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ées 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} est une liste. Mais il existe plusieurs façons d’implémenter une liste en programmation.
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(n) | O(n) |
| Insertion au milieu | O(n) | O(1)* / O(n) |
| Suppression d’un élément | O(n) | O(1)* / O(n) |
* Une fois la position connue, sinon O(n) pour trouver la position
Implémentations des « vraies » listes
Même si on exclut 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 redimensionne automatiquement l’espace mémoire quand nécessaire.
Lorsqu’une liste Python atteint sa capacité maximale et qu’un nouvel élément est ajouté, Python alloue un nouveau tableau un peu plus grand en mémoire. Il copie ensuite tous les éléments existants dans le nouveau tableau, ajoute le nouvel élément et libère l’ancien espace mémoire.
Ce compromis entre mémoire et vitesse permet un bon équilibre pour la plupart des usages courants.
Tableaux dynamiques
- 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
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 on crée une nouvelle
ChainListet ce qui se passe au niveau du script. - Comment ajouter le premier élément "mangue" à cette liste et ce qui se passe au niveau du script.
- Comment ajouter un deuxième élément "banane" à la fin de cette liste et ce qui se passe au niveau du script.
2. Expliquer comment fonctionne la méthode _navigate_to. En quoi cela entraîne une complexité en O(n) ?
3. Compléter la méthode remove_at en vous inspirant de la méthode insert_at.
4. Quel peuvent être les avantages et les inconvénients de cette implémentation par rapport à un tableau ?
Correction
1.a. test = ChainList(). La méthode __init__ est déclenchée. La liste créée a pour head None et pour size 0. D’après la méthode __init__, il n’est pas possible de créer autre chose qu’une liste vide initialement.
1.b. Pour ajouter un premier élément, on doit faire test.append("mangue"). Un nouveau Node est créé, et comme la liste est vide, il devient le head de la liste. Sa taille (size) est augmentée de 1.
1.c. Pour ajouter un deuxième élément à la fin de cette liste, il suffit de refaire test.append("banane"). Un nouveau Node est créé, mais comme il y en a déjà un dans la liste, la méthode append va chercher le dernier Node de la liste et le connecter au nouveau Node créé en le déclarant comme next du dernier Node actuel.
2. La méthode _navigate_to cherche le head de la liste, puis parcourt les Node les uns après les autres jusqu’à arriver au Node correspondant à l’index recherché. La complexité est O(n) car elle est proportionnelle à la taille de la liste.
3. La méthode remove_at :
def remove_at(self, index):
"""Supprime l'élément à la position donnée."""
if index < 0 or index > self.size-1 or self.size == 0:
raise IndexError("Index hors limites")
if index == 0:
self.head = self.head.next
else:
node_before_deletion = self._navigate_to(index-1)
node_before_deletion.next = node_before_deletion.next.next
self.size -= 1
4.Avantages : pas besoin d’un bloc contigu ; insertion et suppression efficaces une fois la position connue.
Inconvénients : surcoût mémoire pour les liens ; accès séquentiel aux éléments.
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 Intarisablement 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 assez inutile.
File d’attente d’un serveur d’impression
Dans un établissement, les élèves envoient leurs documents à une imprimante commune. Les travaux sont imprimés dans leur ordre d’arrivée. On modélise la file par une liste Python.
La file est initialement vide. On reçoit successivement les travaux rapport.pdf, affiche.png et exercice.pdf. L’imprimante traite ensuite une impression. Un nouveau travail, presentation.pdf, arrive, puis l’imprimante traite les travaux restant.
1. Donner le contenu de la file après chaque arrivée et chaque impression.
2. Écrire ajouter_travail(file, nom) et traiter_travail(file). Cette dernière fonction renvoie le document traité et le retire de la file. On suppose que traiter_travail n’est appelée que si la file n’est pas vide.
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