Все темы
24

Сумма любого куска списка за один шаг

Tier 1Бесплатно

Где применяется

1. Задача

В последних уроках окно двигалось по списку, а сумма обновлялась на ходу. Это работает, когда окно одно и движется в одну сторону. С дашбордом иначе. Он задаёт сотни вопросов, и каждый про свой кусок списка, никак не связанный с другими. Скользящее окно тут не поможет.

У тебя дашборд с метриками. На каждую минуту одно число: запросы, байты, ошибки, что угодно. Пользователь двигает ползунок времени и спрашивает: «сколько всего запросов было с 40-й минуты по 90-ю?» Ты складываешь эти 51 число и показываешь ответ.

Потом он двигает ползунок снова: с 5-й минуты по 800-ю. Ты складываешь 796 чисел. Он двигает ещё раз, и ты снова складываешь новый кусок с нуля. Ползунок выдаёт новый диапазон через каждые несколько пикселей движения мыши. Каждый раз ты заново складываешь тысячи чисел. А можно отвечать на любой вопрос «сумма отсюда досюда» одним вычитанием, какой бы длины ни был диапазон.

2. Простыми словами

На некоторых чеках рядом с каждым товаром печатают ещё и итог на этот момент. Допустим, нужно узнать, сколько вместе стоят три товара из середины чека. Складывать их заново не нужно. Возьми итог, напечатанный после третьего из них, и вычти итог, напечатанный прямо перед первым. Разница и есть цена этих трёх товаров.

На этом всё и держится. Один раз запиши итог на каждой позиции. После этого сумма любого куска равна одному итогу минус другой. Это одно вычитание, какой бы длины ни был кусок.

3. Идея

Пройди список один раз и веди накопленную сумму: после каждого числа записывай сумму всех чисел до него включительно. Назови этот список накопленных сумм prefix.

Главное правило: сумма куска равна накопленной сумме в его конце минус накопленная сумма прямо перед его началом. Всё, что стоит до начала куска, при вычитании пропадает. Остаётся ровно тот кусок, который ты просил.

Чтобы индексы сходились, начни список с нуля, ещё до первого числа. Тогда prefix[0] = 0, а каждая следующая ячейка равна предыдущей плюс очередное число: prefix[i] = prefix[i-1] + nums[i-1]. Теперь prefix[i] означает «сумма первых i чисел». Сумма диапазона от l до r (оба конца включены) равна:

prefix[r+1] - prefix[l]

Благодаря начальному нулю диапазон, который начинается с самой первой позиции, считается по той же формуле, без отдельного случая. Без этого нуля каждый диапазон съехал бы на одну позицию.

nums: 2 4 1 3 prefix: 0 2 6 7 10 сумма [1,3] = prefix[4] - prefix[1] = 10 - 2 = 8

Проследим на nums = [2, 4, 1, 3].

  • Строим prefix: 0, потом 0 + 2 = 2, потом 2 + 4 = 6, потом 6 + 1 = 7, потом 7 + 3 = 10. Получаем prefix = [0, 2, 6, 7, 10].
  • Диапазон [1, 3] (числа 4, 1, 3): prefix[4] - prefix[1] = 10 - 2 = 8. Проверка: 4 + 1 + 3 = 8.
  • Диапазон [0, 0] (только 2): prefix[1] - prefix[0] = 2 - 0 = 2.

Ничего не пришлось складывать заново.

4. Во что это обходится

Массив prefix строится за один проход по данным: O(n). После этого каждый вопрос про диапазон решается одним вычитанием: O(1). Для q вопросов всё вместе стоит O(n + q).

Память O(n) на сам массив prefix. Ты тратишь место размером с копию данных, и за это вопросы становятся дешёвыми.

Выигрыш заметен, когда вопросов много. Если складывать каждый диапазон вручную, это O(n) на вопрос и O(n·q) всего. Если один раз посчитать prefix, каждый вопрос стоит постоянное время. Разницу видно уже на одном ползунке дашборда.

5. Где это встречается

Дашборды временных рядов и сводки мониторинга считают сумму за любой диапазон дат и не перечитывают исходные строки. Так же работают накопительные отчёты по выручке и использованию. И любой аналитический API, где одни и те же данные запрашивают по множеству разных диапазонов.

Двумерная версия называется «интегральное изображение». Так фильтры изображений считают сумму любого прямоугольника за постоянное время. Это ускоряет размытие и поиск признаков при распознавании лиц. Приём тот же, только измерений на одно больше.

Суммы на отрезке через массив префиксов
rangeSum(buildPrefix([3, 1, 4, 1, 5, 9]), 1, 4)
arr
3
1
4
1
5
9
prefix (prefix[i] = сумма первых i значений)
0

