9. Запросы на отрезках¶
В этой главе мы разберём структуры данных, которые позволяют быстро отвечать на запросы по подотрезкам массива. В таких задачах нам дана последовательность чисел, а каждый запрос касается некоторого диапазона индексов.
Типичные примеры:
sumq(a, b)— найти сумму элементов на отрезке[a, b]minq(a, b)— найти минимальный элемент на отрезке[a, b]maxq(a, b)— найти максимальный элемент на отрезке[a, b]
Рассмотрим массив:
индекс: 0 1 2 3 4 5 6 7
значение: 2 5 7 1 6 3 4 8
Для отрезка [2, 6] получаем:
sumq(2, 6) = 7 + 1 + 6 + 3 + 4 = 21minq(2, 6) = 1maxq(2, 6) = 7
Самый прямолинейный способ отвечать на такие запросы — каждый раз проходить цикл по нужному диапазону.
int sum(int a, int b) {
int s = 0;
for (int i = a; i <= b; i++) {
s += array[i];
}
return s;
}
Такой подход работает за O(n) на один запрос, где n — размер массива. Если запросов q, то в худшем случае получится O(nq). Когда и массив, и число запросов большие, этого уже недостаточно. Поэтому нужны более быстрые методы.
9.1 Статические запросы к массиву¶
Сначала рассмотрим случай, когда массив не меняется между запросами. Такой массив называют статическим.
Это важное упрощение: если значения не обновляются, то можно один раз построить вспомогательную структуру и затем быстро отвечать на любые запросы.
Запросы суммы¶
Для статического массива суммы на отрезках очень удобно считать с помощью массива префиксных сумм.
Пусть дан массив:
индекс: 0 1 2 3 4 5 6 7
значение: 4 1 3 9 2 6 5 2
Построим массив префиксных сумм. На позиции k в нём хранится сумма элементов от 0 до k включительно.
индекс: 0 1 2 3 4 5 6 7
префиксы: 4 5 8 17 19 25 30 32
Например:
- префикс на позиции
3равен4 + 1 + 3 + 9 = 17 - префикс на позиции
6равен4 + 1 + 3 + 9 + 2 + 6 + 5 = 30
Как отвечать на запрос суммы¶
Если мы умеем быстро находить сумму от начала массива до любой позиции, то сумму на произвольном отрезке [a, b] можно получить так:
Если считать, что
то формула корректно работает и для случая a = 0.
Пример¶
Найдём сумму на отрезке [3, 6] в нашем массиве:
индекс: 0 1 2 3 4 5 6 7
значение: 4 1 3 9 2 6 5 2
Непосредственно:
Через префиксные суммы:
Именно поэтому после предварительной обработки любой запрос суммы можно обрабатывать за O(1).
Построение префиксных сумм¶
Префиксный массив строится за O(n):
prefix[0] = array[0];
for (int i = 1; i < n; i++) {
prefix[i] = prefix[i-1] + array[i];
}
После этого запрос суммы на отрезке можно отвечать так:
int sum(int a, int b) {
if (a == 0) return prefix[b];
return prefix[b] - prefix[a-1];
}
Двумерный случай¶
Идея префиксных сумм обобщается и на двумерные массивы.
Если дана таблица чисел, можно построить двумерный массив префиксных сумм, который позволит за O(1) находить сумму в любом прямоугольнике.
Смысл тот же самый: в каждой клетке хранится сумма элементов от левого верхнего угла до текущей позиции.
Если обозначить четыре области так:
+---------+-----+
| D | B |
+---------+-----+
| C | A |
+---------+-----+
то сумма интересующего прямоугольника A выражается через префиксные суммы так:
Здесь идея полностью аналогична одномерному случаю: лишние области вычитаются, а пересечение возвращается обратно.
Запросы минимума¶
С запросами минимума всё сложнее. Для сумм сработала разность префиксов, а для минимумов такой трюк уже невозможен.
Тем не менее для статического массива есть эффективный способ:
- предварительная обработка за
O(n \log n) - ответ на каждый запрос минимума за
O(1)
Тот же подход подходит и для максимумов, поэтому достаточно разобрать только минимум.
Идея¶
Предварительно вычислим значения minq(a, b) только для отрезков, длина которых является степенью двойки.
Рассмотрим массив:
индекс: 0 1 2 3 4 5 6 7
значение: 5 2 6 7 1 4 3 8
Сначала вычислим минимумы на отрезках длины 1:
[0,0] -> 5
[1,1] -> 2
[2,2] -> 6
[3,3] -> 7
[4,4] -> 1
[5,5] -> 4
[6,6] -> 3
[7,7] -> 8
Потом длины 2:
[0,1] -> 2
[1,2] -> 2
[2,3] -> 6
[3,4] -> 1
[4,5] -> 1
[5,6] -> 3
[6,7] -> 3
Потом длины 4:
[0,3] -> 2
[1,4] -> 1
[2,5] -> 1
[3,6] -> 1
[4,7] -> 1
И наконец длины 8:
[0,7] -> 1
Всего таких значений будет O(n log n), потому что возможных длин-степеней двойки всего O(log n).
Как вычислять эти значения¶
Если длина отрезка равна степени двойки, то его можно разбить на две равные половины.
Пусть длина отрезка [a, b] равна 2w. Тогда:
То есть минимум на длинном отрезке получается как минимум из двух уже известных половин.
Как отвечать на любой запрос минимума¶
Пусть нам нужно найти минимум на отрезке [a, b].
Выберем максимальную степень двойки k, такую что
Тогда ответ можно получить как минимум из двух заранее посчитанных отрезков длины k:
Заметьте, что эти два отрезка могут пересекаться — это не проблема, потому что минимум от этого не меняется.
Пример¶
Возьмём тот же массив:
индекс: 0 1 2 3 4 5 6 7
значение: 5 2 6 7 1 4 3 8
Нужно найти минимум на отрезке [1, 6].
Длина отрезка равна 6, наибольшая степень двойки, не превосходящая 6, — это 4.
Значит, берём два отрезка длины 4:
[1, 4][3, 6]
Тогда
У нас уже заранее есть:
minq(1,4) = 1minq(3,6) = 1
Следовательно,
Почему это работает быстро¶
- Подготовка всех минимумов по степеням двойки занимает
O(n \log n). - Каждый запрос минимума использует только два заранее вычисленных значения.
- Поэтому ответ на запрос даётся за
O(1).
Это один из классических приёмов для статических массивов. Его часто называют sparse table.
Итоги¶
Для статических массивов можно сильно ускорить запросы на отрезках:
Суммы¶
- строим массив префиксных сумм за
O(n) - отвечаем на запрос за
O(1)
Минимумы и максимумы¶
- строим таблицу по отрезкам длины
2^kзаO(n \log n) - отвечаем на запрос за
O(1)
Главная идея проста:
- для суммы удобно накапливать информацию от начала массива;
- для минимума удобно заранее хранить ответы для специальных отрезков длины
2^k.
Эти методы очень полезны в задачах, где массив не меняется, а запросов много.
Небольшое замечание¶
Как только в задаче появляются изменения элементов массива, префиксных сумм и sparse table уже недостаточно. Тогда нужны более гибкие структуры данных, например:
- дерево Фенвика;
- дерево отрезков.