Чему научитесь
- Отвечать на запросы суммы отрезка за одно действие
- Строить массив накопленных сумм без сдвига на единицу
- Накапливать признак, а не значение, и считать количества
- Находить подотрезки с нужной суммой через пары префиксов
Как устроено занятие
Сначала разбор с примерами и короткими проверками понимания — они нужны, чтобы поймать непонятое сразу, а не через три темы.
Дальше 15 задач лестницей: разминка, основа, со звёздочкой. Занятие засчитывается, когда решено 10 — остальные не пропадают и учитываются отдельно.
После занятия — вторая часть, ещё 15 задач на те же приёмы в новых сюжетах.
Сколько это займёт
Примерно час-полтора вместе с задачами. Сроков нет: можно закрыть вкладку и вернуться когда удобно — прогресс сохранится.
Много запросов к одним данным
Задача: дан список и сто тысяч запросов, в каждом просят сумму на отрезке. Прямолинейное решение очевидно:
for _ in range(q):
l, r = map(int, input().split())
print(sum(a[l - 1:r])) # проход по отрезку
Оно верное. И оно не пройдёт: каждый запрос стоит до действий, всего получается — двадцать миллиардов.
Заметьте, что данные при этом не меняются. Все сто тысяч запросов спрашивают об одном и том же списке. Значит можно один раз подготовиться — и отвечать мгновенно.
Это и есть предподсчёт: потратить один раз, чтобы каждый из ответов стоил . Вместо получается .
Приём, которым это делается для сумм, называется префиксными суммами, и он же — образец для целого семейства решений. Дальше в модуле вы встретите ту же идею в других обличьях.
Массив накопленных сумм
Построим список, в котором на месте стоит сумма первых элементов:
prefix = [0] * (n + 1)
for i in range(n):
prefix[i + 1] = prefix[i] + a[i]
Обратите внимание на две вещи. Длина массива — , а не . И начинается он с нуля: prefix[0] = 0 означает «сумма пустого начала».
| список | 3 | 8 | 1 | 9 | |
| префикс | 0 | 3 | 11 | 12 | 21 |
Теперь сумма любого отрезка — это разность двух чисел:
сумма с l-го по r-й = prefix[r] - prefix[l - 1]
Проверим на примере: сумма со второго по четвёртый — это prefix[4] - prefix[1], то есть . И правда, .
Именно ради этой формулы массив делают длиннее и начинают с нуля. Если бы prefix[0] был первым элементом, для отрезка, начинающегося с первого, пришлось бы писать особый случай. А так формула работает всегда, включая l = 1: prefix[0] равен нулю, и вычитать нечего.
Проверка: какая формула
Массив prefix построен так, что prefix[i] — сумма первых элементов, а prefix[0] равен нулю.
Чему равна сумма элементов с -го по -й включительно?
Префикс не обязан быть суммой значений
Вот что превращает приём из узкого в универсальный: накапливать можно что угодно, что складывается.
Нужно узнать, сколько на отрезке положительных чисел? Накапливайте не значения, а единицы и нули:
for i in range(n):
if a[i] > 0:
prefix[i + 1] = prefix[i] + 1
else:
prefix[i + 1] = prefix[i]
Формула ответа не меняется — разность префиксов теперь даёт количество.
| что накапливаем | на что отвечает разность |
|---|---|
| сами значения | сумма на отрезке |
| единицы по условию | сколько подходящих на отрезке |
| квадраты значений | сумма квадратов |
| значения на чётных местах | сумма через одного |
Условие может зависеть и от номера, а не от значения — принцип тот же.
Ограничение одно: признак должен быть известен заранее и не меняться от запроса к запросу. Если бы в каждом запросе спрашивали своё («сколько больше на отрезке»), префикс не помог бы — пришлось бы строить его заново на каждый запрос. Такие задачи решаются иначе, и до них мы дойдём.
Обратный ход: подотрезки с нужной суммой
Пока префиксы отвечали на вопросы «сколько на этом отрезке». Но у формулы есть и обратное прочтение.
Сумма куска с по равна — это то же самое, что
prefix[r] - prefix[l - 1] == x
То есть пара префиксов с разностью . А считать пары с заданной разностью вы умеете с занятия про словари: идти по списку и для каждого нового префикса спрашивать, сколько раз встречалось нужное дополнение.
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} — не украшение. Без неё потерялись бы куски, начинающиеся с первого элемента: для них нужен «пустой префикс» слева.
Тот же ход решает и задачи про делимость: сумма куска делится на тогда, когда префиксы на его концах дают одинаковый остаток. Считать надо не сами префиксы, а их остатки.
Проверка: длина массива
В списке 10 элементов.
Сколько чисел будет в массиве префиксных сумм, построенном как в занятии? Введите целое число.
Три ошибки этого занятия
Сдвиг на единицу. Самая частая. Признак: ответ верен для всех отрезков, кроме тех, что начинаются с первого элемента, — или наоборот, кроме заканчивающихся последним. Проверяйте на отрезке во весь список и на отрезке из одного элемента.
Забытый нулевой префикс. В задачах про подотрезки без записи seen = {0: 1} теряются все куски, начинающиеся с начала. Признак: ответ стабильно меньше верного.
Медленный ввод. При ста тысячах запросов обычный input() в цикле сам по себе становится заметен. Если решение верное, но не укладывается, читайте всё разом: data = sys.stdin.read().split().
Как проверять себя
- отрезок во весь список —
l = 1,r = n; - отрезок из одного элемента, причём первого и последнего;
- список из одного элемента;
- все нули — в задачах про нулевые суммы это худший случай, и ответ там большой.
Практика: пятнадцать задач
Лестница прежняя: пять разминочных, семь основных, три со звёздочкой. Зачёт при десяти решённых.
Разминочные и основные — про запросы к отрезкам: меняется только то, что накапливается. Задачи со звёздочкой — про обратный ход, где префиксы сами становятся данными для словаря.
Ограничения везде такие, что решение без предподсчёта не проходит. Это не придирка: приём ровно для того и нужен, чтобы много запросов перестали быть проблемой.
Сдвиг на единицу
Задача: «дан список из чисел и запросов; для каждого выведите сумму элементов с -го по -й, номера с единицы».
Ученик написал:
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.
Объясните, что не так, почему ошибка выглядит именно так, и как её исправить.