Стек, очередь, дек

Предпосылки: массив, динамический массив, связный список (односвязный, двусвязный).

Парсер арифметических выражений обрабатывает скобки: открывающая — запомнить, закрывающая — проверить, что последняя открытая ей соответствует. Представим, что он использует обычный массив. Технически это работает: append добавляет, pop удаляет с конца. Но интерфейс массива разрешает insert(0, x), delete_at(i), arr[i] = y — операции, которые для парсера бессмысленны и опасны. Если кто-то по ошибке вставит элемент в середину, порядок скобок нарушится.

Ограничение интерфейса до минимально необходимых операций превращает «можно случайно сломать» в «физически невозможно». Стек разрешает только добавление и удаление с одного конца — и этого достаточно для парсера. Очередь разрешает добавление с одного конца и удаление с другого — и этого достаточно для обработки заданий в порядке поступления. Дек разрешает операции с обоих концов. Каждый из этих трёх ADT скрывает лишние операции, делая ошибки невозможными, а намерение — явным.

Стек

Стек работает по принципу LIFO — Last In, First Out: последним пришёл, первым вышел. Как стопка тарелок: берёшь верхнюю, которую положил последней.

ОперацияСигнатураСложность
Положить на вершинуpush(x)O(1)
Снять с вершиныpop() → xO(1)
Посмотреть вершинуpeek() → xO(1)
Проверить пустотуempty?() → boolO(1)

Стек на динамическом массиве идеален: push и pop работают с правого края — массив просто меняет length, никаких сдвигов. Альтернатива — односвязный список (O(1) без resize), но на практике массив выигрывает за счёт кэш-локальности. Call stack работает по LIFO: последняя вызванная функция завершается первой.

Очередь

Стек работает с одного конца. Но задания на принтере обрабатываются в порядке поступления, обход дерева в ширину проходит уровень за уровнем — здесь нужен доступ с противоположного конца: добавляем в хвост, забираем из головы. Это принцип FIFO — First In, First Out: первым пришёл, первым вышел.

ОперацияСигнатураСложность
Добавить в конецenqueue(x)O(1)
Забрать из началаdequeue() → xO(1)
Посмотреть первыйfront() → xO(1)
Проверить пустотуempty?() → boolO(1)

На односвязном списке с tail очередь реализуется просто: enqueue через tail, dequeue через head — обе операции O(1). Но узлы списка разбросаны по куче, и при интенсивной работе cache misses начинают доминировать. Ring buffer решает это, сохраняя данные в непрерывном массиве.

Ring Buffer: от «мёртвой зоны» к кольцу

Наивная реализация очереди на массиве: enqueue — append в конец, O(1). Dequeue — забрать из начала. Сдвигать все элементы (O(n)) слишком дорого, поэтому вместо сдвига двигаем указатель head:

После нескольких dequeue:
[_][_][_][_][A][B][C][D]
             ↑           ↑
           head        tail

Четыре слота в начале «мертвы» — выделены, но никогда не будут использованы повторно. Массив растёт только вправо. Со временем вся память занята «мёртвой зоной», хотя живых элементов — горстка.

Что если, когда tail дошёл до конца массива, он вернётся в начало? «Мёртвая зона» становится свободным местом для новых элементов. Массив логически замкнут в кольцо:

[G][H][_][_][E][F]
       ↑     ↑
     tail   head

Формула перехода через край

Переход на следующую позицию: если текущий индекс — последний слот, следующий должен быть нулевым. Операция по модулю делает именно это:

next = (current + 1) % capacity

Индекс 7 в массиве из 8: (7 + 1) % 8 = 0 — переход в начало. Индекс 3: (3 + 1) % 8 = 4 — обычное продвижение. Одна формула покрывает оба случая.

Обе операции (enqueue и dequeue) — O(1). Никаких сдвигов, никакой мёртвой зоны.

Как отличить пустой буфер от полного?

