Блог пользователя teraqqq

Автор teraqqq, история, 2 года назад, По-русски

Всем привет! Хочу рассказать о достаточно простом приеме (не знаю как называется) для оптимизации динамики. Очень часто бывает так, что при пересчете нам надо искать минимум в какой-нибудь уже посчитанной таблице, но при этом игнорируя некоторое конечное количество строк и столбцов; как оптимизировать такое и подобное, описано в блоге.

Постановка задачи

Итак, у нас есть таблица, предположим, что большая — $$$N \times N$$$, и мы хотим сделать какое-то большое количество запросов вида «Найди максимальное значение в таблице, которое не лежит в столбцах из множества $$$C$$$ и строках из множества $$$R$$$», при этом для каждого запроса гарантируется, что $$$max ( |C|, |R| ) \leq K$$$, где $$$K$$$ -- какая-то очень маленькая константа. Эту задачу мы бы могли решать, например, с помощью двумерного ДО -- но это работает долго и занимает гораздо больше памяти.

Решается задача просто, давайте для начала, найдем $$$x_1$$$ — глобальный максимум в таблице и запомним его позицию $$$(i_1, j_1)$$$. Далее, при дальнейших запросах, возможен вариант, что либо $$$i_1 \in C$$$, либо $$$j_1 \in R$$$, поэтому давайте запустим два рекурсивных вызова, с поиском максимума в таблице, в одном из них запретив строку $$$i_1$$$, а в другом -- столбец $$$j_1$$$. Таким образом каждая рекурсивная функция считает максимум, в случае, если мы запрещаем столбцы $$$C'$$$ и строки $$$R'$$$. Заметим, что при $$$C' > k$$$ и $$$R' > k$$$ значения нам считать не надо, так что можно смело обрывать рекурсию.

реализация

Чтобы в таком случае искать максимум можно просто пройтись по кандидатам $$$O(C_{2K}^K)$$$ или используя дерево рекурсивных вызовов пройтись по нему и найти максимум за $$$O(K)$$$, во всяком случае, вы понимаете, что это работает быстро. Да, кстати, по очевидным причинам алгоритм построения этой таблицы работает за $$$O(N^2 \cdot C_{2K}^K)$$$, и алгоритм является корректным. Почему? Ну потому что если вдруг глобальный максимум не будет запрещен по обоим кандидатам, то очевидно, это будет ответ на запрос, а в ином случае, если запрещена строка, в которой он находится, то в одном из рекурсивных вызовов мы посчитали другого кандидата и так далее.

Почему это бывает полезно?

Приведу пример задачи на эту тему. У нас есть дерево размером $$$N$$$, в каждой вершине которого записана буква от $$$a$$$ до $$$z$$$, назовем строку хорошей, если ни одна его подстрока длиной больше $$$1$$$ не является палиндромом. Требуется найти максимальную подпоследовательность, какого-то простого пути в дереве, такую, что если записать символы в вершинах этой последовательности, то получится хорошая строка.

Докинем в афлавит фиктивный символ $$$\epsilon$$$. За $$$\Sigma$$$ обозначим размер алфавита, в данном случае $$$\Sigma = 26 + 1$$$. Заведем трехмерную динамику $$$dp$$$ с размерностью $$$N \times \Sigma \times \Sigma$$$, в $$$dp[v][i][j]$$$ будем хранить максимальную подпоследовательность, пути идущего сверху-вниз до вершины $$$v$$$, два последних символа которой равны $$$i$$$ и $$$j$$$, $$$i=j=\epsilon$$$ значит, что строка пустая, $$$i=\epsilon$$$ значит, что строка содержит ровно один символ. Динамика очень легко пересчитывается и это суммарно отработает за $$$O(N \cdot \Sigma ^ 2)$$$. Чтобы посчитать ответ на задачу требуется перебрать LCA двух конечных вершин пути и с помощью посчитанных значений динамики найти максимальное значение $$$dp'[u][x][y] + dp[v][z][w]$$$, где $$$dp'[u]$$$ обозначает динамику $$$dp$$$, посчитанную для путей исходящих из поддерева вершины $$$u$$$ и заканчивающих свой путь в предке $$$u$$$, при этом $$$u$$$ и $$$v$$$ -- братья и при этом $$$y \neq z$$$, $$$y \neq w$$$, $$$x \neq z$$$. При этом такой максимум уже можно найти за $$$O(N{\Sigma}^4)$$$, это медленно.. Можно постараться и найти его за $$$O(N{\Sigma}^3)$$$. Но с помощью структуры данных, описанных выше это можно сделать тривиальным образом за $$$O(N{\Sigma}^2)$$$.

