Physique-Chimie & NSI

Cours complets et originaux de Physique-Chimie & NSI

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
Concept de tableau en programmation 0 14 1 2 2 -5 3 20 index valeurs stockées
Tableau statique

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
Structure d’une liste chaînée 14 2 -5 20 valeurs pointeurs vers la valeur suivante
Liste chaînée

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 :

  1. Comment on crée une nouvelle ChainList et ce qui se passe au niveau du script.
  2. Comment ajouter le premier élément "mangue" à cette liste et ce qui se passe au niveau du script.
  3. 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 ?

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 😏

Principe d’une file 5 -2 14 20 33 défiler enfiler
Une file

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)

Principe d’une pile 5 -2 14 20 5 -2 14 20 33 33 empiler dépiler
Une pile

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é.