Find Median from Data Stream: медиана потока на двух кучах
Разбор задачи Find Median from Data Stream: две кучи и их балансировка, O(log n) на вставку и O(1) на медиану, скользящая медиана в окне и follow-up про числа от 0 до 100. Код на Python.
Коротко
| Операция | Две кучи | Отсортированный список | Подсчёт для 0..100 |
|---|---|---|---|
addNum | |||
findMedian | |||
| Память |
- Делим все числа на две половины: меньшую храним в max-куче, большую — в min-куче. Медиана — на вершинах куч.
- Инвариант: размеры куч отличаются не больше чем на один, и любой элемент слева не больше любого справа.
- Для скользящей медианы добавляется удаление — его делают «лениво», через словарь отложенных удалений.
Условие
Нужно реализовать класс MedianFinder (LeetCode 295, Hard):
addNum(num)— добавить число из потока;findMedian()— вернуть медиану всех добавленных чисел.
addNum(1), addNum(2) → findMedian() = 1.5 addNum(3) → findMedian() = 2.0
Ключевое слово — поток. Числа приходят по одному, и медиану могут спросить в любой момент. Если массив дан целиком, хватит quickselect или сортировки. Здесь же важна стоимость каждой операции на протяжении всей жизни объекта.
Наивные решения
Пересортировывать при каждом запросе. findMedian за . При вызовах (ограничение в задаче) это слишком медленно.
Держать отсортированный список. Вставка через бинарный поиск:
from bisect import insort class MedianFinderSorted: def __init__(self): self.data = [] def addNum(self, num): insort(self.data, num) # поиск O(log n), но сдвиг O(n) def findMedian(self): n = len(self.data) if n % 2 == 1: return float(self.data[n // 2]) return (self.data[n // 2 - 1] + self.data[n // 2]) / 2
Место находится за , но вставка в середину массива сдвигает хвост — . На практике insort в Python работает быстро благодаря memmove, и на LeetCode такое решение проходит. Но на собеседовании ответ «у меня на вставку» — повод для следующего вопроса.
Заметим, что для медианы не нужен полностью отсортированный массив. Нужно знать только наибольший элемент меньшей половины и наименьший элемент большей половины. «Быстро дать максимум» и «быстро дать минимум» — это ровно то, что умеет двоичная куча.
Две кучи
Идея
low (max-куча) high (min-куча) [1, 3, 5] вершина = 5 | вершина = 8 [8, 9, 12] меньшая половина | большая половина
Поддерживаем два инварианта:
- Порядок:
max(low) <= min(high). - Размеры:
len(low) == len(high)илиlen(low) == len(high) + 1.
Тогда медиана:
- нечётное количество — вершина
low; - чётное — среднее вершин
lowиhigh.
Балансировка
Как добавить число и не сломать инварианты? Самый короткий и надёжный приём — «прогнать через обе кучи»:
- Кладём число в
low. - Достаём максимум из
lowи перекладываем вhigh. Теперь порядок гарантирован: вhighпопал наибольший элемент левой части. - Если
highстал большеlow, перекладываем минимумhighобратно вlow.
В Python heapq реализует только min-кучу, поэтому max-кучу эмулируют, храня числа с обратным знаком.
import heapq class MedianFinder: def __init__(self): self.low = [] # max-куча (храним с минусом): меньшая половина self.high = [] # min-куча: большая половина def addNum(self, num: int) -> None: heapq.heappush(self.low, -num) heapq.heappush(self.high, -heapq.heappop(self.low)) if len(self.high) > len(self.low): heapq.heappush(self.low, -heapq.heappop(self.high)) def findMedian(self) -> float: if len(self.low) > len(self.high): return float(-self.low[0]) return (-self.low[0] + self.high[0]) / 2
Каждая вставка — не больше трёх операций с кучей, то есть . Медиана — , это просто чтение вершин.
Альтернатива с меньшим числом операций: сравнить num с вершиной low, положить в нужную кучу, а потом выровнять размеры одним перекладыванием. Работает так же асимптотически, но в ней больше ветвлений, где легко ошибиться. На собеседовании короткий вариант выигрывает.
Разбор на примере
Поток 5, 15, 1, 3:
| Добавили | low (меньшая половина) | high (большая половина) | Медиана |
|---|---|---|---|
| 5 | [5] | [] | 5.0 |
| 15 | [5] | [15] | 10.0 |
| 1 | [1, 5] | [15] | 5.0 |
| 3 | [1, 3] | [5, 15] | 4.0 |
На последнем шаге: 3 попадает в low → [1, 3, 5], максимум 5 уходит в high → [5, 15]. Размеры 2 и 2, балансировать не нужно, медиана .
Тот же класс на Java
В Java max-куча делается компаратором, без трюка со знаком:
class MedianFinder { private final PriorityQueue<Integer> low = new PriorityQueue<>(Collections.reverseOrder()); private final PriorityQueue<Integer> high = new PriorityQueue<>(); public void addNum(int num) { low.offer(num); high.offer(low.poll()); if (high.size() > low.size()) low.offer(high.poll()); } public double findMedian() { if (low.size() > high.size()) return low.peek(); return ((long) low.peek() + high.peek()) / 2.0; } }
Обратите внимание на (long) и 2.0: без них сумма двух больших int переполнится, а деление станет целочисленным.
Follow-up 1: все числа от 0 до 100
Классический дополнительный вопрос к этой задаче: «Что если все числа из потока лежат в диапазоне ?»
Тогда кучи не нужны. Заводим массив счётчиков на 101 ячейку — это сортировка подсчётом, только «онлайн». Медиану ищем, накапливая счётчики, пока не дойдём до нужной позиции.
class MedianFinder100: def __init__(self): self.count = [0] * 101 self.n = 0 def addNum(self, num): self.count[num] += 1 self.n += 1 def _kth(self, k): # k-й по счёту, с 1 acc = 0 for value, c in enumerate(self.count): acc += c if acc >= k: return value def findMedian(self): if self.n % 2 == 1: return float(self._kth(self.n // 2 + 1)) return (self._kth(self.n // 2) + self._kth(self.n // 2 + 1)) / 2
addNum — , findMedian — , то есть константа, и памяти тоже константа, сколько бы чисел ни пришло. Если запросов медианы очень много, можно хранить текущую медиану и сдвигать указатель при вставке, но обычно достаточно назвать решение со счётчиками.
Follow-up 2: 99% чисел в диапазоне 0..100
Второй вариант того же вопроса: «А если 99% чисел в , но бывают выбросы?»
Ответ: счётчики для диапазона плюс два отдельных хранилища для выбросов — числа меньше 0 и больше 100 (например, по куче или отсортированному списку на каждую сторону, и ещё просто их количества). Медиана при таком распределении почти наверняка лежит внутри : чтобы она вышла за край, выбросов с одной стороны должно быть больше половины потока. Поэтому:
- знаем
below— сколько чисел меньше 0; - ищем позицию медианы , вычитаем
belowи идём по счётчикам; - если
belowилиbelow + в_диапазоне— медиана среди выбросов, и тогда смотрим в их хранилище.
Интервьюеру здесь важно не точное количество строк кода, а то, что вы заметили: асимметрия данных позволяет отдать основной поток простой структуре, а редкие случаи обработать медленнее.
Скользящая медиана (Sliding Window Median)
Родственная задача (LeetCode 480, Hard): дан массив nums и размер окна k. Нужно вернуть медиану каждого окна длины k.
nums = [1, 3, -1, -3, 5, 3, 6, 7], k = 3 → [1.0, -1.0, -1.0, 3.0, 5.0, 6.0]
Теперь элементы не только приходят, но и уходят из окна. А куча не умеет быстро удалять произвольный элемент: найти его — .
Ленивое удаление
Решение — не удалять сразу. Записываем уходящий элемент в словарь delayed и вычёркиваем его, только когда он окажется на вершине кучи. При этом нужно отдельно хранить «живые» размеры куч — физическая длина списка теперь включает мусор.
import heapq from collections import defaultdict def median_sliding_window(nums, k): low, high = [], [] # max-куча (с минусом) и min-куча delayed = defaultdict(int) # число -> сколько раз ждёт удаления low_size = high_size = 0 # «живые» элементы в каждой куче def prune(heap, sign): # снимаем с вершины элементы, помеченные на удаление while heap and delayed[sign * heap[0]]: delayed[sign * heap[0]] -= 1 heapq.heappop(heap) def rebalance(): nonlocal low_size, high_size if low_size > high_size + 1: heapq.heappush(high, -heapq.heappop(low)) low_size -= 1 high_size += 1 prune(low, -1) elif low_size < high_size: heapq.heappush(low, -heapq.heappop(high)) low_size += 1 high_size -= 1 prune(high, 1) def add(num): nonlocal low_size, high_size if not low or num <= -low[0]: heapq.heappush(low, -num) low_size += 1 else: heapq.heappush(high, num) high_size += 1 rebalance() def remove(num): nonlocal low_size, high_size delayed[num] += 1 if num <= -low[0]: low_size -= 1 if num == -low[0]: prune(low, -1) else: high_size -= 1 if high and num == high[0]: prune(high, 1) rebalance() def median(): if k % 2 == 1: return float(-low[0]) return (-low[0] + high[0]) / 2 for i in range(k): add(nums[i]) result = [median()] for i in range(k, len(nums)): add(nums[i]) remove(nums[i - k]) result.append(median()) return result
Почему это корректно:
- Элемент, помеченный в
delayed, лежит в той куче, куда указывает сравнение с вершинойlow: инвариант порядка гарантирует, что всё<= -low[0]находится слева. - После каждой операции вершины обеих куч — живые элементы. Это обеспечивают вызовы
pruneпосле удаления с вершины и после перекладывания. Поэтомуmedian()читает правильные значения. - Помеченные элементы в глубине куч не мешают: в расчёт они не попадают, а размеры мы считаем отдельно.
Сложность — по времени: каждый элемент добавляется один раз и вычёркивается один раз. Память — в худшем случае, потому что мусор может копиться в глубине кучи.
Вариант попроще: отсортированное окно
Если интервьюер не настаивает на кучах, окно можно держать отсортированным списком:
from bisect import bisect_left, insort def median_sliding_window_sorted(nums, k): window = sorted(nums[:k]) result = [] for i in range(k, len(nums) + 1): if k % 2 == 1: result.append(float(window[k // 2])) else: result.append((window[k // 2 - 1] + window[k // 2]) / 2) if i == len(nums): break window.pop(bisect_left(window, nums[i - k])) insort(window, nums[i]) return result
Это из-за сдвигов, но код короткий и почти не оставляет места для ошибок. В Python с библиотекой sortedcontainers (SortedList) то же решение работает за , однако на собеседовании её может не оказаться под рукой. В C++ аналогичную роль играет multiset с итератором на медиану. Ещё варианты — дерево отрезков или дерево Фенвика по сжатым значениям с поиском -го элемента.
Сама техника сдвига окна разобрана в статье про скользящее окно.
Что уточнить у интервьюера
Задача кажется полностью определённой, но несколько вопросов в начале экономят время и показывают зрелость:
- Соотношение вызовов. Если
findMedianвызывают редко, аaddNum— миллионы раз, можно копить числа в буфере и считать медиану quickselect'ом по требованию. Если наоборот — две кучи с на чтение подходят идеально. - Диапазон значений. Это прямой мост к follow-up про 0..100: маленький диапазон меняет всё решение.
- Нужно ли удаление. Если да — это уже скользящая медиана или задача на сбалансированное дерево, кучи без ленивого удаления не справятся.
- Объём данных. Обе кучи хранят все числа, память . Если поток бесконечный и памяти не хватает, точную медиану не получить; тогда говорят о приближённых методах — гистограммах по корзинам или скетчах квантилей вроде t-digest. Достаточно назвать направление, детали на алгоритмическом интервью обычно не спрашивают.
- Тип ответа. На LeetCode ожидается вещественное число; при чётном количестве медиана может быть дробной даже для целых входных данных.
Типичные ошибки
- Забыли про знак в
heapq. Положилиnumвlowбез минуса — получили вторую min-кучу, и медиана считается неправильно, но иногда случайно совпадает на маленьких тестах. - Нарушили только один инвариант. Выравнивают размеры, но не следят за порядком (или наоборот). Пример: поток
1, 2, 0при правиле «класть в кучу поменьше, при равенстве — вlow» без сравнения с вершинами дастlow = [1, 0],high = [2]— повезло; а3, 1, 2уже даст неверную медиану. Приём «прогнать через обе кучи» закрывает оба инварианта сразу. - Целочисленное деление и переполнение в Java/C++ при
(a + b) / 2. - В скользящей медиане — длина списка вместо живого размера.
len(low)включает помеченный мусор, балансировка по нему ломается. - Не вызвали
pruneпосле перекладывания. Перекладывание может обнажить помеченный элемент на вершине, и следующая медиана прочитает удалённое значение. - Отвечают на follow-up «кучами поменьше». Для диапазона 0..100 ожидается переход к счётчикам, а не оптимизация куч.
Где встречается
Find Median from Data Stream — одна из самых популярных Hard-задач на кучи: её часто включают в подборки задач для собеседований в крупные компании, и её стоит знать наизусть вместе с follow-up. Сам приём «две кучи держат границу» повторяется в других задачах:
- IPO (LeetCode 502) и задачи планирования, где одна куча хранит доступные варианты, а другая — ожидающие.
- Медиана двух отсортированных массивов — та же идея разреза на половины, но для статичных данных и через бинарный поиск: Median of Two Sorted Arrays.
- Top K элементов — одна куча фиксированного размера вместо двух: Top K Frequent Elements.
- Устройство очередей с приоритетом и их реализация — в статье приоритетная очередь.
На собеседовании рассказывайте в таком порядке: отсортированный список и его на вставку → наблюдение «нужны только две средние точки» → две кучи с двумя инвариантами → пример на четырёх числах → follow-up со счётчиками. Это укладывается в 25–30 минут и оставляет время на скользящее окно, если интервьюер решит его спросить.