← Параллель AВсе параллели⬇ Скачать конспект

Занятие 2. Параллель A — Оптимизации DP

Переливайка (small-to-large), рюкзак на дереве, Convex Hull Trick, лямбда-оптимизация, оптимизация Кнута, «разделяй и властвуй» для ДП, оффлайн-связность, матрицы, slope trick.
Лекция + разбор задач ≈ 3 ч 50 мин Язык кода: C++

1. Переливайка (small-to-large) 0:09:00

Дано дерево; для каждой вершины нужно посчитать ДП, размер которой не превосходит размера поддерева. Суммарный размер всех ДП — $O(n^2)$, но есть способ сделать $O(n \log n)$.

Идея. Сначала запускаемся от самого большого сына («тяжёлого») и крадём его ДП (не копируем, а просто «забираем себе», например, через swap). Затем к своей ДП приливаем ДП остальных детей.

Каждый элемент «переезжает» из множества в множество $O(\log n)$ раз, потому что множество-приёмник каждый раз хотя бы вдвое больше. Итого $O(n \log n)$.
Переливайка: множество тяжёлого сына забираем, остальные вливаемПример: множества цветов. У вершины v три сына, их множества размеров 7, 3 и 1.сын 1 — тяжёлый: 7сын 2: 3сын 3: 1множество vswap: забралицеликом, O(1)вливаемпо одному элементуЭлемент переезжает только в множество, которое хотя бы вдвое больше его прежнего,поэтому переездов не больше log₂ n. Итого O(n log n).
Множество тяжёлого сына забираем без копирования, остальные вливаем поэлементно. Каждый элемент переезжает не больше $\log_2 n$ раз.

Классический пример — количество различных цветов в поддереве каждой вершины. В каждой вершине храним set цветов:

C++
vector<set<int>> colors(n);

void dfs(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) любой запрос, который смотрит на абсолютное значение элемента (а не только на относительный порядок/разности внутри множества), должен сначала учесть тег множества — иначе сравнение или поиск по ключу молча работает со смещённым значением.

2. Рюкзак на дереве за $O(n^2)$ 0:25:00

Задача. Посчитать число независимых множеств размера $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)$.
Рюкзак на дереве: слияние детей стоит x · yСливая ДП двух поддеревьев размеров x и y, перебираем все пары (a, b) — по вершине из каждого.va₁a₂a₃b₁b₂b₃b₄x = 3y = 4a₁b₁b₂b₃b₄a₂a₃3 × 4 = 12 пару каждой LCA = vКаждая пара вершин перебирается один раз — в своём LCA. Пар всего ≤ n²/2 ⟹ весь рюкзак O(n²).
Пара вершин $(a, b)$ перебирается ровно один раз — при слиянии в их LCA. Поэтому суммарная работа $O(n^2)$.

Ограничение до $k$. Если нам нужны только размеры $\le k$, ДП в каждой вершине имеет размер $\le \min(k, \text{размер поддерева})$ — суммарно получается $O(nk)$.

3. Convex Hull Trick (CHT) 0:35:00

Задача. Есть прямоугольники (каждый задан шириной и высотой). Нужно выбрать подмножество, максимизирующее сумму площадей минус сумму стоимостей.

Отсортируем по ширине $x$. Ключевое свойство: в оптимальном ответе высоты выбранных прямоугольников убывают (иначе внутренний прямоугольник не даёт вклада в площадь, но за него платим). ДП:

$dp[i] = \max_j\Big(dp[j] + (x_i - x_j) \cdot y_i - c_i\Big) = \max_j\Big(dp[j] - x_j \cdot y_i\Big) + x_i y_i - c_i$.

Внутренний максимум — это максимум прямых $k_j x + b_j$, где $k_j = -x_j$, $b_j = dp[j]$, вычисленных в точке $x = y_i$. Прямые добавляются с убывающими наклонами (так как $x_j$ возрастают).

Convex Hull Trick: максимум из прямых k·x + bВерхняя огибающая — ломаная из прямых. Прямые, которые нигде не максимальны, выкидываем.1.53.55.5запрос x = yᵢогибающая= ответпунктир —лишняя прямая,удалили из стекажёлтые точки:по ним бинпоискищет прямуюx →
Ответ для $y_i$ — значение верхней огибающей в этой точке; нужная прямая ищется бинпоиском по точкам пересечения.

