Много запросов на одни данные

Пусть у нас есть журнал ежедневных изменений баланса: [5, −2, 7, −3, 4]. Пять дней, пять чисел. Положительное число означает приход, отрицательное — расход. Первый день: плюс пять. Второй: минус два. Третий: плюс семь. Четвёртый: минус три. Пятый: плюс четыре. Итого за все пять дней баланс изменился на 11.

Теперь представим, что к этому журналу поступает много вопросов одного типа: «какова сумма изменений за дни с такого-то по такой-то?» Например, со второго по четвёртый включительно: это дни с индексами 1, 2, 3, сумма −2 + 7 + (−3) = 2. Или с третьего по пятый: 7 + (−3) + 4 = 8. Или за все пять дней: 11. Или за пустой промежуток: 0.

Самый прямолинейный способ отвечать на такие вопросы — каждый раз пробегать нужный кусок массива и складывать. Работает? Работает. Но если вопросов много, а куски длинные, мы снова и снова проходим по одним и тем же числам. Каждый запрос стоит O(длины отрезка), и суммарно на q запросов уходит O(q · n). Для больших q и n это заметно. Хочется отвечать на такие вопросы за один шаг, не пересчитывая одно и то же.

Идея префиксных сумм

Идея простая: давайте один раз пройдём по массиву и запишем в отдельную таблицу суммы всех префиксов. То есть для каждой позиции i мы сохраним сумму первых i элементов. Такую таблицу удобно строить слева направо, добавляя по одному элементу.

Заведём массив prefix длины n + 1. Первый элемент положим равным нулю: сумма пустого префикса — это ноль. Это важно: именно из-за него формула запроса получится простой. Дальше для каждого i от 0 до n − 1 делаем prefix[i + 1] = prefix[i] + a[i]. То есть prefix[i + 1] — сумма всех элементов a[0], a[1], …, a[i].

Для нашего массива это выглядит так. prefix[0] = 0. prefix[1] = 0 + 5 = 5. prefix[2] = 5 + (−2) = 3. prefix[3] = 3 + 7 = 10. prefix[4] = 10 + (−3) = 7. prefix[5] = 7 + 4 = 11.

Обратите внимание на смысл каждой ячейки. prefix[k] — это сумма первых k элементов, то есть сумма элементов с индексами от 0 до k − 1. Полный массив — это prefix[5] = 11.

Как из таблицы получить сумму отрезка

Теперь ключевой момент. Пусть нам нужна сумма элементов на позициях с l по r − 1 включительно — то есть полуинтервал [l, r). Сумма первых r элементов равна prefix[r]. Сумма первых l элементов равна prefix[l]. Если из первого вычесть второе, общий префикс «сократится», и останется ровно сумма элементов с l по r − 1. Значит, сумма отрезка [l, r) равна prefix[r] − prefix[l].

Проверим на числах. Отрезок [1, 4): с индексов 1, 2, 3, это −2, 7, −3, сумма 2. По формуле: prefix[4] − prefix[1] = 7 − 5 = 2. Сходится.

Отрезок [2, 5): индексы 2, 3, 4, это 7, −3, 4, сумма 8. По формуле: prefix[5] − prefix[2] = 11 − 3 = 8. Верно.

Полный отрезок [0, 5): prefix[5] − prefix[0] = 11 − 0 = 11. Пустой отрезок, когда l = r, даёт prefix[l] − prefix[l] = 0. Всё согласовано.

Заметьте, насколько это экономно. Один раз прошли по массиву, построили таблицу за O(n). И после этого любой запрос — это одно вычитание, O(1). Больше никаких повторных проходов по кускам массива.

Программа BuildPrefix и RangeSum

Соберём это в программу. Функция BuildPrefix принимает массив и возвращает таблицу префиксных сумм. Функция RangeSum берёт таблицу и границы и возвращает сумму отрезка. Контракт RangeSum: 0 ≤ l ≤ r ≤ n. Если условие нарушено, возвращаем ошибку — это ошибка вызывающей стороны, а не значение. Значение null отклоняется проверкой на входе.

