Programmation structurée (Python) · L1 · Section 4/11
Structures de données
Progression
#Structures de données
Le choix d'une structure détermine quelles opérations sont simples et efficaces. Python offre quatre collections de base: liste (séquence ordonnée modifiable), tuple (séquence ordonnée immuable), dictionnaire (association clé-valeur) et ensemble (éléments uniques, test d'appartenance rapide).
Prérequis: variables et types (mutabilité, aliasing).
Objectifs d'apprentissage:
- Choisir la collection adaptée: séquence, association, unicité.
- Connaître les opérations courantes et leurs ordres de grandeur.
- Utiliser les tuples pour les enregistrements et retours multiples.
- Éviter le piège de la modification pendant itération.
#Listes
Une liste est une séquence ordonnée et mutable. L'accès par index est direct (xs[0], coût constant); l'insertion en tête décale tous les éléments (coût linéaire).
1a = [3, 1, 2]2a.append(4) # ajout en fin: [3, 1, 2, 4]3a.sort() # tri en place4print(a) # [1, 2, 3, 4]5print(a[1:]) # [2, 3, 4]6print(sum(a), max(a), a.index(3)) # 10 4 2Les tranches (a[1:]) produisent une nouvelle liste: a[debut:fin] va de l'index debut inclus à fin exclu. Le tri en place a.sort() modifie la liste et retourne None, tandis que sorted(a) retourne une nouvelle liste triée sans toucher à l'originale.
#Tuples
Un tuple est une séquence ordonnée immuable, adaptée aux enregistrements de petite taille (coordonnées, paires clé-valeur) et aux retours multiples.
1point = (3, 4)2x, y = point # déballage3print(x, y) # 3 44 5def min_max(valeurs: list[int]) -> tuple[int, int]:6 return min(valeurs), max(valeurs)7 8bas, haut = min_max([12, 9, 18])9print(bas, haut) # 9 18L'immuabilité rend les tuples hachables (s'ils ne contiennent que des hachables): ils servent de clés de dictionnaire, ce que les listes ne peuvent pas.
1distances = {(3, 4): 5}2# distances[[3, 4]] = 5 # TypeError: unhashable type: 'list'#Dictionnaires
Un dictionnaire associe des clés (immuables et hachables) à des valeurs. La recherche par clé est quasi constante, ce qui en fait la structure de choix pour "associer".
1prix = {'pomme': 1.2, 'banane': 2.0}2prix['cerise'] = 3.5 # ajout3print(prix['pomme']) # 1.24print(prix.get('kiwi', 0)) # 0 (clé absente, valeur par défaut)5 6for fruit, p in sorted(prix.items()):7 print(f"{fruit}: {p} €")8# Sortie:9# banane: 2.0 €10# cerise: 3.5 €11# pomme: 1.2 €.get(cle, defaut) évite le KeyError des clés absentes. Trois méthodes de parcours: .items() (paires), .keys() (clés), .values() (valeurs).
Comptage idiomatique: le dictionnaire est la structure naturelle pour compter des occurrences.
1mots = ['le', 'chat', 'le', 'chien', 'le']2compteur = {}3for mot in mots:4 compteur[mot] = compteur.get(mot, 0) + 15print(compteur) # {'le': 3, 'chat': 1, 'chien': 1}#Ensembles
Un ensemble stocke des éléments uniques sans ordre. Deux usages: dédupliquer, et tester l'appartenance en temps quasi constant.
1s = {1, 2, 3}2s.add(3) # déjà présent: rien ne change3print(s, len(s)) # {1, 2, 3} 34 5a, b = {1, 2, 3}, {2, 3, 4}6print(a | b) # union: {1, 2, 3, 4}7print(a & b) # intersection: {2, 3}8print(a - b) # différence: {1}9 10print(sorted({5, 5, 3, 1})) # [1, 3, 5]: déduplication puis tri11print(2 in a) # True, test rapide#Choisir: tableau de décision
| Besoin | Structure | Opération clé et coût |
|---|---|---|
| Séquence modifiable | list | xs[i] et append quasi constants; insertion en tête linéaire |
| Enregistrement immuable | tuple | accès par index quasi constant |
| Associer clé → valeur | dict | d[cle] quasi constant |
| Unicité, appartenance | set | x in s quasi constant |
Pour n éléments, parcourir une liste ou un dictionnaire coûte n étapes; tester x in liste coûte jusqu'à n comparaisons, x in ensemble quasi constant: sur un million d'éléments, la différence se voit.
#Ce que la structure impose: coûts comparés
Les collections Python ne sont pas interchangeables, et le cours d'algorithmique justifie ces différences par la représentation en mémoire. Un tableau est une zone contiguë: l'élément d'indice i se calcule par une adresse, ce qui donne un accès direct. Une liste chaînée est une suite de maillons reliés par des pointeurs: pour atteindre le k-ième élément, il faut traverser les k-1 précédents.
| Opération | Tableau | Liste chaînée |
|---|---|---|
Accès au k-ième élément | Θ(1) | Θ(k) |
| Recherche d'une valeur (non trié) | Θ(n) | Θ(n) |
| Recherche d'une valeur (trié) | Θ(log n) par dichotomie | Θ(n) |
| Insertion en tête | Θ(n) (décalage de tous) | Θ(1) |
| Suppression en tête | Θ(n) | Θ(1) |
| Insertion en fin | Θ(1) amorti | Θ(1) avec un pointeur de queue |
Le point à retenir est le compromis: le tableau gagne sur l'accès, la liste chaînée sur la modification en tête. Aucune structure n'est meilleure partout, et c'est la raison pour laquelle le choix de la structure précède l'écriture de l'algorithme.
En Python, la liste combine les deux régimes: a[i] est direct, a.append(x) est en temps constant amorti, mais a.insert(0, x) décale tous les éléments et coûte Θ(n). Pour empiler en tête de façon répétée, on utilise collections.deque.
#Pile et file
Une pile (LIFO, dernier entré premier sorti) et une file (FIFO, premier entré premier sorti) ne diffèrent que par l'extrémité où l'on retire:
1from collections import deque2 3# Pile : on empile et on dépile par la fin4pile = []5pile.append(1); pile.append(2); pile.append(3)6print(pile.pop()) # 3, le dernier entré7 8# File : on ajoute par la fin, on retire par le début9file = deque()10file.append(1); file.append(2); file.append(3)11print(file.popleft()) # 1, le premier entréLes deux opérations sont en temps constant. Utiliser une liste comme file avec pop(0) serait un piège: pop(0) décale tous les éléments restants et transforme un traitement linéaire en traitement quadratique.
#Listes chaînées: la structure récursive en mémoire
La liste chaînée est la traduction directe de la définition récursive [n] · L1. Un maillon porte une valeur et un lien vers le maillon suivant; le lien None joue le rôle de la liste vide.
1class Maillon:2 __slots__ = ('valeur', 'suivant')3 def __init__(self, valeur, suivant=None):4 self.valeur = valeur5 self.suivant = suivant6 7def taille(tete: Maillon | None) -> int:8 if tete is None:9 return 010 return 1 + taille(tete.suivant)11 12def insere_tete(tete: Maillon | None, valeur) -> Maillon:13 return Maillon(valeur, tete) # Θ(1) : deux pointeurs suffisent14 Insérer en tête ne coûte que la création d'un maillon, alors que l'insertion en tête d'une liste Python décale tout. En revanche, atteindre le dernier élément demande de traverser toute la chaîne: la liste chaînée paie en accès ce qu'elle économise en modification.
#Pièges fréquents
#Modifier pendant l'itération
1nombres = [1, 2, 3, 4]2# Mauvais: la suppression décale les indices, des éléments sont sautés3for x in nombres[:]: # copie explicite: sûr4 if x % 2 == 0:5 nombres.remove(x)6print(nombres) # [1, 3]7 8# Meilleur: compréhension, sans mutation9pairs_gardes = [x for x in [1, 2, 3, 4] if x % 2 == 1]10print(pairs_gardes) # [1, 3]#Clé absente
d['inconnue'] lève KeyError: utilisez .get(cle) ou testez if cle in d: quand l'absence est un cas normal.
#Playground
#Exercices
- Tri par sélection en place. Implémentez le tri par sélection (à chaque tour, chercher le minimum du reste et l'échanger avec la position courante). Vérifiez que
[5, 2, 4, 1, 3]devient[1, 2, 3, 4, 5]. - Index inversé. Construisez un dictionnaire associant chaque mot d'un texte à la liste de ses positions. Pour
'le chat le chien', attendez{'le': [0, 2], 'chat': [1], 'chien': [3]}. - Déduplication ordonnée. Supprimez les doublons d'une liste en conservant l'ordre de première apparition. Pour
[3, 1, 3, 2, 1], attendez[3, 1, 2]. - File de priorité avec heapq. Implémentez une file où
pop()retire l'élément de priorité la plus haute en premier. - File avec deux piles. Implémentez une file FIFO en n'utilisant que deux piles (listes avec
append/pop). Analysez le coût amorti d'une suite denopérations. - Liste chaînée. À partir de la classe
Maillon, écrivezrenverse(tete)qui retourne la liste chaînée dans l'ordre inverse, en temps linéaire et sans allouer de nouvelle liste. - Tri par comptage. Implémentez le tri par comptage pour des entiers dans
[0, k]et vérifiez qu'il est stable sur une entrée contenant des doublons.
#Corrections
Correction: tri par sélection en place
1def tri_selection(xs: list[int]) -> None:2 for i in range(len(xs)):3 mini = i4 for j in range(i + 1, len(xs)):5 if xs[j] < xs[mini]:6 mini = j7 xs[i], xs[mini] = xs[mini], xs[i]8 9xs = [5, 2, 4, 1, 3]10tri_selection(xs)11print(xs) # [1, 2, 3, 4, 5]Invariant: après le tour i, xs[:i+1] contient les i+1 plus petits éléments, triés. Coût quadratique en nombre de comparaisons.
Correction: index inversé
1def index_inverse(texte: str) -> dict[str, list[int]]:2 index = {}3 for pos, mot in enumerate(texte.split()):4 index.setdefault(mot, []).append(pos)5 return index6 7print(index_inverse('le chat le chien'))8# {'le': [0, 2], 'chat': [1], 'chien': [3]}enumerate fournit position et élément; setdefault renvoie la liste existante ou la crée. Invariant de structure: chaque liste est strictement croissante.
Correction: déduplication ordonnée
1def sans_doublons(xs: list) -> list:2 vus = set()3 resultat = []4 for x in xs:5 if x not in vus:6 vus.add(x)7 resultat.append(x)8 return resultat9 10print(sans_doublons([3, 1, 3, 2, 1])) # [3, 1, 2]L'ensemble rend le test de présence quasi constant; la liste préserve l'ordre. Convertir en set seul perdrait l'ordre.
Correction: file de priorité avec heapq
1import heapq2 3class FilePriorite:4 def __init__(self):5 self._tas = []6 self._compteur = 07 8 def push(self, item, priorite: int) -> None:9 heapq.heappush(self._tas, (-priorite, self._compteur, item))10 self._compteur += 111 12 def pop(self):13 return heapq.heappop(self._tas)[2]14 heapq implémente un tas-min: on stocke -priorite pour extraire d'abord la priorité la plus haute. Le compteur croissant départage les égalités et stabilise l'ordre d'insertion.
Correction: file avec deux piles
1class FileDeuxPiles:2 def __init__(self):3 self._entree = [] # pile où l'on empile les nouveaux éléments4 self._sortie = [] # pile d'où l'on dépile5 6 def enfile(self, x) -> None:7 self._entree.append(x) # Θ(1)8 9 def defile(self):10 if not self._sortie: # transférer seulement si vide11 while self._entree:12 self._sortie.append(self._entree.pop())13 return self._sortie.pop()14 Chaque élément est empilé une fois dans _entree, transféré au plus une fois, puis dépilé une fois: trois opérations constantes au total pour son passage complet. Sur n opérations, le coût total est Θ(n), soit Θ(1) amorti par opération, même si un defile particulier peut coûter Θ(n) quand il déclenche un transfert. La règle « transférer seulement si _sortie est vide » est ce qui garantit cette borne.
Correction: renversement d'une liste chaînée
1def renverse(tete):2 """Renverse la liste chaînée en Θ(n), sans allocation supplémentaire."""3 precedent = None4 courant = tete5 while courant is not None:6 suivant = courant.suivant # mémoriser avant d'écraser le lien7 courant.suivant = precedent # inverser le pointeur8 precedent = courant9 courant = suivant10 return precedent # l'ancienne queue est devenue la tête11 12# 1 -> 2 -> 3 devient 3 -> 2 -> 113chaine = None14for x in [3, 2, 1]:Ici Maillon et taille sont ceux définis plus haut dans ce chapitre.
L'invariant de la boucle est: precedent pointe la portion déjà renversée, courant pointe la portion restante, et les deux ne se recouvrent jamais. Chaque maillon est visité une fois, donc le coût est Θ(n); aucun maillon n'est créé.
Correction: tri par comptage
1def tri_comptage(a: list[int], k: int) -> list[int]:2 c = [0] * (k + 1)3 for x in a:4 c[x] += 15 for i in range(1, k + 1):6 c[i] += c[i - 1]7 b = [0] * len(a)8 for j in range(len(a) - 1, -1, -1):9 c[a[j]] -= 110 b[c[a[j]]] = a[j]11 return b12 13a = [4, 1, 3, 4, 0, 1, 4]14print(tri_comptage(a, 4)) # [0, 1, 1, 3, 4, 4, 4]Stabilité. Après la deuxième boucle, c[v] est le nombre d'éléments inférieurs ou égaux à v. La troisième boucle parcourt l'entrée de la droite vers la gauche et écrit chaque élément à la dernière place encore libre pour sa valeur: le dernier 4 rencontré (indice 6) va en position 6, puis le précédent en position 5, puis celui d'indice 0 en position 4. L'ordre relatif des trois 4 est donc préservé. Parcourir de gauche à droite les écrirait en ordre inverse et casserait la stabilité.
Coût : trois boucles de longueurs respectives n, k et n, donc Θ(n + k) temps et Θ(n + k) mémoire.