Структура данных дерево отрезков и её применение в задачах. Дерево отрезков
- Структура данных дерево отрезков и её применение в задачах. Дерево отрезков
- Дерево отрезков на указателях. Полное двоичное дерево
- Дерево отрезков с++. Дерево отрезков
- Дерево отрезков алгоритмика. Ок, как это нам поможет?
- Дерево отрезков снизу. Дерево отрезков. Построение
- Массовые операции дерево отрезков. Дерево отрезков для поиска суммы
- Персистентное дерево отрезков. Метод копирование пути
- Китайское дерево отрезков. Предыстория
Структура данных дерево отрезков и её применение в задачах. Дерево отрезков
1. Постановка задачи
Рассмотрим следующую задачу. Дано $n$ ящиков, пронумерованных числами от $1$ до $n$, в каждом из которых лежит несколько шариков. Известно, что $n$ достаточно велико. Нам нужно уметь быстро выполнять следующие операции:
- $add(c, w)$ - изменить количество шариков в ящике $c$, прибавив к нему $w$ шариков ($w$ может быть отрицательным);
- $sum(a, b)$ - посчитать количество шариков в нескольких ящиках, расположенных по порядку с номерами от $a$ до $b$, то есть на отрезке $$.
Мы можем просто завести массив, в котором будем хранить число шариков в ящиках. Почему эта идея может оказаться неоптимальной? При изменении числа шариков в ящике мы меняем значение элемента массива с номером $c$, что выполняется за $O(1)$. Но чтобы посчитать сумму в нескольких идущих подряд ящиках, нам нужно будет сложить $b - a + 1$ чисел. Поскольку длина отрезка может быть почти $n$, то выполняется порядка $n$ сложений, то есть операция ${\it sum}$ выполняется за $O(n)$. При достаточно большом числе запросов суммы на отрезке решение задачи перебором является далеко не лучшим.
2. Корневая оптимизация
Первая идея, которая обычно приходит в голову, это разделить $n$ ящиков на $x$ групп таким образом, что первые несколько ящиков по порядку попадают в первую группу, следующие - во вторую, и так далее. Далее заведем дополнительный массив из $x$ элементов, где будет храниться количество шариков в каждой группе. Тогда при выполнении операции ${\it add}$ нам нужно будет выполнить два действия: изменить значение в соответствующей группе и в самом элементе. Зато операция ${\it sum}$ потребует меньших затрат: нужно сложить элементы группы, которой принадлежит число $a$ (обозначим ее $A$), начиная с элемента с номером $a$ и кончая последним, элементы группы, которой принадлежит число $b$ (обозначим ее $B$), начиная с первого и кончая элементом с номером $b$, что требует временных затрат порядка $O(\frac{n}{x})$ и прибавить к ним суммы элементов в группах, начиная с группы, следующей за $A$ и заканчивая группой, расположенной перед группой $B$, что требует временных затрат порядка $O(x)$. Всего нужно выполнить примерно $\frac{n}{x} + x$ операций. Если мы подберем число $x$ оптимальным образом, то эта идея называется корневой оптимизацией. Чтобы ответить на вопрос, какое $x$ является наилучшим, нужно устремить к минимуму выражение $\frac{n}{x} + x$. Возьмем от него производную по $x$ и приравняем ее к нулю: $-\frac{n}{x^{2}}+1=0$. Отсюда получаем $x=\sqrt{n}$. Таким образом, оптимальное количество групп, на которые нужно разбить ящики: $$. Мы добились оценки времени выполнения операции ${\it sum}$ $O(\sqrt{n})$, правда, увеличился расход по памяти, но всего лищь на $\sqrt{n}$ - число дополнительных ящиков для хранения сумм элементов в группах.
Дерево отрезков на указателях. Полное двоичное дерево
Полное двоичное дерево — это дерево, у каждого элемента которого есть ровно два дочерних элемента.Для работы с полным двоичным деревом можно и нужно использовать такую структуру данных, как массив. Нумеровать этот массив удобно с единицы . Пронумеруем каждый элемент двоичного дерева натуральными числами от 1 до 2n-1:Вся красота подхода в том, что очень легко вычислять номер детей и родителей.Формула вычисления «левого ребёнка»: i*2 , «правого»: i*2+1 .Например, вычислим номера детей у пятого элемента:
- 5 х 2 = 10 ;
- 5 х 2 + 1 = 11 .
- с какого элемента необходимо разместить исходные числа в массиве полного двоичного дерева?
- с какого элемента и в какую сторону мы начнём заполнять наше дерево предварительно рассчитанными суммами?
Дерево отрезков с++. Дерево отрезков
Метки нет()
Добрый день, помогите пож-та решить задачи на с++. Нашел решение (расписаны все алгоритмы, процедуры подсчета и т. д.), но сложность состоит в том, что я не понимаю строищихся структур и вообще никогда не программировал на c++.Поэтому прошу помочь собрать все воедино (чтение из файла, работа программы, запись в файл). Основная задача - считать с файла, воспользоваться функцией, вывести в фай лДан массив целых чисел. Поступают запросы модификации элемента и поиска суммы элементов на отрезке. Необходимо ответить на каждый запрос суммы.Вход:В первой строке через пробел записаны два натуральных числа N и M (N, M . Онбудет содержать сумму элементов A, т.е. всего массива. Левый сын корня будет храниться в элементе T и содержать сумму первой половины массива A: A, а правый сын - в элементе T и содержать сумму элементов AДерево отрезков алгоритмика. Ок, как это нам поможет?
Опишем, как с помощью такой структуры решить исходную задачу.
Запрос обновления . Нам нужно обновить значения в вершинах таким образом, чтобы они соответствовали новому значению $a
Изменим все вершины, в суммах которых участвует $k$-тый элемент. Их будет $\Theta(\log n)$ — по одной с каждого уровня.
Это можно реализовать как рекурсивную функцию: ей передаётся текущая вершина дерева отрезков, и функция выполняет рекурсивный вызов от одного из двух своих сыновей (от того, который содержит $k$-ый элемент в своём отрезке), а после этого — пересчитывает значение суммы в текущей вершине точно таким же образом, как мы это делали при построении дерева отрезков.
Запрос суммы . Мы знаем, что во всех вершинах лежат корректные значения, и нам с помощью них посчитать сумму на отрезке.
Сделаем тоже рекурсивную функцию, рассмотрев три случая:
- Если отрезок вершины лежит целиком в отрезке запроса, то вернуть записанную в ней сумму.
- Если отрезки вершины и запроса не пересекаются, то вернуть 0.
- Иначе разделиться рекурсивно на 2 половины и вернуть сумму этой функции от обоих детей.
Чтобы разобраться, почему это работает за $O(\log n)$, нужно оценить количество «интересных» отрезков — тех, которые порождают новые вызовы рекурсии. Это будут только те, которые содержат границу запросов — остальные сразу завершатся. Обе границы отрезка содержатся в $O(\log n)$ отрезках, а значит и итоговая асимптотика будет такая же.
Дерево отрезков можно использовать для гораздо большего, чем только для суммы.
Далее этой главе мы рассмотрим разные реализации и варианты этой структуры и их применения.
Дерево отрезков снизу. Дерево отрезков. Построение
Дерево отрезков (англ. Segment tree ) — это структура данных, которая позволяет за асимптотику реализовать любые операции, определяемые на множестве, на котором данная операция ассоциативна, и существует нейтральный элемент относительно этой операции, то есть на моноиде . Например, суммирование на множестве натуральных чисел, поиск минимума на любом числовом множестве, перемножение матриц на множестве матриц размера , объединение множеств, поиск наибольшего общего делителя на множестве целых чисел и многочленов.
При этом дополнительно возможно изменение элементов массива: как изменение значения одного элемента, так и изменение элементов на целом подотрезке массива , например разрешается присвоить всем элементам какое-либо значение, либо прибавить ко всем элементам массива какое-либо число. Структура занимает памяти, а ее построение требует времени.
Структура
Структура представляет собой дерево, листьями которого являются элементы исходного массива. Другие вершины этого дерева имеют по ребенка и содержат результат операции от своих детей (например минимум или сумму). Таким образом, корень содержит результат искомой функции от всего массива , левый ребёнок корня содержит результат функции на , а правый, соответственно результат на . И так далее, продвигаясь вглубь дерева.
Построение дерева
Пусть исходный массив состоит из элементов. Для удобства построения увеличим длину массива так, чтобы она равнялась ближайшей степени двойки, т.е. , где . Это сделано, для того чтобы не допустить обращение к несуществующим элементам массива при дальнейшем процессе построения. Пустые элементы необходимо заполнить нейтральными элементами моноида. Тогда для хранения дерева отрезков понадобится массив из элементов, поскольку в худшем случае количество вершин в дереве можно оценить суммой , где . Таким образом, структура занимает линейную память.
Процесс построения дерева заключается в заполнении массива . Заполним этот массив таким образом, чтобы -й элемент являлся бы результатом некоторой бинарной операции (для каждой конкретной задачи своей) от элементов c номерами и , то есть родитель являлся результатом бинарной операции от своих сыновей (обозначим в коде эту операцию как " "). Один из вариантов — делать рекурсивно. Пусть у нас имеются исходный массив , а также переменные и , обозначающие границы текущего полуинтервала. Запускаем процедуру построения от корня дерева отрезков ( , , ), а сама процедура построения, если её вызвали не от листа, вызывает себя от каждого из двух сыновей и суммирует вычисленные значения, а если её вызвали от листа — то просто записывает в себя значение этого элемента массива (Для этого у нас есть исходный массив ). Асимптотика построения дерева отрезков составит, таким образом, .
Выделяют два основных способа построения дерева отрезков: построение снизу и построение сверху. При построении снизу алгоритм поднимается от листьев к корню (Просто начинаем заполнять элементы массива от большего индекса к меньшему, таким образом при заполнении элемента его дети и уже будут заполнены, и мы с легкостью посчитаем бинарную операцию от них), а при построении сверху спускается от корня к листьям. Особенные изменения появляются в реализации запросов к таким деревьям отрезков.
Массовые операции дерево отрезков. Дерево отрезков для поиска суммы
Одна из простейших функций, которую можно считать за \(O(\log N)\) с помощью дерева отрезков - сумма.
При построении дерева в каждой его вершине сохраним сумму на соответствующем отрезке (построение будет вестись рекурсивно, поэтому достаточно просто сложить суммы на двух дочерних отрезках). Затем каждый запрос суммы на отрезке будем разбивать на \(2 \log N\) отрезков, и находить сумму на каждом из них за \(O(1)\), используя предпросчитанные значения. Таким образом сложность запроса суммы равна \(O(\log N)\).
Кроме запросов суммы к дереву отрезков также могут поступать запросы на изменение отдельных элементов (модификацию). Заметим, что от изменения одного элемента значение суммы изменится в одной вершине на каждом уровне дерева отрезков (в которую входит этот элемент). Пересчитаем значения суммы (опять же, рекурсивно) только в этих вершинах. Таким образом сложность запроса модификации равна высоте дерева, или \(O(\log N)\).
Для реализации запроса суммы и запроса модификации обход дерева реализуется
с помощью одного и того же алгоритма, основанного на DFS. Пусть границы нашего
запроса \(
Текущий отрезок полностью входит в запрос (\(L \le TL; TR \le R\)).
Если это запрос суммы, вернём предпросчитанную сумму на отрезке. Если это запрос модификации, изменим элемент и пересчитаем сумму. В дочерние вершины спускаться нет надобности.
Текущий отрезок полностью не входит в запрос (\(TR
Никаких действий выполнять не нужно, просто выйдем из функции. Если это запрос суммы, просто вернём \(0\).
Текущий отрезок частично входит в запрос.
Вызовем функцию рекурсивно для двух дочерних отрезков. Если это запрос суммы, вернём сумму двух полученных значений. Если это запрос модификации, пересчитаем значение суммы для текущего отрезка (так как оно могло измениться).
Персистентное дерево отрезков. Метод копирование пути
Пусть есть сбалансированное дерево поиска . Все операции в нем делаются за , где — высота дерева, а высота дерева , где — количество вершин. Пусть необходимо сделать какое-то обновление в этом сбалансированном дереве, например, добавить очередной элемент, но при этом нужно не потерять старое дерево. Возьмем узел, в который нужно добавить нового ребенка. Вместо того чтобы добавлять нового ребенка, скопируем этот узел, к копии добавим нового ребенка, также скопируем все узлы вплоть до корня, из которых достижим первый скопированный узел вместе со всеми указателями. Все вершины, из которых измененный узел не достижим, мы не трогаем. Количество новых узлов всегда будет порядка логарифма. В результате имеем доступ к обеим версиям дерева.
Так как рассматривается сбалансированное дерево поиска, то поднимая вершину вверх при балансировке, нужно делать копии всех вершин, участвующих во вращениях, у которых изменились ссылки на детей. Таких всегда не более трех, поэтому ассимптотика не пострадает. Когда балансировка закончится, нужно дойти вверх до корня, делая копии вершин на пути.
Этот метод хорошо работает на стеке , двоичных ( декартовых , красно-черных ) деревьях. Но в случае преобразования очереди в персистентную операция добавления будет очень дорогой, так как элемент добавляется в хвост очереди, который достижим из всех остальных элементов. Также не выгодно применять этот метод и в случае, когда в структуре данных имеются ссылки на родителя.
Реализация на основе дерева отрезков
На основе дерева отрезков можно построить полностью персистентную структуру данных.
Для реализации персистентного дерева отрезков удобно несколько изменить структуру дерева. Для этого будем использовать явные указатели и для дочерних элементов. Кроме того, заведем массив , в котором указывает на корень дерева отрезков версии
Для построения персистентного дерева отрезков из элементов необходимо применить раз операцию добавления элемента к последней версии дерева. Для того, чтобы добавить новый элемент к -ой версии дерева, необходимо проверить, является ли оно полным бинарным. Если да, то создадим новый корень, левым сыном сделаем . Иначе, сделаем копию корня исходной версии. Добавим корень в конец массива корней. Далее, спускаясь от корня к первому свободному листу, будем создавать несуществующие узлы и клонировать существующие. После этого в новой ветке необходимо обновить значение функции и некоторые указатели дочерних элементов. Поэтому, возвращаясь из рекурсии, будем менять один указатель на только что созданную или скопированную вершину, а также обновим значение функции, для которой строилось дерево. После этой операции в дереве появится новая версия, содержащая вставленный элемент.
Для того, чтобы изменить элемент в персистентном дереве отрезков, необходимо сделать следующие действия: спустимся в дереве от корня нужной версии до требуемого элемента, скопируем его, изменим значение, и, поднимаясь по дереву, будем клонировать узлы. При этом необходимо менять указатель на одного из детей на узел, созданный при предыдущем клонировании. После копирования корня, добавим новый корень в конец массива корней.
Здесь изображено персистентное дерево отрезков с операцией минимум, в котором изначально было 3 вершины. Сперва к нему была добавлена вершина со значением 2, а потом изменена вершина со значением 7. Цвет ребер и вершин соответствует времени их появления. Синий цвет элементов означает, что они были изначально, зеленый - что они появились после добавления, а оранжевый - что они появились после изменения элемента.
Китайское дерево отрезков. Предыстория
Данная история мало конструктивна, нужна скорее для понимания того, откуда возникла идея для оптимизации. Подробно о самом алгоритме и написано ниже.
Относительно недавно, я задался вопросом, как объяснить ученикам, мотивацию структурной организации дерева отрезков. До этого мы рассматривали корневую декомпозицию, для которой подобный вопрос почти не стоит. Делим массив на блоки, обновляем элемент, обновляем значение блока, при подсчёте выигрываем на том. что часть ответа уже посчитана для некоторых блоков в отрезке.
В результате непродолжительных размышлений, было придумано следущее.
Дерево отрезков похоже на корневую декомпозицию, отличие только в том, что мы не разбиваем массив на множество блоков, а на два равных по длине блока. После спускаемся "внутрь" блоков и снова разбиваем надвое. Можно сказать, что получается своеобразная рекурсивная оптимизация.
А дальше в течение пары дней идея переросла в то, что мы можем делать корневые оптимизации с меньшим числом блоков и большей глубиной. Или даже взять дерево отрезков и на каждом уровне разбивать не на два блока, а на произвольное k.
Очевидный вопрос, а даст ли нам этот подход какой-нибудь бонус?