Результат — long, потому что при индексируемой длине массива суммы int-ов могут выходить за пределы int, но укладываться в long. Это допущение: мы не обещаем, что любая сумма любых long-ов поместится в long; речь именно про массивы int с реальными размерами.

using System;

int[] changes = { 5, -2, 7, -3, 4 };
long[] prefix = BuildPrefix(changes);

Console.WriteLine(RangeSum(prefix, 0, 5));
Console.WriteLine(RangeSum(prefix, 1, 4));
Console.WriteLine(RangeSum(prefix, 2, 5));
Console.WriteLine(RangeSum(prefix, 2, 2));

long[] BuildPrefix(int[] a)
{
    if (a == null) throw new ArgumentNullException(nameof(a));
    long[] p = new long[a.Length + 1];
    for (int i = 0; i < a.Length; i++)
    {
        p[i + 1] = p[i] + a[i];
    }
    return p;
}

long RangeSum(long[] prefix, int l, int r)
{
    if (prefix == null) throw new ArgumentNullException(nameof(prefix));
    int n = prefix.Length - 1;
    if (l < 0 || r < l || r > n)
        throw new ArgumentOutOfRangeException();
    return prefix[r] - prefix[l];
}

Вывод:

11
2
8
0

Первая строка — сумма всех дней, 11. Вторая — отрезок [1, 4), это 2. Третья — отрезок [2, 5), это 8. Четвёртая — пустой отрезок [2, 2), это 0.

Цена

Подготовка таблицы — O(n) по времени и по памяти: одна ячейка на каждый префикс плюс одна на пустой. Каждый запрос — O(1): всего одно вычитание, независимо от длины отрезка и от того, сколько запросов уже было. Это и есть главная выгода: длинная подготовка окупается, когда запросов много.

Но у подхода есть цена, которую важно понимать. Как только массив a меняется, таблица prefix становится неверной: она описывает старое состояние. Если элементы меняются часто, придётся либо каждый раз пересобирать таблицу за O(n), либо искать другой способ. Обновление одного элемента массива не отражается на таблице за O(1): изменение в позиции i затронет все префиксы от i + 1 и дальше. Значит, для частых обновлений префиксные суммы подходят плохо, а для частых запросов на неизменных данных — идеально.

Нулевая сумма отрезка

Отдельно разберём задачу, где префиксные суммы помогают увидеть то, что иначе не бросается в глаза. Спрашивается: есть ли в массиве непустой отрезок, сумма которого равна нулю?

Наивный способ — перебрать все отрезки: для каждой пары границ посчитать сумму. Количество отрезков — порядка n², и перебор становится тяжёлым. Хочется обойтись одним проходом.

Обратимся к префиксным суммам. Сумма отрезка [l, r) равна prefix[r] − prefix[l]. Когда она равна нулю? Ровно тогда, когда prefix[r] = prefix[l]. То есть две позиции в таблице префиксов совпали. При этом l < r, потому что отрезок должен быть непустым. Значит, задача сводится к вопросу: встречается ли какое-то значение префиксной суммы дважды, на разных позициях?

Проверим на примере. Массив [3, −1, −2, 5, −5]. Считаем префиксы: prefix[0] = 0. prefix[1] = 3. prefix[2] = 3 + (−1) = 2. prefix[3] = 2 + (−2) = 0. prefix[4] = 0 + 5 = 5. prefix[5] = 5 + (−5) = 0. Видим: prefix[0] = 0, prefix[3] = 0, prefix[5] = 0. Три позиции с одинаковым значением. Между позициями 0 и 3 лежит отрезок [0, 3): сумма 3 + (−1) + (−2) = 0. Между позициями 3 и 5 лежит отрезок [3, 5): сумма 5 + (−5) = 0. Оба отрезка непусты, оба дают ноль.

Программа HasZeroSum

Для проверки «встречается ли значение дважды» удобно воспользоваться множеством. Заведём HashSet

Стоит уточнить: HashSet мы уже изучали, и как он устроен внутри — не наша забота сейчас. Нам достаточно того, что при подходящих условиях операции Add и Contains выполняются быстро, в среднем за O(1). Именно на этом допущении и держится оценка.

