Дерево отрезков
Дерево отрезков (ДО) — это структура данных, которая позволяет эффективно (т.е. за асимптотику O (log n)) реализовать операции следующего вида:
- нахождение суммы/минимума элементов массива в заданном отрезке (a[l...r]
, где l
и r
поступают на вход алгоритма),
- изменение элементов массива: как изменение значения одного элемента, так и изменение элементов на целом подотрезке массива (т.е. разрешается присвоить всем элементам a[l...r]
какое-либо значение, либо прибавить ко всем элементам массива a[l...r]
какое-либо число).
ДО можно писать или на отрезках или на полуинтервалах,то есть вершина будет хранить какую-нибудь функцию на отрезке
[l;r]
,если писать на отрезках или на полуинтервале
[l;r)
, если писать
на полуинтервалах. Большой разницы нет, в этой статье объяснение будет на полуинтервалах.
Давайте разберем ДО на примере суммы на отрезке и прибавления на отрезке.
Разобьём начальный массив размера
n
на две примерно равные половинки:
[0;n/2)
и
[n/2;n)
. Данные половинки разобьём так же на половинки. И так пока не дойдем до размера 1. При этом считаем функцию на всех этих половинках, размера
n
,
n/2
,
n/4
и т.д.Так будет выглядеть дерево для массива
{1, -5, 6, 10}. Сверху вершины указаны границы ее поддерева, в самой вершине сумма.
Из рисунка видно, чтобы посчитать функцию в текущей вершине надо пересчитать через функции от детей. Например, сумма в текущей вершине – это сумма в левом ребенке + сумма в правом ребенке. Минимум в текущей вершине – это минимум из минимумов детей и т.п.
Так же есть небольшое неудобство, если размер массива это не степень двойки, тогда дерево не будет таким “идеальным”. Но на практике это никакого неудобства не приносит.
Теперь обратимся к коду и реализации такого дерева.
Давайте заведем структуру
Node
, в которой будем хранить границы ее поддерева, функцию на поддереве (например, сумму), ссылки на ее детей (как это реализовать будет чуть позже), и поле
add
,в котором будем хранить сколько нужно прибавить ко всем элементам поддерева (чуть позже объясняется зачем это нужно). В коде это выглядит примерно так. (Если кто не знает поле – это переменная в структуре).
struct Node {
Node* left;
Node* right;
int lb, rb, sum, add;
};
Поля
Node* left;
и
Node* right;
это указатели на левого и правого сыновей соответственно.Поля
int lb
,
int rb
границы вершины,
sum
– сумма на подмассива
[lb;rb)
, поле
add
объясняется чуть позже.
Но есть один вопрос: что делать с листами дерева, у них же нет сыновей? Следовательно придется проверять и пересчитывать функцию не через детей, а просто забивать элемент массива (так как границы у листа
[i;i+1)
). Давайте вместо этого создадим фиктивную вершину, в которой в поле результата функции будет какой-нибудь нейтральный элемент (для суммы это 0, для произведения 1, для минимума бесконечность).
Код
Node* Empty = new Node({ NULL, NULL, 0, 0, 0, 0 });
Кто не знает,
{}
означает конструктор, то есть, таким образом создается указатель на структуру у которой поля
left
и
right
равны
NULL
, поля
lb
,
rb
,
sum
,
add
равны
0
.
NULL
- указатель на пустоту.
Теперь, после создания самой вершины, давайте построим из них само дерево. Напишем функцию
build
, которая будет собственно строить саму структуру дерево, пока без подсчета функции.
Будем делать это следующим образом: будем рекурсивно передавать в функцию границы текущей вершины. Если наша текущая вершина лист,то давайте вернем новую вершину, границы в которой будут нашими текущими границами, сумма 0, поле
add
так же 0 (нам ничего пока что прибавлять не нужно), а дети-фиктивные вершины, то есть
Empty
. Если же текущая вершина не лист, то давайте запустим нашу функцию
build(l, mid)
и
build(mid, r)
, где
mid = (l + r) / 2
. Таким образом, мы построим сыновей текущей вершины. После построения вернем вершину, у которой левый ребенок будет результат выполнения
build(l, mid)
, правый ребенок результатом выполнения
build(mid, r)
, поля
lb
и
rb
нашими текущими вершинами, а поля
sum
и
add
так же 0.
Код
Node* build(int l, int r) {
if (l + 1 == r) { // проверка на то, что вершина - лист
return new Node ({ Empty, Empty, l, r, 0, 0});
}
int mid = (l + r) / 2;
Node* nleft = build(l, mid); // построение левого сына
Node* nright = build(mid, r); // построение правого сына
return new Node({ nleft, nright, l, r, 0, 0 });
}
Например, так будет выглядеть дерево при массиве размера 4. То есть мы запустим функцию
build(0, 4)
.
Теперь давайте посчитаем асимптотику функции
build
для массива размера
n
.
Как было описано ранее начальный массив мы поделим на 2 примерно равные части,затем эти части на еще 2 и так далее, пока не дойдем до размера 1. Сколько раз мы поделим отрезки на 2? Очевидно, что
log(n)
. Следовательно высота нашего дерева будет не больше
log(n)
, и на каждом уровне не больше
n
вершин, следовательно итоговая асимптотика
O(nlogn)
.
Теперь нам надо изменить наши значения в дереве. Можно это делать вручную, просто рекурсивно спускаться по дереву и когда дошли до листа, то присвоить ему элемент соответствующий ему в массиве, а когда будем возвращаться рекурсивно обратно, пересчитывать функцию в текущей вершине.
Код
// v - текущая вершина,
// ind - индекс элемента, к которому надо прибавить,
// d - что прибавить
void change(Node* v, int ind, int d){
if (v->rb <= ind || v->lb > ind)
{
// если мы зашли в вершину, в поддереве которой
// точно нет элемента с номером ind, то дальше идти нет смысла
return;
}
if (v->lb + 1 == v->rb)
{
// если дошли до листа, то прибавляем к его значению d
// и заканчиваем
v->sum += d;
return;
}
change(v->left, ind, d); // запуск от левого сына
change(v->right, ind, d); // запуск от правого сына
v->sum = v->left->sum + v->right->sum; // пересчет функций от детей
}
Асимптотика так же будет
log(n)
, так как высота не больше
log(n)
.
Давайте попробуем реализовать массовое прибавление на полуинтервале
[l;r)
за асимптотику
log(n)
.Тогда прибавление к одному элементу мы сможем делать как массовое прибавление на отрезке длины 1. Для этого нам и пригодится поле
add
. Будем делать следующее: нам пришел запрос прибавления на полуинтервале
[l;r)
числа
d
. Давайте не будем честно идти и прибавлять на всем полуинтервале число
d,
а будем просто запоминать, что мы должны прибавить. Поле
add
и будет отвечать за данное запоминание.
Для начала реализуем такую функцию, которая бы “проталкивала” информацию о прибавлении детям данной вершины. Так как, если нашей текущей вершине надо что-то прибавить, то и ее детям надо прибавить абсолютно тоже самое (это видно из структуры самого дерева). Так же в этой функции будем сразу пересчитывать функцию всего поддерева. Это делается довольно очевидно. Как изменится сумма на отрезке размера
size
, если ко всем его элементам прибавится одно и тоже число
d
? Логично, что прибавиться
d*size
. Если бы мы считали не сумму, то пересчет выводился бы в зависимости от функции. Например,если бы был минимум, то к минимуму бы просто прибавилось число
d
.
Код
void push(Node* v)
{
if (v == Empy)
{
// если мы зашли в фиктивную вершину,
// то "проталкивать" нам некуда,
// так что просто ничего не делаем
return;
}
v->sum += v->add * (v->rb - v->lb); // пересчет функции
v->left->add += v->add; // "проталкивание" информации в левого ребенка
v->right->add += v->add; // "проталкивание" в правого
v->add = 0; // мы уже обновили текущую вершину, так что помнить нам больше не нужно
}
Теперь приведу саму функцию прибавления на отрезке:
void change(Node* v, int l, int r, int d)
{
push(v); // проталкиваем информацию
if (v->lb >= r || v->rb <= l)
{
// если отрезки нашей вершины и отрезка
// пересекаются, то дальше нет смысла идти,
// так как ничего менять не надо
return;
}
if (l <= v->lb && r >= v->rb)
{
// если отрезок нашей вершины
// лежит в отрезке запроса, то нам надо поменять значения
// во всем поддереве
v->add += d;
push(v);
return;
}
// если не выполнились первые два условия, то
// надо спуститься ниже
change(v->left, l, r, d);
change(v->right, l, r, d);
if (v == Empty)
{
return;
}
v->sum = v->left->sum + v->right->sum; // пересчет функции от детей
}
Асимптотика данной функции
O(logn)
.