Кстати, с помощью нашей структуры данных можно пойти гораздо дальше. Можно сказать, что все состояния динамики нам совершенно не нужны. Давайте вместо трехмерной динамики $$$dp$$$ построим динамику $$$dc$$$ размера $$$N$$$, основанную на CandidateSet, в котором есть не более одного запрета $$$x$$$ и не более двух запретов на $$$y$$$, для каждого состояния исходной динамики. Пересчитывать ее очень просто и приятно -- это работает за $$$O(N)$$$, единственная вещь, которую я не описал -- это объединение множества кандидатов и его обрезание, но я верю в то, что вы и сами справитесь :D. Утверждается, что глобальный максимум по $$$dp'[u][x][y] + dp[v][z][w]$$$ достигается и среди сумм $$$dc'[u][x][y] + dc[v][x][y]$$$, определенных по такому же принципу. Доказать это очень просто, пусть это останется как упражнение читателю, но с помощью этого простого трюка наше решение превращается в гордый $$$O(N)$$$ с большой константой. Ура!

пруф

Полный текст и комментарии »

  • Проголосовать: нравится
  • +51
  • Проголосовать: не нравится

Автор teraqqq, история, 3 года назад, По-русски

Итак, многие из Вас знают, что можно легко и просто хешировать множества. Например, множество $$$s = { s_1, s_2, ..., s_n }$$$ можно захешировать числом $$$x(s_1) + x(s_2) + ... + x(s_n)$$$ или даже $$$x(s_1) \oplus ... \oplus x(s_n)$$$ (где $$$x$$$ — какая-то случайная функция). С такими хешом можно поддерживать операции добавления и удаления элемента, слияния множеств, нахождения симметричной разности множеств и так далее. Но у такого подхода есть и достаточно серьезный минус, например, имея на руках только хеш мы не можем понять, какие элементы содержит это множество.

Сегодня я хочу поведать о не очень сложной структуре данных, основанной на дереве отрезков, которая позволяет совершать различные операции с множествами, в частности, сравнивать эти множества за время O(1), надеюсь, после прочтения вы поделитесь своими идеями, как ее можно улучшить и какие еще операции с ней можно делать. :D

Итак, для начала рассмотрим следующую задачу. В первой строке даны числа $$$N,Q \leq 10^5$$$, отвечая на запросы нам требуется поддерживать $$$N$$$ множеств, пронумерованные числами от 1 до $$$N$$$. В последующих $$$Q$$$ строках указаны запросы, каждый из которых может иметь следующий вид:

  • $$$1$$$ $$$i$$$ $$$x$$$ — добавить в $$$i$$$-ое множество элемент $$$1 \leq x \leq N$$$, если $$$x$$$ уже содержится в этом множестве, то ничего делать не надо

  • $$$2$$$ $$$i$$$ $$$x$$$ — удалить элемент $$$1 \leq x \leq N$$$ из $$$i$$$-ого множества, аналогично, если элемента в множестве нет, то множество не изменяется

  • $$$3$$$ $$$i$$$ $$$j$$$ — скопировать множество $$$j$$$ в множество с номером $$$i$$$.

Да, эту задачу можно решать теми же хешами и персистентными декартовыми деревьями, но это слишком сложно. На самом деле задача намного проще чем это кажется на первый взгляд. Давайте заведем дерево отрезков на $$$N$$$ элементов, где каждой вершине будет присвоен некоторый класс $$$h$$$, который будет обладать следующими несложными свойствами:

  • $$$h_v = 0$$$ тогда и только тогда, когда нет ни одного числа, за которое ответственно поддерево вершины $$$v$$$.

  • $$$h_v = 1$$$ тогда и только тогда, когда $$$v$$$ — лист, и число, соответствующее этой вершине содержится в множестве.

  • в ином случае $$$h_v$$$ однозначно определяется классами левого и правого ребенка вершины $$$v$$$ и наоборот.

