Les structures de données constituent l'épine dorsale de l'informatique, définissant la manière dont les informations sont organisées pour une manipulation efficace. Elles représentent une collection d'éléments de données et les relations qui existent entre eux, servant de fondation pour la conception d'algorithmes robustes.
Complexité des Algorithmes
Complexité Temporelle
La complexité temporelle est une mesure qualitative décrivant la durée d'exécution d'un algorithme en fonction de la taille de son entrée. C'est un indicateur essentiel pour évaluer l'efficacité et la scalabilité d'une solution algorithmique. L'analyse de la complexité vise à sélectionner l'algorithme le plus performant pour une tâche donnée ou à améliorer des algorithmes existnats.
Elle est couramment exprimée à l'aide de la notation Grand O (O-notation), qui caractérise la borne supérieure de la fonction de croissance du temps d'exécution à mesure que la taille de l'antrée tend vers l'infini, en ignorant les termes d'ordre inférieur et les coefficients constants.
Méthode de Calcul
- On identifie généralement l'opération fondamentale ou répétée de l'algorithme, dont le nombre d'exécutions dépend de la taille du problème
n. SoitT(n)cette fonction. - Si une fonction auxiliaire
f(n)existe telle que la limite deT(n)/f(n)est une constante non nulle lorsquentend vers l'infini, alorsf(n)est de même ordre de grandeur queT(n). On écrit alorsT(n) = O(f(n)), etO(f(n))est appelée la complexité temporelle asymptotique de l'algorithme.
Un f(n) plus petit indique une complexité temporelle plus faible et une meilleure efficacité de l'algorithme. Les ordres de grandeur courants incluent O(1) (constant), O(log n) (logarithmique), O(n) (linéaire), O(n log n), O(n²) (quadratique), O(n³) (cubique), O(2^n) (exponentiel), et O(n!) (factoriel).
Complexité Spatiale
La complexité spatiale, notée S(n) = O(f(n)), mesure la quantité de mémoire temporaire utilisée par un algorithme pendant son exécution. Par exemple, un tri par insertion a une complexité spatiale de O(1), car il ne nécessite qu'un espace constant. En revanche, de nombreux algorithmes récursifs ont une complexité spatiale de O(n) en raison de la pile d'appels qui stocke les informations de retour pour chaque appel récursif.
Il existe souvent un compromis entre la complexité temporelle et la complexité spatiale. Améliorer la vitesse (réduire le temps) peut nécessiter plus de mémoire, et vice versa. La conception d'un algorithme doit prendre en compte ces facteurs, ainsi que la fréquence d'utilisation de l'algorithme, le volume de données à traiter, et l'environnement d'exécution.
Listes Linéaires
Tableaux Séquentiels
Une liste séquentielle est une structure de données linéaire où les éléments sont stockés dans des emplacements mémoire contigus, typiquement sous la forme d'un tableau. La relation logique d'adjacence entre les éléments est directement reflétée par leur contiguïté physique en mémoire. Cette organisation permet un accès direct aux éléments par leur indice, mais les opérations d'insertion ou de suppression au milieu du tableau peuvent être coûteuses (O(n)) car elles nécessitent le déplacement des éléments suivants.
Listes Chaînées
Contrairement aux tableaux séquentiels, une liste chaînée est une structure de stockage non contiguë où l'ordre logique des éléments est maintenu par des pointeurs. Chaque élément, appelé nœud, est généré dynamiquement et se compose de deux parties : un champ de données et un champ de pointeur qui référence le nœud suivant. L'insertion et la suppression d'un nœud dans une liste chaînée peuvent être effectuées en O(1) si la position est connue, ce qui est beaucoup plus rapide que pour un tableau séquentiel. Cependant, la recherche ou l'accès à un élément par son indice nécessite un parcours séquentiel, résultant en une complexité de O(n).
Terminologie associée :
- Pointeur précédent (prédécesseur) : Référence le nœud qui précède.
- Pointeur suivant (successeur) : Référence le nœud qui suit.
Types de Listes Chaînées :
- Liste chaînée simple : Chaque nœud contient un pointeur vers le nœud suivant uniquement. Le dernier nœud pointe vers
None. - Liste chaînée double : Chaque nœud contient des pointeurs vers le nœud précédent et le nœud suivant. Les opérations de modification doivent gérer les deux pointeurs.
- Liste chaînée circulaire simple : Chaque nœud contient un pointeur vers le nœud suivant, et le pointeur du dernier nœud pointe vers le premier nœud de la liste.
Implémentation en Python
Liste Chaînée Simple
La liste chaînée simple est une structure de données fondamentale où chaque élément, ou nœud, contient une valeur et un pointeur vers le nœud suivant. Elle permet des insertions et suppressions efficaces en tête ou en queue (avec un pointeur vers la queue).
class Node:
"""Représente un nœud dans une liste chaînée simple."""
def __init__(self, value_data):
self.data = value_data
self.next_node = None
class SimpleLinkedList:
"""Implémente une liste chaînée simple."""
def __init__(self, initial_node=None):
self._head = initial_node
def est_vide(self):
"""Vérifie si la liste est vide."""
return self._head is None
def obtenir_longueur(self):
"""Retourne le nombre d'éléments dans la liste."""
current = self._head
count = 0
while current is not None:
count += 1
current = current.next_node
return count
def afficher_elements(self):
"""Parcourt et affiche tous les éléments de la liste."""
current = self._head
elements = []
while current is not None:
elements.append(str(current.data))
current = current.next_node
print(" ".join(elements))
def ajouter_en_tete(self, item):
"""Ajoute un élément au début de la liste (insertion en tête)."""
new_node = Node(item)
new_node.next_node = self._head
self._head = new_node
def ajouter_en_queue(self, item):
"""Ajoute un élément à la fin de la liste (insertion en queue)."""
new_node = Node(item)
if self.est_vide():
self._head = new_node
else:
current = self._head
while current.next_node is not None:
current = current.next_node
current.next_node = new_node
def inserer_a_position(self, position, item):
"""Insère un élément à une position spécifique.
:param position: L'indice où insérer l'élément (0-basé).
"""
if position <= 0:
self.ajouter_en_tete(item)
elif position >= self.obtenir_longueur():
self.ajouter_en_queue(item)
else:
prev_node = self._head
count = 0
while count < position - 1:
count += 1
prev_node = prev_node.next_node
new_node = Node(item)
new_node.next_node = prev_node.next_node
prev_node.next_node = new_node
def supprimer_element(self, item):
"""Supprime la première occurrence d'un élément de la liste."""
current = self._head
previous = None
while current is not None:
if current.data == item:
if previous is None: # Si l'élément à supprimer est la tête
self._head = current.next_node
else:
previous.next_node = current.next_node
return
else:
previous = current
current = current.next_node
def rechercher_element(self, item):
"""Recherche si un élément existe dans la liste."""
current = self._head
while current is not None:
if current.data == item:
return True
current = current.next_node
return False
if __name__ == "__main__":
ma_liste = SimpleLinkedList()
print(f"Liste vide : {ma_liste.est_vide()}")
print(f"Longueur : {ma_liste.obtenir_longueur()}")
ma_liste.ajouter_en_queue(10)
print(f"Liste vide : {ma_liste.est_vide()}")
print(f"Longueur : {ma_liste.obtenir_longueur()}")
ma_liste.ajouter_en_queue(20)
ma_liste.ajouter_en_tete(8)
ma_liste.ajouter_en_queue(30)
ma_liste.ajouter_en_queue(40)
ma_liste.ajouter_en_queue(50)
ma_liste.ajouter_en_queue(60)
# Liste attendue: 8 10 20 30 40 50 60
ma_liste.inserer_a_position(-1, 9) # Devrait insérer en tête
ma_liste.afficher_elements() # 9 8 10 20 30 40 50 60
ma_liste.inserer_a_position(3, 100)
ma_liste.afficher_elements() # 9 8 10 100 20 30 40 50 60
ma_liste.inserer_a_position(10, 200) # Devrait insérer en queue
ma_liste.afficher_elements() # 9 8 10 100 20 30 40 50 60 200
ma_liste.supprimer_element(100)
ma_liste.afficher_elements() # 9 8 10 20 30 40 50 60 200
ma_liste.supprimer_element(9)
ma_liste.afficher_elements() # 8 10 20 30 40 50 60 200
ma_liste.supprimer_element(200)
ma_liste.afficher_elements() # 8 10 20 30 40 50 60
print(f"Recherche 30 : {ma_liste.rechercher_element(30)}") # True
print(f"Recherche 99 : {ma_liste.rechercher_element(99)}") # False
Liste Chaînée Double
La liste chaînée double améliore la liste simple en permettant un parcours bidirectionnel grâce à des pointeurs vers le nœud précédent et le nœud suivant. Cela simplifie la suppression d'un nœud donné sans avoir besoin de son prédécesseur direct.
class DoubleNode:
"""Représente un nœud dans une liste chaînée double."""
def __init__(self, value_data):
self.data = value_data
self.next_node = None
self.prev_node = None
class DoublyLinkedList:
"""Implémente une liste chaînée double."""
def __init__(self, initial_node=None):
self._head = initial_node
def est_vide(self):
"""Vérifie si la liste est vide."""
return self._head is None
def obtenir_longueur(self):
"""Retourne le nombre d'éléments dans la liste."""
current = self._head
count = 0
while current is not None:
count += 1
current = current.next_node
return count
def afficher_elements(self):
"""Parcourt et affiche tous les éléments de la liste."""
current = self._head
elements = []
while current is not None:
elements.append(str(current.data))
current = current.next_node
print(" ".join(elements))
def ajouter_en_tete(self, item):
"""Ajoute un élément au début de la liste."""
new_node = DoubleNode(item)
if self._head:
new_node.next_node = self._head
self._head.prev_node = new_node
self._head = new_node
def ajouter_en_queue(self, item):
"""Ajoute un élément à la fin de la liste."""
new_node = DoubleNode(item)
if self.est_vide():
self._head = new_node
else:
current = self._head
while current.next_node is not None:
current = current.next_node
current.next_node = new_node
new_node.prev_node = current
def inserer_a_position(self, position, item):
"""Insère un élément à une position spécifique.
:param position: L'indice où insérer l'élément (0-basé).
"""
if position <= 0:
self.ajouter_en_tete(item)
elif position >= self.obtenir_longueur():
self.ajouter_en_queue(item)
else:
current = self._head
for _ in range(position): # Aller jusqu'à la position cible
current = current.next_node
new_node = DoubleNode(item)
new_node.next_node = current
new_node.prev_node = current.prev_node
if current.prev_node:
current.prev_node.next_node = new_node
current.prev_node = new_node
def supprimer_element(self, item):
"""Supprime la première occurrence d'un élément de la liste."""
current = self._head
while current is not None:
if current.data == item:
if current == self._head: # Si c'est la tête
self._head = current.next_node
if self._head: # S'il y a un nouveau chef
self._head.prev_node = None
else: # Noeud intermédiaire ou queue
current.prev_node.next_node = current.next_node
if current.next_node:
current.next_node.prev_node = current.prev_node
return
current = current.next_node
def rechercher_element(self, item):
"""Recherche si un élément existe dans la liste."""
current = self._head
while current is not None:
if current.data == item:
return True
current = current.next_node
return False
if __name__ == "__main__":
ma_liste_double = DoublyLinkedList()
print(f"Liste vide : {ma_liste_double.est_vide()}")
print(f"Longueur : {ma_liste_double.obtenir_longueur()}")
ma_liste_double.ajouter_en_queue(10)
print(f"Liste vide : {ma_liste_double.est_vide()}")
print(f"Longueur : {ma_liste_double.obtenir_longueur()}")
ma_liste_double.ajouter_en_queue(20)
ma_liste_double.ajouter_en_tete(8)
ma_liste_double.ajouter_en_queue(30)
ma_liste_double.ajouter_en_queue(40)
ma_liste_double.ajouter_en_queue(50)
ma_liste_double.ajouter_en_queue(60)
# Liste attendue: 8 10 20 30 40 50 60
ma_liste_double.inserer_a_position(-1, 9) # Devrait insérer en tête
ma_liste_double.afficher_elements() # 9 8 10 20 30 40 50 60
ma_liste_double.inserer_a_position(3, 100)
ma_liste_double.afficher_elements() # 9 8 10 100 20 30 40 50 60
ma_liste_double.inserer_a_position(10, 200) # Devrait insérer en queue
ma_liste_double.afficher_elements() # 9 8 10 100 20 30 40 50 60 200
ma_liste_double.supprimer_element(100)
ma_liste_double.afficher_elements() # 9 8 10 20 30 40 50 60 200
ma_liste_double.supprimer_element(9)
ma_liste_double.afficher_elements() # 8 10 20 30 40 50 60 200
ma_liste_double.supprimer_element(200)
ma_liste_double.afficher_elements() # 8 10 20 30 40 50 60
Liste Chaînée Circulaire Simple
Dans une liste chaînée circulaire simple, le dernier nœud de la liste pointe vers le premier nœud, formant une boucle. Cela permet un parcours continu sans début ni fin distincts, utile pour des structures comme les files d'attente circulaires.
class CircularNode:
"""Représente un nœud dans une liste chaînée circulaire simple."""
def __init__(self, value_data):
self.data = value_data
self.next_node = None
class SingleCircularLinkedList:
"""Implémente une liste chaînée circulaire simple."""
def __init__(self, initial_node=None):
self._head = initial_node
if initial_node:
initial_node.next_node = initial_node # Pour une liste d'un seul nœud
def est_vide(self):
"""Vérifie si la liste est vide."""
return self._head is None
def obtenir_longueur(self):
"""Retourne le nombre d'éléments dans la liste."""
if self.est_vide():
return 0
count = 0
current = self._head
while True:
count += 1
current = current.next_node
if current == self._head:
break
return count
def afficher_elements(self):
"""Parcourt et affiche tous les éléments de la liste."""
if self.est_vide():
return
current = self._head
elements = []
while True:
elements.append(str(current.data))
current = current.next_node
if current == self._head:
break
print(" ".join(elements))
def ajouter_en_tete(self, item):
"""Ajoute un élément au début de la liste (nouvelle tête)."""
new_node = CircularNode(item)
if self.est_vide():
self._head = new_node
new_node.next_node = new_node
else:
current = self._head
while current.next_node != self._head: # Trouver le dernier nœud
current = current.next_node
new_node.next_node = self._head
self._head = new_node
current.next_node = self._head # Le dernier nœud pointe vers la nouvelle tête
def ajouter_en_queue(self, item):
"""Ajoute un élément à la fin de la liste."""
new_node = CircularNode(item)
if self.est_vide():
self._head = new_node
new_node.next_node = new_node
else:
current = self._head
while current.next_node != self._head: # Trouver le dernier nœud
current = current.next_node
new_node.next_node = self._head
current.next_node = new_node
def inserer_a_position(self, position, item):
"""Insère un élément à une position spécifique.
:param position: L'indice où insérer l'élément (0-basé).
"""
if position <= 0:
self.ajouter_en_tete(item)
elif position >= self.obtenir_longueur():
self.ajouter_en_queue(item)
else:
prev_node = self._head
for _ in range(position - 1):
prev_node = prev_node.next_node
new_node = CircularNode(item)
new_node.next_node = prev_node.next_node
prev_node.next_node = new_node
def supprimer_element(self, item):
"""Supprime la première occurrence d'un élément de la liste."""
if self.est_vide():
return
current = self._head
previous = None
if self._head.data == item:
if self.obtenir_longueur() == 1: # Si un seul élément et c'est celui à supprimer
self._head = None
return
# Si la tête est l'élément et il y a d'autres éléments
tail_node = self._head
while tail_node.next_node != self._head:
tail_node = tail_node.next_node
self._head = self._head.next_node
tail_node.next_node = self._head
return
previous = self._head
current = self._head.next_node
while current != self._head:
if current.data == item:
previous.next_node = current.next_node
return
previous = current
current = current.next_node
def rechercher_element(self, item):
"""Recherche si un élément existe dans la liste."""
if self.est_vide():
return False
current = self._head
while True:
if current.data == item:
return True
current = current.next_node
if current == self._head:
break
return False
if __name__ == "__main__":
ma_liste_circulaire = SingleCircularLinkedList()
print(f"Liste vide : {ma_liste_circulaire.est_vide()}")
print(f"Longueur : {ma_liste_circulaire.obtenir_longueur()}")
ma_liste_circulaire.ajouter_en_queue(10)
print(f"Liste vide : {ma_liste_circulaire.est_vide()}")
print(f"Longueur : {ma_liste_circulaire.obtenir_longueur()}")
ma_liste_circulaire.ajouter_en_queue(20)
ma_liste_circulaire.ajouter_en_tete(8)
ma_liste_circulaire.ajouter_en_queue(30)
ma_liste_circulaire.ajouter_en_queue(40)
ma_liste_circulaire.ajouter_en_queue(50)
ma_liste_circulaire.ajouter_en_queue(60)
# Liste attendue: 8 10 20 30 40 50 60
ma_liste_circulaire.inserer_a_position(-1, 9) # Devrait insérer en tête
ma_liste_circulaire.afficher_elements() # 9 8 10 20 30 40 50 60
ma_liste_circulaire.inserer_a_position(3, 100)
ma_liste_circulaire.afficher_elements() # 9 8 10 100 20 30 40 50 60
ma_liste_circulaire.inserer_a_position(10, 200) # Devrait insérer en queue
ma_liste_circulaire.afficher_elements() # 9 8 10 100 20 30 40 50 60 200
ma_liste_circulaire.supprimer_element(100)
ma_liste_circulaire.afficher_elements() # 9 8 10 20 30 40 50 60 200
ma_liste_circulaire.supprimer_element(9)
ma_liste_circulaire.afficher_elements() # 8 10 20 30 40 50 60 200
ma_liste_circulaire.supprimer_element(200)
ma_liste_circulaire.afficher_elements() # 8 10 20 30 40 50 60
Algorithmes de Tri
Stabilité des Algorithmes de Tri
Un algorithme de tri est dit stable si, pour tout ensemble de records ayant des clés égales, l'ordre relatif de ces records n'est pas modifié par le tri. Si deux éléments r[i] et r[j] ont la même clé et que r[i] apparaît avant r[j] dans la séquence originale, alors r[i] doit apparaître avant r[j] dans la séquence triée pour que l'algorithme soit stable.
Tri à Bulles (Bubble Sort)
Le tri à bulles est un algorithme de tri simple qui parcourt répétitivement une liste, compare les éléments adjacents et les échange s'ils sont dans le mauvais ordre. Les éléments plus grands "remontent" progressivement vers la fin de la liste, comme des bulles dans un liquide. Le processus se répète jusqu'à ce qu'aucun échange ne soit nécessaire, indiquant que la liste est triée.
Son principe est le suivant :
- Comparer chaque paire d'éléments adjacents.
- Échanger les éléments s'ils ne sont pas dans le bon ordre.
- Après un passage, l'élément le plus grand (ou le plus petit) est à sa position finale.
- Répéter le processus pour les éléments restants, en excluant les éléments déjà placés.
- Continuer jusqu'à ce qu'aucun échange ne se produise lors d'un passage complet.
def tri_a_bulles(liste_elements):
"""Implémente le tri à bulles."""
taille = len(liste_elements)
# Parcourt tous les éléments de la liste
for i in range(taille - 1):
# Le dernier i éléments sont déjà en place
for j in range(taille - 1 - i):
# Compare les éléments adjacents et échange si nécessaire
if liste_elements[j] > liste_elements[j + 1]:
liste_elements[j], liste_elements[j + 1] = liste_elements[j + 1], liste_elements[j]
if __name__ == "__main__":
liste_desordonnee = [54, 26, 93, 17, 77, 31, 44, 55, 20]
print(f"Liste originale: {liste_desordonnee}")
tri_a_bulles(liste_desordonnee)
print(f"Liste triée: {liste_desordonnee}")
Tri par Sélection (Selection Sort)
Le tri par sélection est un autre algorithme de tri simple et intuitif. Il fonctionne en divisant la liste en deux parties : une partie triée à gauche et une partie non triée à droite. À chaque itération, l'algorithme trouve l'élément minimum (ou maximum) dans la partie non triée et l'échange avec le premier élément de cette partie, l'ajoutant ainsi à la partie triée.
La distinction avec le tri à bulles réside dans le fait que le tri par sélection effectue une seule permutation par passage pour placer l'élément correct à sa position finale, tandis que le tri à bulles effectue de multiples échanges d'éléments adjacents.
def tri_par_selection(liste_elements):
"""Implémente le tri par sélection."""
taille = len(liste_elements)
# Parcourt la liste jusqu'à l'avant-dernier élément
for i in range(taille - 1):
index_min = i # Supposons que l'élément actuel est le minimum
# Trouve l'élément minimum dans le reste de la liste non triée
for j in range(i + 1, taille):
if liste_elements[j] < liste_elements[index_min]:
index_min = j
# Échange l'élément minimum trouvé avec l'élément à la position i
liste_elements[i], liste_elements[index_min] = liste_elements[index_min], liste_elements[i]
if __name__ == "__main__":
liste_desordonnee = [54, 26, 93, 17, 77, 31, 44, 55, 20]
print(f"Liste originale: {liste_desordonnee}")
tri_par_selection(liste_desordonnee)
print(f"Liste triée: {liste_desordonnee}")
Tri par Insertion (Insertion Sort)
Le tri par insertion est un algorithme qui construit la liste triée un élément à la fois. Il procède par N-1 passes. Il commence par le premier élément (considéré comme une liste triée d'un seul élément). Ensuite, à chaque passe, il prend l'élément suivant de la partie non triée et l'insère à sa position correcte dans la partie déjà triée.
Le principe est récursif : lorsqu'un élément à l'indice i est trié, la sous-séquence [0, i-1] est déjà triée.
def tri_par_insertion(liste_elements):
"""Implémente le tri par insertion."""
taille = len(liste_elements)
# Commence à partir du deuxième élément
for i in range(1, taille):
element_a_inserer = liste_elements[i]
j = i - 1 # Pointeur vers le dernier élément de la sous-liste triée
# Déplace les éléments de la sous-liste triée qui sont plus grands
# que element_a_inserer d'une position vers la droite
while j >= 0 and element_a_inserer < liste_elements[j]:
liste_elements[j + 1] = liste_elements[j]
j -= 1
# Insère l'élément à sa position correcte
liste_elements[j + 1] = element_a_inserer
if __name__ == "__main__":
liste_desordonnee = [54, 26, 93, 17, 77, 31, 44, 55, 20]
print(f"Liste originale: {liste_desordonnee}")
tri_par_insertion(liste_desordonnee)
print(f"Liste triée: {liste_desordonnee}")
Tri de Shell (Shell Sort)
Le tri de Shell est une amélioration du tri par insertion. Il fonctionne en comparant des éléments éloignés dans la liste, puis en réduisant progressivement l'écart (ou "gap") entre les éléments comparés. Le but est de déplacer rapidement les éléments loin de leur position finale, avant d'effectuer un tri par insertion sur l'ensemble de la liste avec un écart de 1.
La méthode consiste à prendre un incrément initial d1, puis à diviser la liste en groupes d'éléments espacés de d1. Chaque groupe est trié par insertion. Ensuite, un nouvel incrément d2 < d1 est choisi, et le processus est répété, jusqu'à ce que l'incrément soit de 1, ce qui revient à un tri par insertion standard sur une liste déjà "presque" triée.
def tri_de_shell(liste_elements):
"""Implémente le tri de Shell."""
taille = len(liste_elements)
intervalle = taille // 2 # Incrément initial, généralement la moitié de la taille
# Continue tant que l'intervalle est supérieur à 0
while intervalle > 0:
# Tri par insertion avec un pas de 'intervalle'
for i in range(intervalle, taille):
valeur_actuelle = liste_elements[i]
position = i
# Déplace les éléments des sous-listes qui sont plus grands
# que valeur_actuelle d'un 'intervalle' vers la droite
while position >= intervalle and liste_elements[position - intervalle] > valeur_actuelle:
liste_elements[position] = liste_elements[position - intervalle]
position -= intervalle
liste_elements[position] = valeur_actuelle
# Réduit l'intervalle pour la prochaine passe
intervalle //= 2
if __name__ == "__main__":
liste_desordonnee = [54, 26, 93, 17, 77, 31, 44, 55, 20]
print(f"Liste originale: {liste_desordonnee}")
tri_de_shell(liste_desordonnee)
print(f"Liste triée: {liste_desordonnee}")
Tri Rapide (Quick Sort)
Le tri rapide est un algorithme de tri par "diviser pour régner". Il sélectionne un élément appelé "pivot" dans le tableau, puis partitionne le reste des éléments en deux sous-tableaux : ceux inférieurs au pivot et ceux supérieurs au pivot. Le pivot est placé à sa position finale correcte. Ensuite, l'algorithme est appliqué récursivement aux deux sous-tableaux. Le tri rapide n'est généralement pas un algorithme de tri stable.
Une passe de tri rapide typique se déroule comme suit :
- Choisir un pivot (souvent le premier élément).
- Utiliser deux pointeurs, un à partir du début (
gauche) et un à partir de la fin (droite) du tableau. - Déplacer le pointeur
droitevers la gauche jusqu'à trouver un élément plus petit que le pivot, puis échanger cet élément avec l'élément pointé pargauche. - Déplacer le pointeur
gauchevers la droite jusqu'à trouver un élément plus grand que le pivot, puis échanger cet élément avec l'élément pointé pardroite. - Répéter les étapes 3 et 4 jusqu'à ce que les pointeurs
gaucheetdroitese croisent ou se rencontrent. - Placer le pivot à la position finale des pointeurs.
- Appliquer récursivement le processus aux sous-tableaux à gauche et à droite du pivot.
def tri_rapide(liste_elements, debut, fin):
"""Implémente le tri rapide."""
if debut >= fin:
return
valeur_pivot = liste_elements[debut]
ptr_gauche = debut
ptr_droite = fin
while ptr_gauche < ptr_droite:
# Déplace ptr_droite vers la gauche tant que l'élément est >= pivot
while ptr_gauche < ptr_droite and liste_elements[ptr_droite] >= valeur_pivot:
ptr_droite -= 1
liste_elements[ptr_gauche] = liste_elements[ptr_droite] # Place l'élément trouvé à gauche
# Déplace ptr_gauche vers la droite tant que l'élément est < pivot
while ptr_gauche < ptr_droite and liste_elements[ptr_gauche] < valeur_pivot:
ptr_gauche += 1
liste_elements[ptr_droite] = liste_elements[ptr_gauche] # Place l'élément trouvé à droite
# Quand les pointeurs se rencontrent, placer le pivot à cette position
liste_elements[ptr_gauche] = valeur_pivot
# Trier récursivement la sous-liste à gauche du pivot
tri_rapide(liste_elements, debut, ptr_gauche - 1)
# Trier récursivement la sous-liste à droite du pivot
tri_rapide(liste_elements, ptr_gauche + 1, fin)
if __name__ == "__main__":
liste_desordonnee = [54, 26, 93, 17, 77, 31, 44, 55, 20]
print(f"Liste originale: {liste_desordonnee}")
tri_rapide(liste_desordonnee, 0, len(liste_desordonnee) - 1)
print(f"Liste triée: {liste_desordonnee}")
Tri Fusion (Merge Sort)
Le tri fusion est un algorithme de tri efficace basé sur le principe "diviser pour régner". Il consiste à diviser récursivement la liste en sous-listes jusqu'à ce que chaque sous-liste ne contienne qu'un seul élément (qui est intrinsèquement trié). Ensuite, ces sous-listes sont fusionnées de manière ordonnée pour former des listes triées de plus grande taille, jusqu'à ce que la liste complète soit triée.
L'opération clé est la fusion, qui prend deux sous-listes triées et les combine en une seule liste triée.
Exemple : Soit la liste {6, 202, 100, 301, 38, 8, 1}
-
Division initiale :
{6, 202, 100, 301, 38, 8, 1} -
Première phase de division (récursive) :
{6}, {202}, {100}, {301}, {38}, {8}, {1}(chaque élément est une liste triée)
-
Première phase de fusion :
{6, 202}fusion de {6} et {202}{100, 301}fusion de {100} et {301}{8, 38}fusion de {38} et {8}{1}(reste seul)
Liste partielle après cette phase:
{{6, 202}, {100, 301}, {8, 38}, {1}} -
Deuxième phase de fusion :
{6, 100, 202, 301}fusion de {6, 202} et {100, 301}{1, 8, 38}fusion de {8, 38} et {1}
Liste partielle après cette phase:
{{6, 100, 202, 301}, {1, 8, 38}} -
Troisième phase de fusion :
{1, 6, 8, 38, 100, 202, 301}fusion des deux listes précédentes.
Liste finale triée.
def tri_par_fusion(liste_elements):
"""Implémente le tri par fusion."""
taille = len(liste_elements)
if taille <= 1:
return liste_elements
point_milieu = taille // 2
# Divise la liste en deux moitiés
moitie_gauche = tri_par_fusion(liste_elements[:point_milieu])
moitie_droite = tri_par_fusion(liste_elements[point_milieu:])
# Fusionne les deux moitiés triées
index_gauche, index_droite = 0, 0
liste_fusionnee = []
while index_gauche < len(moitie_gauche) and index_droite < len(moitie_droite):
if moitie_gauche[index_gauche] <= moitie_droite[index_droite]:
liste_fusionnee.append(moitie_gauche[index_gauche])
index_gauche += 1
else:
liste_fusionnee.append(moitie_droite[index_droite])
index_droite += 1
# Ajoute les éléments restants (s'il y en a)
liste_fusionnee.extend(moitie_gauche[index_gauche:])
liste_fusionnee.extend(moitie_droite[index_droite:])
return liste_fusionnee
if __name__ == "__main__":
liste_desordonnee = [54, 26, 93, 17, 77, 31, 44, 55, 20]
print(f"Liste originale: {liste_desordonnee}")
liste_triee = tri_par_fusion(liste_desordonnee)
print(f"Liste originale (non modifiée): {liste_desordonnee}") # Le tri fusion retourne une nouvelle liste
print(f"Liste triée: {liste_triee}")
Algorithmes de Recherche
Recherche Binaire (Binary Search)
Prérequis : La liste doit être triée. Si elle ne l'est pas, un tri préalable est nécessaire.
La recherche binaire, aussi connue sous le nom de recherche par dichotomie ou recherche par demi-intervalle, est un algorithme de recherche efficace pour trouver la position d'une valeur cible dans une liste triée. L'idée principale est de comparer la valeur cible avec l'élément du milieu de la liste. Si les valeurs sont égales, l'élément est trouvé. Si la cible est inférieure, la recherche continue dans la moitié inférieure de la liste ; si elle est supérieure, la recherche continue dans la moitié supérieure. Ce processus est répété récursivement ou itérativement jusqu'à ce que l'élément soit trouvé ou que l'intervalle de recherche devienne vide.
Analyse de la complexité : Dans le pire des cas, le nombre de comparaisons est de log₂(n+1), ce qui donne une complexité temporelle attendue de O(log n).
def recherche_binaire_recursive(liste_triee, cible):
"""Implémente la recherche binaire de manière récursive."""
if not liste_triee:
return False
milieu_idx = len(liste_triee) // 2
valeur_milieu = liste_triee[milieu_idx]
if valeur_milieu == cible:
return True
elif cible < valeur_milieu:
return recherche_binaire_recursive(liste_triee[:milieu_idx], cible)
else:
return recherche_binaire_recursive(liste_triee[milieu_idx + 1:], cible)
def recherche_binaire_iterative(liste_triee, cible):
"""Implémente la recherche binaire de manière itérative."""
debut_idx = 0
fin_idx = len(liste_triee) - 1
while debut_idx <= fin_idx:
milieu_idx = (debut_idx + fin_idx) // 2
valeur_milieu = liste_triee[milieu_idx]
if valeur_milieu == cible:
return True
elif cible < valeur_milieu:
fin_idx = milieu_idx - 1
else:
debut_idx = milieu_idx + 1
return False
if __name__ == "__main__":
ma_liste_triee = [17, 20, 26, 31, 44, 54, 55, 77, 93]
print(f"Recherche récursive 55 : {recherche_binaire_recursive(ma_liste_triee, 55)}") # True
print(f"Recherche récursive 100 : {recherche_binaire_recursive(ma_liste_triee, 100)}") # False
print(f"Recherche itérative 55 : {recherche_binaire_iterative(ma_liste_triee, 55)}") # True
print(f"Recherche itérative 100 : {recherche_binaire_iterative(ma_liste_triee, 100)}") # False
Arbres Binaires
En informatique, un arbre binaire est une structure de données arborescente où chaque nœud a au plus deux enfants, généralement appelés "enfant gauche" et "enfant droit". Les arbres binaires sont des structures fondamentales utilisées pour implémenter des arbres de recherche binaires (BST) et des tas binaires.
Les enfants d'un nœud dans un arbre binaire sont distingués par leur position (gauche ou droite) et leur ordre est significatif. Voici quelques propriétés clés des arbres binaires :
- Le nombre maximal de nœuds au niveau
iest2^(i-1)(avec la racine au niveau 1). - Un arbre binaire de profondeur
k(ou hauteurk) a au maximum2^k - 1nœuds et au minimumknœuds. - Pour tout arbre binaire non vide
T, siN₀est le nombre de nœuds feuilles etN₂est le nombre de nœuds avec deux enfants, alorsN₀ = N₂ + 1.
Terminologie associée :
- Nœud : Élément de l'arbre contenant une donnée et des références vers ses enfants.
- Nœud enfant : Un nœud directement relié et descendant d'un autre nœud.
- Nœud parent : Le nœud directement au-dessus d'un enfant.
- Nœuds frères : Nœuds ayant le même parent.
- Nœud ancêtre : Tout nœud sur le chemin de la racine à un nœud donné.
- Nœud descendant : Tout nœud dans le sous-arbre enraciné par un nœud donné.
- Niveau d'un nœud : La racine est au niveau 1, ses enfants au niveau 2, et ainsi de suite.
- Profondeur (ou hauteur) de l'arbre : Le niveau maximal de tous les nœuds de l'arbre.
- Degré d'un nœud : Le nombre de ses enfants.
- Degré de l'arbre : Le degré maximal de tous les nœuds de l'arbre.
- Nœud feuille (ou terminal) : Un nœud de degré 0 (n'a pas d'enfants).
- Nœud interne (ou branche) : Un nœud de degré non nul.
Arbre Binaire Complet
Un arbre binaire complet de profondeur h est un arbre où tous les niveaux de 1 à h-1 sont entièrement remplis, et le niveau h est rempli de gauche à droite. Il y a un total de n nœuds, et si ces nœuds étaient numérotés séquentiellement de 1 à n de haut en bas et de gauche à droite, ils occuperaient les positions 1 à n dans un arbre binaire plein de profondeur h.
Arbre Binaire Plein (ou Parfait)
Un arbre binaire plein est un arbre où chaque nœud interne a exactement deux enfants et tous les nœuds feuilles sont au même niveau. Un arbre binaire de profondeur k est plein si et seulement s'il contient exactement 2^k - 1 nœuds.
class ArbreNode:
"""Représente un nœud dans un arbre binaire."""
def __init__(self, value):
self.val = value
self.left = None
self.right = None
class BinaryTree:
"""Implémente un arbre binaire."""
def __init__(self):
self.root = None
def add_node(self, item):
"""Ajoute un nœud à l'arbre en utilisant une approche de parcours en largeur (niveau par niveau),
pour créer un arbre binaire presque complet.
"""
new_node = ArbreNode(item)
if self.root is None:
self.root = new_node
return
queue = [self.root]
while queue:
current_node = queue.pop(0)
if current_node.left is None:
current_node.left = new_node
return
else:
queue.append(current_node.left)
if current_node.right is None:
current_node.right = new_node
return
else:
queue.append(current_node.right)
def parcours_largeur(self):
"""Effectue un parcours en largeur (BFS) de l'arbre."""
if self.root is None:
return
queue = [self.root]
elements_visites = []
while queue:
current_node = queue.pop(0)
elements_visites.append(str(current_node.val))
if current_node.left is not None:
queue.append(current_node.left)
if current_node.right is not None:
queue.append(current_node.right)
print(" ".join(elements_visites))
def parcours_prefixe(self, node_courant):
"""Effectue un parcours préfixe (Racine-Gauche-Droite) de l'arbre."""
if node_courant is None:
return
print(node_courant.val, end=" ")
self.parcours_prefixe(node_courant.left)
self.parcours_prefixe(node_courant.right)
def parcours_infixe(self, node_courant):
"""Effectue un parcours infixe (Gauche-Racine-Droite) de l'arbre."""
if node_courant is None:
return
self.parcours_infixe(node_courant.left)
print(node_courant.val, end=" ")
self.parcours_infixe(node_courant.right)
def parcours_postfixe(self, node_courant):
"""Effectue un parcours postfixe (Gauche-Droite-Racine) de l'arbre."""
if node_courant is None:
return
self.parcours_postfixe(node_courant.left)
self.parcours_postfixe(node_courant.right)
print(node_courant.val, end=" ")
if __name__ == "__main__":
mon_arbre = BinaryTree()
for i in range(10):
mon_arbre.add_node(i)
print("Parcours en largeur:")
mon_arbre.parcours_largeur() # 0 1 2 3 4 5 6 7 8 9
print("\nParcours préfixe:")
mon_arbre.parcours_prefixe(mon_arbre.root) # 0 1 3 7 8 4 9 2 5 6
print("\nParcours infixe:")
mon_arbre.parcours_infixe(mon_arbre.root) # 7 3 8 1 9 4 0 5 2 6
print("\nParcours postfixe:")
mon_arbre.parcours_postfixe(mon_arbre.root) # 7 8 3 9 4 1 5 6 2 0
print("")
Types de Parcours d'Arbres :
- Parcours Préfixe (Pre-order Traversal) : Visite la racine, puis le sous-arbre gauche, puis le sous-arbre droit (Racine-Gauche-Droite).
- Parcours Infixe (In-order Traversal) : Visite le sous-arbre gauche, puis la racine, puis le sous-arbre droit (Gauche-Racine-Droite).
- Parcours Postfixe (Post-order Traversal) : Visite le sous-arbre gauche, puis le sous-arbre droit, puis la racine (Gauche-Droite-Racine).
Si vous connaissez le parcours infixe et l'un des deux autres parcours (préfixe ou postfixe), vous pouvez reconstruire la structure unique de l'arbre binaire.