awesome-everything EN
↑ Обратно к восхождению

Алгоритмы с нуля

Очереди и деки

Суть Очередь: FIFO контейнер где enqueue на спине, dequeue с передней. Обе O(1) с правильной реализацией. Дек расширяет это поддерживая O(1) на обоих концах, применяется в BFS и любой ситуации требующей справедливости.
◷ 24 min

В прошлом уроке ты выучил стек — 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.

Две операции:

  1. Enqueue: Добавь элемент на спину очереди. Возвращает ничего; обновляет очередь на месте.
  2. 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 }

Сложность

Операции очереди (реализация связным списком):

ОперацияВремяПространство
EnqueueO(1)O(1)
DequeueO(1)O(1)
PeekO(1)O(1)
isEmptyO(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):

ОперацияВремяПространство
PushFrontO(1)O(1)
PushBackO(1)O(1)
PopFrontO(1)O(1)
PopBackO(1)O(1)
PeekFrontO(1)O(1)
PeekBackO(1)O(1)

Все шесть операций это O(1) постоянное время с правильной circular-buffer или двусвязный список реализацией.

Практика 0 / 5

Ты 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 и передний/задний дисциплина

  1. FIFO правило: Первый элемент enqueue’ленный это первый что dequeue’ленный. Думай о очереди на кассе.

  2. Две основные операции: enqueue (добавь на спину, O(1)), dequeue (удали с передней, O(1)). Обе это постоянное время с правильной реализацией.

  3. Три реализации:

    • Связный список: O(1) для обоих, простой, нет потраченного пространства.
    • Circular buffer: O(1) для обоих, пространство-эффективный (переиспользует слоты), требует модуло арифметика.
    • Наивный массив с .shift(): O(n) для dequeue. Избегай.
  4. Дек (двухконечная очередь): Позволяет O(1) операции (push, pop) на обоих передней и спине. Реализована с двусвязный список или circular buffer.

  5. Почему circular buffers это умный: Модуло % оператор оборачивает указатели. Когда rear достигает конец массива, это оборачивается на 0. Dequeue’ленные слоты переиспользуются; нет пространства потрачено.

  6. Реально мировые использования: BFS traversal, задачи очереди печати, level-order tree traversal, очередь сообщений, load balancing.

  7. Ключевой insight: Очередь это абстрактная дисциплина. Ты выбираешь реализацию (связный список, circular buffer, итд) основана память ограничения и use case. FIFO семантика это что имеет значение.

Далее, ты выучишь монотонный стек — стек спаренный с значением ограничение решить задачи как “следующий больший элемент” и “наибольший прямоугольник в гистограмме” эффективно.

Продолжить восхождение ↑Монотонный стек
хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources5
expand
  1. 01
  2. 02
  3. 03
  4. 04
  5. 05

Trademarks belong to their respective owners. Editorial reference only.