Понятно, что заранее мы никак не можем определить классы на все случаи жизни, так что давайте постепенно будем их добавлять по мере надобности, для этого заведем функцию vertex(i, j), которая по классам i и j будет определять класс вершины, являющейся родителем этих вершин:

Реализация функции vertex

Дело остается за малым, давайте напишем стандартное дерево отрезков и будет нам счастье! Но, к сожалению все не так просто, если мы будем честно поддерживать дерево отрезков то на поддержку $$$N$$$ деревьев нам понадобится $$$O(N^2)$$$ памяти, вместо этого давайте просто поддерживать класс, к которому относится корень нашего дерева отрезков, но уже, кстати, неявного, ведь по классу вершины можно однозначно восстановить классы ее сыновей. Запросы добавления и удаления ограничиваются спуском по дереву и аккуратным пересчетом классов вершин, код, поясняющий данную идею:

Коды функций add и del

Данные функции возвращают класс полученного множества целиком, для того, чтобы сравнить два множества требуется просто сравнить их классы, которые по совместительству и являются идентификаторами наших множеств. Кстати, это весь код решения, исключая ввод и вывод данных. Функции del и add работают за $$$O({\log}^2 n)$$$ или за $$$O(\log n)$$$ если в функции vertex использовать unordered_map, так же возможны константные оптимизации.

Теперь предоставлю упражнения читателям, то есть те вещи, которые умеет делать эта структура, но на которых мне не хочется подробно останавливаться:

  • Найти минимальный элемент принадлежащий ровно одному множеству из двух множеств $$$a$$$ и $$$b$$$ (просто спуск по дереву отрезков).

  • Объединение двух множеств (при условии, что нет операции клонирования).

  • Удаление всех элементов множества, меньших чем $$$x$$$, а так же больших чем $$$x$$$.

  • Всевозможные суммы, lower_bound и почти все, что умеет обычное ДО.

К тому же, если элементы, хранимые в множестве это, например, не числа, или слишком большие числа, то использование дерева отрезков может быть невозможно, для таких целей можно аналогичным образом хешировать и декартовы деревья. Но тогда по значению ключей, хранящихся в декартовом дереве должна однозначно восстанавливаться структура дерева, добиться этого можно, если значением приоритета вершина будет какая-то функция, зависящая от ключа в вершине (можно, например, просто сохранять приоритет для ключей в unordered_map, хотя, как кажется это немного неадекватно). При совпадении ключа надо считать, что приоритет выше у той вершины, у которой больше соответствующий ключ. Тогда все операции будут выполнимы за $$$O(\log n)$$$ с использованием $$$O(n \log n)$$$ памяти.

Данное рассуждение можно распространить и для хеширования персистентных стеков (обычные стеки всегда можно заменить на персистентные без особых потерь). Тогда хеш пустого стека примем равным $$$0$$$, а хеш стека $$$S$$$ будет взаимнооднозначно определяться хешем стека $$$S$$$ без верхнего элемента и верхним элементом стека $$$S$$$. Условно говоря, это другой взгляд на хранение персистентного стека с помощью дерева.

Теперь перейдем к задачам, которые можно решить с помощью этой структуры данных или описанных выше идей.

(Задача 1). Пусть дано подвешенное дерево на $$$N \leq 10^5$$$ вершинах, где в каждой в каждой вершине $$$v$$$ написано какое-то натуральное число $$$A_v \in { 1, 2, ... , N }$$$. Рассмотрим все вертикальные пути (простой путь у которого один конец это предок другого конца) длины $$$K$$$, для каждого пути посчитаем множество чисел, записанных на вершинах этого пути. Требуется вывести количество различных множеств, которые мы можем определить.

