TL;DR
- Le Problème: Optimisation de la complexité temporelle et spatiale des structures de données.
- L'Approche: Problème style CTCI 3.3 pour débutants: quand une pile d'assiettes est trop haute, on en ouvre une autre. Construire SetOfStacks pour que push et pop restent comme une seule pile, puis une courte note sur popAt(index).
- Complexité: Compromis optimal entre temps et mémoire avec gestion des cas limites.
Tu essuies des assiettes après le dîner. Une pile sur le plan de travail va bien jusqu'à ce qu'elle tangue. À une certaine hauteur tu ouvres une deuxième pile à côté, puis une troisième. De l'extérieur tu prends toujours l'assiette du dessus de la pile la plus récente et tu poses l'assiette propre sur cette même pile récente. En interne, ce sont plusieurs piles courtes, pas un gratte-ciel. C'est SetOfStacks.
Ce billet est un enseignement original pour débutants en Java. Même famille de questions d'entretien sur la capacité des piles, pas une copie de livre. Fait partie de la série CTCI en Java. Chapitre 3, piles et files.
1. Analogie du quotidien
Imagine des assiettes de table et une règle: aucune pile plus haute que capacity.
- Chaque tas physique est une pile interne avec une taille max (disons 5 assiettes).
- Quand le tas courant est plein, tu ouvres un nouveau tas à droite.
- push pose toujours une assiette sur le tas le plus à droite qui a encore de la place (ou crée un nouveau tas si le plus à droite est plein).
- pop prend toujours une assiette du tas non vide le plus à droite.
- Si un tas devient vide après un pop, tu l'enlèves pour que le "plus à droite" reste honnête.
Les appelants ne gèrent pas les numéros de tas. Ils appellent seulement push et pop comme s'il y avait une pile logique. Tu caches la comptabilité multi-piles.
Le follow-up est plus méchant: popAt(index) retire l'assiette du dessus d'un tas précis (par index de sous-pile), pas seulement du plus récent. Ça peut laisser un trou au milieu de ta rangée de tas. Tu choisis de faire rouler les assiettes vers la gauche pour combler, ou de laisser des sous-piles creuses. Les interviewers veulent que tu nommes le compromis.
2. Problème en mots simples
Construire une structure SetOfStacks avec un capacity fixe par pile interne.
Opérations:
push(value): empile sur la pile logique (sous-pile la plus récente, ou une nouvelle si besoin).pop(): dépile de la pile logique (sommet de la sous-pile non vide la plus récente). Se comporter comme une seule pile en ordre LIFO.- Follow-up optionnel:
popAt(index): pop seulement sur la sous-pileindex.
Invariants:
- Aucune pile interne ne contient plus de
capacityéléments. - Les piles vides en fin de liste ne doivent pas rester après un
pop. - Un
popsur une structure totalement vide doit échouer proprement (exception ou signal défini).
Exemples (capacity = 3):
| Action | Piles internes (gauche = plus ancienne) | Notes |
|---|---|---|
| push 1,2,3 | [1,2,3] |
première pile pleine |
| push 4 | [1,2,3] [4] |
nouvelle pile créée |
| push 5,6 | [1,2,3] [4,5,6] |
deuxième pleine |
| pop | [1,2,3] [4,5] |
renvoie 6 |
| pop, pop | [1,2,3] |
deuxième pile retirée quand vide |
| popAt(0) après d'autres pushes | dépend | pop seulement du sommet de la pile 0 |
Clarifier avant de coder:
- Capacity fixée à la construction? (Oui pour ce billet.)
- Et si capacity vaut 0 ou est négative? (Refuser dans le constructeur.)
- pop sur vide: lever, ou renvoyer null? (On lève
EmptyStackException.) - popAt: rollover (décalage) ou laisser des trous au milieu? (Discuter les deux; implémenter la version simple sans re-remplir et mentionner le rollover.)
3. Réfléchir d'abord
Un seul ArrayDeque ne suffit pas
Un seul Stack ou ArrayDeque donne déjà push/pop. Le point de ce problème est la contrainte de capacité par pile physique, comme des assiettes qui basculeraient, ou des pages de taille fixe dans une histoire mémoire.
Liste de piles
Garde un ArrayList<Stack<Integer>> (ou ArrayList<ArrayDeque<Integer>>) nommé stacks.
push(v):
- Si
stacksest vide, ou si la taille de la dernière pile vautcapacity, ajoute une nouvelle pile vide. - Pousse
vsur la dernière pile.
- Si
pop():
- S'il n'y a aucune pile, lever vide.
- Pop de la dernière pile.
- Si cette pile est maintenant vide, retire-la de la liste.
- Renvoyer la valeur.
Helper
lastStack(): renvoie la pile la plus à droite, ou null s'il n'y en a pas.
C'est tout le design de base. Pas d'arbre bizarre. Juste une liste extensible de seaux LIFO à capacité fixe.
Modèle mental du follow-up popAt
popAt(index) a besoin de contrôles de bornes: index dans l'intervalle, pile non vide.
Après un pop sur une pile du milieu, options:
- Laisser des trous. La pile
ipeut être plus courte que capacity alors que la pilei+1a encore des éléments. Code plus simple.pushne touche toujours que la dernière pile (sauf si tu rééquilibres aussi au push, ce que la plupart des solutions ne font pas). - Rollover / shift. Quand tu pops de la pile
i, tu prends l'élément du fond de la pilei+1et tu le pousses sur le sommet de la pilei, en cascade. Garde chaque pile pleine sauf peut-être la dernière. Plus de code, layout "dense" plus propre, O(N) au pire par popAt s'il y a beaucoup de piles.
Dis les deux à voix haute. Implémente la version simple sauf s'ils exigent le rollover.
4. Solution Java (SetOfStacks)
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.EmptyStackException;
import java.util.List;
/**
* Several fixed-capacity stacks that behave as one logical stack for push/pop.
* Capacity is per inner stack. New stacks open when the current one is full.
*/
class SetOfStacks {
private final int capacity;
private final List<Deque<Integer>> stacks = new ArrayList<>();
SetOfStacks(int capacity) {
if (capacity < 1) {
throw new IllegalArgumentException("capacity must be at least 1");
}
this.capacity = capacity;
}
void push(int value) {
Deque<Integer> last = lastStack();
if (last == null || last.size() == capacity) {
last = new ArrayDeque<>();
stacks.add(last);
}
last.push(value);
}
int pop() {
Deque<Integer> last = lastStack();
if (last == null) {
throw new EmptyStackException();
}
int value = last.pop();
if (last.isEmpty()) {
stacks.remove(stacks.size() - 1);
}
return value;
}
/**
* Pop only from sub-stack at index (0 = oldest).
* Leaves later stacks as-is (no rollover). See section 5.
*/
int popAt(int index) {
if (index < 0 || index >= stacks.size()) {
throw new IndexOutOfBoundsException("sub-stack index: " + index);
}
Deque<Integer> stack = stacks.get(index);
if (stack.isEmpty()) {
throw new EmptyStackException();
}
int value = stack.pop();
if (stack.isEmpty()) {
stacks.remove(index);
}
return value;
}
boolean isEmpty() {
return stacks.isEmpty();
}
int numberOfStacks() {
return stacks.size();
}
private Deque<Integer> lastStack() {
if (stacks.isEmpty()) {
return null;
}
return stacks.get(stacks.size() - 1);
}
}
Parcours avec capacity 3:
push(1..3)→ une pile pleine[1,2,3](sommet = 3).push(4)→ une deuxième pile apparaît:[1,2,3] [4].pop()→ 4; la deuxième pile est vide et retirée →[1,2,3].pop()→ 3 →[1,2].- Après d'autres pushes avec trois piles,
popAt(0)ne retire que le sommet de la plus ancienne. Les suivantes restent (pas de shift).
Pourquoi ArrayDeque plutôt que java.util.Stack? Stack est une vieille sous-classe synchronisée de Vector. ArrayDeque est le choix LIFO moderne habituel en entretien Java. Le comportement est le même pour nous.
5. Note sur popAt (follow-up)
popAt(index) est le twist qui montre si tu as seulement mémorisé "liste de piles" ou réfléchi à la structure.
Version simple (ci-dessus): pop sur stacks.get(index), retire la sous-pile si elle est vide. Les piles du milieu peuvent rester sous capacity alors que les plus récentes sont pleines. C'est acceptable si le problème demande seulement un pop légal sur cette sous-pile.
Version rollover (esquisse, code non obligatoire):
- Pop sur la pile
index. - Tant qu'il y a une pile suivante, prendre son élément du fond (il faut une structure qui expose le fond, ou reconstruire) et le pousser sur la pile courante pour restaurer capacity.
- Répéter en chaîne jusqu'à la dernière pile.
Le rollover garde la métaphore des assiettes serrée: quand tu retires une assiette d'un tas plus ancien, les assiettes "tombent à gauche" depuis les tas plus récents pour qu'aucun tas du milieu ne reste à moitié vide. Le coût croît avec le nombre de piles et d'éléments déplacés. Mentionne-le; n'implémente que si on le demande.
Clarifie aussi le sens de l'index: 0 est-il la pile la plus ancienne ou la plus récente? Choisis-en un et tiens-toi-y. Dans le code ci-dessus, 0 est la plus ancienne.
6. Tableau de complexité
| Opération | Temps | Espace extra (au-delà des éléments) | Notes |
|---|---|---|---|
push |
O(1) amorti | O(1) | allocation occasionnelle d'une nouvelle pile |
pop |
O(1) | O(1) | peut retirer une pile vide en fin de liste |
popAt (sans rollover) |
O(1) ou O(S) | O(1) | O(S) si retirer une pile vide au milieu décale la liste |
popAt (avec rollover) |
O(N) pire cas | O(1) | peut toucher chaque pile suivante |
isEmpty |
O(1) | O(1) | vide ssi aucune sous-pile ne reste |
N est le total d'éléments sur toutes les piles. S est le nombre de sous-piles. L'espace de la structure est O(N) pour stocker les valeurs, comme une grande pile, plus un petit nombre d'en-têtes de pile.
7. Cas limites et erreurs fréquentes
Les interviewers testent ceux-ci:
- capacity = 1 → chaque push ouvre une nouvelle pile (ou remplit une pile de taille 1 et le push suivant en ouvre une autre). pop retire toujours la plus récente. Ça marche si tu ne fais pas de cas spécial.
- capacity invalide → lever dans le constructeur, ne pas attendre le push.
- pop sur vide → lever. Ne renvoie pas 0 ou -1 sauf si le problème autorise un sentinelle.
- pop jusqu'à vide, puis push à nouveau → la liste de piles repart de zéro proprement.
- popAt hors bornes → exception de bounds.
- popAt qui vide une pile du milieu → retire cette entrée (les index suivants bougent) ou laisse une tombe. Retirer est plus propre; documente que les index suivants changent.
- Une seule pile, pas pleine → push reste sur cette pile. Ne crée pas une deuxième pile trop tôt.
Erreurs fréquentes:
- Oublier de retirer les piles vides en fin après pop. Alors
lastStack()pointe un tas vide et le pop suivant échoue ou a besoin de plus de null checks. - Pousser sur la dernière pile déjà pleine. Toujours vérifier
size() == capacityavant le push. - Traiter popAt comme pop. Ce sont des API différentes. L'appelant de popAt a choisi une sous-pile précise.
- Prendre capacity comme capacité totale de toutes les piles. Capacity est par sous-pile.
- Implémenter le rollover par accident avec un seul ArrayList de valeurs et de l'arithmétique modulaire. Ça peut marcher pour un autre design, mais alors les "sous-piles" deviennent virtuelles. Préfère une liste explicite de deques pour que la métaphore des assiettes reste visible au tableau.
8. Récap à raconter à un ami
Stack of plates demande: plusieurs piles courtes sous une limite de capacité, mais push et pop qui se sentent comme une seule pile.
- Tiens une liste ordonnée de piles internes. Seule la dernière reçoit les push normaux.
- Si la dernière est pleine, ajoute une nouvelle pile vide, puis push.
- Pop de la dernière. Si elle se vide, supprime-la de la liste.
- L'ordre LIFO de la pile logique est préservé: l'assiette la plus récente sort en premier, même en traversant les frontières de tas.
- popAt(index) ne pop que ce tas. Soit tu laisses des trous, soit tu fais rouler les assiettes à gauche. Dis lequel tu as choisi.
Si tu peux dessiner trois piles de hauteur 3, pousser une 10e assiette, pop deux fois, et expliquer pourquoi le tas vide le plus à droite disparaît, tu maîtrises le problème 3.3.
Série
- Guide: Guide de la série CTCI
- Précédent: Stack Min
- Suivant: Queue via Stacks
