Кольцевой буфер: реализация на массиве и индексах
Кольцевой буфер (circular buffer, ring buffer) - это структура данных с фиксированным размером, которая ведёт себя как очередь, но физически хранится в обычном массиве без сдвига элементов. Вместо того чтобы после каждого чтения сдвигать все оставшиеся элементы влево, буфер просто пускает индексы записи и чтения по кругу: дойдя до конца массива, они перескакивают обратно к нулю. Ниже разберём, как это реализовать на практике - какие поля нужны, как вывести формулы для индексов через остаток от деления и почему наивная реализация с двумя указателями обязательно спотыкается на одном и том же вопросе: буфер сейчас полон или пуст. Чтобы сразу увидеть, как индексы двигаются и где происходит перенос через край, покрутите калькулятор ниже - он строит схему массива по вашим числам.
Зачем нужен кольцевой буфер
Кольцевой буфер решает конкретную практическую задачу: нужно накапливать поток данных ограниченного объёма без постоянных перевыделений памяти. Классические примеры - буфер аудиосэмплов между звуковой картой и обработчиком, буфер сетевых пакетов между сетевой картой и приложением, кольцевой лог последних N событий, очередь заданий между производителем и потребителем в конвейере. Во всех этих случаях важны две вещи: операции добавления и извлечения элемента должны выполняться за константное время , и память должна выделяться один раз, а не расти безгранично. Обычный массив с постоянным сдвигом элементов при извлечении даёт на операцию - для потоковых данных это неприемлемо. Кольцевой буфер убирает сдвиг совсем: элемент просто остаётся на своём месте, пока индекс чтения не дойдёт до него.
Массив и два индекса: базовая реализация
Основа реализации - фиксированный массив длиной (ёмкость буфера) и два индекса: head - физический индекс самого старого элемента, который будет прочитан следующим, и tail - физический индекс, куда попадёт следующая запись. Запись элемента (enqueue) кладёт значение в ячейку tail и сдвигает tail на одну позицию вперёд; чтение (dequeue) забирает значение из ячейки head и сдвигает head на одну позицию вперёд. Хитрость в том, что сдвиг делается не простым +1, а по кругу: дойдя до последней ячейки массива, индекс должен вернуться к нулю, а не выйти за границу.
Именно этот перенос через край и даёт буферу название «кольцевой»: логически ячейки образуют круг, хотя физически это по-прежнему линейный участок памяти. Реализация не хранит никакого «кольца» - она просто применяет остаток от деления к обычному линейному индексу.
Формулы head, tail и переноса через модуль
Пусть - ёмкость буфера, а и - счётчики операций: считает, сколько записей всего было успешно выполнено с момента создания буфера, - сколько было успешно выполнено чтений. Важно, что оба счётчика монотонно растут и никогда не убывают и не оборачиваются сами - оборачивается только их проекция на физический индекс:
Число элементов, реально хранящихся в буфере прямо сейчас, - это разница счётчиков:
Свободных ячеек, соответственно, . Занятые физические индексы - это count ячеек подряд начиная с head: для . Если это множество индексов «перепрыгивает» через конец массива (то есть ), значит, часть элементов физически лежит в хвосте массива, а часть - в начале: это и есть тот самый перенос через край.

Проблема head == tail: буфер полон или пуст
Если реализовать буфер наивно - хранить только физические индексы head и tail без отдельных счётчиков - возникает неприятная неоднозначность. Пустой буфер, из которого ничего не читали и не писали, имеет head == tail (оба указывают на нулевую ячейку). Но полностью заполненный буфер, где записали ровно элементов без единого чтения, после -й записи получает tail, снова равный head: индекс просто обошёл круг целиком и вернулся туда, откуда начал. По одним лишь физическим индексам эти два состояния - «нет ни одного элемента» и «занято абсолютно всё» - неотличимы.

