La structure de données de type pile (Stack) est un composant fondamental en informatique, offrant des opérations telles que l'insertion (push), la suppression (pop) et la consultation de l'élément au sommet (top), le tout avec une complexité temporelle de O(1). Cependant, un besoin fréquent dans diverses applications, comme l'évaluation d'expressions ou certains algorithmes de piles monotones, est de pouvoir récupérer le minimum actuel de la pile en temps constant.
Une implémentation naïve de cette fonctionnalité consisterait à parcourir tous les éléments de la pile à chaque requête de minimum, ce qui dégraderait la performance à O(n), où 'n' est le nombre d'éléments. Pour des ensembles de données volumineux, cette approche est inefficace. L'objectif est donc de concevoir une structure de pile modifiée, souvent appelée "pile minimale" (MinStack), permettant à l'opération getMin d'atteindre également une complexité de O(1).
Stratégie Fondamentale : L'Approche à Double Pile
Pour graantir un accès O(1) au minimum, la clé réside dans la maintenance proactive de cette valeur. Nous utilisons une pile auxiliaire qui fonctionne en conjonction avec la pile principale, s'assurant que le sommet de la pile auxiliaire représente toujours le minimum actuel de la pile principale.
Répartition des Rôles entre les Piles
- Pile Principale (
pileContenu) : Elle stocke tous les éléments ajoutés et gère les opérations classiques d'une pile (empiler, dépiler, consulter le sommet). - Pile des Minimums (
pileMinAuxiliaire) : Cette pile ne contient que les minimums "stade par stade". Son sommet pointe toujours vers la plus petite valeur présente dans lapileContenu.
Règles Essentielles de Synchronisation
Les opérations de la pile auxiliaire doivent suivre des règles strictes pour que son sommet reste toujours le minimum global :
- Opération
empiler(push) :- L'élément est d'abord inséré dans la
pileContenu. - Si la
pileMinAuxiliaireest vide, ou si le nouvel élément est inférieur ou égal au sommet de lapileMinAuxiliaire, alors cet élément est également inséré dans lapileMinAuxiliaire. L'utilisation de "inférieur ou égal" est cruciale pour gérer les cas de valeurs minimales dupliquées et éviter de perdre le minimum.
- L'élément est d'abord inséré dans la
- Opération
depiler(pop) :- Avant de retirer un élément, on vérifie si le sommet de la
pileContenuest identique au sommet de lapileMinAuxiliaire. Si c'est le cas, cela signifie que le minimum actuel est sur le point d'être retiré, et lapileMinAuxiliairedoit être synchronisée en dépilant son propre sommet. - Ensuite, l'élément au sommet de la
pileContenuest retiré.
- Avant de retirer un élément, on vérifie si le sommet de la
- Opération
obtenirMinimum(getMin) : Le sommet de lapileMinAuxiliaireest simplement retourné. Cette valeur est garantie d'être le minimum actuel. - Opération
sommet(top) : Le sommet de lapileContenuest retourné, comme pour une pile standard.
Implémentation en C++ avec Double Pile
Voici une implémentation de la pile minimale utilisant deux piles standards C++.
#include <stack> // Nécessaire pour std::stack
#include <algorithm> // Nécessaire pour std::min, bien que non utilisé dans cette version
class PileAvecMinimum {
private:
// Pile principale : stocke tous les éléments
std::stack<int> pileDonnees;
// Pile auxiliaire : stocke les minimums successifs
// Le sommet de cette pile est toujours le minimum actuel de pileDonnees
std::stack<int> pileMinAuxiliaire;
public:
// Constructeur : initialise les piles (fait par défaut pour std::stack)
PileAvecMinimum() {
// Les constructeurs par défaut de std::stack gèrent l'initialisation.
}
// Ajoute un élément à la pile
void empiler(int valeur) {
// 1. Ajouter l'élément à la pile principale
pileDonnees.push(valeur);
// 2. Mettre à jour la pile des minimums si nécessaire
// L'élément est ajouté si pileMinAuxiliaire est vide
// OU si l'élément est inférieur ou égal au minimum actuel.
if (pileMinAuxiliaire.empty() || valeur <= pileMinAuxiliaire.top()) {
pileMinAuxiliaire.push(valeur);
}
}
// Retire l'élément du sommet de la pile
void depiler() {
// Vérifier si la pile principale n'est pas vide avant de dépiler
if (pileDonnees.empty()) {
// Optionnel: lever une exception ou gérer l'erreur
return;
}
// Si l'élément à dépiler de la pile principale est le minimum actuel,
// il faut aussi le dépiler de la pile des minimums.
if (pileDonnees.top() == pileMinAuxiliaire.top()) {
pileMinAuxiliaire.pop();
}
// Dépiler l'élément de la pile principale
pileDonnees.pop();
}
// Retourne l'élément au sommet de la pile principale
int sommet() {
if (pileDonnees.empty()) {
// Optionnel: lever une exception ou retourner une valeur par défaut
throw std::runtime_error("La pile est vide.");
}
return pileDonnees.top();
}
// Retourne le minimum actuel de la pile
int obtenirMinimum() {
if (pileMinAuxiliaire.empty()) {
// Optionnel: lever une exception ou retourner une valeur par défaut
throw std::runtime_error("La pile est vide, pas de minimum.");
}
return pileMinAuxiliaire.top();
}
};
Éclaircissements sur les Choix de Conception
- Pourquoi "inférieur ou égal" lors de l'
empiler? Imaginez une séquence d'insertion :2 → 2.- Si nous n'ajoutions que les éléments "strictement inférieurs" : Le premier
2irait danspileMinAuxiliaire. Le second2ne serait pas ajouté (car il n'est pas < au sommet depileMinAuxiliaire). Lorsque le premier2est retiré de la pile principale,pileMinAuxiliaireserait aussi dépilée, devenant vide. La pile principale contiendrait encore2, maisobtenirMinimuméchouerait ou serait incorrect. - En utilisant "inférieur ou égal", les deux
2seraient stockés danspileMinAuxiliaire. Le minimum resterait correct même après le dépilement du premier2.
- Si nous n'ajoutions que les éléments "strictement inférieurs" : Le premier
- Pourquoi dépiler la pile des minimums avant la pile principale ? C'est l'inverse : on dépile la pile principale, mais on vérifie d'abord si son sommet correspond au minimum actuel (sommet de
pileMinAuxiliaire). Si la pile principale était dépilée en premier, nous perdrions la valeur de son sommet et ne pourrions plus faire la comparaison nécessaire pour savoir sipileMinAuxiliairedoit être mise à jour.
Analyse de Complexité
| Opération | Complexité Temporelle | Complexité Spatiale |
|---|---|---|
empiler(valeur) |
O(1) | O(n) |
depiler() |
O(1) | O(n) |
sommet() |
O(1) | O(n) |
obtenirMinimum() |
O(1) | O(n) |
- Complexité Temporelle : Toutes les opérations fondamentales (
push,pop,topdes piles internes) sont en temps constant O(1). - Complexité Spatiale : Dans le pire des cas (par exemple, si les éléments sont insérés en ordre strictement décroissant :
5→4→3→2→1), lapileMinAuxiliairepeut stocker autant d'éléments que lapileContenu. La complexité spatiale est donc O(n), où 'n' est le nombre d'éléments dans la pile principale.
Alternative : L'Approche à Pile Unique
Une autre technique pour construire une pile minimale utilise une seule pile qui stocke des paires d'éléments. Chaque élément de la pile est une paire (valeur_actuelle, minimum_jusqu_ici).
Exemple d'Implémentation en C++ (Pile Unique)
#include <stack>
#include <utility> // Nécessaire pour std::pair
#include <algorithm> // Nécessaire pour std::min
class PileMinimaleCompacte {
private:
// Pile stockant des paires {valeur de l'élément, minimum à ce point de la pile}
std::stack<std::pair<int, int>> pileElementsEtMin;
public:
PileMinimaleCompacte() {}
// Ajoute un élément
void ajouter(int element) {
if (pileElementsEtMin.empty()) {
// Si la pile est vide, l'élément est son propre minimum
pileElementsEtMin.push({element, element});
} else {
// Le nouveau minimum est le minimum entre l'élément actuel et le minimum précédent
int nouveauMin = std::min(element, pileElementsEtMin.top().second);
pileElementsEtMin.push({element, nouveauMin});
}
}
// Retire l'élément au sommet
void retirer() {
if (pileElementsEtMin.empty()) {
throw std::runtime_error("La pile est vide.");
}
pileElementsEtMin.pop();
}
// Retourne la valeur au sommet de la pile
int voirSommet() {
if (pileElementsEtMin.empty()) {
throw std::runtime_error("La pile est vide.");
}
return pileElementsEtMin.top().first; // 'first' est l'élément lui-même
}
// Retourne le minimum actuel
int trouverMinimum() {
if (pileElementsEtMin.empty()) {
throw std::runtime_error("La pile est vide.");
}
return pileElementsEtMin.top().second; // 'second' est le minimum à ce point
}
};
Comparaison des Deux Approches
| Critère | Approche Double Pile | Approche Pile Unique |
|---|---|---|
| Logique | Nécessite de gérer la synchronisation entre deux piles distinctes. | Plus intégrée, chaque élément "porte" son minimum contextuel. |
| Occupation spatiale | Pire cas O(n) (lorsque la pile auxiliaire croît proportionnellement à la principale) ; Meilleur cas O(1) (si la pile principale est strictement croissante). | Toujours O(n) car chaque élément de la pile stocke une paire (valeur + min), doublant l'espace nécessaire par élément. |
| Clarté du code | Peut sembler plus complexe en raison des règles de synchronisation. | Généralement plus concise et directe. |
Le choix entre ces deux méthodes dépend souvent des contraintes spécifiques du projet : si l'optimisation spatiale est cruciale dans certains scénarios (par exemple, si les valeurs empilées sont fréquemment croissantes), la double pile peut être préférée. Si la simplicité et la compacité du code sont prioritaires, l'approche à pile unique est une excellente option.