Корневые эвристики — это обобщённое название различных методов и структур данных, опирающихся на тот факт, что если мы разделим какое-то множество из элементов на блоки по элементов, то самих этих блоков будет не более .
Центральное равенство этой статьи: .
#Деление на тяжелые и легкие объекты
Всем известный алгоритм факторизации за корень опирается на тот факт, что каждому «большому» делителю числа соответствует какой-то «маленький» делитель .
Подобное полезное свойство (что маленькие объекты маленькие, а больших объектов не много) можно найти и у других объектов.
#Длинные и короткие строки
Задача. Требуется в онлайне обрабатывать три типа операций над множеством строк:
- Добавить строку в множество.
- Удалить строку из множества.
- Для заданной строки, найти количество её вхождений как подстроку среди всех строк множества.
Одно из решений следующее: разделим все строки на короткие () и длинные (), где означает суммарную длину всех строк. Заметим, что длинных строк немного — не более .
С запросами будем справляться так:
- Заведём хеш-таблицу, и когда будем обрабатывать запрос добавления или удаления, будем прибавлять или отнимать соответственно единицу по хешам всех её коротких подстрок. Это можно сделать суммарно за : для каждой строки нужно перебрать разных длин и окном пройтись по всей строке.
- Для запроса третьего типа для короткой строки, просто посчитаем её хеш и посмотрим на значение в хеш-таблице.
- Для запроса третьего типа для длинной строки, мы можем позволить себе посмотреть на все неудаленные строки, потому что таких случаев будет немного, и если мы можем за линейное время найти все вхождения новой строки, то работать это будет тоже за . Например, можно посчитать z-функцию для всех строк вида
s#t
, где это строка из запроса, а это строка из множества; здесь, правда, есть нюанс: может быть большой, а маленьких строк много — нужно посчитать z-функцию сначала только от , а затем виртуально дописывать к ней каждую и досчитывать функцию.
Иногда отдельный подход к тяжелым и лёгким объектам не требуется, но сама идея помогает увидеть, что некоторые простые решения работают быстрее, чем кажется.
#Треугольники в графе
Задача. Дан граф из вершин и рёбер. Требуется найти в нём количество циклов длины три.
Будем называть вершину тяжелой, если она соединена с более чем другими вершинами, и лёгкой в противном случае.
Попытаемся оценить количество соединённых вместе троек вершин, рассмотрев все возможные 4 варианта:
- В цикле нет тяжелых вершин. Рассмотрим какое-нибудь ребро цикла. Третья вершина должна лежать в объединении списков смежности и , а раз обе эти вершины лёгкие, то таких вершин найдётся не более . Значит, всего циклов этого типа может быть не более .
- В цикле одна тяжелая вершина. Аналогично — есть одно «лёгкое» ребро, а значит таких циклов тоже .
- В цикле две тяжелые вершины — обозначим их как и , а лёгкую как . Зафиксируем пару — способов это сделать , потому что всего столько рёбер. Для этого ребра будет не более рёбер , потому что столько всего тяжелых вершин. Получается, что всего таких циклов может быть не более .
- Все вершины тяжелые. Аналогично — тип третьей вершины в разборе предыдущего случая нигде не использовался; важно лишь то, что тяжелых вершин немного.
Получается, что циклов длины 3 в графе может быть не так уж и много — не более .
Само решение максимально простое: отсортируем вершины графа по их степени и будем перебирать все пути вида и проверять существование ребра .
vector<int> g[maxn], p(n); // исходный граф и список номеров вершин
iota(p.begin(), p.end(), 0); // 0, 1, 2, 3, ...
// чтобы не копипастить сравнение:
auto cmp = [&](int a, int b) {
return g[a].size() < g[b].size() || (g[a].size() == g[b].size() && a < b);
};
// в таком порядке мы будем перебирать вершины
sort(p.begin(), p.end(), cmp);
// теперь отсортируем списки смежности каждой вершины
for (int v = 0; v < n; ++v)
sort(g[v].begin(), g[v].end(), cmp);
// рядом с каждой вершиной будем хранить количество
// ранее просмотренных входящих рёбер (v -> w)
vector<int> cnt(n, 0);
int ans = 0;
for (int v : p) {
for (int w : g[v])
cnt[w]++;
for (int u : g[v]) {
if (cmp(v, u))
break;
for (int w : g[u]) {
if (cmp(u, w))
break; // если ребро плохое -- не берем треугольник
ans += cnt[w]; // если в графе нет петель, то cnt[w] = {0, 1}
}
}
// при переходе к следующему v массив нужно занулить обратно
for (int w : g[v])
cnt[w]--;
}
#Рюкзак за
Если у нас есть предметов с весами , , , , такими что , то мы можем решить задачу о рюкзаке за время стандартной динамикой. Чтобы решить задачу быстрее, попытаемся сделать так, чтобы число предметов стало .
Заметим, что количество различных весов будет , так как для любых различных чисел с суммой
Откуда значит, что .
Рассмотрим теперь некоторый вес , который раз встречается в наборе весов. «Разложим» по степеням двойки и вместо всех вхождений этого веса добавим веса , , , , , где это максимальное целое число, для которого выполняется . Легко видеть, что все суммы вида () и только их по-прежнему можно набрать.
Алгоритм в этом, собственно, и заключается: проведем данную операцию со всеми уникальными значениями весов и после чего запустим стандартное решение. Уже сейчас легко видеть, что новое количество предметов будет , потому что для каждого веса мы оставили не более весов, а всего различных весов было .
Упражнение. Докажите, что предметов на самом деле будет .
Примечание. Классическое решение рюкзака можно ускорить на несколько порядков, если использовать bitset.
Также корневые эвристики можно использовать в контексте структур данных и обработки запросов.