DP на дереве — естественное обобщение линейного DP: вместо того чтобы идти по массиву слева направо, обход идёт от листьев к корню, и в каждой вершине ответ для поддерева складывается из ответов для поддеревьев детей. Эта статья разбирает классический шаблон такого DP с накоплением подответов через свёртку — на задаче, где этот приём раскрывается особенно прозрачно.
Идея, к которой стоит привыкнуть: при обходе дерева в каждой вершине мы держим не одно число, а массив длины , где -я ячейка отвечает на «сколько вершин в поддереве на расстоянии от корня поддерева». Когда вершина сшивает в себя очередную дочернюю вершину, мы сначала подсчитываем пары длины , у которых эта вершина — точка поворота, и только потом вливаем массив дочерней в свой. Этот «свёртка перед слиянием» — главный приём шаблона.
Что дано
Дано неориентированное дерево с вершинами (нумерация ) и целое число . Нужно найти количество неупорядоченных пар вершин , , таких, что длина простого пути от до в дереве (число рёбер на пути) равна ровно .
Ограничения
- .
- .
- Время — 2 секунды, память — 256 МБ.
Формат ввода
n k
u_1 v_1
u_2 v_2
...
u_{n-1} v_{n-1}
Первая строка — два числа: количество вершин и требуемое расстояние. Затем строка, каждая с парой вершин — концами очередного ребра.
Формат вывода
Одно целое число — количество искомых пар.
Пример 1
Вход:
5 2
1 2
2 3
3 4
2 5
Вывод:
4
Дерево выглядит так:
1
└── 2
├── 3
│ └── 4
└── 5
Пары на расстоянии 2: (через вершину 2), (через 2), (через 2), (через 3). Итого 4.
Пример 2
Вход:
5 3
1 2
2 3
3 4
4 5
Вывод:
2
Дерево — вытянутая в линию цепочка . Пары на расстоянии 3: и . Итого 2.
Разберём первый пример руками
Подвесим дерево за вершину 1 как корень. У 1 одна дочерняя вершина — 2; у 2 две дочерние вершины — 3 и 5; у 3 одна дочерняя вершина — 4; у 4 и 5 дочерних вершин нет.
Подумаем, как все 4 искомые пары устроены относительно этого корня:
| Пара | LCA пары | Путь |
|---|---|---|
| 1 | ||
| 1 | ||
| 2 | ||
| 2 |
Каждая пара имеет ровно одну общую «точку поворота» — наименьшего общего предка (LCA). Если мы научимся для каждой вершины считать «сколько пар имеют расстояние и LCA в » — можно просто просуммировать по всем , и общая сумма даст ответ без двойного счёта.
Считаем для как для LCA: расстояние . Если — это сам , то первое слагаемое — 0.
Если в каждой вершине запомнить таблицу «сколько вершин в поддереве находятся на расстоянии от » (для от 0 до ) — посчитать пары с LCA в можно за : перебрать пару с и из разных поддеревьев (или одно из них — сам ). Алгоритм нашего шаблона делает ровно это.
Идея решения: tree DP со свёрткой через точку поворота
Алгоритм в одну фразу.
Подвешиваем дерево за корень, делаем DFS. В каждой вершине держим массив длины , где — количество вершин в поддереве с корнем на расстоянии от . Сшивая поддеревья дочерних вершин, перед слиянием очередной дочерней вершины в добавляем к ответу свёртку — это пары длины , у которых — точка поворота.
Почему это работает
Зафиксируем произвольный корень — пусть это вершина 1. Любая пара имеет в этом корневом дереве уникальный наименьший общий предок — то есть путь от до идёт вверх до , а потом вниз. Это даёт ключевую декомпозицию: общий ответ равен сумме по всем вершинам количества пар длины , у которых — это LCA. Считать слагаемые будем по ходу DFS — когда обрабатываем и сшиваем её поддеревья.
Сшиваем поддерево очередной дочерней вершины в уже накопленный (который пока описывает саму и все её ранее обработанные поддеревья). Любая пара с LCA , где из «уже накопленного», а — из поддерева , выражается через расстояния так: расстояние — это какое-то , расстояние — это , где — расстояние от до (плюс единица за ребро ). Итог , то есть .
Считая такие пары по всем возможным — приходим к свёртке:
Прибавляем к ответу — и сливаем в со сдвигом : dp[w][d+1] += dp[c][d] для всех (вершины из поддерева удалены от на единицу больше, чем от ). Когда переходим к следующей дочерней вершине , в уже учтены и сама , и все ранее обработанные поддеревья — следующая свёртка автоматически считает пары «между новым поддеревом и всем уже сшитым».
Почему пары не дублируются и не теряются
- Не дублируются: пара имеет уникальный LCA , и алгоритм добавляет её в ответ ровно один раз — в момент, когда обрабатывается первый из двух поддеревьев , содержащий один из концов пары (то есть в момент сшивания второго поддерева, содержащего второй конец).
- Не теряются: все пары попадают в одну из категорий. Если (одна из вершин — сам LCA), то присутствует в ещё до начала перебора детей; пара учтётся при сшивании поддерева, содержащего .
Эти два свойства дают корректность алгоритма без дополнительных проверок. Ошибочные варианты «перебрать пары вершин и посчитать расстояние через LCA» имеют сложность и на не проходят по времени.
Решение: псевдокод
# глобально
ans ← 0
dp[v] для каждого v — массив длины k+1, изначально пустой
функция dfs(v, parent):
dp[v] ← массив [0, 0, ..., 0] длины k+1
dp[v][0] ← 1 # сама вершина v на расстоянии 0
для каждого соседа c вершины v, c ≠ parent:
dfs(c, v)
# ① подсчёт пар с LCA в v: один конец в уже сшитом dp[v], второй — в dp[c]
для i от 0 до k-1:
ans ← ans + dp[v][i] * dp[c][k - 1 - i]
# ② слияние dp[c] в dp[v] со сдвигом +1
для d от 0 до k-1:
dp[v][d + 1] ← dp[v][d + 1] + dp[c][d]
освободить dp[c] # больше не понадобится, экономим память
# запуск
dfs(1, 0)
вывести ans
Почему важен именно такой порядок «① свёртка → ② слияние». Если поменять местами, после слияния в окажутся вершины из , и при «свёртке после» мы будем считать в том числе пары вида — но они не имеют LCA в , их LCA лежит глубже, в самом или ниже. Эти пары уже учтены раньше (в момент, когда сшивал свои собственные поддеревья). Получится двойной счёт.
Сложность: каждое ребро дерева вносит работы (два цикла длины ). Рёбер . Итог — по времени.
Код решения
Переключи вкладку, если хочешь видеть второй язык. Для Python важная деталь: рекурсия глубиной до упирается в дефолтный лимит (~1000) и стек ОС, поэтому используем итеративный DFS с явным построением post-order. В C++ обычная рекурсия проходит — стек ОС обычно МБ.
Комментарии по реализации.
- Long long обязателен. Произведение достигает , а сумма ответа — до . В C++ —
long long, иначе переполнение. В Python целые автоматически большие, заботы нет. - Освобождение памяти. Без
dp[c] = None(Python) илиvector<long long>().swap(dp[c])(C++) в худшем случае все массивов длины держатся одновременно — МБ. С освобождением одновременно живут только массивы вершин на «активной ветке» от корня до текущей. - Рекурсия в Python — рискованно. Дефолтный лимит ловит цепочку почти сразу. Поднятие через
sys.setrecursionlimit(60000)помогает, но стек ОС при глубине всё равно может переполниться. Итеративный DFS — надёжнее. - Производительность Python. Для , — операций в чистом Python. На жёстких лимитах (– секунды) может не пройти. Для CF 161D проще писать на C++ или PyPy; для Python — векторизация через
numpyилиarray.array('q')помогает.
Проверим на всех примерах из условия
Прогон выполняем по алгоритму выше, с подвешиванием за вершину 1. Будем записывать как список (нумерация позиций — расстояние от до её потомка).
Пример 1: , рёбра
Корень = 1. Post-order обхода: .
dfs(4): . Детей нет, выход.
dfs(3): старт . Дочерняя вершина — 4.
- Свёртка: : . : . .
- Слияние: . .
- Итого . ans = 0.
dfs(5): . Детей нет.
dfs(2): старт . Дочерняя вершина 3 (первый из обработанных порядком).
- Свёртка: : . : . . ans = 1.
- Слияние: . .
- .
Дочерняя вершина 5.
- Свёртка: : . : . . ans = 2.
- Слияние: . .
- Итого .
dfs(1): старт . Дочерняя вершина 2.
- Свёртка: : . : . . ans = 4.
- Слияние: . .
Ответ: 4 ✓.
Пример 2: , рёбра (линия)
Корень = 1. Post-order: .
dfs(5): . Нет детей.
dfs(4): старт . Дочерняя вершина 5.
- Свёртка: : все слагаемые . .
- Слияние: . Остальные — без изменений.
- .
dfs(3): старт . Дочерняя вершина 4.
- Свёртка: : . : . : . .
- Слияние: . .
- .
dfs(2): старт . Дочерняя вершина 3.
- Свёртка: : . : . : . . ans = 1.
- Слияние: . . .
- .
dfs(1): старт . Дочерняя вершина 2.
- Свёртка: : . : . : . . ans = 2.
Ответ: 2 ✓.
Оба примера сходятся с эталоном.
Крайние случаи
1.
Дерево — одна вершина, рёбер нет. , циклов по детям не выполняется. Ответ: 0 для любого . По условию — пар с нет, всё корректно.
2. Звезда — одна центральная вершина и листьев
. В корне последовательно сшиваются дочерняя вершина.
При : на каждой дочерней вершине свёртка даёт . Первое слагаемое всегда 0 (у листа нет вершин на расстоянии 1). Второе — равно , то есть числу уже обработанных листьев. Итог по всем дочерним вершинам — . Для : .
Этот случай — главная причина, почему ответ нужно держать в long long: (всё ещё в int32, но при ответ может ещё вырасти).
3. Линейное дерево («гусеница без листьев») — цепочка
Глубина рекурсии в наивной C++-реализации — . Стек ОС в МБ обычно проходит, но рекурсия с большими локальными переменными (вектор длины ) рискованна. На жёсткой границе по памяти переключиться на итеративный DFS — как в Python-коде выше.
В Python наивная рекурсия здесь падает с RecursionError на дефолтном лимите. Поднятие лимита до + увеличение размера стека (threading.stack_size) помогает, но снова — итеративная версия надёжнее.
4. — соседи
Свёртка с : ходит от 0 до 0, и . Каждое ребро добавляет ровно 1 к ответу. Ответ — это число рёбер, то есть . Алгоритм сходится тривиально.
5. Большое — рассмотрим
Если в дереве есть пара вершин с расстоянием — это значит, дерево содержит гамильтонов путь, а это бывает только для линейного дерева (цепочки). В этом случае ответ — 1 (две концевые вершины) или 0 (если дерево не цепочка). Алгоритм считает корректно. По ограничениям , поэтому случай близкого к не встречается; но проверить алгоритм на нём всё равно полезно — выход за пределы массива нигде не происходит, так как индексы всегда валидны.
6. Память при
Без освобождения после слияния все массивов длины из long long ( байт) держатся одновременно — МБ. Лимит задачи — МБ, формально проходим, но в Python из-за overhead'а каждого int (28 байт) запас исчезает. С освобождением живых одновременно — только массивы вершин на пути от корня до текущей: от для сбалансированного дерева до для цепочки.
Типичные ошибки
- Перепутан порядок «свёртка → слияние». Если сначала влить в , а потом считать свёртку, получится двойной счёт пар, которые лежат полностью в — их LCA не , они уже учтены глубже.
- Свёртка вместо . Самая частая алгебраическая ошибка: забыть про «единицу за ребро ». Тогда считаются пары длины , не .
- Слияние без сдвига: вместо . Тогда вершины из числятся «на том же расстоянии от , что и от » — неверно, нужно за ребро.
- Проход по соседям без пропуска предка. Дерево хранится как неориентированный граф (списки смежности), и в
for c in adj[v]оказывается непосредственный предок . Без проверкиif c == parent: continue— бесконечная рекурсия. Решение — либо передаватьparentпараметром, либо хранить ориентированное дерево. intвместоlong longв C++. Для ответ может достигать и более при больших на специфичных конфигурациях. Промежуточное произведение в свёртке может временно быть ещё больше.int32достаточно близок к , чтобы рискованно — беритеlong long.- Не освобождать
dp[c]после слияния. На пределе памяти ( МБ) полное хранение массивов длины выливается в МБ; в Python с overhead'ом — гораздо больше. - Рекурсия глубины в Python. Дефолтный лимит — . На цепочке падает почти сразу с
RecursionError. Поднятие лимита черезsys.setrecursionlimitпомогает только до пределов стека ОС; итеративный DFS надёжнее. - Считать «упорядоченные пары» вместо «неупорядоченных». Если кажется, что свёртка симметрична и считает каждую пару дважды — это иллюзия. На самом деле каждая пара учитывается ровно один раз: при сшивании поддерева, содержащего «второй» из её концов в порядке обхода. Никакого дополнительного деления на 2 не нужно.
Анализ сложности
- Время: . Каждое ребро обрабатывается ровно один раз, и работа в этот момент — два цикла по итераций (свёртка и слияние). Всего рёбер . Для , — операций; на C++ укладывается в секунду с большим запасом.
- Память: в худшем случае (цепочка) — при наивном хранении всех , и при освобождении после слияния. Для сбалансированного дерева — , для цепочки — те же .
Что ещё полезно потренировать
Задачи на ту же идею — DP на дереве с накоплением подответов через свёртку или max/sum-комбинацию. Все — с возраст-нейтральных платформ.
- Codeforces Div.2 D/E уровня – с тегом
dp on trees: классические постановки «найти максимум / посчитать число / выбрать подмножество вершин с ограничением». Шаблон тот же — DFS post-order, в каждой вершине агрегируются ответы детей. - Codeforces задача «independent set» на дереве (–): состояние из двух чисел «вершина взята / не взята», переход — из ответов детей. Самая лёгкая для входа в технику.
- acmp.ru, раздел «Деревья»: задачи – на простые tree DP — поиск диаметра, числа листьев, суммарного расстояния.
- Centroid decomposition — альтернативный подход к этой же задаче с асимптотикой независимо от . Сложнее в реализации, но окупается на задачах с (когда становится квадратичным).
Для следующего шага — попробовать tree DP, где состояние двумерное (например, «количество вершин, выбранных в подмножество, ») или где помимо числа вершин нужно агрегировать сумму, минимум, дисперсию.
Итого
- Идея: подвешиваем дерево за корень, в каждой вершине держим массив — число вершин в поддереве на расстоянии . При сшивании очередной дочерней вершины сначала добавляем к ответу свёртку (это пары длины с LCA в ), потом сливаем в со сдвигом .
- Сложность: по времени, по памяти при освобождении после слияния.
- Ловушки: перепутанный порядок «свёртка → слияние», свёртка вместо , слияние без сдвига, рекурсия в Python на цепочке,
intвместоlong longв C++. - Стороннее замечание: для алгоритм деградирует до — здесь нужен переход на centroid decomposition.