Это классическая ошибка при реализации кольцевого буфера «в лоб»: код инициализирует head = tail = 0, а потом сравнивает их равенство как признак пустоты - и первая же попытка заполнить буфер полностью незаметно превращается в баг, который выглядит как «буфер внезапно опустел», хотя на самом деле он полон.
Решение через монотонные счётчики операций
Есть несколько способов снять неоднозначность. Самый прямолинейный - завести отдельный флаг is_full или поле count, которое инкрементируется при записи и декрементируется при чтении. Другой популярный трюк - специально использовать только ячеек из выделенных: буфер считается полным на одну ячейку раньше, чем физически заполнен весь массив, и тогда head == tail однозначно означает «пусто» - ценой одной вечно свободной ячейки.
Третий подход - тот, что использован в формулах выше и в калькуляторе - считать не физические индексы напрямую, а монотонные счётчики и , которые растут без ограничения (или до переполнения типа, что на практике почти никогда не достигается для 32- или 64-битных счётчиков). Физические head и tail - это лишь проекция счётчиков на диапазон через остаток от деления. Поскольку и сами по себе никогда не совпадают случайно (они точно равны только когда буфер пуст: count = w - r = 0), сравнение w == r однозначно определяет пустоту, а w - r == N - полноту, без дополнительных флагов и без потери одной ячейки.
enqueue(value):
if w - r == N: отклонить (буфер полон)
buffer[w mod N] = value
w = w + 1
dequeue():
if w - r == 0: отклонить (буфер пуст)
value = buffer[r mod N]
r = r + 1
return value
Где применяется кольцевой буфер на практике
В аудиообработке кольцевой буфер сглаживает разницу в скорости между потоком, который генерирует сэмплы (микрофон, декодер), и потоком, который их потребляет (звуковая карта, кодировщик): пока производитель немного опережает или отстаёт, буфер поглощает эту разницу без пропуска и без блокировки. В сетевом стеке кольцевые буферы используются в кольцах приёма и передачи сетевых карт (RX/TX ring) - драйвер и сетевая карта пишут и читают дескрипторы пакетов через общий кольцевой буфер без блокировок. В логировании кольцевой буфер фиксированного размера хранит последние записей: старые записи автоматически вытесняются новыми, память не растёт, а последняя история событий перед сбоем всегда доступна. Общая черта всех случаев - поток данных ограниченного темпа, где важна предсказуемая по времени работа без аллокаций в реальном времени.
Частые ошибки
- Сравнение
head == tailкак признак пустоты без дополнительного поля - не различает пустой и полный буфер, см. раздел выше. - Инкремент индекса без взятия остатка:
tail = tail + 1без% Nрано или поздно выйдет за границы массива. - Отсутствие проверки переполнения при записи: без проверки
count == Nпередenqueueновая запись затирает ещё не прочитанный элемент, и данные теряются молча. - Путаница, какой индекс двигать при записи, а какой при чтении:
tailдвигается при записи (enqueue),head- при чтении (dequeue); перепутанные роли ломают порядок FIFO. - Использование знаковой арифметики без нормализации остатка: в некоторых языках операция
%от отрицательного числа даёт отрицательный результат - при вычислении индексов это нужно учитывать отдельной нормализацией.
FAQ
Чем кольцевой буфер отличается от обычной очереди на массиве? Обычная очередь на массиве без циклических индексов после каждого извлечения сдвигает оставшиеся элементы к началу массива - это операция . Кольцевой буфер вместо сдвига переносит индексы по кругу через остаток от деления, поэтому запись и чтение остаются независимо от того, сколько элементов уже прошло через буфер.
Что происходит при попытке записи в полный кольцевой буфер? Зависит от реализации: чаще всего запись либо отклоняется (вызывающий код получает сигнал «буфер полон» и должен подождать), либо буфер настроен на перезапись самого старого элемента (характерно для буферов логов и аудио, где свежие данные важнее старых).
Можно ли реализовать кольцевой буфер без счётчиков, только на head и tail? Можно, но тогда нужен либо отдельный флаг заполненности, либо намеренный отказ от использования последней ячейки (буфер ёмкостью реально хранит не больше элементов). Оба варианта работают, но счётчики операций и дают самую простую и наименее подверженную ошибкам формулу.
Коротко
Кольцевой буфер - это массив фиксированного размера с двумя индексами, head и tail, которые вместо выхода за границу массива переносятся обратно к нулю через остаток от деления: , , где и - общее число выполненных записей и чтений. Число элементов в буфере - это , и именно оно, а не сравнение самих индексов, надёжно отличает полный буфер от пустого, потому что при head == tail эти два состояния физически неразличимы без дополнительной информации. Такая реализация даёт запись и чтение за константное время без единой лишней аллокации памяти, поэтому кольцевые буферы - стандартный выбор для аудиопотоков, сетевых очередей и логов ограниченного размера.
Читайте также

Поиск в двоичном дереве поиска: алгоритм и сложность
Как работает поиск в двоичном дереве поиска (BST): пошаговый алгоритм, число сравнений в среднем и худшем случае, влияние баланса дерева и типичные ошибки в задачах.

Стек на списке: реализация push и pop через массив
Как реализовать стек на списке: индекс вершины, амортизированная сложность push O(1), расширение массива вдвое при переполнении и почему pop не освобождает память сразу.

Высота и глубина дерева: формулы и примеры
Разбираем высоту и глубину дерева в информатике: точные определения, формулы для полного двоичного и вырожденного дерева, примеры задач и типичные ошибки.

B-дерево: вставка ключа и разделение узла
Разбираем вставку в B-дерево по шагам: минимальная степень t, инвариант t-1..2t-1 ключей и разделение переполненного узла с подъёмом среднего ключа к родителю.

AVL-дерево: как работает балансировка и ротации
Разбираем, как AVL-дерево восстанавливает баланс после вставки и удаления: инвариант высоты, balance factor и четыре ротации LL, RR, LR, RL за O(log n).

Куча Фибоначчи: ленивая структура и амортизация
Куча Фибоначчи: амортизированный на insert и decrease-key, ленивая консолидация при extract-min, потенциал, каскадный cut и применение в алгоритме Дейкстры.