(Задача 2). Есть страна, состоящая из $$$N \leq 10^5$$$ городов, далее с ней по очереди происходят $$$Q$$$ действий, каждое из которых относится к одному из следующих типов:

  1. Добавляется ребро $$$(u, v)$$$.
  2. В город $$$v$$$ приезжает ученый, который занимается наукой с порядковым номером $$$1 \leq x \leq 10^6$$$.
  3. Запрос вида $$$(u, v, R)$$$, на который требуется ответить минимальным натуральным числом $$$L$$$ таким, что, область науки $$$L \leq x \leq R$$$ развита в компоненте связности вершины $$$u$$$ тогда и только тогда, когда он развита в компоненте $$$v$$$. Область науки развита в компоненте связности тогда и только тогда, когда хотя бы в одном городе из этой компоненты есть ученый, занятый этой областью.
  4. По приказу государства этой страны все ученые из компоненты вершины $$$v$$$, занимающиеся науками из отрезка $$$[L,R]$$$ переезжают в компоненту $$$u$$$.

(Задача 3). Есть подвешенное дерево, на каждом ребре которого записана какая-нибудь латинская буква. Скажем, что корню соответствует пустая строка $$$s(v_0) = \epsilon$$$, а вершине $$$v$$$, из которой ведет ребро с символом $$$w$$$ в ее предка $$$p$$$, сопоставим строку $$$s(v) = w \cdot s(p)$$$. Требуется для любой пары вершин $$$(u,v)$$$ уметь лексикографически сравнивать строки $$$s(u)$$$ и $$$s(v)$$$ за время $$$O(\log N)$$$ и c предпосчетом за $$$O(N \log^2 N)$$$ без использования рандомизированных алгоритмов.

(Задача 4). Даны $$$N$$$ чисел $$$a_1, a_2, \ldots, a_N$$$ в двоичной системе счисления суммарной длиной $$$M$$$. Требуется уметь сравнивать на больше/меньше $$$(a_i \mod 2^x)$$$ с $$$(a_j \mod 2^y)$$$ за время $$$O(1)$$$ и с предпосчетом за время $$$O(M \log N)$$$.

По-хорошему, все эти идеи описывают несколько извращенную персистентность и хеширование деревьев. При достаточной степени развязности сексуальных предпочтений можно так же понять, какой смысл имеет такое хеширование при рассмотрении ориентированных графов, как таким образом можно научиться "хешировать" деки и, например, что метод построения суффиксного массива за $$$O(N \log N)$$$ тесно связан с этой идеей.

Полный текст и комментарии »

  • Проголосовать: нравится
  • +61
  • Проголосовать: не нравится

Автор teraqqq, история, 5 лет назад, По-русски

Доброго времени суток! Началось все с того, что после прочтения статьи про встречное дерево Фенвика, я начал искать в интернете его реализацию, но (спойлер) так и не нашел. Безусловно, тема не нова, но, к сожалению, статью, описывающую эту структуру данных с приведенными примерами реализации, я так и не нашел. Перед прочтением данного поста советую ознакомиться с указанной статьей, т.к. на нее будут опираться все дальнейшие рассуждения.

Встречное дерево Фенвика

Для начала, вспомним определение: Встречное дерево Фенвика (англ. counter tree Fenwick) — дерево Фенвика, в котором над каждым столбцом идет столбец такой же высоты, вычисляемый по формуле $$$f_{rev}[i]=\sum\limits_{j=i+1}^{i+2^{h(i)}}a[j]$$$. Сразу сделаю уточнение, что под прямым деревом Фенвика подразумевается массив, посчитанный формуле: $$$f_{for}[i]=\sum\limits_{j=i-2^{h(i)}+1}^i a[j]$$$. Для пущей понятности, прикреплю картинку.

Зная только то, что встречное дерево Фенвика симметрично прямому, мы можем написать код для построения встречного и прямого деревьев Фенвика, где F — функция, значение которой мы хотим считать на отрезках:

void bit_build(int n, int src[]) {
	int m = 1; while(m < n) m *= 2; m -= 2;
	for(int i = 0; i < n; ++i) {
		a[i] = src[i]; // прямое дерево Фенвика
		b[i] = i+1 < n ? src[i+1] : NEUTRAL; // встречное дерево Фенвика
	}
	for(int i = 0, j; i < n; ++i) {
		const int j = i|i+1;
		if(j < n) a[j] = F(a[i], a[j]);
	}
	for(int i = m-n+2; i < m; ++i) {
		const int j = i|i+1;
		const int u = m-i, v = m-j;
		if(j < m) b[v] = F(b[v], b[u]);
	}
}

