5. Path Sum III
«Всякий след, оставленный при спуске вглубь, должен быть аккуратно стерт при возвращении».
— Брайан Керниган
Задача Path Sum III (LeetCode 437) — это элегантный финал нашего путешествия по динамическому программированию на деревьях. Она образует концептуальный мост между геометрией путей в деревьях из 4. Binary Tree Maximum Path Sum и математическим аппаратом префиксных сумм из 4. Subarray Sum Equals K.
На собеседованиях в бигтех (Google, Uber, Amazon) эта задача служит лакмусовой бумажкой: способен ли кандидат спроецировать одномерный паттерн хеш-таблицы на нелинейную иерархическую структуру и грамотно организовать откат состояния (Backtracking) без утечек памяти и давления на GC.
1. Постановка задачи и свойства путей
Дано бинарное дерево, узлы которого содержат целые числа (включая отрицательные и ноль), и целевое значение targetSum. Необходимо найти суммарное количество путей, сумма значений узлов в которых в точности равна targetSum.
Инварианты пути:
- Путь должен идти строго вниз (от родителя к ребенку).
- Путь не обязан начинаться в корне дерева.
- Путь не обязан заканчиваться в листе.
10
/ \
5 -3
/ \ \
3 2 11
/ \ \
3 -2 1
Target = 8:
Путь 1: 5 -> 3 (сумма 8)
Путь 2: 5 -> 2 -> 1 (сумма 8)
Путь 3: -3 -> 11 (сумма 8)
Итого: 3 пути
В распределенных системах это точная модель анализа бюджетов задержек (Latency Budgets / SLO) в деревьях распределенной трассировки: сколько непрерывных цепочек синхронных подзапросов вносят суммарную задержку ровно в target миллисекунд.
2. Квадратичный тупик: Двойной DFS за $O(N^2)$
Наивный подход заключается в запуске двойного обхода:
- Внешний DFS обходит каждый узел дерева.
- Для каждого посещенного узла запускается внутренний DFS, ищущий все нисходящие пути с суммой
targetSum.
// АНТИПАТТЕРН: Двойной DFS O(N^2) в худшем случае
func pathSumNaive(root *TreeNode, targetSum int) int {
if root == nil {
return 0
}
return countFromNode(root, targetSum) +
pathSumNaive(root.Left, targetSum) +
pathSumNaive(root.Right, targetSum)
}
Для вырожденного в цепочку дерева (бамбука) из $N = 10\,000$ узлов это потребует $\approx 5 \cdot 10^7$ рекурсивных вызовов. В production-сервисе квадратичный алгоритм приведет к блокировке потоков и срабатыванию таймаутов.
3. Оптимальное решение: Префиксные суммы + Backtracking за $O(N)$
Вспомним фундаментальное свойство префиксных сумм на массивах:
$$\text{Sum}(i, j) = \text{PrefixSum}[j] - \text{PrefixSum}[i-1] = \text{target}$$
$$\text{PrefixSum}[i-1] = \text{PrefixSum}[j] - \text{target}$$
В процессе спуска вглубь дерева (DFS) активный путь от корня до текущего узла представляет собой обыкновенный одномерный срез!
Если в текущем узле накопленная сумма равна currSum, и где-то выше на этой же ветке уже встречалась префиксная сумма $\text{currSum} - \text{targetSum}$, значит, вертикальный отрезок между тем предком и текущим узлом дает в сумме ровно targetSum.
Проблема ветвления и решение через откат (Backtracking)
В массиве префиксные суммы накапливаются строго линейно. В дереве же после обхода левого поддерева мы возвращаемся в родительский узел и уходим в правое поддерево.
Префиксные суммы, сгенерированные в левой ветке, не имеют никакого отношения к правой ветке! Если их не удалить, правая ветвь посчитает пути, физически проходящие сквозь чужих «кузенов», нарушив топологию дерева.
flowchart TD
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef mutate fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
classDef backtrack fill:#78350f,stroke:#fbbf24,stroke-width:2px,color:#f8fafc
classDef finish fill:#064e3b,stroke:#34d399,stroke-width:2px,color:#f8fafc
A["Вход в узел Node"]:::entry --> B["currSum += int64(Node.Val)"]:::process
B --> C["Поиск currSum - targetSum в map"]:::process
C --> D["Добавление в ответ: count += map[currSum - targetSum]"]:::finish
D --> E["Инкремент счетчика префикса: map[currSum]++"]:::mutate
E --> F["Рекурсивный спуск: dfs(Left), dfs(Right)"]:::process
F --> G["ОТКАТ СОСТОЯНИЯ (Backtracking):<br>map[currSum]--"]:::backtrack
G --> H["Возврат в родительский фрейм"]:::entry
4. Идиоматичная реализация на Go
package main
// TreeNode представляет узел бинарного дерева.
type TreeNode struct {
Val int
Left *TreeNode
Right *TreeNode
}
// PathSum находит количество вертикальных путей с суммой targetSum.
// Временная сложность: O(N) — каждый узел посещается один раз.
// Память: O(H) — глубина дерева для мапы и стека рекурсии.
func PathSum(root *TreeNode, targetSum int) int {
// Мапа префиксных сумм: значение суммы -> сколько раз встретилась на текущей ветке
prefixCount := make(map[int64]int)
// Базовый случай: пустой префикс (сумма 0) встретился ровно 1 раз до корня
prefixCount[0] = 1
totalPaths := 0
var dfs func(node *TreeNode, currSum int64)
dfs = func(node *TreeNode, currSum int64) {
if node == nil {
return
}
// Предотвращаем целочисленное переполнение при суммировании
currSum += int64(node.Val)
// 1. Проверяем, существует ли комплементарный префикс выше по ветке
complement := currSum - int64(targetSum)
totalPaths += prefixCount[complement]
// 2. Регистрируем текущий префикс для потомков
prefixCount[currSum]++
// 3. Рекурсивный спуск в поддеревья
dfs(node.Left, currSum)
dfs(node.Right, currSum)
// 4. Backtracking: убираем текущий узел из истории перед выходом!
// Это гарантирует чистоту контекста для параллельных веток.
prefixCount[currSum]--
}
dfs(root, 0)
return totalPaths
}
5. Mechanical Sympathy: Мутация одной мапы против копирования
На технических собеседованиях начинающие инженеры часто предлагают:
«А давайте просто передавать новую копию map в каждый вызов dfs, чтобы не заморачиваться с откатом состояния?»
Давайте сопоставим это предложение с архитектурой железа и рантаймом Go:
| Характеристика | Копирование мапы на каждом шаге | Мутация одной мапы с Backtracking |
|---|---|---|
| Аллокации в куче | $\mathcal{O}(N)$ аллокаций новых хэш-таблиц | Строго 1 аллокация на старте |
| Расход памяти | $\mathcal{O}(N \cdot H)$ байт (десятки мегабайт) | $\mathcal{O}(H)$ записей (не больше глубины дерева) |
| Нагрузка на GC | Колоссальная (GC вынужден сканировать тысячи объектов) | Практически нулевая |
| Копирование данных | $\mathcal{O}(H)$ операций на каждый узел дерева | $\mathcal{O}(1)$ инкремент и декремент |
| Общее время работы | $\mathcal{O}(N \cdot H)$ — деградация до секунд | $\mathcal{O}(N)$ — единицы миллисекунд |
Почему явный декремент быстрее defer в глубокой рекурсии?
В коде выше мы выполнили откат явной строкой prefixCount[currSum]-- в конце функции, а не через defer func() { prefixCount[currSum]-- }().
Несмотря на то, что с Go 1.14+ открытый defer (open-coded defer) инлайнится компилятором, внутри функций с рекурсией компилятор часто вынужден сохранять метаинформацию отложенных вызовов в стековом фрейме. Явный декремент в конце функции гарантирует нулевые накладные расходы рантайма (0 CPU-тактов оверхеда).
6. Подводный камень: Переполнение int32 в LeetCode 437
7. System Design: Распределенный контекст и изоляция скоупов
Паттерн «накопление $\to$ спуск $\to$ очистка» в точности повторяет работу распределенного контекста (context.Context) в Go и OpenTelemetry Tracing:
- При поступлении входящего запроса создается трассировочный контекст с уникальным
TraceID. - При вызове зависимых сервисов контекст обогащается новыми атрибутами спана (
SpanID). - Когда подзапрос завершается, дочерний спан закрывается, не загрязняя контекст соседних независимых горутин.
Умение контролировать жизненный цикл локального состояния гарантирует отсутствие утечек данных между параллельными потоками исполнения.
8. Итоги кластера: Деревья покорены!
Завершая кластер динамического программирования на деревьях, зафиксируем единую систему ориентиров:
- Конечный автомат узла (3. House Robber III): Возвращайте из Post-order обхода вектор взаимоисключающих состояний
(robbed, notRobbed). Регистровый ABI Go (ABIInternal) передает их мгновенно без аллокаций. - Ветвь vs Арка (4. Binary Tree Maximum Path Sum): Четко разделяйте то, что возвращается родителю (линейная ветвь $\max(L, R)$), и то, что обновляет глобальный оптимум (изгибающаяся арка). Отсекайте убыточные ветви через
max(gain, 0). - Префиксные суммы на дереве (5. Path Sum III): Линейные алгоритмы переносятся на деревья при условии обязательного отката состояния (Backtracking) при возврате из рекурсии.
- Защита от переполнений: Накапливающиеся префиксы требуют 64-битных целых чисел (
int64).
В следующем модуле мы спустимся на фундаментальный аппаратный уровень и изучим битовую магию процессоров: регистры, битовые маски, инструкции конвейера и битовые трюки, лежащие в основе высокопроизводительного кода: 1. Теория. Битовые операции.