Дано дерево; для каждой вершины нужно посчитать ДП, размер которой не превосходит размера поддерева. Суммарный размер всех ДП — $O(n^2)$, но есть способ сделать $O(n \log n)$.
Идея. Сначала запускаемся от самого большого сына («тяжёлого») и крадём его ДП (не копируем, а просто «забираем себе», например, через swap). Затем к своей ДП приливаем ДП остальных детей.
Каждый элемент «переезжает» из множества в множество $O(\log n)$ раз, потому что множество-приёмник каждый раз хотя бы вдвое больше. Итого $O(n \log n)$.
Множество тяжёлого сына забираем без копирования, остальные вливаем поэлементно. Каждый элемент переезжает не больше $\log_2 n$ раз.
Классический пример — количество различных цветов в поддереве каждой вершины. В каждой вершине храним set цветов:
C++
vector<set<int>> colors(n);
voiddfs(int v, int p) {
int heavy = -1;
for (int u : g[v]) if (u != p)
if (heavy == -1 || colors[u].size() > colors[heavy].size()) heavy = u;
for (int u : g[v]) if (u != p && u != heavy) dfs(u, v);
if (heavy != -1) {
dfs(heavy, v);
swap(colors[v], colors[heavy]); // крадём сет тяжёлого сына
}
colors[v].insert(color[v]);
for (int u : g[v]) if (u != p && u != heavy)
for (int c : colors[u]) colors[v].insert(c);
ans[v] = colors[v].size();
}
Вариация: ленивый сдвиг
Иногда в сеты нужно приливать не значения, а «значения + константа» (например, расстояния до вершин). Прибавление константы ко всем элементам множества — обратимая операция, поэтому её можно не делать явно, а хранить «отложенную добавку» (ленивый тег) для каждого множества. При этом нужно следить за двумя вещами: (1) при переливании элемента из одного множества в другое его хранимое значение нужно скорректировать на разницу тегов (а не просто скопировать как есть); (2) любой запрос, который смотрит на абсолютное значение элемента (а не только на относительный порядок/разности внутри множества), должен сначала учесть тег множества — иначе сравнение или поиск по ключу молча работает со смещённым значением.
Задача. Посчитать число независимых множеств размера $k$ для всех $k$ в дереве. Наивный рюкзак по сыновьям даёт $O(n^2)$ (а не $O(n^3)$).
ДП: $dp[v][k][t]$ — число независимых множеств размера $k$ в поддереве $v$, где $t \in \{0,1\}$ — взяли ли саму вершину $v$. При слиянии двух ДП размеров $x$ и $y$ перебираем пары — это $O(xy)$.
Почему суммарно $O(n^2)$
Каждая пара вершин $(a, b)$ «встречается» при переборе ровно один раз — в LCA этих вершин. Всего пар $\binom{n}{2} = O(n^2)$.
Пара вершин $(a, b)$ перебирается ровно один раз — при слиянии в их LCA. Поэтому суммарная работа $O(n^2)$.
Ограничение до $k$. Если нам нужны только размеры $\le k$, ДП в каждой вершине имеет размер $\le \min(k, \text{размер поддерева})$ — суммарно получается $O(nk)$.
Задача. Есть прямоугольники (каждый задан шириной и высотой). Нужно выбрать подмножество, максимизирующее сумму площадей минус сумму стоимостей.
Отсортируем по ширине $x$. Ключевое свойство: в оптимальном ответе высоты выбранных прямоугольников убывают (иначе внутренний прямоугольник не даёт вклада в площадь, но за него платим). ДП:
Внутренний максимум — это максимум прямых $k_j x + b_j$, где $k_j = -x_j$, $b_j = dp[j]$, вычисленных в точке $x = y_i$. Прямые добавляются с убывающими наклонами (так как $x_j$ возрастают).
Ответ для $y_i$ — значение верхней огибающей в этой точке; нужная прямая ищется бинпоиском по точкам пересечения.
Структура. Поддерживаем стек прямых, образующих верхнюю огибающую, вместе с координатами точек пересечения. При добавлении новой прямой:
C++
// прямые y = k*x + b, добавляются с убывающими k; ищем максимумvector<Line> hull; // стек прямыхvector<longdouble> pt; // pt[i] — правая граница отрезка, где i-я прямая лучшая; pt[0] = +INF, дальше убываютvoidadd(Line l) {
while (!hull.empty()) {
longdouble x = intersect(l, hull.back()); // левее x прямая l выше последнейif (x >= pt.back()) { // последняя прямая нигде не лучшая
hull.pop_back();
pt.pop_back();
} elsebreak;
}
pt.push_back(hull.empty() ? INF : intersect(l, hull.back()));
hull.push_back(l);
}
longlongquery(longlong x) {
// последняя i с pt[i] >= x (pt убывает, поэтому такие i образуют префикс)int id = partition_point(pt.begin(), pt.end(), [&](longdouble v) { return v >= x; }) - pt.begin() - 1;
return hull[id].k * x + hull[id].b;
}
Запрос — бинпоиск по точкам пересечения ($O(\log n)$). У верхней огибающей с убывающими наклонами новая прямая выигрывает левее, поэтому точки пересечения в стеке убывают. (Для минимума при убывающих наклонах точки, наоборот, возрастают, и подойдёт обычный upper_bound.) Если и запросы по $x$ монотонны, можно поддерживать указатель на последнюю оптимальную прямую и двигать его вперёд — запросы за $O(1)$ амортизированно.
Когда наклоны не монотонны
Если прямые добавляются в произвольном порядке, используют Li Chao tree — дерево отрезков по координатам $x$, где в каждой вершине хранится «лучшая на текущий момент» прямая.
Задача. Разбить $n$ точек на $k$ отрезков, минимизировав сумму квадратов длин отрезков.
Исходная ДП $dp[i][k]$ за $O(n^2k)$. Идея: добавим штраф $\lambda$ за каждый отрезок и будем решать задачу без ограничения на число отрезков (просто минимизируем $\sum \text{квадрат} + \lambda \cdot (\text{число отрезков})$). Это одномерная ДП, решаемая CHT за $O(n \log n)$.
$cost_{\lambda} = \min_k \Big(f(k) + \lambda k\Big)$, где $f(k)$ — ответ исходной задачи ровно с $k$ отрезками.
Функция $f(k)$ выпукла (это ключевое условие). Тогда $cost_\lambda$ — нижняя огибающая семейства прямых $f(k) + \lambda k$. Бинпоиском по $\lambda$ подбираем такое значение, при котором оптимальное число отрезков равно $k$, и восстанавливаем $f(k) = cost_\lambda - \lambda k$.
На чём держится приём — три условия, а не одна выпуклость
1) Дискретность: $k$ — целое число отрезков из конечного набора $0,\dots,n$, то есть $f$ задана на целых точках, а не на отрезке вещественной прямой. 2) Монотонность оптимума по штрафу: из выпуклости $f$ следует, что $k^*(\lambda)=\arg\min_k\bigl(f(k)+\lambda k\bigr)$ не возрастает по $\lambda$ — именно это (а не сама выпуклость как таковая) даёт право искать нужное $\lambda$ бинарным поиском, а не перебором всех значений. 3) Корректность восстановления $f(k)=cost_\lambda-\lambda k$: верно только для такого $\lambda$, при котором $k$ действительно достигается как $k^*(\lambda)$ — если бинпоиск остановился на $\lambda$ с $k^*(\lambda)\ne k$ (например, из-за целочисленного шага бинпоиска или неединственности, см. ниже), восстановленное значение будет неверным.
Чем больше штраф $\lambda$, тем меньше отрезков в оптимуме. Бинпоиском подбираем $\lambda$, при котором их ровно $k$.
Алгоритм
1) Бинпоиск по $\lambda$. 2) Для каждого $\lambda$ решаем «штрафную» задачу (быстро) и узнаём оптимальное число отрезков. 3) Двигаем границы, пока число отрезков не станет $k$. Итог — $O(n \log n \log C)$.
Нестрогая выпуклость
Если $f(k)$ выпукла не строго (несколько прямых пересекаются в одной точке), бинпоиск всё равно находит $\lambda$, но для восстановления $f(k)$ может не быть «единственного» отрезка на огибающей. Тогда при восстановлении ответа нужно хранить не одно число-предка, а отрезок возможных $k$, из которого выбирать нужное.
Задача. Разбить массив на $k$ отрезков, минимизируя сумму квадратов сумм элементов на отрезках.
Пусть $opt[i][j]$ — оптимальное место разреза для $dp[i][j]$ (разбиение первых $i$ элементов на $j$ отрезков). Ключевое свойство:
$opt[i][j-1] \le opt[i][j] \le opt[i+1][j]$.
Поэтому, считая ДП в правильном порядке ($j$ по возрастанию, $i$ по убыванию), для каждого состояния перебираем $k$ только в границах $[opt[i][j-1],\ opt[i+1][j]]$. Суммарная работа по каждой диагонали $O(n)$, а диагоналей $O(n)$ — итого $O(n^2)$.
Когда это свойство верно: четырёхугольное неравенство, а не просто «выпуклость»
Монотонность $opt$ не следует из одной лишь выпуклости стоимости отрезка — нужно конкретное условие на функцию стоимости $w(l,r)$ (стоимость отрезка $[l,r]$): четырёхугольное неравенство (quadrangle inequality)
$$ w(a,c) + w(b,d) \le w(a,d) + w(b,c) \qquad \text{для всех } a\le b\le c\le d. $$
Если это неравенство выполнено, монотонность $opt[i][j]$ по обоим индексам доказывается обменным рассуждением: если бы оптимальный разрез при большем $i$ (или $j$) был левее, чем при меньшем, обмен разрезов между двумя состояниями не увеличивал бы суммарную стоимость — противоречие с оптимальностью. Для суммы квадратов сумм на отрезке это неравенство проверяется явно и выполняется (при неотрицательных элементах массива), поэтому оптимизация применима именно к этой задаче. Переносить вывод «стоимость выпукла $\Rightarrow$ подходит оптимизация Кнута» на произвольную ДП с разрезами нельзя — нужно отдельно проверять четырёхугольное неравенство для конкретной функции стоимости.
Настоящая таблица $opt$ для массива 3 1 4 1 5 9 2 6: перебор для каждой клетки зажат соседними клетками.
Итог
Оптимизация Кнута пересчитывает всю ДП по всем $k$ за $O(n^2)$ — быстрее CHT. Монотонность $opt$ следует из четырёхугольного неравенства; если проверить его трудно, на олимпиаде при сомнениях сверяют решение с перебором стресс-тестом.
Та же задача (разбиение точек на $k$ отрезков). Считаем ДП по слоям (по числу отрезков). Внутри слоя выполняется пересчёт с монотонными оптимумами: если $i$ растёт, то оптимальный разрез тоже не убывает.
Пишем рекурсивную функцию, которая знает, что оптимумы для всех $i$ на отрезке $[l, r]$ лежат в $[optL, optR]$:
C++
// считаем dp[][cur] по dp[][prev]; оптимумы монотонныvoidsolve(int l, int r, int optL, int optR) {
if (l > r) return;
int m = (l + r) / 2, opt = -1;
dp[m] = INF;
for (int k = optL; k <= min(optR, m - 1); ++k) {
longlong cur = prev[k] + cost(k + 1, m);
if (cur < dp[m]) { dp[m] = cur; opt = k; }
}
solve(l, m - 1, optL, opt);
solve(m + 1, r, opt, optR);
}
Оптимум середины делит диапазон кандидатов: левой половине достаются меньшие, правой — большие.
Суммарная длина отрезков $[optL, optR]$ на каждом уровне рекурсии $O(n)$, уровней $\log n$ — итого $O(n \log n)$ на слой, всего $O(nk \log n)$.
Практика
Разделяйка пишется за минуты и отлично ложится в кэш (идёт по элементам подряд). Очень часто заходит даже там, где асимптотически есть более быстрый, но громоздкий алгоритм.
Даны массивы $a$ и $b$; нужно посчитать $c_i = \min_{x+y=i} (a_x + b_y)$. Для произвольных массивов быстрее $O(n^2)$ не известно. Но если $a$ выпуклый, то оптимумы монотонны и работает та же разделяйка — $O(n \log n)$ (а специальными алгоритмами даже $O(n)$).
$c_i = \min_{y} (a_{i-y} + b_y)$ — если $a$ выпукло, оптимальное $y$ не убывает с ростом $i$.
7. Оффлайн-связность и очередь из роллбека 2:05:00
Задача. Дан массив рёбер; для каждого запроса $[l, r]$ проверить, связен ли граф из рёбер отрезка.
Зафиксировав левую границу $l$, по правой есть монотонность: начиная с некоторого $r$ граф связен, и эта граница не убывает с ростом $l$. Значит, можно применить разделяйку с ДСУ с откатами (rollback DSU).
Граница «с какого $r$ граф связен» не убывает с ростом $l$ — та же монотонность, что нужна разделяйке.
Поддерживаем ДСУ, которая умеет добавлять ребро и откатывать последнее добавление (храня стек изменений). В разделяйке для центральной левой границы добавляем рёбра, пока граф не станет связным; для рекурсивных вызовов аккуратно откатываем/накатываем нужные рёбра.
Альтернатива: очередь на структуре с откатом
Есть техника, превращающая структуру «с откатом» (стек) в очередь: элементы добавляются в конец и удаляются из начала. Удаление из начала имитируется через «перестраивание» — когда нужно удалить первый элемент, достаём все элементы, удаляем первый и перезаписываем остальные в правильном порядке. Каждый элемент участвует в $O(\log n)$ таких перестроек, итого $O(n \log n)$ операций со структурой.
Умножение матриц $A = B \cdot C$: $a_{ij} = \sum_k b_{ik} c_{kj}$. Оно ассоциативно (но не коммутативно), поэтому матрицу можно возводить в степень бинарным методом за $O(n^3 \log k)$.
Количество путей длины $k$ в графе
$(A^k)_{ij}$ = число путей из $i$ в $j$ длины $k$, где $A$ — матрица смежности.
Элемент $(A^2)_{ij}$ — сумма $\sum_k A_{ik}A_{kj}$, то есть число путей $i \to k \to j$.
Пути длины $\le k$: сумма $I + A + A^2 + \dots + A^k$ считается рекурсивно (геометрическая прогрессия матриц) за $O(n^3 \log k)$.
Линейные рекурренты
Например, Фибоначчи: $f_i = f_{i-1} + f_{i-2}$. Храним вектор $(f_i, f_{i-1})$ и умножаем на матрицу:
Любую линейную рекурренту $f_i = c_1 f_{i-1} + \dots + c_k f_{i-k}$ можно считать за $O(k^3 \log n)$.
Оптимизация для многих запросов
Если нужно много раз вычислить $A^n b$ (матрица на вектор) для разных $n$, заранее предподсчитаем $A^{2^0}, A^{2^1}, A^{2^2}, \dots$ и для каждого $n$ умножаем вектор на нужные степени — $O(k^2 \log n)$ на запрос вместо $O(k^3 \log n)$.
Задача. Дан массив; за 1 рубль можно изменить любой элемент на $+1$ или $-1$. Найти минимум стоимости, чтобы сделать массив строго возрастающим.
Замена $b_i = a_i - i$ сводит задачу к тому, чтобы сделать $b$ неубывающим.
Идея slope trick. ДП $dp_i(x)$ — минимальная стоимость сделать префикс длины $i$ неубывающим, где последний элемент $\le x$. Все функции $dp_i$ — кусочно-линейные и выпуклые (это доказывается по индукции: база — модуль, переход сохраняет выпуклость).
Вместо всей функции храним только её точки перегиба (и, при необходимости, значение в одной точке):
Интерактив: двигайте ползунок — красная точка ходит по выпуклой функции dp(x), жёлтые точки — изломы (точки перегиба).
Операция перехода $dp_i \to dp_{i+1}$ сводится к добавлению одной точки (перегиба в $a_i$) и удалению последней точки — всё за $O(\log n)$. Суммарно задача решается за $O(n \log n)$, и при этом реализация — буквально несколько операций с кучей.
Область применения
Slope trick применяется к задачам, где ДП-функции выпуклы и кусочно-линейны, а переход — это сдвиг, прибавление модуля/галочки и взятие минимума. Придумать такое решение сложно, но пишется оно очень коротко.
Задача. Есть $n$ покемонов; в каждого можно кинуть покебол (вероятность поимки $p_i$) или ультрабол (вероятность $q_i$). У нас $A$ покеболов и $B$ ультраболов. Максимизировать матожидание числа пойманных.
Наивная ДП по числу использованных покеболов/ультраболов — $O(nAB)$ (слишком много).
Идея (random shuffle). Порядок покемонов не важен, поэтому мысленно перемешаем их случайно. В случайной перестановке на префиксе длины $k$ в среднем $k \cdot A/n$ покеболов и $k \cdot B/n$ ультраболов, а отклонение от матожидания — порядка $O(\sqrt{n})$. Поэтому в ДП по позиции храним только значения, отличающиеся от «средних» не более чем на $c\sqrt n$:
Состояний $O(n \cdot \sqrt n \cdot \sqrt n)$, что на практике быстро (дисперсия мала, хвосты отбрасываются).
После случайного перемешивания число потраченных покеболов редко отходит от среднего больше чем на $O(\sqrt n)$.
Это вероятностная/приближённая техника: отклонение ограничивается дисперсией, а редкие хвосты распределения отбрасываются. Подобные идеи используются на сложных контестах.