Структура. Поддерживаем стек прямых, образующих верхнюю огибающую, вместе с координатами точек пересечения. При добавлении новой прямой:

C++
// прямые y = k*x + b, добавляются с убывающими k; ищем максимум
vector<Line> hull;      // стек прямых
vector<long double> pt; // pt[i] — правая граница отрезка, где i-я прямая лучшая; pt[0] = +INF, дальше убывают

void add(Line l) {
    while (!hull.empty()) {
        long double x = intersect(l, hull.back());   // левее x прямая l выше последней
        if (x >= pt.back()) {                        // последняя прямая нигде не лучшая
            hull.pop_back();
            pt.pop_back();
        } else break;
    }
    pt.push_back(hull.empty() ? INF : intersect(l, hull.back()));
    hull.push_back(l);
}
long long query(long long x) {
    // последняя i с pt[i] >= x (pt убывает, поэтому такие i образуют префикс)
    int id = partition_point(pt.begin(), pt.end(), [&](long double 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$, где в каждой вершине хранится «лучшая на текущий момент» прямая.

4. Лямбда-оптимизация (Aliens trick) 1:06:00

Задача. Разбить $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$ (например, из-за целочисленного шага бинпоиска или неединственности, см. ниже), восстановленное значение будет неверным.
Лямбда-оптимизация: штраф λ за каждый отрезокf(k) — ответ ровно с k отрезками, выпуклая. Минимум f(k) + λk — точка касания прямой наклона −λ.12345678k — число отрезковf(k)λ = 10 → k* = 3λ = 3 → k* = 5Больше λ —отрезки дороже,их меньше.Бинпоиск по λ,пока k* ≠ нужного k.Ответ:f(k) = cost − λk
Чем больше штраф $\lambda$, тем меньше отрезков в оптимуме. Бинпоиском подбираем $\lambda$, при котором их ровно $k$.
Алгоритм
1) Бинпоиск по $\lambda$. 2) Для каждого $\lambda$ решаем «штрафную» задачу (быстро) и узнаём оптимальное число отрезков. 3) Двигаем границы, пока число отрезков не станет $k$. Итог — $O(n \log n \log C)$.
Нестрогая выпуклость
Если $f(k)$ выпукла не строго (несколько прямых пересекаются в одной точке), бинпоиск всё равно находит $\lambda$, но для восстановления $f(k)$ может не быть «единственного» отрезка на огибающей. Тогда при восстановлении ответа нужно хранить не одно число-предка, а отрезок возможных $k$, из которого выбирать нужное.

5. Оптимизация Кнута 1:34:00

Задача. Разбить массив на $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[i][j] монотонен по обоим индексамМассив 3 1 4 1 5 9 2 6, разбиение на j отрезков. В клетке — где выгоднее всего последний разрез.j = 1j = 2j = 3j = 4i = 10i = 201i = 3022i = 40233i = 50344i = 60455i = 70556i = 80566Ищем opt[6][2] (оранжевая).Снизу ограничиваетopt[5][2] = 3 (синяя),сверху — opt[6][3] = 5(зелёная).Перебираем k тольков [3, 5], а не все 1…5.Сумма длин таких отрезковпо диагонали — O(n),итого O(n²).
Настоящая таблица $opt$ для массива 3 1 4 1 5 9 2 6: перебор для каждой клетки зажат соседними клетками.
Итог
Оптимизация Кнута пересчитывает всю ДП по всем $k$ за $O(n^2)$ — быстрее CHT. Монотонность $opt$ следует из четырёхугольного неравенства; если проверить его трудно, на олимпиаде при сомнениях сверяют решение с перебором стресс-тестом.

6. Разделяй и властвуй + мин-плюс свёртка 1:47:00

Разделяй и властвуй для ДП

Та же задача (разбиение точек на $k$ отрезков). Считаем ДП по слоям (по числу отрезков). Внутри слоя выполняется пересчёт с монотонными оптимумами: если $i$ растёт, то оптимальный разрез тоже не убывает.

Пишем рекурсивную функцию, которая знает, что оптимумы для всех $i$ на отрезке $[l, r]$ лежат в $[optL, optR]$:

