4. Median from Data Stream
«Медиана — самый упрямый из статистических показателей: среднее можно обмануть одним гигантским числом, медиану — никогда».
— Брайан Керниган
Медиана в потоке данных (Median from Data Stream): инвариант двух куч и механика балансировки
Задача непрерывного вычисления медианы потока чисел (Find Median from Data Stream, LeetCode 295) — классический вопрос на стыке алгоритмов и системного мониторинга.
В отличие от среднего арифметического, которое легко вычисляется за $O(1)$ памяти через сумму и счетчик, медиана устойчива к выбросам (Outliers) и аномалиям, что делает ее стандартом для метрик латентности серверов (p50 latency). Однако для точного расчета медианы требуется знать центральный элемент отсортированного ряда. Сортировать весь поток за $O(N \log N)$ при каждом новом числе недопустимо.
Решение заключается в поддержании двух встречно направленных куч (Two Heaps), что обеспечивает вставку за $O(\log N)$ и получение медианы за $O(1)$.
flowchart TD
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
A["AddNum(num)"]:::entry --> B{"lower пуста или num <= max(lower)?"}:::warning
B -->|"Да"| C["Push в lower (Max-Heap)"]:::process
B -->|"Нет"| D["Push в higher (Min-Heap)"]:::process
C --> E{"Проверка баланса размеров"}:::warning
D --> E
E -->|"len(lower) > len(higher) + 1"| F["Переместить корень lower в higher"]:::error
E -->|"len(higher) > len(lower)"| G["Переместить корень higher в lower"]:::error
E -->|"Сбалансировано"| H["Инвариант соблюден"]:::success
F --> H
G --> H
1. Инвариант двух куч (Two Heaps Invariant)
Числовой ряд мысленно делится строго пополам на две половины:
- Нижняя половина (Меньшие числа): организуется как Max-Heap (
lower). Корень кучи содержит максимальный из меньших элементов. - Верхняя половина (Большие числа): организуется как Min-Heap (
higher). Корень кучи содержит минимальный из больших элементов.
Строгие инварианты структуры:
- Инвариант упорядоченности: любой элемент из
lowerне превышает любого элемента изhigher:
$$\max(\text{lower}) \le \min(\text{higher})$$ - Инвариант баланса размеров: размер
lowerлибо равен размеруhigher, либо превышает его ровно на $1$:
$$|\text{lower}| = |\text{higher}| \quad \text{или} \quad |\text{lower}| = |\text{higher}| + 1$$
Вычисление медианы за $O(1)$:
- Если суммарное количество чисел нечетное ($|\text{lower}| > |\text{higher}|$): медиана равна корню
lower. - Если суммарное количество чисел четное ($|\text{lower}| = |\text{higher}|$): медиана равна полусумме корней:
$$\text{median} = \frac{\max(\text{lower}) + \min(\text{higher})}{2.0}$$
2. Идиоматичная реализация на Go
Для исключения аллокаций и накладных расходов интерфейса any реализуем типизированные кучи целых чисел:
package median
import (
"container/heap"
"sync"
)
// maxHeap реализует Max-Heap для нижней половины чисел
type maxHeap []int
func (h maxHeap) Len() int { return len(h) }
func (h maxHeap) Less(i, j int) bool { return h[i] > h[j] } // Обратный порядок
func (h maxHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
func (h *maxHeap) Push(x any) { *h = append(*h, x.(int)) }
func (h *maxHeap) Pop() any {
old := *h
n := len(old)
x := old[n-1]
*h = old[0 : n-1]
return x
}
// minHeap реализует Min-Heap для верхней половины чисел
type minHeap []int
func (h minHeap) Len() int { return len(h) }
func (h minHeap) Less(i, j int) bool { return h[i] < h[j] } // Прямой порядок
func (h minHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
func (h *minHeap) Push(x any) { *h = append(*h, x.(int)) }
func (h *minHeap) Pop() any {
old := *h
n := len(old)
x := old[n-1]
*h = old[0 : n-1]
return x
}
// MedianFinder координирует балансировку двух куч
type MedianFinder struct {
mu sync.RWMutex
lower maxHeap // Max-Heap для меньшей половины
higher minHeap // Min-Heap для большей половины
}
func Constructor() MedianFinder {
return MedianFinder{
lower: make(maxHeap, 0, 64),
higher: make(minHeap, 0, 64),
}
}
// AddNum добавляет число в структуру за O(log N)
func (mf *MedianFinder) AddNum(num int) {
mf.mu.Lock()
defer mf.mu.Unlock()
// Шаг 1: Первичная маршрутизация
if mf.lower.Len() == 0 || num <= mf.lower[0] {
heap.Push(&mf.lower, num)
} else {
heap.Push(&mf.higher, num)
}
// Шаг 2: Ребалансировка размеров куч
// Инвариант: len(lower) == len(higher) или len(lower) == len(higher) + 1
if mf.lower.Len() > mf.higher.Len()+1 {
val := heap.Pop(&mf.lower).(int)
heap.Push(&mf.higher, val)
} else if mf.higher.Len() > mf.lower.Len() {
val := heap.Pop(&mf.higher).(int)
heap.Push(&mf.lower, val)
}
}
// FindMedian возвращает текущую медиану за O(1)
func (mf *MedianFinder) FindMedian() float64 {
mf.mu.RLock()
defer mf.mu.RUnlock()
if mf.lower.Len() > mf.higher.Len() {
return float64(mf.lower[0])
}
return float64(mf.lower[0]+mf.higher[0]) / 2.0
}
3. Аппаратная механика: кэш-линии и IEEE-754
Локальность данных в срезе кучи
Двоичная куча хранится в непрерывном срезе памяти []int.
Для узла с индексом $i$:
- Левый потомок: $2i + 1$.
- Правый потомок: $2i + 2$.
- Родитель: $\lfloor \frac{i-1}{2} \rfloor$.
Поскольку кучи lower и higher растут одновременно, их размер вдвое меньше суммарного объема потока. Верхние уровни дерева кучи (первые 4–5 поколений, до 31 элемента) непрерывно удерживаются в кэше L1/L2 процессора, делая первые шаги операций siftUp и siftDown практически бесплатными.
4. Вопросы для собеседования
Резюме
- Алгоритм Two Heaps разделяет поток на две половины: Max-Heap для меньшей части и Min-Heap для большей.
- Поддержание баланса размеров гарантирует доступ к медиане ровно за $O(1)$.
- Любое добавление элемента требует не более одного шага ребалансировки за $O(\log N)$.
- При вычислении полусуммы корней четного потока необходимо избегать целочисленного переполнения и потери дробной части.
В следующей статье мы разберем расчет медианы в бесконечном потоке с фиксированным расходом памяти $O(1)$ с помощью алгоритма $P^2$: 5. Find Median in Infinite Stream.