EduBrick
классная работа

Префиксные суммы

Программирование на Python: от нуля до олимпиад

0/18решено 0 из 18до зачёта осталось 10

Чему научитесь

  • Отвечать на запросы суммы отрезка за одно действие
  • Строить массив накопленных сумм без сдвига на единицу
  • Накапливать признак, а не значение, и считать количества
  • Находить подотрезки с нужной суммой через пары префиксов

Как устроено занятие

Сначала разбор с примерами и короткими проверками понимания — они нужны, чтобы поймать непонятое сразу, а не через три темы.

Дальше 15 задач лестницей: разминка, основа, со звёздочкой. Занятие засчитывается, когда решено 10 — остальные не пропадают и учитываются отдельно.

После занятия — вторая часть, ещё 15 задач на те же приёмы в новых сюжетах.

Сколько это займёт

Примерно час-полтора вместе с задачами. Сроков нет: можно закрыть вкладку и вернуться когда удобно — прогресс сохранится.

теория

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

Задача: дан список и сто тысяч запросов, в каждом просят сумму на отрезке. Прямолинейное решение очевидно:

for _ in range(q):
    l, r = map(int, input().split())
    print(sum(a[l - 1:r]))        # проход по отрезку

Оно верное. И оно не пройдёт: каждый запрос стоит до nn действий, всего получается nqn \cdot q — двадцать миллиардов.

Заметьте, что данные при этом не меняются. Все сто тысяч запросов спрашивают об одном и том же списке. Значит можно один раз подготовиться — и отвечать мгновенно.

Это и есть предподсчёт: потратить O(n)O(n) один раз, чтобы каждый из qq ответов стоил O(1)O(1). Вместо nqn \cdot q получается n+qn + q.

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

теория

Массив накопленных сумм

Построим список, в котором на месте ii стоит сумма первых ii элементов:

prefix = [0] * (n + 1)
for i in range(n):
    prefix[i + 1] = prefix[i] + a[i]

Обратите внимание на две вещи. Длина массива — n+1n + 1, а не nn. И начинается он с нуля: prefix[0] = 0 означает «сумма пустого начала».

список 3 8 1 9
префикс 0 3 11 12 21

Теперь сумма любого отрезка — это разность двух чисел:

сумма с l-го по r-й = prefix[r] - prefix[l - 1]

Проверим на примере: сумма со второго по четвёртый — это prefix[4] - prefix[1], то есть 213=1821 - 3 = 18. И правда, 8+1+9=188 + 1 + 9 = 18.

Именно ради этой формулы массив делают длиннее и начинают с нуля. Если бы prefix[0] был первым элементом, для отрезка, начинающегося с первого, пришлось бы писать особый случай. А так формула работает всегда, включая l = 1: prefix[0] равен нулю, и вычитать нечего.

тест

Проверка: какая формула

Массив prefix построен так, что prefix[i] — сумма первых ii элементов, а prefix[0] равен нулю.

Чему равна сумма элементов с ll-го по rr-й включительно?

Войдите, чтобы ответить.
теория

Префикс не обязан быть суммой значений

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

Нужно узнать, сколько на отрезке положительных чисел? Накапливайте не значения, а единицы и нули:

for i in range(n):
    if a[i] > 0:
        prefix[i + 1] = prefix[i] + 1
    else:
        prefix[i + 1] = prefix[i]

Формула ответа не меняется — разность префиксов теперь даёт количество.

что накапливаем на что отвечает разность
сами значения сумма на отрезке
единицы по условию сколько подходящих на отрезке
квадраты значений сумма квадратов
значения на чётных местах сумма через одного

Условие может зависеть и от номера, а не от значения — принцип тот же.

Ограничение одно: признак должен быть известен заранее и не меняться от запроса к запросу. Если бы в каждом запросе спрашивали своё xx («сколько больше xx на отрезке»), префикс не помог бы — пришлось бы строить его заново на каждый запрос. Такие задачи решаются иначе, и до них мы дойдём.

теория

Обратный ход: подотрезки с нужной суммой

Пока префиксы отвечали на вопросы «сколько на этом отрезке». Но у формулы есть и обратное прочтение.

Сумма куска с ll по rr равна xx — это то же самое, что

prefix[r] - prefix[l - 1] == x

То есть пара префиксов с разностью xx. А считать пары с заданной разностью вы умеете с занятия про словари: идти по списку и для каждого нового префикса спрашивать, сколько раз встречалось нужное дополнение.

seen = {0: 1}          # пустой префикс уже встречался один раз
total = 0
count = 0

for value in a:
    total += value
    count += seen.get(total - x, 0)
    seen[total] = seen.get(total, 0) + 1

Строка seen = {0: 1} — не украшение. Без неё потерялись бы куски, начинающиеся с первого элемента: для них нужен «пустой префикс» слева.

Тот же ход решает и задачи про делимость: сумма куска делится на kk тогда, когда префиксы на его концах дают одинаковый остаток. Считать надо не сами префиксы, а их остатки.

расчёт

Проверка: длина массива

В списке 10 элементов.

Сколько чисел будет в массиве префиксных сумм, построенном как в занятии? Введите целое число.

Войдите, чтобы ответить.
теория

Три ошибки этого занятия

Сдвиг на единицу. Самая частая. Признак: ответ верен для всех отрезков, кроме тех, что начинаются с первого элемента, — или наоборот, кроме заканчивающихся последним. Проверяйте на отрезке во весь список и на отрезке из одного элемента.

Забытый нулевой префикс. В задачах про подотрезки без записи seen = {0: 1} теряются все куски, начинающиеся с начала. Признак: ответ стабильно меньше верного.

Медленный ввод. При ста тысячах запросов обычный input() в цикле сам по себе становится заметен. Если решение верное, но не укладывается, читайте всё разом: data = sys.stdin.read().split().

Как проверять себя

  • отрезок во весь списокl = 1, r = n;
  • отрезок из одного элемента, причём первого и последнего;
  • список из одного элемента;
  • все нули — в задачах про нулевые суммы это худший случай, и ответ там большой.
теория

Практика: пятнадцать задач

Лестница прежняя: пять разминочных, семь основных, три со звёздочкой. Зачёт при десяти решённых.

Разминочные и основные — про запросы к отрезкам: меняется только то, что накапливается. Задачи со звёздочкой — про обратный ход, где префиксы сами становятся данными для словаря.

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

лестница задач
развёрнутый ответ

Сдвиг на единицу

Задача: «дан список из nn чисел и qq запросов; для каждого выведите сумму элементов с ll-го по rr-й, номера с единицы».

Ученик написал:

n, q = map(int, input().split())
a = list(map(int, input().split()))

prefix = [0] * n
prefix[0] = a[0]
for i in range(1, n):
    prefix[i] = prefix[i - 1] + a[i]

for _ in range(q):
    l, r = map(int, input().split())
    print(prefix[r - 1] - prefix[l - 1])

На запросе «со второго по четвёртый» для списка 3 8 1 9 программа выводит 10, хотя верный ответ 18. А на запросе «с первого по четвёртый» выводит 18 вместо 21.

Объясните, что не так, почему ошибка выглядит именно так, и как её исправить.

Войдите, чтобы ответить.