Алгоритмы с нуля
Очереди и деки
В прошлом уроке ты выучил стек — LIFO (последний-вошедший-первый-вышедший) структуру. Сегодня ты выучишь очередь — его противоположность, FIFO (первый-вошедший-первый-вышедший) структуру. Очередь это как линия на кассе: ты присоединяешься сзади, и человек спереди обслуживается первый. Никакого line-cutting позволено. В программировании очереди моделируют очередь ожидания, поиск в ширину (BFS), планирование задач печати, и любую ситуацию где справедливость (первый пришедший, первый обслужен) критична. Ты также выучишь дек (двухконечную очередь), что добавляет мощь FIFO и способность работать на обоих концах за O(1) время. Ключевой insight: наивный массив .shift() это O(n), но circular buffer или связный список делают оба конца O(1).
После этого урока ты можешь объяснить FIFO дисциплину и две основные операции очереди (enqueue, dequeue), реализовать очередь используя связный список (O(1) для обеих операций), узнать почему наивный массив .shift() это O(n), понять дек (двухконечную очередь) и почему circular-buffer реализация делает оба конца O(1), решать BFS и level-order traversal задачи используя очередь, и узнать когда очередь или дек это правильная структура данных для задачи.
FIFO правило:
Очередь это линейная структура данных где вставки происходят на одном конце (спине) и удаления на другом (передней). Правило это FIFO (первый вошедший, первый вышедший): элемент добавленный первый это тот что удаляется первый.
Представь очередь на кассе в магазине. Покупатели присоединяются сзади и уходят с передней. Первый что присоединился это первый что платит и уходит. Ты никогда не перепрыгиваешь; никто не режет перед. Это FIFO.
Две операции:
- Enqueue: Добавь элемент на спину очереди. Возвращает ничего; обновляет очередь на месте.
- Dequeue: Удали и верни передний элемент очереди. Если очередь пуста, или верни null или подними ошибку (зависит от реализации).
Есть также часто передний или peek операция видеть передний элемент без удаления.
const queue = []; // Пустая очередь
// Enqueue: добавь 10, 20, 30
queue.push(10); // Очередь: [10]
queue.push(20); // Очередь: [10, 20]
queue.push(30); // Очередь: [10, 20, 30]
// Peek: видь передний
console.log(queue[0]); // 10 (без удаления)
// Dequeue: удали передний
// НЕ используй queue.shift() в реальной очереди! Это O(n).
const front = queue[0];
queue = queue.slice(1); // Неэффективно! Почему?Почему .shift() это O(n), и как это исправить:
Когда ты используешь JavaScript .shift() или удаляешь с передней части массива, язык должен переиндексировать все оставшиеся элементы:
До: [10, 20, 30, 40] (индексы 0, 1, 2, 3)
Удали индекс 0 (10).
После: [20, 30, 40] (индексы 0, 1, 2)
↑ Переиндексируй все!Это переиндексирование это O(n) работа. Если ты зовёшь dequeue n раз, это стоит O(n²) итого — очень дорого.
Решение 1: используй front и rear указатели.
Вместо удаления, держи front указатель. Enqueue идёт на спину; dequeue инкрементирует передний указатель.
class Queue {
constructor() {
this.items = [];
this.front = 0; // Указатель на передний
}
enqueue(value) {
this.items.push(value);
}
dequeue() {
if (this.front >= this.items.length) return null;
const value = this.items[this.front];
this.front++;
return value;
}
peek() {
if (this.front >= this.items.length) return null;
return this.items[this.front];
}
isEmpty() {
return this.front >= this.items.length;
}
}Теперь и enqueue и dequeue это O(1). Но есть проблема: front указатель только движется вперёд. После многих dequeues, передняя часть массива это потраченное пространство. Если ты dequeue 1000 элементов, первые 1000 слотов никогда не переиспользуются.
Решение 2: используй circular buffer.
Circular buffer оборачивается. Когда ты достигаешь конца массива, следующий enqueue идёт назад на индекс 0 (если этот слот пуст). Это переиспользует пространство и держит оба конца O(1):
class CircularQueue {
constructor(capacity) {
this.items = new Array(capacity);
this.front = 0;
this.rear = -1;
this.size = 0;
this.capacity = capacity;
}
enqueue(value) {
if (this.size === this.capacity) {
// Очередь полна. Вырасти в массиве.
this._resize();
}
this.rear = (this.rear + 1) % this.capacity;
this.items[this.rear] = value;
this.size++;
}
dequeue() {
if (this.size === 0) return null;
const value = this.items[this.front];
this.front = (this.front + 1) % this.capacity;
this.size--;
return value;
}
peek() {
if (this.size === 0) return null;
return this.items[this.front];
}
_resize() {
const newCapacity = this.capacity * 2;
const newItems = new Array(newCapacity);
for (let i = 0; i < this.size; i++) {
newItems[i] = this.items[(this.front + i) % this.capacity];
}
this.items = newItems;
this.front = 0;
this.rear = this.size - 1;
this.capacity = newCapacity;
}
}Модуло % оператор оборачивает указатели: когда rear достигает конца, оно оборачивается на 0.
Решение 3: используй связный список.
Связный список естественно поддерживает O(1) вставку в хвост и O(1) удаление в голове:
class Node {
constructor(value, next = null) {
this.value = value;
this.next = next;
}
}
class QueueLinkedList {
constructor() {
this.head = null; // Передний очереди
this.tail = null; // Спина очереди
}
enqueue(value) {
const newNode = new Node(value);
if (this.tail === null) {
this.head = this.tail = newNode;
} else {
this.tail.next = newNode;
this.tail = newNode;
}
}
dequeue() {
if (this.head === null) return null;
const value = this.head.value;
this.head = this.head.next;
if (this.head === null) this.tail = null;
return value;
}
peek() {
if (this.head === null) return null;
return this.head.value;
}
isEmpty() {
return this.head === null;
}
}Дек: очереди на обоих концах
Дек (двухконечная очередь) позволяет вставку и удаление на обоих концах. Ты можешь push и pop с передней и спины. Дек реализованный с двусвязный список или circular buffer поддерживает все четыре операции за O(1):
class Deque {
constructor() {
this.items = [];
this.front = 0;
this.rear = -1;
this.size = 0;
}
pushBack(value) {
this.items.push(value);
this.rear++;
this.size++;
}
pushFront(value) {
this.items.unshift(value);
this.rear++;
this.size++;
}
popBack() {
if (this.size === 0) return null;
this.size--;
this.rear--;
return this.items.pop();
}
popFront() {
if (this.size === 0) return null;
this.size--;
this.front++;
return this.items.shift(); // Это O(n)!
}
peekFront() {
if (this.size === 0) return null;
return this.items[this.front];
}
peekBack() {
if (this.size === 0) return null;
return this.items[this.rear];
}
}Заметь: выше наивный дек это неэффективный (popFront используешь .shift()). Правильный дек используешь circular buffer или двусвязный список гарантировать O(1) для всех операций.
Очередь подкреплённая связным списком (O(1) enqueue и dequeue):
class Node {
constructor(value, next = null) {
this.value = value;
this.next = next;
}
}
class Queue {
constructor() {
this.head = null; // Передний
this.tail = null; // Спина
}
enqueue(value) {
const newNode = new Node(value);
if (this.tail === null) {
this.head = this.tail = newNode;
} else {
this.tail.next = newNode;
this.tail = newNode;
}
}
dequeue() {
if (this.head === null) return null;
const value = this.head.value;
this.head = this.head.next;
if (this.head === null) this.tail = null;
return value;
}
peek() {
if (this.head === null) return null;
return this.head.value;
}
isEmpty() {
return this.head === null;
}
}Очередь подкреплённая circular-buffer (массив с передними/задними указателями):
class CircularQueue {
constructor(capacity = 10) {
this.items = new Array(capacity);
this.front = 0;
this.rear = -1;
this.size = 0;
this.capacity = capacity;
}
enqueue(value) {
if (this.size === this.capacity) {
this._resize();
}
this.rear = (this.rear + 1) % this.capacity;
this.items[this.rear] = value;
this.size++;
}
dequeue() {
if (this.size === 0) return null;
const value = this.items[this.front];
this.front = (this.front + 1) % this.capacity;
this.size--;
return value;
}
peek() {
if (this.size === 0) return null;
return this.items[this.front];
}
isEmpty() {
return this.size === 0;
}
_resize() {
const newCapacity = this.capacity * 2;
const newItems = new Array(newCapacity);
for (let i = 0; i < this.size; i++) {
newItems[i] = this.items[(this.front + i) % this.capacity];
}
this.items = newItems;
this.front = 0;
this.rear = this.size - 1;
this.capacity = newCapacity;
}
}Попробуй обе в интерактивном runner:
Давай проследим последовательность enqueue и dequeue операций на очереди.
Очередь: изначально пуста. Операции: enqueue(10), enqueue(20), enqueue(30), dequeue(), dequeue(), enqueue(40), dequeue().
1
while (ops.length > 0) {
2
const op = ops.shift();
3
if (op.type === 'enqueue') {
4
queue.enqueue(op.value);
5
} else if (op.type === 'dequeue') {
6
queue.dequeue();
7
}
8
}
Операции очереди (реализация связным списком):
| Операция | Время | Пространство |
|---|---|---|
| Enqueue | O(1) | O(1) |
| Dequeue | O(1) | O(1) |
| Peek | O(1) | O(1) |
| isEmpty | O(1) | O(1) |
Все операции выполняются за O(1) постоянное время потому что ты только трогаешь head и tail указатели. Нет traverse нужно; нет цикла что итерирует структуру.
Операции очереди (реализация circular-buffer с указателями):
Тоже как выше: O(1) для всех операций. Модуло % оператор это O(1), так что указатель арифметика это O(1).
Наивный массив с .shift() (НЕ ИСПОЛЬЗУЙ):
- Enqueue: O(1)
- Dequeue: O(n) — потому что
.shift()должен переиндексировать все оставшиеся элементы. - Если ты enqueue m элементов потом dequeue m элементов, итого стоимость это O(m²). Избегай этого.
Операции дека (реализация circular-buffer):
| Операция | Время | Пространство |
|---|---|---|
| PushFront | O(1) | O(1) |
| PushBack | O(1) | O(1) |
| PopFront | O(1) | O(1) |
| PopBack | O(1) | O(1) |
| PeekFront | O(1) | O(1) |
| PeekBack | O(1) | O(1) |
Все шесть операций это O(1) постоянное время с правильной circular-buffer или двусвязный список реализацией.
Ты enqueue 10, 20, и 30 (в том порядке) в очередь, потом dequeue дважды. Что это передний элемент сейчас?
Почему массив `.shift()` это не подходящий для реализации dequeue операции очереди?
Circular queue оборачивает передний и задний указатели используя модуло (%). Что это главное преимущество?
Дек поддерживает O(1) операции на передней и спине. Какая структура данных это лучше подходящий эффективно реализовать это?
Ты выполняешь 1000 enqueue и 1000 dequeue на наивный массив очередь (используя `.shift()`). Что это итого временная сложность?
Граничные случаи
Операции на пустой очереди: Когда очередь пуста и ты пытаешься dequeue или peek, что должно произойти? Разные языки отличаются: некоторые возвращают null, некоторые подымают ошибку. В JavaScript, очередь LinkedList возвращает null. Circular queue также возвращает null. Решай заранее: верни null для пустого dequeue, или подними ошибку? Для BFS и level-order traversal, мы возвращаем null и проверяем это.
Частая ошибка
«Очередь это просто массив.» Нет. Очередь это абстрактная дисциплина (FIFO с ограниченным доступом к передней и спине), не структура данных. Массив это структура данных (случайный доступ, индексирование). Ты можешь реализовать очередь используя массив (с передними/задними указателями или circular buffer), но очередь скрывает способность массива доступить середину. Не путай абстрактную идею (очередь) с реализацией (массив, связный список, circular buffer).
Ещё практика
Последующее: поиск в ширину (BFS). Очереди это сердце BFS traversal на графах и деревьях. Ты начинаешь на root узле, enqueue это, потом повторно dequeue узел, посещаешь это, и enqueue его непосещённых соседей. FIFO порядок гарантирует ты посещаешь узлы level за level. Деки также позволяют двухсторонний exploration, полезный в определённых продвинутых алгоритмах графа.
Почему очередь (не стек) это идеальная структура данных для поиска в ширину (BFS) traversal?
Очередь: FIFO и передний/задний дисциплина
-
FIFO правило: Первый элемент enqueue’ленный это первый что dequeue’ленный. Думай о очереди на кассе.
-
Две основные операции: enqueue (добавь на спину, O(1)), dequeue (удали с передней, O(1)). Обе это постоянное время с правильной реализацией.
-
Три реализации:
- Связный список: O(1) для обоих, простой, нет потраченного пространства.
- Circular buffer: O(1) для обоих, пространство-эффективный (переиспользует слоты), требует модуло арифметика.
- Наивный массив с
.shift(): O(n) для dequeue. Избегай.
-
Дек (двухконечная очередь): Позволяет O(1) операции (push, pop) на обоих передней и спине. Реализована с двусвязный список или circular buffer.
-
Почему circular buffers это умный: Модуло
%оператор оборачивает указатели. Когдаrearдостигает конец массива, это оборачивается на 0. Dequeue’ленные слоты переиспользуются; нет пространства потрачено. -
Реально мировые использования: BFS traversal, задачи очереди печати, level-order tree traversal, очередь сообщений, load balancing.
-
Ключевой insight: Очередь это абстрактная дисциплина. Ты выбираешь реализацию (связный список, circular buffer, итд) основана память ограничения и use case. FIFO семантика это что имеет значение.
Далее, ты выучишь монотонный стек — стек спаренный с значением ограничение решить задачи как “следующий больший элемент” и “наибольший прямоугольник в гистограмме” эффективно.