C++
// считаем dp[][cur] по dp[][prev]; оптимумы монотонны
void solve(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) {
        long long 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);
}
Разделяй и властвуй: оптимумы монотонныsolve(l, r, optL, optR): считаем середину m перебором, потом делим и задачу, и диапазон оптимумов.i →optm: перебор всех optлевая половина: opt ≤ opt[m]правая: opt ≥ opt[m]Каждый уровеньрекурсии суммарноперебирает O(n)кандидатов.Уровней log n ⟹O(n log n) на слой.
Оптимум середины делит диапазон кандидатов: левой половине достаются меньшие, правой — большие.

Суммарная длина отрезков $[optL, optR]$ на каждом уровне рекурсии $O(n)$, уровней $\log n$ — итого $O(n \log n)$ на слой, всего $O(nk \log n)$.

Практика
Разделяйка пишется за минуты и отлично ложится в кэш (идёт по элементам подряд). Очень часто заходит даже там, где асимптотически есть более быстрый, но громоздкий алгоритм.

Мин-плюс свёртка 1:55:00

Даны массивы $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).

Связность на отрезке рёбер: граница монотоннаГраф на 5 вершинах, рёбра пронумерованы 0…8. Клетка (l, r) — связен ли граф из рёбер l…r.l = 0012345678l = 1l = 2l = 3l = 4l = 5l = 6l = 7l = 8r →связенне связенОранжевая граница:первое r, с которогограф связен.С ростом l онатолько сдвигаетсявправо ⟹ разделяйка+ ДСУ с откатами.
Граница «с какого $r$ граф связен» не убывает с ростом $l$ — та же монотонность, что нужна разделяйке.

Поддерживаем ДСУ, которая умеет добавлять ребро и откатывать последнее добавление (храня стек изменений). В разделяйке для центральной левой границы добавляем рёбра, пока граф не станет связным; для рекурсивных вызовов аккуратно откатываем/накатываем нужные рёбра.

Альтернатива: очередь на структуре с откатом
Есть техника, превращающая структуру «с откатом» (стек) в очередь: элементы добавляются в конец и удаляются из начала. Удаление из начала имитируется через «перестраивание» — когда нужно удалить первый элемент, достаём все элементы, удаляем первый и перезаписываем остальные в правильном порядке. Каждый элемент участвует в $O(\log n)$ таких перестроек, итого $O(n \log n)$ операций со структурой.

8. Матрицы и возведение в степень 2:41:00

Умножение матриц $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ᵏ[i][j] — число путей длины k из i в jA — матрица смежности. Возводя её в квадрат, считаем пути из двух рёбер.123A011101100→A²201111011(A²)₁₁ = 2: пути 1 → 2 → 1 и 1 → 3 → 1Aᵏ — бинарным возведением за O(n³ log k)
Элемент $(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})$ и умножаем на матрицу:

$\begin{pmatrix} f_{i+1} \\ f_i \end{pmatrix} = \begin{pmatrix} 1 & 1 \\ 1 & 0 \end{pmatrix} \begin{pmatrix} f_i \\ f_{i-1} \end{pmatrix}$.

Любую линейную рекурренту $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)$.

9. Slope trick 3:01:00

Задача. Дан массив; за 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 применяется к задачам, где ДП-функции выпуклы и кусочно-линейны, а переход — это сдвиг, прибавление модуля/галочки и взятие минимума. Придумать такое решение сложно, но пишется оно очень коротко.

10. Random shuffle (покемоны) 3:32:00

Задача. Есть $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)$, что на практике быстро (дисперсия мала, хвосты отбрасываются).
Random shuffle: храним только полосу вокруг среднегоСостояние — (позиция k, сколько покеболов уже потрачено). После перемешивания их около k · A / n.k · A / nпозиция k →aЗелёная полосашириной ~ c√n —храним только её.Серые состоянияпочти невероятны —отбрасываем.
После случайного перемешивания число потраченных покеболов редко отходит от среднего больше чем на $O(\sqrt n)$.

Это вероятностная/приближённая техника: отклонение ограничивается дисперсией, а редкие хвосты распределения отбрасываются. Подобные идеи используются на сложных контестах.

Конспект подготовлен по записи занятия Яндекс Кружка. Таймкоды соответствуют исходной видеозаписи. Код на C++ приведён в виде, эквивалентном разобранному на занятии.