6. Prefix sums - префиксные суммы
Введение: Магия предвычислений и константный диапазон
В финале предыдущей главы 5. Sliding window - скользящее окно мы столкнулись с принципиальным архитектурным ограничением: динамическое или фиксированное скользящее окно безупречно справляется с единичным потоковым проходом по массиву. Но что делать, если наш сервис однажды загружает исторический срез финансовых котировок за год, после чего получает миллионы конкурентных пользовательских запросов вида: «Какова была суммарная прибыль за период с 14-го по 89-й день включительно?»
Если на каждый подобный запрос запускать цикл суммирования или двигать окно, временная сложность составит $O(Q \cdot K)$, где $Q$ — поток запросов, а $K$ — средняя длина запрашиваемого интервала. При миллионах запросов в секунду процессор окажется полностью парализован.
Для мгновенного ответа на диапазонные агрегационные запросы (Range Queries) в компьютерных науках применяется классический паттерн упреждающих вычислений — Префиксные суммы (Prefix Sums).
Архитектура и математика
Концепция префиксных сумм поразительно проста и элегантна: мы создаем вспомогательный массив P, где каждая ячейка P[i] сохраняет кумулятивную сумму всех элементов исходного массива A от нулевого индекса до i-1.
Рекуррентная формула построения:
$$P[i] = P[i-1] + A[i-1]$$
Какой выигрыш это дает? Теперь сумма любого непрерывного подмассива от индекса L до индекса R (включительно) вычисляется ровно за одно арифметическое действие (скалярное вычитание):
$$Sum(L, R) = P[R+1] - P[L]$$
Мы берем суммарную массу элементов от начала массива до правого рубежа (P[R+1]) и отсекаем от нее накопленный префикс, лежащий левее целевого интервала (P[L]). В результате остается ровно то, что расположено между границами L и R.
flowchart LR
subgraph Исходный массив A
A0["A[0]<br/>3"] --- A1["A[1]<br/>1"] --- A2["A[2]<br/>4"] --- A3["A[3]<br/>2"]
end
subgraph Префиксный массив P
P0["P[0]<br/>0"] --- P1["P[1]<br/>3"] --- P2["P[2]<br/>4"] --- P3["P[3]<br/>8"] --- P4["P[4]<br/>10"]
end
A0 -.->|"0 + 3"| P1
A1 -.->|"3 + 1"| P2
A2 -.->|"4 + 4"| P3
A3 -.->|"8 + 2"| P4
classDef default fill:#0f172a,stroke:#334155,color:#f8fafc
classDef zeroNode fill:#1e293b,stroke:#f43f5e,color:#f43f5e
classDef sumNode fill:#1e293b,stroke:#10b981,color:#10b981
class A0,A1,A2,A3 default
class P0 zeroNode
class P1,P2,P3,P4 sumNode
Mechanical Sympathy: Нагрузка на железо
Префиксные суммы — образцовая демонстрация классического инженерного компромисса Space-Time (Память против Времени):
- Построение: Выполняется ровно один раз за строго линейное время $O(N)$. Это чистый последовательный проход: аппаратный
Hardware Prefetcherфункционирует со 100% эффективностью, непрерывно накачивая массивыAиPв кэш-линииL1-кэша. - Диапазонные выборки: Выполняются за безупречно константное время $O(1)$.
- Память: Расходует $O(N)$ дополнительной оперативной памяти под хранение массива
P.
Если данные неизменяемы (Immutable), префиксные суммы не имеют равных по производительности.
Однако если массив интенсивно мутирует, паттерн теряет привлекательность: изменение значения A[0] влечет за собой необходимость каскадного пересчета всего среза P за линейное время $O(N)$. (Для динамически обновляемых наборов применяются более продвинутые логарифмические структуры, такие как Дерево отрезков — Segment Tree или дерево Фенвика, разобранные нами в седьмом разделе).
Production-Ready реализация на Go
Реализуем надежную, типобезопасную структуру для обработки префиксных сумм с защитой от целочисленных переполнений:
package main
import "errors"
var ErrInvalidRange = errors.New("invalid range for prefix sum")
// PrefixSum инкапсулирует массив предвычисленных кумулятивных сумм.
type PrefixSum struct {
p []int64 // Задействуем 64-битные целые числа для надежной защиты от Integer Overflow
}
// NewPrefixSum создает и инициализирует структуру за время O(N).
func NewPrefixSum(arr []int) *PrefixSum {
n := len(arr)
// Выделяем N+1 элементов с нулевым базовым элементом (Branchless trick)
p := make([]int64, n+1)
for i := 0; i < n; i++ {
p[i+1] = p[i] + int64(arr[i])
}
return &PrefixSum{p: p}
}
// QuerySum возвращает агрегированную сумму на отрезке [left, right] включительно за O(1).
// Индексы соответствуют координатам исходного массива arr (от 0 до len-1).
func (ps *PrefixSum) QuerySum(left, right int) (int64, error) {
if left < 0 || right >= len(ps.p)-1 || left > right {
return 0, ErrInvalidRange
}
// Константное извлечение значения без использования циклов
return ps.p[right+1] - ps.p[left], nil
}
Хардкорные паттерны (Уровень Senior)
В рамках системных интервью в технологические компании префиксные суммы редко встречаются в изолированном виде. Наиболее мощный паттерн — это синергия префиксных сумм и хеш-таблиц, позволяющая решать задачи за строго линейное время $O(N)$ там, где наивный перебор требует квадратичных $O(N^2)$.
Паттерн: Subarray Sum Equals K
Задача (LeetCode 560): Дан срез целых чисел (среди которых могут присутствовать отрицательные значения) и число K. Требуется подсчитать количество непрерывных подмассивов, сумма элементов которых в точности равна K.
Почему здесь неприменимо Скользящее окно? При наличии отрицательных чисел монотонность суммы разрушается: окно не способно детерминированно решить, нужно ли ему расширяться или сжиматься при встрече с очередным отрицательным значением вроде -5.
Здесь на помощь приходит строгая алгебра префиксных сумм:
Если сумма на непрерывном отрезке $[L, R]$ равна $K$, то:
$$P[R+1] - P[L] = K \implies P[L] = P[R+1] - K$$
Алгоритмическое решение: Мы обходим массив, накапливая текущую префиксную сумму (currentSum = P[R+1]), и на каждом шаге проверяем по хеш-таблице: встречалась ли нам ранее префиксная сумма, равная currentSum - K? Если да, то между теми индексами и текущей позицией сумма подмассива в точности равна K!
// SubarraySum вычисляет количество подмассивов с суммой K за строго линейное время O(N).
func SubarraySum(nums []int, k int) int {
count := 0
currentSum := 0
// map фиксирует частоту появления каждой вычисленной префиксной суммы
// Ключ: значение префиксной суммы; Значение: количество вхождений
prefixCounts := make(map[int]int)
// Базовый случай: префиксная сумма 0 формально встретилась ровно 1 раз (до старта массива)
prefixCounts[0] = 1
for _, num := range nums {
currentSum += num // На лету аккумулируем префиксную сумму
// Если разность (currentSum - k) уже встречалась в истории, добавляем ее частоту к ответу
target := currentSum - k
if occurrences, exists := prefixCounts[target]; exists {
count += occurrences
}
// Фиксируем текущую префиксную сумму в словаре
prefixCounts[currentSum]++
}
return count
}
(Детали того, как оптимизировать аллокации памяти при работе с мапами, мы детально исследовали в главе 5. Внутреннее устройство map в Go).
Расширения паттерна
Симбиоз префиксных сумм и частотных хеш-таблиц открывает решение широкого спектра задач:
- Поиск подмассива с нулевой суммой: Проверка равенства
currentSum == 0либо обнаружение дубликатаcurrentSumв историиmap. - Максимальный подмассив с равным числом нулей и единиц: Заменяем все нули на
-1и ищем самый длинный сегмент с нулевой суммой. - Двумерные префиксные суммы (2D Prefix Sums): Обобщение на прямоугольные матрицы, где матрица $P[i][j]$ агрегирует сумму подматрицы от угла $(0,0)$ до $(i,j)$ за $O(1)$. Техника активно применяется в компьютерной графике, GameDev и фильтрации изображений.
Итог
- Префиксные суммы — фундаментальный паттерн компромисса
Space-Time: мы инвестируем $O(N)$ памяти и времени на этапе инициализации, чтобы гарантировать извлечение суммы любого интервала за строгое время $O(1)$. - Для полного исключения инструкций условного перехода (
Branchless Programming) префиксный массив формируется размерностью $N+1$ со стартовым нулевым элементом. - Для защиты от целочисленного переполнения (
Integer Overflow) тип аккумулятора следует выбирать с запасом (использоватьint64вместоint32илиmath/bigдля сверхбольших чисел). - Симбиоз Префиксных сумм и Хеш-таблицы (
map) — общепринятый стандарт для нахождения подмассивов с заданными свойствами в срезах с знакопеременными числами.
Мы успешно завершили масштабный девятый раздел, исследовав эволюцию алгоритмов поиска: от прямолинейного сканирования и логарифмических границ до двух указателей, скользящих окон и префиксных структур.
Теперь мы переходим на принципиально новый уровень архитектурного обобщения. В фокусе нашего внимания — фундаментальные Алгоритмические парадигмы: универсальные стратегии решения комплексных инженерных проблем.
И первым краеугольным камнем распределенного бэкенда станет парадигма: 1. Divide and conquer - разделяй и властвуй.