using System;
using System.Collections.Generic;

Console.WriteLine(HasZeroSum(new[] { 3, -1, -2, 5, -5 }));
Console.WriteLine(HasZeroSum(new[] { 1, 2 }));
Console.WriteLine(HasZeroSum(new[] { 0 }));
Console.WriteLine(HasZeroSum(Array.Empty<int>()));

bool HasZeroSum(int[] a)
{
    if (a == null) throw new ArgumentNullException(nameof(a));
    var seen = new HashSet<long> { 0 };
    long running = 0;
    foreach (int value in a)
    {
        running += value;
        if (!seen.Add(running))
        {
            return true;
        }
    }
    return false;
}

Вывод:

True
False
True
False

Разберём каждый случай. На [3, −1, −2, 5, −5] — True: мы нашли два совпадающих префикса, значит, нулевой отрезок существует. На [1, 2] — False: префиксы 0, 1, 3 — все разные, нулевого отрезка нет. На [0] — True: единственный элемент равен нулю, и это непустой отрезок с нулевой суммой. Префиксы: 0 (изначально), потом 0 (после прибавления нуля) — совпадение. На пустом массиве — False: нам нужен непустой отрезок, а в пустом массиве его нет.

Обратите внимание на два места, где легко ошибиться. Первое: мы ищем именно непустой отрезок. Поэтому пустой массив даёт False, хотя формально сумма пустого отрезка равна нулю. Второе: начальное значение 0 в множестве нужно для того, чтобы отрезок, начинающийся с нулевого индекса, тоже был обнаружен. Если текущая сумма в какой-то момент снова стала нулём, значит, от начала массива до этой позиции сумма равна нулю — это непустой отрезок (при условии, что мы уже прошли хотя бы один элемент).

Цена программы HasZeroSum

Идём по массиву один раз, на каждом шаге делаем одно прибавление и одну операцию с множеством. При допущении, что операции множества выполняются в среднем за O(1), общее время — O(n). Дополнительная память — O(n): в худшем случае множество накопит n + 1 различных значений префиксных сумм.

И ещё одна мысль, которую стоит проговорить. Мы не перечисляем отрезки и не храним их. Мы работаем со значениями префиксных сумм, а множество лишь отвечает на вопрос «это значение уже встречалось?» Именно в этом и заключается сила приёма: вместо перебора O(n²) отрезков мы проходим массив один раз и запоминаем всего лишь факт появления каждого значения. Префиксная сумма — это инструмент, который превращает задачу про отрезки в задачу про значения в таблице.

Префиксные суммы — это таблица сумм всех начальных отрезков массива: prefix[0] = 0, prefix[i + 1] = prefix[i] + a[i]. Один проход по массиву строит такую таблицу за O(n), и после этого сумма любого отрезка [l, r) вычисляется как prefix[r] − prefix[l] за O(1). Общий префикс при вычитании сокращается, и остаётся ровно искомая сумма. Пустой отрезок даёт ноль, полный — сумму всего массива. Дополнительная память — O(n) на таблицу. Обратная сторона: если массив меняется, таблица устаревает, и обновление одного элемента не отражается на ней за O(1) — потребуется пересборка.

Префиксные суммы позволяют увидеть задачи про отрезки в новом свете. Сумма отрезка равна нулю ровно тогда, когда две префиксные суммы совпадают, а значит, вопрос «есть ли нулевой отрезок» превращается в вопрос «встречается ли какое-то значение дважды». При этом мы не перебираем отрезки и не храним их: мы ведём текущую сумму и отмечаем, какие значения уже были. Множество здесь — удобный инструмент для такой отметки; при подходящих условиях операции с ним быстры, что даёт ожидаемое O(n) времени и O(n) памяти. Суммы int-ов помещаются в long при индексируемых размерах массива, и этого достаточно для разобранных задач.

Обсуждение урока

0
Комментарии видны всем. Чтобы участвовать в обсуждении, войдите или зарегистрируйтесь.
Модерация сообщества

Пожаловаться на комментарий

Расскажите модераторам, что именно требует внимания.