Строим prefix на одну ячейку длиннее arr. prefix[0] это сумма ничего, поэтому там 0.

Гонка скорости
40 сумм на отрезках по одним и тем же 40 числам

Столбики: 40 значений. Префиксные суммы сначала превращают их в накопленные итоги. Запросов столько же, сколько значений, и здесь, и в таблице ниже.

Одна операция: одно чтение значения. За каждый кадр все дорожки делают одинаковое число операций, поэтому медленная отстаёт.

Цикл по каждому отрезку O(n·q)
операций: 0
Префиксные суммы O(n + q)
операций: 0

 

А если чисел больше

Операции для n чисел и примерное время при 100 миллионах простых операций в секунду. Это оценка по тому же счёту, а не замер.

n = 1 000
Цикл по каждому отрезку334,3 тыс. · 3,3 мс
Префиксные суммы3 тыс. · меньше 1 мс
n = 100 000
Цикл по каждому отрезку3,3 млрд · 33 с
Префиксные суммы300 тыс. · 3 мс
n = 10 000 000
Цикл по каждому отрезку33,3 трлн · 3,9 дн.
Префиксные суммы30 млн · 300 мс

Как узнать

Префиксные суммы (Prefix sums)

Эти слова в условии значат этот паттерн

  • много запросов на сумму по диапазону
  • ответить на несколько вопросов о сумме диапазона в одном массиве
  • сумма от индекса l до r
  • накопленная или кумулятивная сумма

Похоже, но не то

  • Скользящее окно (фиксированный размер) (Sliding window (fixed size))

    Окно отвечает про один движущийся отрезок. Префиксные суммы отвечают про любой названный отрезок.

  • Массив разностей (Difference array)

    Это зеркальный случай: много записей на отрезках, одно чтение в конце. Здесь наоборот, много чтений.

  • Дерево Фенвика / дерево отрезков (Fenwick tree / segment tree)

    Префиксный массив ломается при первом же изменении значения. Дерево Фенвика платит log n за право менять.

  • Хеш-множество / словарь (Hash set / map)

    У словаря нет понятия отрезка вообще. Для подсчёта подмассивов рядом нужна накопленная сумма.

n до 1e6 и много запросов на отрезках дают O(n) на подготовку. Каждый запрос стоит O(1), плюс O(n) памяти.

На LeetCode

Задачи, где нужно много раз узнать сумму отрезка или сравнить то, что слева от индекса, с тем, что справа. Один раз построй префиксные суммы и отвечай на каждый вопрос одним вычитанием.

Твоя задача (Tier 1)

Напиши функцию rangeSum(nums, queries).

Нужно быстро отвечать на много вопросов вида «сумма с позиции l по позицию r». Один раз построй вспомогательный список накопленных сумм, потом отвечай на каждый вопрос одним вычитанием.

  • Вход: nums, список чисел, и queries, список пар [l, r] (начальная и конечная позиция). Пример: [2, 4, 1, 3], [[1, 3]].
  • Возвращает: по одной сумме на каждый запрос, то есть сумму nums с позиции l по позицию r.

Шаги:

  1. Построй вспомогательный список prefix. Начни его с 0. Затем каждое число прибавляй к последнему значению: prefix[i] = prefix[i-1] + nums[i-1]. Так prefix[i] будет суммой первых i чисел.
  2. Для каждой пары [l, r] из queries сумма с l по r равна prefix[r+1] - prefix[l]. Добавь её в список ответов.
  3. Верни список ответов. Начальный 0 в prefix нужен, чтобы каждый диапазон считался верно.

Готово, когда проходят все тесты:

  • [2, 4, 1, 3], [[1,3], [0,0], [0,3]] -> [8, 2, 10]
  • [5, 6, 7], [[1,1]] -> [6] (одна позиция)
  • [4], [[0,0]] -> [4]
  • [], [] -> []
  • [1, 2, 3], [] -> [] (нет запросов)

Практика

rangeSum

rangeSum(nums, queries) -> number[]

export function rangeSum(nums, queries) {
  // Your code here. The task is described in the box above the editor.
}

Ты здесь как гость

Тесты запускаются прямо здесь, без аккаунта. Код сохраняется только в этом браузере. Бесплатный аккаунт сохранит его на любом устройстве, отметит шаг пройденным и покажет, где ты в курсе.

Лестница подсказок

Застрял? Открывай по одной, но сначала попробуй сам.

    Мы используем необходимые куки для работы сайта (вход и язык). Если ты согласишься, мы также загрузим Google Analytics, чтобы видеть, какие страницы читают, и Google reCAPTCHA для защиты форм обратной связи и сообщений об ошибке от спама. Политика конфиденциальности