Université de Genève — Algorithmique & Structures de données
Objectifs d’apprentissage¶
À la fin de ce séminaire, vous serez capable de :
Expliquer les propriétés fondamentales des tableaux, des listes chaînées, des piles, des files et des tables de hachage
Implémenter chaque structure de données en Python à partir des principes de base
Analyser la complexité temporelle et spatiale des opérations principales de chaque structure
Choisir la structure de données appropriée pour un problème algorithmique donné
Slides : Des animations pour les piles LIFO, les files FIFO, les deques, l’insertion dans une liste chaînée et le chaînage pour les tables de hachage se trouvent dans le diaporama Data Structures (
docs/_static/slides/en/06_data_structures.htmllorsqu’il est généré). Des problèmes d’entraînement supplémentaires des versions précédentes de ce notebook sont conservés dans06_data_structures_impl_full.ipynb.
Partie 1 : Théorie et implémentations¶
1.1 Pourquoi les structures de données sont importantes¶
Une structure de données est une manière systématique d’organiser des données dans un ordinateur afin qu’elles puissent être consultées et modifiées efficacement. Choisir la mauvaise structure de données peut être la différence entre un algorithme qui s’exécute en millisecondes et un autre qui met des heures.
Considérez une analogie concrète :
Une pile d’assiettes dans une cafétéria — on prend toujours au sommet et on empile au sommet (LIFO).
Une file d’attente à la banque — premier arrivé, premier servi (FIFO).
Un annuaire téléphonique — trié alphabétiquement pour pouvoir faire une recherche binaire par nom.
Un dictionnaire / table de hachage — consulter un mot directement sans parcourir toutes les entrées.
Chaque structure a un point fort : des opérations qu’elle réalise en temps constant ou logarithmique, et des opérations coûteuses.
| Structure de données | Accès | Recherche | Insertion | Suppression | Cas d’utilisation principal |
|---|---|---|---|---|---|
| Tableau | O(1) | O(n) | O(n) — O(1) amorti à la fin | O(n) | Accès aléatoire, collections de taille connue |
| Liste chaînée | O(n) | O(n) | O(1) en tête | O(1) si on a la référence | Insertions/suppressions fréquentes en tête/queue |
| Pile | O(n) | O(n) | O(1) push | O(1) pop | Systèmes d’annulation, analyse d’expressions, DFS |
| File | O(n) | O(n) | O(1) enqueue | O(1) dequeue | BFS, ordonnancement, mise en tampon |
| Table de hachage | O(1) avg | O(1) avg | O(1) avg | O(1) avg | Recherche rapide par clé, cache, ensembles |
Remarque : O(1) amorti pour l’append d’une
listPython signifie que parfois la liste doit être redimensionnée (O(n)), mais moyenné sur de nombreuses opérations, le coût par opération est constant.
1.2 Tableaux en Python¶
La list intégrée de Python est un tableau dynamique. Elle stocke des références vers des objets de façon contiguë en mémoire, permettant un accès aléatoire O(1) par indice.
Propriétés clés :
Accès aléatoire :
arr[i]est O(1) parce que l’adresse de l’élémentiestbase_address + i * element_size.Append : O(1) amorti — Python sur-alloue de la capacité (croît d’environ 12,5 % à chaque redimensionnement).
Insertion à une position arbitraire : O(n) — tous les éléments après le point d’insertion doivent être décalés vers la droite.
Suppression à une position arbitraire : O(n) — même raison.
Slides : Voir la diapositive Python
listas a dynamic array dans le diaporama Data Structures pour un schéma compact de l’indexation et de la croissance de capacité.
1.3 Listes chaînées¶
Une liste chaînée est une structure de données linéaire dans laquelle chaque élément (appelé noeud) contient :
Un champ data stockant la valeur de l’élément.
Une référence
nextvers le noeud suivant.
Le next du dernier noeud vaut None, marquant la fin de la liste.
Avantages par rapport aux tableaux :
Insertion/suppression en tête (ou si l’on a la référence au prédécesseur) en O(1) — pas de décalage.
La taille n’est pas fixe ; les noeuds sont alloués individuellement sur le tas.
Inconvénients :
Pas d’accès aléatoire — pour atteindre l’élément
i, il faut parcourir depuis la tête : O(n).Mémoire supplémentaire par noeud pour la référence (typiquement 8 octets sur système 64 bits).
Mauvaise localité de cache comparée aux tableaux (les noeuds peuvent être dispersés en mémoire).
Exemple (recherche). Supposons que la liste soit 3 → 5 → 7 → None et que l’on veuille trouver 7. En partant de la tête, on compare à 3, puis 5, puis 7 — on s’arrête dès que l’on trouve la valeur. Dans le pire cas (valeur absente ou en queue) on peut visiter tous les noeuds, donc la recherche coûte O(n).
Étant donné uniquement head, suivez next jusqu’à trouver la cible ou atteindre None :
def contains(head, target):
cur = head
while cur is not None:
if cur.data == target:
return True
cur = cur.next
return False
# Worst case: visit every node → O(n)# ---------------------------------------------------------------------------
# Singly Linked List implementation
# ---------------------------------------------------------------------------
class Node:
"""A single node in a singly linked list."""
def __init__(self, data):
self.data = data # The payload stored in this node
self.next = None # reference to the next node (None = end of list)
class LinkedList:
"""Singly linked list: O(1) prepend at head, O(n) append at tail (traverse to end)."""
def __init__(self):
self.head = None # The first node; None when the list is empty
self.size = 0 # Track length to avoid O(n) traversal for len()
# ------------------------------------------------------------------
# Insertion operations
# ------------------------------------------------------------------
def prepend(self, data):
"""Insert a new node at the FRONT of the list — O(1)."""
new_node = Node(data)
new_node.next = self.head # New node points to old head
self.head = new_node # Head now points to new node
self.size += 1
def append(self, data):
"""Insert a new node at the BACK of the list — O(n)."""
new_node = Node(data)
if self.head is None: # Empty list: new node becomes head
self.head = new_node
else:
# Traverse to the last node
current = self.head
while current.next is not None:
current = current.next
current.next = new_node # Attach new node at the tail
self.size += 1
def insert_after(self, target_data, new_data):
"""Insert new_data immediately after the first node with target_data — O(n)."""
current = self.head
while current is not None:
if current.data == target_data:
new_node = Node(new_data)
new_node.next = current.next
current.next = new_node
self.size += 1
return
current = current.next
raise ValueError(f"Value {target_data} not found in list.")
# ------------------------------------------------------------------
# Deletion
# ------------------------------------------------------------------
def delete(self, data):
"""Remove the first node containing data — O(n)."""
if self.head is None:
raise IndexError("Cannot delete from an empty list.")
# Special case: the node to delete is the head
if self.head.data == data:
self.head = self.head.next
self.size -= 1
return
# General case: traverse until we find the predecessor of the target node
current = self.head
while current.next is not None:
if current.next.data == data:
current.next = current.next.next # Bypass the target node
self.size -= 1
return
current = current.next
raise ValueError(f"Value {data} not found in list.")
# ------------------------------------------------------------------
# Traversal / utility
# ------------------------------------------------------------------
def to_list(self):
"""Return the linked list contents as a Python list — O(n)."""
result = []
current = self.head
while current is not None:
result.append(current.data)
current = current.next
return result
def __repr__(self):
return ' -> '.join(str(v) for v in self.to_list()) + ' -> None'
# ---------------------------------------------------------------------------
# Demonstration
# ---------------------------------------------------------------------------
ll = LinkedList()
for val in [10, 20, 30, 40, 50]:
ll.append(val)
print("After appending 10..50 :", ll)
ll.prepend(5)
print("After prepend(5) :", ll)
ll.insert_after(20, 25)
print("After insert_after(20,25):", ll)
ll.delete(25)
print("After delete(25) :", ll)After appending 10..50 : 10 -> 20 -> 30 -> 40 -> 50 -> None
After prepend(5) : 5 -> 10 -> 20 -> 30 -> 40 -> 50 -> None
After insert_after(20,25): 5 -> 10 -> 20 -> 25 -> 30 -> 40 -> 50 -> None
After delete(25) : 5 -> 10 -> 20 -> 30 -> 40 -> 50 -> None
1.4 Piles¶
Une pile est un type abstrait de données qui suit le principe Last-In, First-Out (LIFO). Le dernier élément poussé sur la pile est le premier à en être retiré.
Opérations principales :
| Opération | Description | Complexité |
|---|---|---|
push(x) | Ajouter l’élément x au sommet | O(1) |
pop() | Retirer et retourner l’élément au sommet | O(1) |
peek() / top() | Retourner l’élément au sommet sans le retirer | O(1) |
is_empty() | Vrai si la pile est vide | O(1) |
size() | Nombre d’éléments | O(1) |
Applications : pile d’appels, annulation/rétablissement, évaluation d’expressions, vérification de crochets, DFS.
# ---------------------------------------------------------------------------
# Stack implementation using a Python list as the underlying storage.
# The END of the list is the TOP of the stack (append/pop are both O(1)).
# ---------------------------------------------------------------------------
class Stack:
"""LIFO stack backed by a Python list."""
def __init__(self, capacity=None):
"""
Parameters
----------
capacity : int or None
Maximum number of elements (None = unbounded).
"""
self._data = [] # Internal storage
self._capacity = capacity # Optional fixed capacity
def push(self, item):
"""Push item onto the top of the stack — O(1) amortised."""
if self._capacity is not None and len(self._data) >= self._capacity:
raise OverflowError("Stack overflow: capacity exceeded.")
self._data.append(item)
def pop(self):
"""Remove and return the top element — O(1)."""
if self.is_empty():
raise IndexError("Stack underflow: cannot pop from an empty stack.")
return self._data.pop() # list.pop() removes from the end in O(1)
def peek(self):
"""Return the top element without removing it — O(1)."""
if self.is_empty():
raise IndexError("Stack is empty.")
return self._data[-1]
def is_empty(self):
"""Return True if the stack contains no elements — O(1)."""
return len(self._data) == 0
def size(self):
"""Return the number of elements in the stack — O(1)."""
return len(self._data)
def __repr__(self):
if self.is_empty():
return "Stack(empty)"
return "Stack(bottom -> top): " + str(self._data)
# Quick demo (no plotting — see slides for LIFO animation)
s = Stack()
for v in [10, 20, 30, 40]:
s.push(v)
print(s)
Stack(bottom -> top): [10, 20, 30, 40]
Application : Parenthèses équilibrées¶
Problème : chaque ( a-t-il une ) correspondante dans le bon ordre ?
((()))— OK (chaque paire se ferme dans le bon ordre).(()(— pas OK (un(n’a jamais de)correspondante).())— pas OK (un)apparaît alors qu’il n’y a plus de(pour le matcher).
Règle générale : chaque ) doit correspondre à un ( qui est apparu plus tôt dans la chaîne.
Pourquoi une pile : la pile mémorise les crochets ouverts qui attendent encore un partenaire. Le sommet de la pile est toujours le ( qui doit matcher la prochaine ). C’est exactement du LIFO — même idée que la démonstration push/pop ci-dessus.
Parcours de ((()))¶
Règle : sur ( → push ; sur ) → pop un ( depuis la pile. À la fin, la pile doit être vide.
Commencer avec une pile vide.
Voir
(→ push → la pile contient un(.Voir
(→ push → la pile contient deux(.Voir
(→ push → la pile contient trois(.Voir
)→ pop → deux(restent.Voir
)→ pop → un(reste.Voir
)→ pop → la pile est vide.Fin de la chaîne et pile vide → équilibré.
Parcours de (()(¶
Même règle : ( push, ) pop. Chaîne : (()(
Voir
(→ push → un(sur la pile.Voir
(→ push → deux(sur la pile.Voir
)→ pop → un(sur la pile.Voir
(→ push → deux(sur la pile.Fin de la chaîne — mais la pile n’est pas vide.
Il reste donc des
(non fermés → pas équilibré.
def is_balanced(expr):
stack = Stack()
for ch in expr:
if ch == '(':
stack.push(ch)
elif ch == ')':
if stack.is_empty():
return False # ')' with no matching '('
stack.pop()
return stack.is_empty() # True only if all '(' were matched
print(is_balanced('((()))')) # True
print(is_balanced('(()(')) # False
print(is_balanced('())')) # False
True
False
False
1.5 Files¶
Une file est un type abstrait de données qui suit le principe First-In, First-Out (FIFO). Pensez à une file en caisse : la première personne arrivée est la première servie.
| Opération | Description | Complexité |
|---|---|---|
enqueue(x) | Ajouter x à l’ARRIÈRE de la file | O(1) |
dequeue() | Retirer et retourner l’élément à l’AVANT | O(1) |
peek() | Consulter l’élément en avant sans le retirer | O(1) |
is_empty() | Vrai si la file est vide | O(1) |
Important : N’utilisez PAS une simple list Python avec list.pop(0) comme file — c’est O(n) car tous les éléments doivent être décalés. Utilisez plutôt collections.deque qui offre des opérations O(1) aux deux extrémités.
Applications : parcours en largeur (BFS), ordonnancement des processus, spooling d’impression, mise en tampon de paquets réseau.
from collections import deque
# ---------------------------------------------------------------------------
# Queue implementation using collections.deque
# deque (double-ended queue) provides O(1) appendleft and pop from either end.
# We use: append() to enqueue at the right, popleft() to dequeue from the left.
# ---------------------------------------------------------------------------
class Queue:
"""FIFO queue backed by collections.deque."""
def __init__(self):
self._data = deque() # deque gives O(1) at both ends
def enqueue(self, item):
"""Add item to the BACK of the queue — O(1)."""
self._data.append(item)
def dequeue(self):
"""Remove and return the FRONT element — O(1)."""
if self.is_empty():
raise IndexError("Queue underflow: cannot dequeue from an empty queue.")
return self._data.popleft()
def peek(self):
"""Return the front element without removing it — O(1)."""
if self.is_empty():
raise IndexError("Queue is empty.")
return self._data[0]
def is_empty(self):
return len(self._data) == 0
def size(self):
return len(self._data)
def __repr__(self):
return "Queue(front -> back): " + str(list(self._data))
# ---------------------------------------------------------------------------
# Demonstrate FIFO vs LIFO
# ---------------------------------------------------------------------------
print("=== QUEUE (FIFO) ===")
q = Queue()
for v in [1, 2, 3, 4, 5]:
q.enqueue(v)
print(f" enqueue({v}) -> {q}")
print()
for _ in range(3):
val = q.dequeue()
print(f" dequeue() -> {val} | {q}")
print()
print("=== STACK (LIFO) ===")
st = Stack()
for v in [1, 2, 3, 4, 5]:
st.push(v)
print(f" push({v}) -> {st}")
print()
for _ in range(3):
val = st.pop()
print(f" pop() -> {val} | {st}")=== QUEUE (FIFO) ===
enqueue(1) -> Queue(front -> back): [1]
enqueue(2) -> Queue(front -> back): [1, 2]
enqueue(3) -> Queue(front -> back): [1, 2, 3]
enqueue(4) -> Queue(front -> back): [1, 2, 3, 4]
enqueue(5) -> Queue(front -> back): [1, 2, 3, 4, 5]
dequeue() -> 1 | Queue(front -> back): [2, 3, 4, 5]
dequeue() -> 2 | Queue(front -> back): [3, 4, 5]
dequeue() -> 3 | Queue(front -> back): [4, 5]
=== STACK (LIFO) ===
push(1) -> Stack(bottom -> top): [1]
push(2) -> Stack(bottom -> top): [1, 2]
push(3) -> Stack(bottom -> top): [1, 2, 3]
push(4) -> Stack(bottom -> top): [1, 2, 3, 4]
push(5) -> Stack(bottom -> top): [1, 2, 3, 4, 5]
pop() -> 5 | Stack(bottom -> top): [1, 2, 3, 4]
pop() -> 4 | Stack(bottom -> top): [1, 2, 3]
pop() -> 3 | Stack(bottom -> top): [1, 2]
1.6 Tables de hachage (dictionnaires — l’idée)¶
Vous utilisez déjà des dictionnaires en Python : scores["Ada"] = 91. La question est : comment Python trouve "Ada" sans parcourir tous les noms ?
Imaginez une rangée de casiers numérotés (0, 1, 2, …). Vous avez une clé (par ex. un nom d’étudiant) et une valeur (par ex. une note). La table de hachage choisit un numéro de casier à partir de la clé, y stocke la paire, et ouvre ce même casier plus tard pour la relire. Si choisir le numéro est rapide, la recherche semble « instantanée » — c’est le comportement moyen O(1) que vous voyez dans le tableau de la section 1.1.
Trois étapes en mots :
Transformer la clé en un grand entier en utilisant une fonction de hachage (en Python : la fonction intégrée
hash()— voir la cellule de code suivante).Transformer cet entier en un petit indice de casier, par ex.
hash(key) % M, oùMest le nombre de casiers.Stocker ou rechercher la paire
(key, value)dans ce casier.
Une collision signifie que deux clés différentes veulent le même casier. C’est normal ; la table doit gérer cela. Deux idées courantes : garder une petite liste de paires dans chaque casier (chaînage — ce que fait SimpleHashMap ci-dessous), ou chercher le prochain casier libre (adressage ouvert — ce que fait en interne le dict de CPython). Vous n’avez pas besoin des détails de l’adressage ouvert pour ce cours ; savoir que les collisions existent suffit.
Les dict et set de Python sont des tables de hachage très optimisées. Notre SimpleHashMap est une petite version pédagogique avec chaînage, mais elle utilise la même idée : hash(key) pour choisir un bucket.
# ---------------------------------------------------------------------------
# Python's built-in hash() — used by dict, set, and our SimpleHashMap
# ---------------------------------------------------------------------------
# hash() maps a key to an integer. In one run of the program, the same key
# always gets the same hash (so lookups are consistent).
print("hash('hello') =", hash("hello"))
print("hash('hello') again =", hash("hello")) # same value in one run
print("hash(42) =", hash(42))
print("hash((1, 2)) =", hash((1, 2))) # tuple OK if contents are hashable
# Tables only have finitely many "lockers" (buckets). Modulo picks which one:
M = 8
for key in ["cat", "dog", "bird", "Ada", "Bob"]:
bucket = hash(key) % M
print(f"{key!r:6} -> bucket {bucket}")
# Only *hashable* types work — lists and dicts are not allowed as dict keys:
# hash([]) # TypeError: unhashable type: 'list'
hash('hello') = 3758311864781760680
hash('hello') again = 3758311864781760680
hash(42) = 42
hash((1, 2)) = -3550055125485641917
'cat' -> bucket 7
'dog' -> bucket 0
'bird' -> bucket 2
'Ada' -> bucket 4
'Bob' -> bucket 3
# ---------------------------------------------------------------------------
# A simple hash map from scratch using SEPARATE CHAINING for collision resolution.
# Each bucket is a list of (key, value) pairs (a chain).
# ---------------------------------------------------------------------------
class SimpleHashMap:
"""
Hash map with separate chaining.
Average-case O(1) get/set/delete (assuming a good hash and low load factor).
"""
DEFAULT_CAPACITY = 16 # Number of buckets; should be a power of 2
LOAD_FACTOR_THRESHOLD = 0.75 # Resize when this fraction of buckets are occupied
def __init__(self, capacity=DEFAULT_CAPACITY):
self._capacity = capacity
self._buckets = [[] for _ in range(self._capacity)] # Each bucket = a chain (list)
self._size = 0 # Number of key-value pairs stored
# ------------------------------------------------------------------
# Internal hash function
# ------------------------------------------------------------------
def _bucket_index(self, key):
"""Map a key to a bucket index using Python's built-in hash."""
return hash(key) % self._capacity
# ------------------------------------------------------------------
# Core operations
# ------------------------------------------------------------------
def put(self, key, value):
"""
Insert or update key -> value.
Average O(1); O(n) worst case (all keys collide into one bucket).
"""
idx = self._bucket_index(key)
chain = self._buckets[idx]
# Check if key already exists in the chain -> update
for i, (k, v) in enumerate(chain):
if k == key:
chain[i] = (key, value) # Update value in place
return
# Key not found -> append new pair
chain.append((key, value))
self._size += 1
# Resize if load factor exceeded
if self._size / self._capacity > self.LOAD_FACTOR_THRESHOLD:
self._resize()
def get(self, key, default=None):
"""Return the value associated with key, or default if not found."""
idx = self._bucket_index(key)
for k, v in self._buckets[idx]:
if k == key:
return v
return default
def delete(self, key):
"""Remove key from the map. Raise KeyError if not found."""
idx = self._bucket_index(key)
chain = self._buckets[idx]
for i, (k, v) in enumerate(chain):
if k == key:
chain.pop(i)
self._size -= 1
return
raise KeyError(key)
def _resize(self):
"""Double capacity and rehash all entries — O(n)."""
old_buckets = self._buckets
self._capacity *= 2
self._buckets = [[] for _ in range(self._capacity)]
self._size = 0
for chain in old_buckets:
for k, v in chain:
self.put(k, v) # Re-insert into new bucket array
def load_factor(self):
return self._size / self._capacity
def __repr__(self):
items = []
for chain in self._buckets:
items.extend(chain)
return '{' + ', '.join(f'{k!r}: {v!r}' for k, v in items) + '}'
# ---------------------------------------------------------------------------
# Demonstrate the hash map
# ---------------------------------------------------------------------------
hm = SimpleHashMap(capacity=8)
students = [('Alice', 92), ('Bob', 85), ('Charlie', 78), ('Diana', 95), ('Eve', 88)]
for name, grade in students:
hm.put(name, grade)
print("HashMap:", hm)
print(f"Load factor: {hm.load_factor():.2f}")
print(f"get('Alice') = {hm.get('Alice')}")
print(f"get('Zara') = {hm.get('Zara', 'Not found')}")
hm.put('Alice', 97) # Update existing key
print(f"After update, get('Alice') = {hm.get('Alice')}")
HashMap: {'Diana': 95, 'Bob': 85, 'Alice': 92, 'Charlie': 78, 'Eve': 88}
Load factor: 0.62
get('Alice') = 92
get('Zara') = Not found
After update, get('Alice') = 97
Partie 2 : Exercices¶
Travaillez les exercices 2.1–2.5 dans l’ordre. N’utilisez que des listes Python simples, ou les classes LinkedList, Stack, Queue, et SimpleHashMap de ce notebook sauf indication contraire. Ajoutez vos propres vérifications assert là où cela aide.
Ces exercices sont guidés : complétez les lignes marquées # YOUR CODE ou remplacez pass. Si vous êtes bloqué·e, relisez la section de théorie correspondante (Section 1.2 pour 2.1, 1.3 pour 2.2, 1.4 pour 2.3, 1.5 pour 2.4, 1.6 pour 2.5).
En un coup d’œil : 2.1 listes simples · 2.2 LinkedList · 2.3 Stack · 2.4 Queue · 2.5 SimpleHashMap — chaque exercice pratique une idée de la Partie 1.
Exercice 2.1 — Fusionner deux listes triées¶
Étant donné deux listes triées a et b (ordre non décroissant), renvoyez une nouvelle liste triée contenant tous les éléments des deux, en temps O(n) où n = len(a) + len(b).
Utilisez deux indices (fusion comme dans le tri fusion). Ne faites pas appel à sorted(a + b) comme solution.
Recette (fusion comme deux tas de cartes triés) :
Initialiser
i, j = 0, 0et une liste videout.Tant que
ietjsont des indices valides : compareza[i]etb[j], append la plus petite valeur dansout, et avancez l’indice dont vous avez pris l’élément.Quand une liste est épuisée, étendez
outavec tout ce qui reste dans l’autre liste (deux courts boucleswhile).
Exemples :
assert merge_sorted([1, 3, 5], [2, 4, 6]) == [1, 2, 3, 4, 5, 6]
assert merge_sorted([], [1, 2]) == [1, 2]
assert merge_sorted([3], [1, 2]) == [1, 2, 3]def merge_sorted(a: list, b: list) -> list:
"""Return merged sorted list in O(len(a)+len(b))."""
out = []
i, j = 0, 0
# Merge while both lists still have elements
while i < len(a) and j < len(b):
if a[i] <= b[j]:
# YOUR CODE: append a[i] to out and increment i
pass
else:
# YOUR CODE: append b[j] to out and increment j
pass
# Remainder of the longer list
while i < len(a):
# YOUR CODE: append from a
pass
while j < len(b):
# YOUR CODE: append from b
pass
return out
Exercice 2.2 — Inverser une liste chaînée¶
Implémentez reverse_linked_list(ll: LinkedList) -> None de sorte qu’après l’appel, ll se parcourt à l’envers par rapport à avant. ll.size reste inchangé ; mettez à jour correctement ll.head.
Objectif minimal : la liste doit être lue en ordre inverse (par ex. 1 -> 2 -> 3 devient 3 -> 2 -> 1).
Choisissez une piste :
Niveau A (plus facile) : solution autorisée — lire les valeurs avec
to_list(), réinitialiser la liste (head = None,size = 0), puis reconstruire en utilisantprependdans l’ordre inverse. Utile si la manipulation de pointeurs vous semble floue.Niveau B (exercice de pointeurs) : retravailler uniquement les pointeurs
next— O(n) en temps, O(1) d’espace supplémentaire. Gardez trois noms :prev,cur,nxt(indice : sauvegardernxtavant d’écrasercur.next).
Exemple : Après reverse_linked_list(ll) sur 1 -> 2 -> 3 -> None, vous devriez obtenir 3 -> 2 -> 1 -> None.
def reverse_linked_list(ll: "LinkedList") -> None:
"""Reverse nodes; update ll.head (ll.size unchanged)."""
# --- Level A (easier): rebuild using to_list + prepend (default skeleton) ---
vals = ll.to_list()
ll.head = None
ll.size = 0
for v in reversed(vals):
pass # YOUR CODE: prepend v onto ll
# --- Level B (pointer reverse): comment out Level A above, then implement ---
# prev = None
# cur = ll.head
# while cur is not None:
# nxt = cur.next
# cur.next = prev
# prev = cur
# cur = nxt
# ll.head = prev
Exercice 2.3 — Crochets équilibrés avec une pile¶
Implémentez is_balanced_brackets(expression: str) -> bool en utilisant la classe Stack de ce notebook.
Supportez ( ), [ ], et { }. Chaque crochet fermant doit correspondre au crochet ouvrant le plus récent non apparié (voir la Section 1.4).
Fermant → ouvrant (pour vérifier après un pop) :
| Fermant | Ouvrant |
|---|---|
) | ( |
] | [ |
} | { |
Caractères : seuls les crochets participent — ignorez les espaces, lettres et chiffres (ne les empilez pas).
| Expression | Équilibré ? |
|---|---|
"{[]()}" | Oui |
"([)]" | Non |
"((()))" | Oui |
Aussi : En une phrase, expliquez pourquoi une file serait un mauvais type abstrait pour ce problème (un commentaire facultatif dans votre cellule suffit).
def is_balanced_brackets(expression: str) -> bool:
"""True if (), [], {} are all matched in LIFO order."""
pairs = {")": "(", "]": "[", "}": "{"}
stack = Stack()
for ch in expression:
if ch in "([{":
stack.push(ch)
elif ch in ")]}":
if stack.is_empty():
return False
# YOUR CODE: pop opening bracket and compare to pairs[ch]
pass
# else: ignore non-bracket characters
return stack.is_empty()
Exercice 2.4 — Hot potato (file)¶
Implémentez hot_potato(names: list[str], k: int) -> str en utilisant la classe Queue de la Section 1.5.
Algorithme :
Enqueuez chaque nom.
Tant que il reste plus d’un joueur :
Répéter
k - 1fois :dequeuela personne à l’avant et enqueuez-la à l’arrière (rotation).Une fois de plus :
dequeuela personne à l’avant — elle est éliminée.
Retourner le dernier nom restant.
Trace avec names = ["A", "B", "C"] et k = 2 :
Après un tour d’élimination, vous devriez retirer B. Continuez jusqu’à ce que C gagne.
assert hot_potato(["A", "B", "C"], 2) == "C"def hot_potato(names: list[str], k: int) -> str:
"""Last remaining name using Queue and the rules in Exercise 2.4."""
q = Queue()
for n in names:
q.enqueue(n)
while q.size() > 1:
for _ in range(k - 1):
# YOUR CODE: move front player to the rear (same pattern as the slides)
pass
# YOUR CODE: eliminate the player at the front after those rotations
pass
return q.dequeue()
Exercice 2.5 — Comptage de mots avec SimpleHashMap¶
Implémentez word_counts(text: str) -> SimpleHashMap. Séparez text sur les espaces (split()), comptez combien de fois chaque mot apparaît.
Étapes : créez une SimpleHashMap → pour chaque mot → get du compte courant (utilisez 0 si la clé est absente) → put le compte augmenté de un.
N’utilisez que SimpleHashMap de ce notebook — pas de dict intégré, collections.Counter ou defaultdict.
Exemple :
m = word_counts("cat dog cat")
# Après votre implémentation : get("cat") -> 2, get("dog") -> 1def word_counts(text: str) -> "SimpleHashMap":
"""Word frequencies using SimpleHashMap only."""
m = SimpleHashMap()
for w in text.split():
c = m.get(w, 0)
# YOUR CODE: store count + 1 for this word
pass
return m