Introduction
Une Pile, Stack en anglais, est une structure de données qui suit le principe LIFO (Last In First Out - dernier entré, premier sorti). En d'autres termes: on contraint une structure de données dans laquelle l'élément inséré en dernier doit être le premier élément à sortir.
Inversement, cela signifie également que le premier élément mis dans la pile finira toujours par être le dernier élément sorti.
Ce concept limitant est extrêmement utile et permet de manipuler facilement des structures représentant celles de notre monde réel. Un pile représentera par exemple parfaitement une pile d'assiettes ou de livres dans laquelle nous ne pouvons retirer/ajouter, à chaque fois, uniquement l’élément au dessus de la pile.
Les piles sont utilisées absolument partout dans le monde de la programmation. En voici quelques exemples:
Objectifs
Faire fonctionner une pile. Avoir une meilleure compréhension de la récursion. Créer nos propres structures de pile. Mettre en pratique les structures de tableaux et de listes chaînées. Comprendre les avantages / inconvénients de chaque implémentation.
Et ensuite ?
Si nous n’avons pas encore vu les queues (files d'attentes), c’est l’occasion : elles sont implémentées selon le principe FIFO (First-In-First-Out) inversement aux piles vues ici.
Nous pouvons maintenant jouer avec des algorithmes avancés tels que la génération de labyrinthes utilisant la Recherche en Profondeur (DFS). Nous pouvons aussi continuer avec des structures de données plus avancées telles que : les arbres binaires (binary trees) ou les tables de hachage (hash tables).
Utilisation
Nous avons opté pour le C++ pour l'illustration du code, mais pas d'inquiétude, les autres langues utilisent la même logique et le plus souvent une syntaxe similaire.
Une pile est utilisée pour les deux opérations principales suivantes :
void push(const T& data); // Insère un nouvel élément en haut de la pile void pop(); // Supprime l'élément au-dessus de la pile.
Pour utiliser correctement une pile, les fonctionnalités suivantes sont ajoutées :
bool empty() const; // Teste si la pile est vide. const T& top() const; // Montre l’élément du dessus de la pile.
Exemple
stack< string > history; // Initialise la pile history.push("paste"); // ["paste"] history.push("turn"); // ["paste", "turn"] history.pop(); // ["paste"] history.top(); // --> "paste" history.push("color"); // ["paste", "color"] history.push("clean"); // ["paste", "color", "clean"] // Get drawing history from the last action while (!history.empty()) { auto current = history.top(); history.pop(); }
Implémentons les nôtres
Cool, maintenant que nous savons utiliser une pile, un question demeure : comment construire notre propre structure de pile? Bonne nouvelle : c’est assez facile. Nous verrons plusieurs implémentations afin d’en comprendre les fonctionnements et pouvoir choisir la plus optimale.
Remarque: La pile est une structure de données abstraite. La définition de leur structure est basée sur leur comportement et non sur leur implémentation. Cela contraste avec les structures de données plus fondamentales, telles que les tableaux et les listes chaînées, qui ont des exigences strictes pour le stockage de leurs données.
En Liste chaînée (Linked List)
L'implémentation utilisant la liste chaînée est l'une des implémentations de pile les plus simples que nous puissions faire. Reprenons le squelette de notre structure vue dans le cours 'liste chaînée'.
template < typename T > class Stack { public: Stack() : head(nullptr) {} // Construct as an empty stack ~Stack() { ... } // Destructor (same as linked list) private: // Node structure declaration struct Node { // Constructor Node(const T& data) : data(data), next(nullptr) {} T data; // Value Node* next; // Pointer to the next node }; Node* head; // Pointer on the first element (also the top of the stack) }
Lorsque nous souhaitons ajouter quelque chose à la pile (Push), il nous suffit de l'ajouter au début de la liste (le dessus de notre pile). Mais... c'est exactement ce que fait la fonction PushFront, non?
void Push(const T& data) { Node* newNode = new Node(data); // Allocate memory newNode->next = head; // Set the next node to be the head head = newNode; // Set head to the new node }
Pour la fonction Pop, rien de plus simple : supprimons le premier élément de la liste.
void Pop() { if (!head) // Empty stack error return; auto tmp = head; // Get the current top head = head->next; // Set the head to point to the next node delete tmp; // Delete the node }
Les fonctions Empty et Top sont elles aussi triviales.
bool Empty() const { return (head) ? false : true; } const T& Top() const { if (!head); // Throw Exception return *head; }
L'implémentation ci-dessus nous donne les idées impliquées, et toute optimisation dont nous pourrions avoir besoin peut être accomplie en modifiant directement une structure de liste chaînée.
En Tableau (array)
Utiliser une implémentation de tableau pour une pile est une approche simple et implique uniquement de garder le compte.
template < typename T > class Stack { public: // Allocate/Deallocate stack memory Stack(const int size) : count(0), max_size(size) { stack = new T[max_size]; } ~Stack() { delete[] stack; } private: int count; // Keep track of current stack size (and therefore the top index) const int max_size; // Keep track of the max size to check if full T* stack; // Pointer on the array Stack(); // Forbid default constructor }
Lorsque nous souhaitons ajouter quelque chose à la pile (Push), il nous suffit de le mettre au niveau de l'index et l'incrémenter.
void Push(const T new_data) { if (count == max_size) // Stack overflow exception stack[count] = new_data; // Set data ++count; // Update count }
Pour la fonction Pop, rien de plus simple : nous decrémentons l'index.
void Pop() { if (count == 0) // Empty stack error return; --count; // Change top index }
Les fonctions Empty et Top sont elles aussi encore triviales.
bool Empty() const { return count == 0; } const T& Top() const { return stack[count]; }
Stack Overflow !
C'est ce qu'il se passe si nous essayons d'ajouter un élément dans une pile pleine.
Un dépassement de pile ou débordement de pile (stack overflow) est un bug causé par un processus qui, lors de l'écriture dans une pile, écrit à l'extérieur de l'espace alloué à la pile.
Dans le cas de la call stack (pile d'appels des fonctions) par exemple, cela se produira si nous avons une boucle/récursion infinie. (un bug assez fréquent!)
En Tableau dynamique (Vector)
Si nous utilisons une implémentation en tableau dynamique, il nous faudra simplement doubler la capacité de notre tableau lorsque la pile est pleine. Cela implique une perte de performance car cela nécessite une allocation mémoire ainsi qu'une copie de tous les éléments.
En bref
Liste chaînée (Linked List) Opérations d'insertion et de suppression en O(1). Peut grandir de manière dynamique. Allocation/Deallocation mémoire nécessaire pour les opérations d'insertion et de suppression. Utilise très légèrement plus de mémoire en raison du stockage supplémentaire de pointeurs.
Tableau (Array) Opérations d'insertion et de suppression en O(1). Ne peut grandir de manière dynamique : il nécessite une taille maximale à l'initialisation. Un tableau surdimensionné entraîne quantité de mémoire gaspillée. Risque de débordement de pile (stack overflow).
Tableau dynamique (Vector) Opérations d'insertion et de suppression en O(1) (hors redimesionnement). Peut grandir de manière dynamique. La redimensionnement d'un tableau est en O(n). Un tableau surdimensionné entraîne quantité de mémoire gaspillée.