При $$$n=2^k$$$ видно, что отрезки, покрываемые прямым и обратным деревом Фенвика идентичны дереву отрезков длины n. Из этого видно, что любой отрезок делится на два других отрезка так, что один из них полностью покрывается встречным деревом Фенвика, а другой — прямым.

Остается только сопоставить получившееся дерево Фенвика и ДО. Представлю иллюстрацию для случая n=8.

Отсюда становятся очевидными три важных факта:

  1. Каждая вершина ДО (кроме листьев) составленная из элементов дерева Фенвика имеет две дочерние вершины, имеющие одинаковые индексы, но одно из них лежит в прямом дереве Фенвике, а другое во встречном.

  2. В двоичной системы счисления номера листья оканчиваются на "0", вершины лежащие на втором снизу уровне обладают суффиксом "01", на третьем — "011", четвертом — "0111" и т.д.

  3. Номера вершин ДО, лежащих на одном уровне, строго возрастают в порядке слева-направо.

Рассматривая номера вершин на пути от листа $$$v$$$ легко проверить, что все вершины при их записи в двоичной системе счисления будут иметь префикс такой же, как у $$$v$$$, но суффикс будет другим, в зависимости от того, на каком уровне лежит вершина. Массив же, которому принадлежит вершина будем определять непосредственно смотря на значение очередного бита в номере листа. Пример пути от листа до верхней вершины дерева отрезков для позиции номер 20 в массиве.

Дальше, я надеюсь, идея обновления значения элемента в массиве становится понятной — просто обновляем значение листа, отвечающего за значение нужного элемента массива и идем вверх, пересчитывая значения всех рассматриваемых вершин. Поиск значения функции F на отрезке я описывать не буду, потому что (а) мне лень, и (б) алгоритм очень схож с поиском ответа на запрос в обычном дереве Фенвика, только в нашем случае мы дополнительно используем встречное дерево Фенвика.

// Обновление произвольного элемента в массиве
void bit_update(int id, int val) {
	if(id&1) id=id^1, b[id] = r; // в зависимости от четности обновляем значение в нужном массиве
	else              a[id] = r;
	for(int j = ~1, t=2, z=1, p=id; ; z=z|t, t=t<<1) {
		j = j^t; const int v = id&j|z; if(v >= n) break;
		// При рассмотрении следующей вершины будем учитывать, что обе дочерние вершины
		// имеют одинаковый индекс, т.е. находить их явно, нам не надо.
		if(id&t) b[v] = F(a[p], b[p]);
		else     a[v] = F(a[p], b[p]);
		p = v;
	}
}

// Ответ на запрос
int bit_ask(int l, int r) {
	int res = NEUTRAL;
	for(; (r&r+1) >= l && r >= l; r=(r&r+1)-1)
		res = F(a[r], res);
	for(; (l|l-1) <= r && l && l < n; l=(l|l-1)+1) 
		res = F(b[l-1], res);
	return res;
}

Код RMQ с использованием данной структуры:

Spoiler

P.S.: Сегодня протестировал свою программу на рандомных и не совсем рандомных тестах при различных ограничениях. По скорости сравнивал с этой реализацией ДО. Оказалось, что ДО все же быстрее, при большом количестве запросов разница достигает 100мс. Выводы можно делать разные, например oversolver предположил, что реализация встречного дерева Фенвика, где индексация будет начинаться не с нуля, а с единицы, может оказаться быстрее. Ну что ж, в ближайшее время постараюсь проверить.

Таблица с результатами:

Ограничения Встречное дерево Фенвика Дерево отрезков Разница
n=3e5, m=5e5 156 ms171 ms+15 ms
n=3e5, m=1e6 265 ms280 ms+15 ms
n=7e5, m=10e5 343 ms311 ms-32 ms
n=7e5, m=25e5 780 ms686 ms-94 ms
n=1e6, m=2e6 686 ms608 ms-78 ms
n=1e6, m=3e6 872 ms795 ms-77 ms
n=1e6, m=5e6 1466 ms1372 ms-94 ms

Полный текст и комментарии »

  • Проголосовать: нравится
  • +81
  • Проголосовать: не нравится