Введение
Многие знакомы с алгоритмами дерева отрезков и корневой декомпозицией. Однако, не многие задаются вопросом о том, почему они устроены именно так, как они устроенны, и нельзя ли немного изменив их получить выигрыш во времени работы или памяти. Одно из таких изменений я бы и хотел рассмотреть в этой статье.
Предыстория
Данная история мало конструктивна, нужна скорее для понимания того, откуда возникла идея для оптимизации. Подробно о самом алгоритме и написано ниже.
Относительно недавно, я задался вопросом, как объяснить ученикам, мотивацию структурной организации дерева отрезков. До этого мы рассматривали корневую декомпозицию, для которой подобный вопрос почти не стоит. Делим массив на блоки, обновляем элемент, обновляем значение блока, при подсчёте выигрываем на том. что часть ответа уже посчитана для некоторых блоков в отрезке.
В результате непродолжительных размышлений, было придумано следущее.
Дерево отрезков похоже на корневую декомпозицию, отличие только в том, что мы не разбиваем массив на множество блоков, а на два равных по длине блока. После спускаемся "внутрь" блоков и снова разбиваем надвое. Можно сказать, что получается своеобразная рекурсивная оптимизация.
А дальше в течение пары дней идея переросла в то, что мы можем делать корневые оптимизации с меньшим числом блоков и большей глубиной. Или даже взять дерево отрезков и на каждом уровне разбивать не на два блока, а на произвольное k.
Очевидный вопрос, а даст ли нам этот подход какой-нибудь бонус?
Чисто математически, кажется что ответ - нет. Так как
Стоит отметить, что лучшее k среди действительных чисел это e. (что вполне не сложно показать, используя знания первого курса матана). Но понятно, что на практике, как минимум из-за работы с памятью, может оказаться, что лучшее основание даже близко не лежит к 2 или e...
Постановка задачи
Пусть дан массив из n элементов, и есть два вида операций
обновление значения элемента на позиции i заданным числом new_value
найти максимум на отрезке от элемента с индексом L до элемента с индексом R
В дальнейшем будем для удобства называть дерево отрезков - ДО, корневую декомпозицию - корневой, а описанный выше подход РДО/SST (Разделённое дерево отрезков/Splitted Segment Tree).
Реализация
Поговорим о том, как написать описанную выше структуру. Будем использовать стандартную для дерева отрезков структуру, и алгоритм будем писать рекурсивно. Собственно вся разница между SST и ДО состоит в том, у каждой вершины, листов будет не два, а k потомков.
Собственно код:
struct SST {
int n;
int in_size;
vector<int> tree;
SST(int n, int cnt_split, int resize_value) { //init struct
this->n = n;
this->in_size = cnt_split;
this->tree.resize(resize_value, INT_MIN);
}
void
build(int v, int l, int r, vector<int> &array, int size) { //build struct with elements of array
if (l + 1 == r && l < (int)array.size()) {
this->tree[v] = array[l];
} else if (l < (int)array.size()) {
for (int i = 1; i <= this->in_size; ++i) {
build(this->in_size * v + i, //v
l + size * (i - 1), l + size * i, //l, r
array, size / this->in_size);
this->tree[v] = max(this->tree[v], this->tree[this->in_size * v + i]);
}
}
}
void
update_list(int v, int l, int r, int size, int position, int value) {
if (l + 1 == r) {
this->tree[v] = value;
} else {
int block = (position - l) / size + 1;
update_list(this->in_size * v + block, //v
l + size * (block - 1), l + size * block, //l, r
size / this->in_size, position, value);
this->tree[v] = max(this->tree[v], this->tree[this->in_size * v + block]);
}
}
void
update(int v, int l, int r, int size, int position, int value) {
if (l + 1 == r) {
this->tree[v] = value;
} else {
int block = (position - l) / size + 1;
update(this->in_size * v + block, //v
l + size * (block - 1), l + size * block, //l, r
size / this->in_size, position, value);
this->tree[v] = INT_MIN;
for (int i = 1; i <= this->in_size; ++i) {
this->tree[v] = max(this->tree[v], this->tree[this->in_size * v + i]);
}
}
}
int
get_max(int v, int l, int r, int size, int ql, int qr) {
if (r <= ql || qr <= l) {
return INT_MIN;
} else if (ql <= l && r <= qr) {
return this->tree[v];
} else {
int result = INT_MIN;
for (int i = 1; i <= this->in_size; i++) {
result = max(result,
get_max(this->in_size * v + i, //v
l + (i - 1) * size, l + i * size, //l, r
size / this->in_size, ql, qr));
}
return result;
}
}
};
Пояснять код я не буду, так как я уже отмечал, что это по сути обычное ДО, просто при пересчёте и вычислении ответа, в некоторых случаях, нужно запукаться не от двух сыновей, а от ~k. Так же не буду утверждать, что код написан оптимально (в частности это одна из причин по которой я публикую это, чтобы вы, читатели подсказали на возможные упущения сделанные в процессе анализа этого подхода).
Тестирование
Кроме анализа времени работы, так же следует смотреть на затрачиваемую память, так как это тоже немаловажный аспект.
Для тестирования были написаны решения задачи с использованием дерева отрезков, корневой оптимизации и SST. И скрипт на питоне, который работает с терминалом и анализирует время работы и память. Питон был выбран из-за наличия изученных библиотек для отрисовки графиков.
Весь код, а также данные полученные в результате 10 часового подсчёта на моём ноутбуке выложены тут.
О графиках, которые были построены в результате тестирования.
Каждый тест состаял из 1'200'000 запросов (рандомно сгенерированных единожды, в этом тоже может быть проблема, но если генерировать их каждый раз, то тестирование будет занимать в разы больше чем 10 часов, что я к сожалению не могу себе позволить), а так же из n элементов (n от 1000 до 10'000'000).
На первом графике изображена зависимость среднего времени работы по всем различным n в зависимости от выбранного k. Так как ДО и корневая не зависят от этого параметра, то они представляют собой константы.
На втором графике изображена зависимость времени работы от k на максимальном тесте, где n = 10'000'000.
На третьем графике можем увидеть зависимость затрачиваемой памяти от количества элементов.
На последнем, изображена зависимость "лучшего" k от количества элементов (использовалось взвешенное среднее по 5 быстрейшим k, чтобы минимизировать выбросы).
Итог
Как мы видим, подход SST имеет выигрыш по памяти (обычно не менее чем в 2 раза). А так же время работы аналогичное ДО, а иногда даже меньшее. И да, понятно, что подобный подход на практике, вероятно, не особо применим в чистом виде, но имеется явная возможность распараллелить вычисления на этом дереве, что требует дальнейшего изучения.
Тем не менее, это мой первый опыт в подобных исследованиях и мной могли быть допущены ошибки, если таковые имеются, буду рад услышать/прочитать ваше мнение и советы.
P.S.
В дополнение ко всему скажу, что теоретически можно для каждой вершины выбирать собственное разбиение, то есть условно для корневой вершины мы рассматриваем 10 потомков (10 блоков), а для какого-нибудь из её сыновей 5 или другое число. То есть число потомков для вершины это некоторая функция f(n), где n - количество элементов за которое отвечает текущая вершина. Сейчас собственно занимаюсь поиском/исследованием этой функции.
Так же если у вас возникают вопросы почти любого характера начиная от неясностей в работе алгоритма до совета, как и где узнать/почитать/послушать про алгоритмы в спортивном программировании, или же вы видите ошибки допущенные мной в процессе анализа алгоритма, буду рад, если вы об этом напишете.
Комментарии (4)
DanShaders
07.11.2022 13:25+1В моих экспериментах (несколько лет назад) никогда не получалось заставить работать подобную структуру быстрее аккуратно написанного "китайского дерева отрезков" (https://codeforces.com/blog/entry/18051), более того, есть версии ДО, которые ещё сильнее оптимизированы с помощью SIMD инструкций (https://codeforces.com/blog/entry/89399).
AndrewOvvv Автор
07.11.2022 13:39Ещё не смотрел ссылку которую во приложили, но по контексту кажется, что вы ссылаетесь на обычное дерево отрезков.
Но всё равно будет интересно глянуть, особенно про simd иструкции.
upd: глянул первую ссылку, кажется это обычное ДО, но просто написано нерекурсивно, могу конечно ошибаться
TheCalligrapher
Ым... Может быть надо бы расширить немного вводную часть, чтобы было понятно, о чем идет речь?
"Многие знакомы с алгоритмами дерева отрезков"? Да, многие действительно знакомы с весьма распространенной и полезной хрестоматийной структурой данных - деревом отрезков (aka interval tree) - и сопутствующими алгоритмами, использующимися для решения таких задач как эффективное обнаружение перекрывающихся интервалов, попадания точки в набор интервалов и т.п.
Какое отношение ко всему этому имеют ваши рассуждения - тайна за семью печатями. "Дерево отрезков похоже на корневую декомпозицию"... Оок....
"Собственно код на С (подключенным для удобства STL)"? Чего? STL в С?
AndrewOvvv Автор
По поводу первых двух абзацев. Подразумевается. что вы понимаете, что такое дерево отрезков, кроме того на статье стоит тег "спортивное программирование", то есть подразумевается то, что нужно/можно погуглить "дерево отрезков спортивное программирование", если у вас возникли сомнения... Кроме того, буквально после введения, которое нужно чисто для понимания того, откуда идея возникла, идёт прямая постановка задачи, которую я решаю, что сужает круг "поиска" до 1 конкретного алгоритма.
Про "Дерево отрезков похоже на корневую декомпозицию", да, это некоторое не совсем корректное сравнение, которое мне показалось забавным, поэтому оно и находится в "неформальной части" статьи, но могу добавить "рекурсивная корневая" чтобы было понятнее, что я имею ввиду.
Последнее поправил, хотя кажется понятно что я подразумаевал под этим, просто неаккуратно выразился.
Кроме того, я считаю ваш ответ достаточно агрессивным, не очень понимаю чем это вызвано, возможно, я ошибаюсь(
P.S. по итогу я не очень понял что именно мне следует добавить в введение, опять же статья расчитана на людей которые сталкивались и активно использовали дерево отрзков (https://neerc.ifmo.ru/wiki/index.php?title=Дерево_отрезков._Построение)
upd: если вбить запрос "дерево отрезков", то первые пять ссылок будут о нужном в статье дереве отрезков, так что претензия что "непонятно что за дерево" мне кажется вообще не справедливой