8.9 KiB
Задача 03 — кольцевой буфер (C++)
Разминка уровня «основная»: структура маленькая, но встречается везде. Ориентир — 40–60 минут.
Глава I. Общая информация
- Цель: научиться писать структуру с фиксированной памятью и без сдвигов элементов.
- Почему это в Eltex: приём/передача пакетов, буферы DMA, очереди между потоками —
всё это кольцевые буферы. Понимание wrap-around и «полный/пустой» — прямой вопрос на
собеседовании. Механизм тот же, что у сетевой карты: пакеты пишутся в кольцо по мере
прихода, драйвер вычитывает их с другого конца, и обе стороны никогда не двигают уже
записанные данные по памяти — двигаются только индексы
head/tail. - Что сдаётся:
solution.cpp, проходящийpython3 grade.py 03.
Глава II. Что нужно знать до старта
Идея: массив фиксированного размера + два индекса. head — куда писать, tail — откуда
читать. Когда индекс доходит до конца, он возвращается в начало: idx = (idx + 1) % capacity.
Сдвигать элементы не нужно никогда — в этом весь смысл: push/pop двигают только индекс,
а не байты в памяти, поэтому обе операции остаются O(1) независимо от размера буфера.
Ловушка, из-за которой задача попадает в собеседования: как отличить пустой буфер от
полного, если оба индекса совпали? При head == tail возможны оба состояния — буфер мог
быть только что создан (пуст) или заполнен ровно capacity раз (полон) — по одним индексам
это не различить. Варианты: хранить счётчик size, либо оставлять одну ячейку свободной.
В нашем интерфейсе есть size(), поэтому проще хранить счётчик.
Почему дальше: тот же счётчик size_ вместо пересчёта по индексам — частый приём и в
std::deque, и в реализациях lock-free очередей, где индексы вообще нельзя лишний раз читать.
Глава III. Задание
class RingBuffer {
public:
explicit RingBuffer(size_t capacity);
~RingBuffer();
bool push(int v); // false, если буфер полон (перезапись НЕ делать)
bool pop(int& out); // false, если буфер пуст
size_t size() const;
size_t capacity() const;
bool empty() const;
bool full() const;
};
Требования:
- O(1) на
push/pop, никаких сдвигов элементов; - память выделяется один раз в конструкторе и освобождается в деструкторе;
- корректное поведение после заворачивания индексов (записали до конца, продолжили с начала);
- двойного освобождения быть не должно.
Глава IV. Ступени
Ступень 1 (10 минут). Поля: указатель на массив, capacity_, size_, head_, tail_.
Конструктор выделяет массив и обнуляет поля. Деструктор освобождает. Если хочешь короче —
std::vector<int> вместо ручного new[], тогда деструктор не нужен вовсе.
Ступень 2 (10 минут). empty() — это size_ == 0, full() — size_ == capacity_.
Проверь на буфере ёмкости 1: после одного push он полон, после одного pop пуст.
Ступень 3 (15 минут). push: если полон — вернуть false и ничего не менять; иначе
записать в head_, сдвинуть head_ = (head_ + 1) % capacity_, увеличить size_, вернуть true.
Ступень 4 (15 минут). pop: если пуст — false; иначе отдать buffer_[tail_], сдвинуть
tail_, уменьшить size_, вернуть true.
Ступень 5. Проверь руками wrap-around: ёмкость 4, положи 4 элемента, сними 2, положи
ещё 3 — все значения должны идти в правильном порядке. Затем python3 grade.py 03.
Глава V. Критерии приёмки
PASS от grade.py 03: сборка без предупреждений, ASAN/UBSAN чистые, все проверки ok,
включая порядок FIFO и корректный wrap-around.
Глава VI. Подсказки (после первой попытки)
Значения после заворота идут не в том порядке
Проверь, что pop читает именно tail_, а push пишет именно в head_, и что оба индекса
инкрементируются после операции. Классическая ошибка — сдвинуть head_ до записи.
Перезапись при полном буфере
Ты не проверяешь full() в push. По условию перезапись запрещена: полный буфер возвращает
false и не портит старые данные.
ASAN: утечка или двойное освобождение
Если память выделена через new[], освобождать нужно delete[] (не delete). Проще взять
std::vector<int> — тогда вопрос исчезает.
Глава VII. Ловушки
- Сдвиг элементов вместо индексов → цена
push/popвырастает до O(n) → теряется весь смысл структуры, видно по сравнению с наивнымstd::vectorна бенчмарке. - Путаница «пусто/полно» при совпавших индексах (
head_ == tail_) без отдельногоsize_→full()иempty()дают одинаковый ответ на разных состояниях → тест на заполненный буфер ёмкости > 1 падает. %на каждом шаге там, где можно было обойтись условием — не ошибка, но на собеседовании спросят «а быстрее можно?» (ответ:if (++idx == cap) idx = 0;, деление по модулю дороже сравнения).- Копирование объекта без правила трёх/пяти → два объекта владеют одним и тем же
new[]-буфером → двойное освобождение при выходе обоих из области видимости, ловится ASAN. Если сдаёшь с ручнымnew[], запрети копирование (= delete).
Факты для карточек
- base | Сложность
push/popкольцевого буфера? — O(1) - core | Как отличить пустой буфер от полного при
head_ == tail_? — хранить отдельный счётчикsize_(или жертвовать одной ячейкой) - core | Чем
delete[]отличается отdeleteдля массива, выделенногоnew[]? —deleteбез[]на массиве — UB, вызовет деструктор только для первого элемента и испортит подсчёт размера аллокации - base | Какое условие делает
pushдешевле, чем% capacity_на каждый вызов? —if (++idx == cap) idx = 0;
После сдачи
Разбор: как устроены буферы в драйверах (ring buffer с head/tail для DMA), почему в
многопоточном варианте нужны атомарные индексы и memory_order.