1. Теория. Префиксные суммы

«Вместо того чтобы делать одну и ту же работу снова и снова, вычислите результат один раз и сохраните его. Память дешева, время процессора драгоценно».
— Джон Бентли, «Жемчужины программирования»

💡 Связанные темы:


Введение: Магия накопленного итога

Представьте себе одометр автомобиля. Когда вы садитесь за руль в начале года, на счетчике красуется 42 000 км. В конце июня одометр показывает 54 000 км, а в конце августа — 61 000 км. Если вам нужно узнать, сколько километров вы проехали за два месяца лета (июль и август), вы не восстанавливаете каждую отдельную поездку в магазин или на дачу. Вы просто берете два показания счетчика и вычитаете одно из другого:
$$61\,000 - 54\,000 = 7\,000\text{ км}$$

В этом и заключается фундаментальная идея префиксных сумм (Prefix Sums): мы тратим время один раз, чтобы накопить «показания одометра» вдоль массива, а затем отвечаем на любые вопросы о сумме любого отрезка ровно за одну арифметическую операцию — $O(1)$.

В бэкенде эта техника встречается повсеместно:

  • Оконные функции SQL SUM(...) OVER (ORDER BY created_at);
  • Агрегации в таймсериях (Prometheus, VictoriaMetrics, ClickHouse);
  • Подсчет RPS и ошибок за произвольные скользящие интервалы;
  • Алгоритмы взвешенного выбора (Weighted Random Selection) в балансировщиках трафика.

🎯 Вопросы для уточнения условий (Interview Warm-up)

Когда на собеседовании задача пахнет префиксными суммами, задайте интервьюеру 4 вопроса:

  1. Диапазон чисел и переполнение: «Могут ли элементы быть отрицательными? Каков порядок сумм — уместится ли сумма в int (32/64 бита) или нужен int64?»
  2. Частота модификаций: «Массив неизменяемый (static/immutable), или предполагаются частые вставки и обновления?» (Если есть обновления — нужны 2. Разностные массивы или дерево Фенвика/отрезков).
  3. Плотность запросов: «Сколько ожидается запросов диапазона $Q$? Имеет ли смысл предрасчет $O(N)$ ради $O(1)$ на запрос?»
  4. Формат границ: «Включают ли границы [left, right] оба конца (inclusive) или правая граница полуоткрытая [left, right)?»

Наивный подход vs Префиксные суммы

Представьте лог задержек (latency) 100 000 HTTP-запросов к микросервису. Дашборд аналитики присылает 100 000 запросов вида: «Какова суммарная задержка запросов с индекса $l$ по индекс $r$?».

Наивное решение: $O(Q \cdot N)$

При каждом запросе мы запускаем цикл:

Go
// rangeSumNaive выполняет честный перебор элементов от l до r.
// Сложность: O(r - l + 1) на запрос, в худшем случае O(N).
func rangeSumNaive(arr []int, l, r int) int64 {
    var sum int64
    for i := l; i <= r; i++ {
        sum += int64(arr[i])
    }
    return sum
}

При $N = 10^5$ и $Q = 10^5$ общее число итераций достигает $10^{10}$. На современном ядре процессора это займет несколько секунд заблокированного CPU — неприемлемо для микросервиса.

Оптимальное решение: $O(N)$ предрасчет, $O(1)$ запрос

Мы за один линейный проход формируем префиксный массив $P$, где:
$$P[k] = \sum_{i=0}^{k-1} arr[i], \quad P[0] = 0$$

Сумма на отрезке $[l, r]$ (включительно) находится за один такт:
$$\text{Sum}(l, r) = P[r+1] - P[l]$$

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 accent  fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
    classDef success fill:#064e3b,stroke:#34d399,stroke-width:2px,color:#f8fafc
    classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc

    A["Исходный массив: arr = [2, -1, 3, 4, -2]"]:::entry --> B["Префиксный массив P (размер N+1)<br/>P = [0, 2, 1, 4, 8, 6]"]:::process
    B --> C{"Запрос отрезка [l=1, r=3]<br/>arr[1..3] = [-1, 3, 4]"}:::accent
    C --> D["Вычисление: P[r+1] - P[l]<br/>P[4] - P[1]"]:::process
    D --> E["8 - 2 = 6"]:::success
    E -.-> F["Проверка: (-1) + 3 + 4 = 6"]:::warning
  

🛠️ Идиоматичный Go: Трюк со сдвигом на 1 (1-based Indexing)

В учебниках часто определяют $P[i]$ как сумму от $0$ до $i$. Но тогда для $l = 0$ формула $P[r] - P[l-1]$ требует проверки граничного условия: if l == 0 { return P[r] }.

В продакшн-коде на Go всегда используют сдвиг на единицу:

  1. Размер префиксного среза равен $N + 1$;
  2. P[0] = 0 — виртуальный пустой префикс;
  3. P[k] — сумма первых $k$ элементов;
  4. Формула P[r+1] - P[l] работает абсолютно для любых допустимых индексов, полностью устраняя ветвление (if).
Go
package prefixsum

// PrefixSum хранит предрассчитанные префиксные суммы.
type PrefixSum struct {
    prefix []int64 // int64 защищает от переполнения 32-битных чисел
}

// NewPrefixSum строит префиксную таблицу за O(N) времени и O(N) памяти.
func NewPrefixSum(nums []int) *PrefixSum {
    n := len(nums)
    p := make([]int64, n+1) // len = n+1, p[0] инициализировано нулем
    for i := 0; i < n; i++ {
        p[i+1] = p[i] + int64(nums[i])
    }
    return &PrefixSum{prefix: p}
}

// Query возвращает сумму элементов nums[l..r] включительно за O(1).
func (ps *PrefixSum) Query(l, r int) int64 {
    if l < 0 || r >= len(ps.prefix)-1 || l > r {
        return 0
    }
    return ps.prefix[r+1] - ps.prefix[l]
}

⚙️ Mechanical Sympathy: Аппаратная эффективность


📐 2D Префиксные суммы: Матрицы и площади

Концепция легко обобщается на 2D-матрицы. Пусть $P[i][j]$ — сумма элементов в подматрице от $(0,0)$ до $(i-1, j-1)$.

TEXT
   (0,0) ----------- (0, c2)
     |                 |
   (r1, 0) -- [ A ] -- (r1, c2)
     |        |   |    |
   (r2, 0) -- [ B ] -- (r2, c2)

По принципу включения-исключения:

  • Предрасчет:
    $$P[i][j] = arr[i-1][j-1] + P[i-1][j] + P[i][j-1] - P[i-1][j-1]$$
  • Запрос прямоугольника $[r_1, c_1] \dots [r_2, c_2]$:
    $$\text{Sum} = P[r_2+1][c_2+1] - P[r_1][c_2+1] - P[r_2+1][c_1] + P[r_1][c_1]$$
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 accent  fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
    classDef success fill:#064e3b,stroke:#34d399,stroke-width:2px,color:#f8fafc
    classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc

    A["Полный прямоугольник (0,0) -> (r2, c2)"]:::entry --> B["Вычитаем верхнюю полосу: P[r1][c2+1]"]:::process
    B --> C["Вычитаем левую полосу: P[r2+1][c1]"]:::process
    C --> D["Прибавляем дважды вычтенный угол: + P[r1][c1]"]:::accent
    D --> E["Искомая сумма прямоугольника за O(1)"]:::success
  

🚨 Подводные камни и частые ошибки


💡 Вопросы для самопроверки и собеседования


Назад Вперед
— 2. Разностные массивы →