Массивы и списки хранят данные, к которым можно обратиться как угодно. Стеки и очереди — другой сорт структур: это инструменты, где доступ к элементам намеренно ограничен. Кажется, что ограничение — это минус, но именно оно делает эти структуры простыми и быстрыми. Общие термины — в вводной статье раздела.
Стек: последним пришёл — первым вышел
Стек устроен как стопка бумаг на столе: новый лист кладут наверх, и берут тоже сверху. Доступен только верхний элемент — тот, что положили последним. Это и есть принцип LIFO (Last In, First Out — «последним пришёл, первым вышел»).
Операций всего три:
- push — положить элемент на вершину;
- pop — снять элемент с вершины (и получить его);
- peek — посмотреть верхний элемент, не снимая.
Реализовать стек проще всего на массиве: храним индекс вершины top, при push увеличиваем его и пишем в ячейку, при pop читаем и уменьшаем.
живой пример
class Stack {
private final long[] a;
private int top = -1;
Stack(int size) { a = new long[size]; }
void push(long v) { a[++top] = v; }
long pop() { return a[top--]; }
long peek() { return a[top]; }
boolean isEmpty() { return top == -1; }
public static void main(String[] args) {
Stack s = new Stack(4);
s.push(17);
s.push(42);
s.push(8);
System.out.println("на вершине: " + s.peek());
StringBuilder order = new StringBuilder();
while (!s.isEmpty()) order.append(s.pop()).append(' ');
System.out.println("выходят: " + order);
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Неделя бесплатно →
Положили 17, 42, 8 — вышли 8, 42, 17. Все операции — O(1): доступ всегда к одной и той же позиции, вершине.
push кладёт элемент на вершину, pop снимает оттуда же — доступен только верх (LIFO). Каждая операция трогает одну позицию, поэтому O(1).
Где применяется стек:
- обратный порядок. Ровно то, что напечатал пример выше: так переворачивают строку.
- проверка парных скобок. Открывающую скобку кладём в стек, на закрывающую снимаем и проверяем, что она парная. Так компиляторы ловят несбалансированные
(,{,[. - отмена действий (undo). Каждое действие — в стек; отмена снимает последнее.
- стек вызовов. Сам язык использует стек: при вызове метода в него кладётся адрес возврата и аргументы, при выходе — снимаются. Поэтому рекурсия и работает.
- разбор выражений. Инфиксную запись
3 × (4 + 5)переводят в постфиксную3 4 5 + ×, где скобки не нужны, и вычисляют — оба шага делает стек. Так работали старые калькуляторы, так устроена виртуальная машина Java.
Очередь: первым пришёл — первым вышел
Очередь — как очередь в магазине: встают в хвост, обслуживают с головы. Это принцип FIFO (First In, First Out). Операции:
- enqueue — добавить элемент в хвост;
- dequeue — забрать элемент из головы.
Очередь незаменима, когда задачи нужно обрабатывать в порядке поступления: сообщения, задания на печать, запросы к сервису, события. Она же — рабочая лошадка обхода графа в ширину.
Циклическая очередь
Наивная очередь на массиве быстро упирается в проблему: голова и хвост «уползают» вправо, левая часть массива пустует зря. Решение — циклическая очередь (кольцевой буфер): когда хвост доходит до конца массива, он перескакивает в начало, если там уже освободилось место. Индексы считают по модулю длины массива. Так enqueue и dequeue остаются O(1) без сдвигов.
Дек
Дек (deque, double-ended queue) — «двусторонняя очередь»: добавлять и забирать можно с обоих концов. Это обобщение: разрешить операции только с одного конца — дек превращается в стек; если добавлять с одного, а забирать с другого — в очередь.
Приоритетная очередь
В обычной очереди порядок выхода определяет время прихода. В приоритетной очереди — приоритет элемента: первым выходит не тот, кто раньше пришёл, а тот, у кого выше приоритет. Аналогия — приёмный покой больницы: тяжёлого пациента примут раньше, даже если он пришёл позже.
Простую приоритетную очередь можно сделать на упорядоченном массиве (вставляем с сохранением порядка — тогда выемка максимума мгновенна, но вставка O(N)). Когда нужна эффективность, её строят на особой структуре — куче (пирамиде), где и вставка, и выемка приоритетного элемента стоят O(log N). Это сердце многих алгоритмов, например поиска кратчайшего пути Дейкстры.
Как это сделано в Java
Стек и очередь легко написать самому — кода в них на десять строк, — но в стандартной библиотеке они есть. Выбирать приходится между двумя классами, и один из них лучше обойти стороной.
Правильный — ArrayDeque. Это дек из предыдущего раздела, и внутри у него циклическая очередь: обычный массив, два индекса — голова и хвост — и переход через край обратно в начало массива. Причём никакой арифметики с остатком для этого перехода нет: индекс увеличивают на единицу и, если он упёрся в длину массива, обнуляют — одна проверка вместо деления. Когда места перестаёт хватать, содержимое переезжает в массив побольше: пока дек маленький, длина удваивается, а после нескольких десятков элементов растёт в полтора раза.
Все операции — O(1) (амортизированно — из-за того самого редкого переезда в больший массив):
- как стек:
pushкладёт в голову,popзабирает оттуда же,peekсмотрит; - как очередь:
offerдобавляет в хвост,pollзабирает из головы.
Один класс закрывает обе структуры — дек их обобщает. Разница видна на одних и тех же данных:
живой пример
import java.util.ArrayDeque;
import java.util.Deque;
class StackVsQueue {
public static void main(String[] args) {
Deque<String> stack = new ArrayDeque<>();
Deque<String> queue = new ArrayDeque<>();
for (String task : new String[]{"первая", "вторая", "третья"}) {
stack.push(task);
queue.offer(task);
}
System.out.println("стек: " + stack.pop() + ", " + stack.pop());
System.out.println("очередь: " + queue.poll() + ", " + queue.poll());
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Неделя бесплатно →
Три одинаковых задачи, один и тот же класс — но стек начинает с последней, а очередь с первой. А интерфейсы Deque и Queue, которые он реализует, — это и есть ADT в чистом виде: код пишут против поведения, а реализацию подставляют любую.
Класс, который обходят стороной, — Stack. Он появился в самой первой версии Java и унаследован от Vector, а значит, каждый его метод синхронизирован: за блокировки вы платите даже в одном потоке, где они не нужны. Хуже другое: Stack — это ещё и список, и перебирается он снизу вверх, от самого старого элемента к вершине, то есть в порядке, обратном тому, в котором стек отдаёт элементы через pop. Новый код на Stack не пишут — берут ArrayDeque.
Приоритетная очередь живёт отдельно: это PriorityQueue, построенная на куче.
Грабля у ArrayDeque одна, но о неё спотыкаются регулярно: null в него класть нельзя — прилетит NullPointerException. poll и peek возвращают null, когда дек пуст, и разреши мы хранить null внутри — этот ответ стал бы двусмысленным. LinkedList в роли дека null принимает, и там пустой дек и дек с null на конце различить уже нельзя.
Коротко
- Стек — LIFO, доступен только верхний элемент; операции push/pop/peek, все O(1). Применяют для обращения порядка, проверки скобок, отмены действий, стека вызовов.
- Очередь — FIFO, добавляют в хвост, забирают с головы; для обработки задач в порядке поступления. Циклическая очередь использует массив по кругу, чтобы не терять место.
- Дек — очередь с доступом с обоих концов; обобщает стек и очередь.
- Приоритетная очередь отдаёт первым элемент с наибольшим приоритетом; эффективно реализуется на куче.
- В Java обе структуры закрывает один
ArrayDeque;Stackне берут — он синхронизирован и перебирается снизу вверх, против LIFO.
Что почитать дальше
- Связанные списки — ещё одна начинка для стека и очереди: вставка и удаление без сдвига элементов.
- Пирамиды (кучи) — как приоритетная очередь укладывается в O(log N) вместо O(N).
- Монотонный стек — приём, где стек за один проход находит ближайший больший элемент.
- Рекурсия — стек вызовов изнутри: что именно переполняется при глубокой рекурсии.