Разностный массив: прибавление на отрезке за O(1)
Разностный массив простыми словами: как прибавить число на отрезке за O(1), восстановить массив префиксной суммой, 2D-вариант и разбор задач Corporate Flight Bookings и Car Pooling.
Коротко
| Что | Суть |
|---|---|
| Задача | Много операций «прибавь x ко всем элементам с l по r», потом читаем массив |
| Главная идея | Храним не значения, а разности соседних элементов: diff[i] = a[i] - a[i-1] |
| Одно обновление | Две точки: diff[l] += x, diff[r + 1] -= x — |
| Восстановление | Префиксная сумма по diff — |
| Итого | вместо |
| Когда не подходит | Если запросы чтения перемешаны с обновлениями — нужно дерево Фенвика или отрезков |
Разностный массив — зеркальная пара к префиксным суммам. Префиксная сумма превращает массив в «накопленный» и позволяет быстро читать сумму на отрезке. Разностный массив делает обратное: позволяет быстро писать на отрезок, а прочитать результат можно одной префиксной суммой в конце.
Задача, с которой всё начинается
Есть массив из нулей и операций вида (l, r, x): прибавить x ко всем элементам с индекса l по r включительно. После всех операций нужно вывести массив.
Наивно каждая операция — цикл по отрезку:
def apply_naive(n: int, updates: list[tuple[int, int, int]]) -> list[int]: a = [0] * n for l, r, x in updates: for i in range(l, r + 1): a[i] += x return a
Это . При получаем до операций — далеко за пределами любого лимита времени.
Идея: храним разности
Посмотрим не на сами значения, а на то, насколько каждый элемент отличается от предыдущего:
a = [1, 3, 3, 2, 0]
diff = [1, 2, 0, -1, -2] # diff[i] = a[i] - a[i-1], diff[0] = a[0]
Обратно массив восстанавливается накопленной суммой: . То есть a — это префиксная сумма от diff.
Теперь главное. Что происходит с разностями, когда мы прибавляем x ко всем элементам отрезка [l, r]?
- Внутри отрезка соседние элементы выросли одинаково — их разность не изменилась.
- На левой границе
a[l]вырос наx, аa[l - 1]нет —diff[l]увеличилась наx. - Сразу за правой границей
a[r + 1]не изменился, аa[r]вырос —diff[r + 1]уменьшилась наx.
Обновление целого отрезка свелось к изменению двух чисел:
Можно думать об этом как о сигнале: в точке l мы «включаем» прибавку x, в точке r + 1 «выключаем». Префиксная сумма несёт включённую прибавку слева направо, пока её не погасят.
Реализация
from itertools import accumulate def apply_updates(n: int, updates: list[tuple[int, int, int]]) -> list[int]: diff = [0] * (n + 1) # лишняя ячейка для r + 1 == n for l, r, x in updates: diff[l] += x diff[r + 1] -= x # префиксная сумма превращает разности обратно в значения return list(accumulate(diff[:n])) apply_updates(5, [(1, 3, 2), (0, 2, 1)]) # [1, 3, 3, 2, 0]
Разберём пример по шагам:
| Шаг | diff | Пояснение |
|---|---|---|
| Старт | [0, 0, 0, 0, 0, 0] | все нули |
(1, 3, +2) | [0, 2, 0, 0, -2, 0] | включили +2 в 1, выключили в 4 |
(0, 2, +1) | [1, 2, 0, -1, -2, 0] | включили +1 в 0, выключили в 3 |
| Префиксная сумма | [1, 3, 3, 2, 0] | итоговый массив |
Лишняя ячейка в конце нужна, чтобы обновление с r = n - 1 не выходило за границу. Это тот же приём, что и фиктивный ноль в префиксных суммах: одна дополнительная ячейка убирает ветвление.
Если массив не нулевой
Когда начальный массив уже заполнен, сначала строим из него разности, потом применяем обновления и в конце восстанавливаем:
from itertools import accumulate class DiffArray: def __init__(self, nums: list[int]): self.diff = [nums[0]] + [nums[i] - nums[i - 1] for i in range(1, len(nums))] self.diff.append(0) def add(self, l: int, r: int, x: int) -> None: self.diff[l] += x self.diff[r + 1] -= x def result(self) -> list[int]: return list(accumulate(self.diff[:-1])) d = DiffArray([1, 2, 3, 4, 5]) d.add(1, 3, 10) d.add(0, 0, -1) d.result() # [0, 12, 13, 14, 5]
Можно и проще: применить обновления к нулевому разностному массиву, восстановить прибавки и сложить их с исходным массивом поэлементно. Результат тот же.
Сложность
- Каждое обновление — .
- Восстановление — один проход, .
- Итого по времени и дополнительной памяти.
Важное ограничение: техника работает в режиме «сначала все записи, потом чтение». Если чтения перемешаны с обновлениями, каждое чтение требует пересчёта префиксной суммы, и выигрыш пропадает. Для такого режима есть дерево Фенвика поверх того же разностного массива (прибавление на отрезке и чтение точки за ) или дерево отрезков с ленивыми обновлениями.
Задача: Corporate Flight Bookings
LeetCode 1109. Есть n рейсов, пронумерованных с 1. Дан список бронирований [first, last, seats]: на каждый рейс с first по last включительно забронировано seats мест. Вернуть массив, где answer[i] — общее число мест на рейсе i + 1.
Это задача про разностный массив в чистом виде. Единственная ловушка — нумерация с единицы:
from itertools import accumulate def corp_flight_bookings(bookings: list[list[int]], n: int) -> list[int]: diff = [0] * (n + 1) for first, last, seats in bookings: diff[first - 1] += seats # переводим в индексацию с нуля diff[last] -= seats # (last - 1) + 1 return list(accumulate(diff[:n])) corp_flight_bookings([[1, 2, 10], [2, 3, 20], [2, 5, 25]], 5) # [10, 55, 45, 25, 25]
Время . Именно эту задачу удобно давать на собеседовании как первую: наивное решение очевидно, а оптимальное требует одной идеи.
Задача: Car Pooling
LeetCode 1094. Машина вмещает capacity пассажиров и едет только вперёд. Поездки [numPassengers, from, to]: столько пассажиров садятся в точке from и выходят в точке to. Можно ли развезти всех?
Сформулируем иначе: на каждом участке дороги число пассажиров в машине — это сумма поездок, покрывающих этот участок. Нужно проверить, что максимум не превышает вместимость. Это снова прибавление на отрезке, только отрезок полуоткрытый: [from, to). В точке to пассажиры уже вышли, поэтому гасим прибавку прямо в to, а не в to + 1:
def car_pooling(trips: list[list[int]], capacity: int) -> bool: diff = [0] * 1001 # координаты по условию от 0 до 1000 for passengers, start, end in trips: diff[start] += passengers diff[end] -= passengers # на end пассажиры уже вышли load = 0 for delta in diff: load += delta if load > capacity: return False return True car_pooling([[2, 1, 5], [3, 3, 7]], 4) # False: на участке 3–5 едут 5 человек car_pooling([[2, 1, 5], [3, 3, 7]], 5) # True car_pooling([[2, 1, 5], [3, 5, 7]], 3) # True: в точке 5 одни вышли, другие сели
Последний пример — проверка на правильные границы. Если бы мы гасили прибавку в end + 1, в точке 5 машина считалась бы загруженной на 5 человек, и ответ был бы неверным.
Если координаты большие
Массив на 1001 ячейку работает, потому что координаты маленькие. Если бы они были до , разностный массив не поместился бы в память. Тогда используют ту же идею в виде событий: каждая поездка даёт два события (start, +p) и (end, -p), события сортируются, и мы идём по ним, накапливая сумму:
def car_pooling_sweep(trips: list[list[int]], capacity: int) -> bool: events = [] for passengers, start, end in trips: events.append((start, passengers)) events.append((end, -passengers)) events.sort() # в одной точке отрицательные события идут раньше load = 0 for _, delta in events: load += delta if load > capacity: return False return True
Это и не зависит от диапазона координат. По сути это разностный массив, в котором хранятся только ненулевые ячейки. На этой же идее решается задача о минимальном числе переговорок — она разобрана в статье про задачи на интервалы.
Я сверил оба варианта Car Pooling на тысячах случайных входов — ответы совпадают.
Двумерный разностный массив
Та же идея переносится на матрицу: прибавить x ко всем клеткам прямоугольника с углами (r1, c1) и (r2, c2). Теперь нужно отметить четыре угла:
def apply_2d(rows: int, cols: int, updates: list[tuple]) -> list[list[int]]: diff = [[0] * (cols + 1) for _ in range(rows + 1)] for r1, c1, r2, c2, x in updates: diff[r1][c1] += x diff[r1][c2 + 1] -= x diff[r2 + 1][c1] -= x diff[r2 + 1][c2 + 1] += x # префиксные суммы по строкам, затем по столбцам for r in range(rows): for c in range(1, cols): diff[r][c] += diff[r][c - 1] for r in range(1, rows): for c in range(cols): diff[r][c] += diff[r - 1][c] return [row[:cols] for row in diff[:rows]] apply_2d(3, 4, [(0, 0, 1, 1, 1), (1, 1, 2, 3, 2)]) # [[1, 1, 0, 0], # [1, 3, 2, 2], # [0, 2, 2, 2]]
Почему четыре угла: после двумерной префиксной суммы значение в (r1, c1) «растекается» вправо и вниз на весь квадрант. Два минуса отрезают лишнее справа и снизу, а плюс в (r2 + 1, c2 + 1) возвращает угол, который вычли дважды. Это та же формула включений-исключений, что и в двумерных префиксных суммах, только применённая наоборот.
Сложность — на обновление и на восстановление. На LeetCode эта техника нужна, например, в задаче 2536 «Increment Submatrices by One».
Типичные ошибки
- Забыть лишнюю ячейку. Без
n + 1обновление сr = n - 1падает сIndexError. - Путать закрытые и полуоткрытые отрезки. Для
[l, r]гасим вr + 1, для[l, r)— вr. В Car Pooling это ровно разница между верным и неверным ответом. - Сбиться с индексацией. В Corporate Flight Bookings рейсы с единицы:
first - 1иlast, а неfirstиlast + 1. - Читать до восстановления.
diff[i]— не значение элемента, а разность. Пока не посчитана префиксная сумма, массив читать нельзя. - Применять, когда чтения идут вперемешку с записями. Тогда пересчёт префиксной суммы на каждое чтение даёт те же . Нужна структура с логарифмическими операциями.
- В 2D забыть угол
+x. Без четвёртой точки нижний правый квадрант уйдёт в минус.
Как распознать задачу на собеседовании
Признаки:
- много операций вида «прибавь к отрезку», «забронируй диапазон», «включи на интервале»;
- ответ нужен один раз, в конце, или нужен максимум по всем точкам;
- спрашивают «сколько интервалов покрывает каждую точку» или «бывает ли момент, когда одновременно больше
k».
Хорошая подача ответа: сначала наивное , потом наблюдение, что внутри отрезка разности не меняются, затем две точки на обновление и префиксная сумма в конце. Если интервьюер спрашивает про большие координаты — переход к сортировке событий. Если про чтения посреди обновлений — дерево Фенвика или отрезков.
Что запомнить
- Разностный массив хранит
a[i] - a[i-1]; исходный массив — его префиксная сумма. - Прибавление
xна[l, r]— этоdiff[l] += xиdiff[r + 1] -= x. - Итоговая сложность , но только если все чтения после всех записей.
- Для полуоткрытых отрезков гасим прибавку в правой границе, а не после неё.
- При больших координатах тот же приём работает через сортировку событий.
- В 2D отмечаем четыре угла прямоугольника и считаем двумерную префиксную сумму.