4. Moving Average from Data Stream
«Не пытайся объять необъятное: бесконечный поток требует конечного окна и постоянного времени».
— Кен Томпсон
Потоковая обработка данных и агрегация в реальном времени
В классических алгоритмах мы обычно оперируем статическими массивами или строками, целиком загруженными в оперативную память. Однако в реальном бэкенде — при сборе телеметрии и метрик (Prometheus, OpenTelemetry), анализе биржевых котировок или мониторинге задержек API (latency) — данные приходят непрерывным, бесконечным потоком (Data Stream).
Попытка сохранить всю историю потока в памяти приведет к неминуемому аварийному завершению процесса по Out of Memory (OOM). Архитектура сервиса обязана агрегировать показатели «на лету», используя строго ограниченный объем памяти и гарантированное константное время $O(1)$ на каждое входящее событие.
Задача Moving Average from Data Stream (LeetCode 346) — это эталонный пример построения скользящего аналитического окна над бесконечным потоком.
Архитектура решения: Инвариант бегущей суммы и кольцевой буфер
Наивный подход начинающего разработчика — складывать все входящие числа в слайс, а при вызове Next брать срез последних $K$ элементов и в цикле вычислять их сумму.
В чем изъян? Вычисление суммы в цикле требует $O(K)$ операций. Если размер окна составляет 500 000 элементов (например, мониторинг запросов за последние 10 минут), каждый вызов метода превратится в тяжелую бессмысленную нагрузку на CPU.
Инвариант бегущей суммы (Running Sum)
Зрелый инженер использует математический инвариант скользящего окна. Зачем суммировать все элементы заново, если изменились всего два значения?
$$\text{Сумма}_{\text{новая}} = \text{Сумма}_{\text{старая}} - \text{Элемент}_{\text{старейший}} + \text{Элемент}_{\text{новый}}$$
Чтобы мгновенно получать самый старый элемент, нам требуется очередь FIFO вместимостью $K$. А чтобы исключить аллокации памяти в цикле, мы применим Кольцевой буфер (Ring Buffer) фиксированного размера $K$.
flowchart TD
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
Val["Новое значение потока: val"]:::entry --> CheckFull{"Окно заполнено?<br>(count == size)"}:::warning
CheckFull -->|"Да: вытесняем старейший"| SubOld["sum -= window[ptr]<br>(вычитаем старое значение)"]:::process
SubOld --> Overwrite["window[ptr] = val<br>(перезаписываем ячейку)"]:::accent
CheckFull -->|"Нет: окно еще растет"| IncCount["count++<br>window[ptr] = val"]:::process
IncCount --> AddNew["sum += val"]:::accent
Overwrite --> AddNew
AddNew --> IncPtr["ptr = (ptr + 1) % size<br>(сдвигаем единый указатель)"]:::process
IncPtr --> Return["Возврат: float64(sum) / float64(count)"]:::success
Изящный трюк: Единый указатель кольцевого буфера
В классическом кольцевом буфере используются два раздельных указателя: head (для чтения) и tail (для записи).
Однако в задаче скользящего окна с фиксированной вместимостью $K$ вытеснение старого элемента происходит в ту же самую позицию, куда должен быть записан новый элемент!
- Ячейка
ptr, на которую указывает наш текущий индекс, содержит старейший элемент окна. - Мы вычитаем его значение из бегущей суммы.
- Записываем в эту же позицию новое число:
window[ptr] = val. - Прибавляем новое число к сумме.
- Сдвигаем
ptr = (ptr + 1) % size.
Один-единственный целочисленный индекс полностью заменяет сложную структуру с двумя указателями.
Идиоматичный Go-код
package movingaverage
// MovingAverage вычисляет скользящее среднее арифметическое над потоком чисел.
type MovingAverage struct {
window []int // Кольцевой буфер фиксированного размера
size int // Максимальная вместимость окна
sum int // Текущая бегущая сумма элементов окна
ptr int // Единый циклический указатель вытеснения и записи
count int // Реальное количество элементов в окне (пока count < size)
}
// Constructor инициализирует буфер заданного размера.
func Constructor(size int) MovingAverage {
return MovingAverage{
window: make([]int, size), // Выделяем память ровно один раз!
size: size,
}
}
// Next принимает очередное значение потока и возвращает актуальное среднее за O(1).
func (m *MovingAverage) Next(val int) float64 {
if m.count == m.size {
// Окно заполнено: вычитаем старейший элемент, на который указывает ptr
m.sum -= m.window[m.ptr]
} else {
// Окно еще только набирает элементы
m.count++
}
// Перезаписываем слот старого элемента новым значением
m.window[m.ptr] = val
m.sum += val
// Циклически сдвигаем указатель вперед
m.ptr = (m.ptr + 1) % m.size
// Возвращаем среднее значение
return float64(m.sum) / float64(m.count)
}
Взгляд под капот: Mechanical Sympathy
Этот короткий фрагмент кода демонстрирует высочайшую культуру низкоуровневой оптимизации под рантайм Go и архитектуру современных CPU:
- Zero Heap Allocations (Нулевая нагрузка на сборщик мусора):
Буферwindowаллоцируется единственный раз при вызовеConstructor. МетодNextоперирует исключительно плоскими примитивами (int,float64) по фиксированным смещениям. За миллионы вызововNextне происходит ни единого обращения к аллокатору кучи, а сборщик мусора (GC) не тратит ни микросекунды на сканирование структуры. - Кэш-линии CPU и аппаратный Prefetcher:
Срезwindowпредставляет собой непрерывный блок памяти. Для окна $K=1024$ чисел типаint(8 байт на 64-битных системах) весь буфер занимает ровно 8 КБ. Это значение с огромным запасом помещается в кэш данных первого уровня ядра процессора (L1d Cache, обычно 32–48 КБ на ядро). Процессор никогда не обращается к медленной системной памяти (DRAM) — время доступа к ячейке составляет рекордные 1–3 такта CPU. -
Оптимизация деления по модулю:
Если размер окна зафиксирован степенью двойки (например, 1024 или 4096), вместо тяжелой процессорной инструкции деленияDIVдостаточно применить побитовую операцию:Go m.ptr = (m.ptr + 1) & (m.size - 1)
Итог
Задача Moving Average from Data Stream учит нас правильному мышлению при проектировании долгоживущих демонов и систем потоковой аналитики: фиксированная память, круговая перезапись и непрерывные инварианты агрегации.
Но что делать, если окно событий ограничивается не фиксированным количеством записей, а временным интервалом (например, последние 3 секунды)? В следующей статье мы исследуем структуру, лежащую в основе сетевых фильтров и Rate Limiters: 5. Number of Recent Calls.