Очередь. Логическое описание и физическое представление (массив): кольцевой буфер

Модуль "Очередь и дек" • урок 3 из 7 • ~8 минут

Суть урока. Очередь на статическом массиве упирается в проблему: голова и хвост "ползут" по массиву вперед. Оптимальное решение - кольцевой буфер: концы массива замыкаются через остаток от деления, индекс хвоста вычисляется как (first + size) mod POOL_SIZE, и обе операции выполняются за O(1) без сдвигов.
Очередь. Логическое описание и физическое представление (массив): голова first указывает на ячейку 2, а хвост по формуле остатка от деления попадает в ячейку 0 в начале массива
Массив на пять ячеек: хвост перескочил через край и указывает на ячейку 0

В чем проблема

Со стеком на массиве все было просто: оба действия происходят с одного конца, элементы накапливаются и убывают у одной границы. У очереди концы разные: добавляем справа, забираем слева. Занятый участок массива постепенно смещается вправо - и рано или поздно упирается в конец, хотя в начале массива полно свободного места.

Три стратегии

  1. Сдвиг при каждом извлечении. Голова всегда в ячейке 0; после Dequeue все оставшиеся элементы сдвигаются на позицию влево. Извлечение дорожает до O(N) - в учебнике эта стратегия иронично названа стратегией "трудоголика".
  2. "Ленивая" стратегия. Голова и хвост движутся вправо; когда хвост упирается в конец массива, вся очередь разом копируется в начало. В среднем быстро, но изредка случаются задержки O(N).
  3. Кольцевой буфер - оптимальная стратегия. Массив мысленно замыкается в кольцо: дойдя до конца, индекс перескакивает на 0, где место уже освободили ушедшие элементы. Никаких перемещений данных, обе операции - O(1).

Очередь. Логическое описание и физическое представление (массив): как устроен кольцевой буфер

Структура хранит массив, индекс головы first и количество элементов size. "Замыкание" массива дает операция остатка от деления:

Тренажер: кольцевой буфер по шагам

Проследим за буфером на POOL_SIZE = 5 ячеек: двенадцать операций, из которых одна перескакивает через край массива, а одна упирается в переполнение. Хвост каждый раз считаем по формуле.

элемент в очереди ячейка текущей операции свободная ячейка
first: 0 size: 0 Хвост: 0 Шаг: 0 / 0
А как в Python? Вызов list.pop(0) - та самая стратегия "трудоголика": интерпретатор физически сдвигает все оставшиеся элементы влево. На списке из миллиона элементов это заметно тормозит. А collections.deque устроен как блочный кольцевой буфер - сдвигов не происходит вовсе.

Реализация на C

Очередь на кольцевом буфере: код целиком C, 32 строки
#include <stdbool.h>

#define POOL_SIZE 100

typedef struct {
    int first;           /* индекс головы (откуда берем) */
    int size;            /* текущее количество элементов */
    int data[POOL_SIZE]; /* статический массив */
} Queue;

void Create(Queue* q) {
    q->first = 0;
    q->size = 0;
}

/* Добавление: хвост вычисляется через остаток от деления */
bool Push(Queue* q, int val) {
    if (q->size == POOL_SIZE) return false; /* буфер полон */

    q->data[(q->first + q->size) % POOL_SIZE] = val;
    q->size++;
    return true;
}

/* Извлечение: голова сдвигается по кольцу */
bool Pop(Queue* q) {
    if (q->size == 0) return false; /* очередь пуста */

    q->first = (q->first + 1) % POOL_SIZE;
    q->size--;
    return true;
}

Плата та же, что у стека на массиве: размер зафиксирован, при size == POOL_SIZE наступает переполнение. Как снять этот лимит - в следующем уроке.

Коротко: главное

  • У очереди на массиве занятый участок ползет вправо - в отличие от стека.
  • Сдвигать элементы при каждом извлечении - O(N); копировать при упоре в конец - редкие задержки O(N).
  • Кольцевой буфер: массив замыкается через остаток от деления (mod).
  • Структура: массив + first + size; хвост = (first + size) % POOL_SIZE.
  • Обе операции - O(1) без перемещений данных; лимит - POOL_SIZE.

Проверьте себя

Кольцевой буфер: POOL_SIZE = 5, first = 3, size = 4. В какую ячейку встанет следующий добавленный элемент?

Как вам урок? Спасибо за отзыв!