И пустой, и полный буфер дают head == tail — противоречие. Два классических решения: хранить отдельный счётчик size (пусто когда size == 0, полно когда size == capacity) или жертвовать одной ячейкой — capacity на 1 больше максимума элементов, и буфер полон когда (tail + 1) % capacity == head.

Рост буфера

При переполнении буфер растёт по тому же принципу, что и динамический массив: выделяется новый буфер удвоенного размера, элементы копируются «выпрямляя» кольцо — head сбрасывается в 0, tail в size. Амортизированный O(1).

Дек

Стек работает с одного конца, очередь — с двух, но роли фиксированы: один для добавления, другой для удаления. Иногда нужны оба конца для обеих операций. Дек (deque — double-ended queue, произносится как “deck”, колода карт) даёт именно это.

ОперацияСигнатураСложность
Добавить в началоpush_front(x)O(1)
Добавить в конецpush_back(x)O(1)
Удалить из началаpop_front() → xO(1)
Удалить из концаpop_back() → xO(1)

Односвязный список для дека не подходит: pop_back требует предыдущий узел — O(n). Реализуют дек на ring buffer или двусвязном списке. На практике дек встречается в work-stealing планировщиках (потоки крадут задачи с противоположного конца), алгоритме BFS с приоритетами (0-1 BFS), и реализации sliding window.

Сводка: три уровня свободы

ADTДобавлениеУдалениеМинимальная структура
Стекодин конецтот жемассив или односвязный
Очередьодин конецпротивоположныйring buffer или односвязный с tail
Декоба концаоба концаring buffer или двусвязный

Проблема: доступ только по числовому ключу

Все три ADT работают с позицией: начало, конец, индекс. Ключ — всегда число.

Что если нужен доступ по произвольному ключу? Строка “alice”, объект, UUID? Хеш-таблица превращает произвольный ключ в O(1) доступ.

Реализация (Ruby)

Реализация (Ruby)

Стек на односвязном списке

class Node
  attr_accessor :value, :next
 
  def initialize(value, next_node = nil)
    @value = value
    @next = next_node
  end
end
 
class Stack
  def initialize
    @head = nil
  end
 
  def push(value)
    @head = Node.new(value, @head)
  end
 
  def pop
    return nil unless @head
    value = @head.value
    @head = @head.next
    value
  end
 
  def peek
    @head ? @head.value : nil
  end
 
  def empty?
    @head.nil?
  end
end

Стек работает с head: push создаёт узел, указывающий на текущий head; pop двигает head на следующий. Оба — O(1). Работа с tail потребовала бы предыдущий узел при pop — O(n) в односвязном списке. Направление указателей (каждый новый узел указывает на тот, что был до него) совпадает с порядком LIFO.

Очередь на ring buffer

В Ruby в stdlib уже есть Queue. Здесь RingQueue — учебная реализация для понимания механики ring buffer.

class RingQueue
  def initialize(initial_capacity = 8)
    @buffer = Array.new(initial_capacity)
    @head = 0
    @tail = 0
    @size = 0
  end
 
  def enqueue(value)
    resize if @size == @buffer.length
    @buffer[@tail] = value
    @tail = (@tail + 1) % @buffer.length
    @size += 1
  end
 
  def dequeue
    return nil if @size == 0
    value = @buffer[@head]
    @buffer[@head] = nil
    @head = (@head + 1) % @buffer.length
    @size -= 1
    value
  end
 
  def front
    return nil if @size == 0
    @buffer[@head]
  end
 
  def empty?
    @size == 0
  end
 
  private
 
  def resize
    new_buffer = Array.new(@buffer.length * 2)
    @size.times { |i| new_buffer[i] = @buffer[(@head + i) % @buffer.length] }
    @buffer = new_buffer
    @head = 0
    @tail = @size
  end
end

При resize элементы копируются в начало нового буфера, «выпрямляя» кольцо: head сбрасывается в 0 — как при росте динамического массива.