7. Lowest Common Ancestor
«Простота — необходимое условие надежности».
— Эдсгер Вибе Дейкстра
LeetCode 236. Lowest Common Ancestor of a Binary Tree. Дано бинарное дерево и два различных узла $p$ и $q$, гарантированно присутствующие в нём. Требуется найти их наименьшего общего предка (Lowest Common Ancestor, LCA) — наиболее глубокий узел в дереве, для которого как $p$, так и $q$ являются потомками (при этом узел считается потомком самого себя).
После решения задачи о максимальной сумме пути (6. Binary Tree Maximum Path Sum) мы продолжаем исследовать мощь обратного обхода (Postorder DFS). Но если в предыдущей задаче мы передавали снизу вверх числовые суммы, то в задаче о наименьшем общем предке наверх транслируются топологические маркеры обнаружения.
Для Senior Go-разработчика эта задача — эталон чистоты рекурсивного мышления: элегантный код в 12 строк без вспомогательных структур данных и без единой аллокации в куче заменяет громоздкие решения с сохранением путей и родительских указателей.
1. Архитектурная идея: восходящий сбор сигналов
Вместо того чтобы строить пути от корня к узлам $p$ и $q$ и затем искать их расхождение, мы используем естественную топологию дерева. Запустим рекурсию в поддеревья и сформулируем строгий контракт функции:
Инвариант
lowestCommonAncestor(node, p, q):
- Если в поддереве
nodeнет ни $p$, ни $q$ $\rightarrow$ возвращаетnil.- Если в поддереве
nodeнайден ровно один из искомых узлов $\rightarrow$ возвращает указатель на этот узел ($p$ или $q$).- Если в поддереве
nodeнайдены оба узла $\rightarrow$ возвращает их наименьшего общего предка.
Случай 1: Разделение ветвей Случай 2: Один предок другого
[ Node (LCA) ] [ p (LCA) ]
/ \ \
... ... ...
/ \ \
[p] [q] [q]
left != nil && right != nil left == nil, right == q (или наоборот)
-> Node и есть ответ! -> p возвращается сразу наверх!
Почему корректно не спускаться глубже при node == p || node == q?
Если мы дошли до узла p, мы сразу возвращаем p родителю, даже не заглядывая в его детей. Почему?
- Если $q$ лежит в поддереве $p$: по определению, узел является предком самого себя, значит $p$ и есть искомый LCA! Нам не нужно искать $q$ внутри.
- Если $q$ лежит в другой ветке дерева: рекурсивный поиск в другой ветке найдет $q$, а их общий предок выше объединит результаты
left != nil && right != nilи вернет истинный LCA.
2. Вопросы интервьюеру перед реализацией
- Гарантируется ли наличие обоих узлов в дереве?
Ответ: В стандартной задаче LeetCode 236 — да, $p$ и $q$ всегда присутствуют и $p \neq q$. (Если узлы не гарантированы — см. раздел «Собеседование» ниже). - Могут ли узлы дублироваться по значению?
Ответ: Все значения узлов уникальны, однако наш алгоритм сравнивает указатели на узлы, а не их значения, поэтому он инвариантен к значениям. - Разрешено ли мутировать узлы дерева (добавлять указатель на родителя)?
Ответ: Нет, дерево неизменяемо (read-only).
3. Идиоматическая реализация на Go
package main
// TreeNode представляет узел бинарного дерева.
type TreeNode struct {
Val int
Left *TreeNode
Right *TreeNode
}
// lowestCommonAncestor находит наименьшего общего предка для узлов p и q.
// Временная сложность: O(N) в худшем случае (посещаем каждый узел один раз).
// Пространственная сложность: O(H) — глубина рекурсивного стека, где H — высота дерева.
func lowestCommonAncestor(root, p, q *TreeNode) *TreeNode {
// Базовый случай: пустое дерево или один из искомых узлов найден.
if root == nil || root == p || root == q {
return root
}
// Пост-обход (Postorder): сначала ищем в поддеревьях.
left := lowestCommonAncestor(root.Left, p, q)
right := lowestCommonAncestor(root.Right, p, q)
// Развилка: если оба вызова вернули не-nil, то p и q находятся
// в разных поддеревьях относительно текущего root. Значит, root — их LCA!
if left != nil && right != nil {
return root
}
// Если один из вызовов вернул результат (а второй nil),
// пробрасываем найденный узел (или уже найденный LCA) выше по стеку.
if left != nil {
return left
}
return right
}
4. Визуализация логики принятия решений
flowchart TD
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
Start["Вход: LCA(root, p, q)"]:::entry --> BaseCheck{"root == nil<br>|| root == p<br>|| root == q?"}:::warning
BaseCheck -- "Да" --> ReturnRoot["return root"]:::accent
BaseCheck -- "Нет" --> SearchChildren["left = LCA(root.Left, p, q)<br>right = LCA(root.Right, p, q)"]:::process
SearchChildren --> CheckBoth{"left != nil<br>&& right != nil?"}:::warning
CheckBoth -- "Да: развилка обнаружена" --> FoundLCA["return root // текущий узел и есть LCA"]:::success
CheckBoth -- "Нет" --> CheckLeft{"left != nil?"}:::warning
CheckLeft -- "Да" --> ReturnLeft["return left"]:::process
CheckLeft -- "Нет" --> ReturnRight["return right"]:::process
5. Сравнение подходов: Рекурсивный DFS vs Родительский словарь
| Критерий | Рекурсивный DFS (наш выбор) | Хранение родителей (map[*TreeNode]*TreeNode) |
|---|---|---|
| Аллокации в куче | 0 B/op (чистый стек) | Сотни КБ на хеш-таблицу связей |
| Сложность по памяти | $O(H)$ (глубина стека) | $O(N)$ (каждый узел в map) |
| Количество проходов | 1 проход | 2 прохода (построение дерева + подъём от предков) |
| Идиоматичность Go | Высокая, минимум кода | Избыточная сложность, давление на GC |
В варианте с хеш-таблицей родителей (parentMap := make(map[*TreeNode]*TreeNode)) приходится заполнять словарь на $N$ элементов, а затем строить visited := make(map[*TreeNode]bool) для подъёма от $p$ к корню. Это порождает паразитное выделение памяти в куче и нагружает GC. Рекурсивное решение работает исключительно на стеке горутины.
6. Механическая симпатия Go: указатели и регистры ABI
- Регистровый ABI (Go 1.17+):
Параметрыroot, p, q— это три 64-битных указателя (*TreeNode). Они передаются напрямую через регистры CPU (например,RAX, RBX, RCXна amd64), исключая обращения к памяти для передачи аргументов функции. - Сравнение указателей в железе:
Проверкаroot == pкомпилируется в одну ассемблерную инструкциюCMPQ reg1, reg2. Это мгновенное сравнение двух целочисленных адресов памяти без разыменования указателей. - Отсутствие Escape Analysis:
Поскольку функция оперирует исключительно адресами существующих узлов и не создаёт новых переменных в куче, рантайм Go выполняет её со стоимостью 0 B/op.
7. Граничные случаи (Edge Cases)
- $p$ является прямым родителем $q$:
Алгоритм встретит $p$, сработает базовый случайroot == p, и $p$ немедленно вернется наверх. Правая/левая ветки под $p$ даже не будут обходиться. Ответ $p$ корректен. - Узлы находятся в противоположных концах дерева:
Корень увидитleft != nilиз одного дальнего листа иright != nilиз другого дальнего листа. Результатrootвернется на самом верху дерева. - Вырожденное дерево-список глубины $N$:
Стек горутины плавно вырастет до необходимого размера за счет динамического реаллоцирования страниц стека (runtime.morestack).
Резюме для интервью
| Параметр | Значение |
|---|---|
| Алгоритм | Postorder DFS (снизу вверх) |
| Временная сложность | $O(N)$ — худший случай при полном обходе |
| Пространственная сложность | $O(H)$ — глубина дерева в стеке вызовов ($O(\log N)$ в сбалансированном) |
| Память в куче | 0 B/op (zero-alloc) |
| Ключевой трюк | left != nil && right != nil означает, что мы стоим в точке наименьшего предка |
В следующей задаче — 8. Flood Fill — мы завершим кластер DFS, перейдя от древовидных структур к матрицам и алгоритмам растровой заливки.