第 11 站 · 链表与树
树上路径与递归汇总
后序返回单边贡献,全局记录拐弯路径 时间 O(n) · 递归栈 O(h)
- 先学
- 学完去
- 面试频次
- 5 家公司考过 · 面经出现 6 次Meta 2、Adobe 1、Capital One 1、Salesforce 1、TikTok / ByteDance 1
先做这道
LC 112路径总和卡住再看
沿根到叶路径扣减目标和,明确必须终止于叶子。
要点
- 递归函数返回「从当前结点往下走的最好单边路径」,因为要继续往上接的路径只能选一边。
- 在当前结点拐弯的路径(左 + 根 + 右)只用来更新全局答案,不返回给父结点。
- 根到叶的路径题用「进入时加、离开时减」维护当前路径;任意起点的路径和用前缀和 + 哈希表。
易错点
- 全局答案初始化成 0,所有值都是负数的树会得到错误的 0。
- 子树贡献为负时要截成 0(这一边不接),否则会把答案拖低。
- 把拐弯路径也返回给父结点,等于让一条路径分叉,结果会偏大。
看动画
一步一步播放这个模式在小例子上的执行过程。可以暂停、单步前进或后退,拖动进度条跳到任意一步。
二叉树遍历 · 栈与队列
前序、中序、后序、层序遍历这棵树
换个输入
- 遍历
- 前序
- 输出
- —
同一棵树,四种遍历顺序。节点下的 #k 是它在当前遍历里第几个输出。
始终成立深度优先的三种顺序只差在「输出根」排在左右子树的前、中、后;层序用队列,先进先出就是一层一层。
模板
选一种语言展开。四种写法实现的是同一个模板,每一份都编译运行过,并用测试数据核对了输出。看完用它重写热身题 LC 112。
Python
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val, self.left, self.right = val, left, right
def max_path_sum(root) -> int:
"""Largest sum of any non-empty path between two nodes."""
best = float("-inf")
def gain(node) -> int: # best downward path sum that starts at node
nonlocal best
if not node:
return 0
left, right = max(gain(node.left), 0), max(gain(node.right), 0)
best = max(best, node.val + left + right) # the path that bends at node
return node.val + max(left, right) # only one side can continue upward
gain(root)
return bestC++
struct TreeNode {
int val;
TreeNode *left, *right;
TreeNode(int v, TreeNode* l = nullptr, TreeNode* r = nullptr) : val(v), left(l), right(r) {}
};
// Largest sum of any non-empty path between two nodes.
int maxPathSum(TreeNode* root) {
int best = INT_MIN;
function<int(TreeNode*)> gain = [&](TreeNode* node) { // best downward path from node
if (!node) return 0;
int left = max(gain(node->left), 0), right = max(gain(node->right), 0);
best = max(best, node->val + left + right); // the path that bends at node
return node->val + max(left, right); // only one side continues upward
};
gain(root);
return best;
}Java
class TreeNode {
int val;
TreeNode left, right;
TreeNode(int val) {
this.val = val;
}
TreeNode(int val, TreeNode left, TreeNode right) {
this.val = val;
this.left = left;
this.right = right;
}
}
class Solution {
private int best;
// Largest sum of any non-empty path between two nodes.
int maxPathSum(TreeNode root) {
best = Integer.MIN_VALUE;
gain(root);
return best;
}
private int gain(TreeNode node) { // best downward path sum that starts at node
if (node == null) return 0;
int left = Math.max(gain(node.left), 0), right = Math.max(gain(node.right), 0);
best = Math.max(best, node.val + left + right); // the path that bends at node
return node.val + Math.max(left, right); // only one side continues upward
}
}Go
import "math"
type TreeNode struct {
Val int
Left, Right *TreeNode
}
// maxPathSum returns the largest sum of any non-empty path between two nodes.
func maxPathSum(root *TreeNode) int {
best := math.MinInt
var gain func(*TreeNode) int // best downward path sum that starts at node
gain = func(node *TreeNode) int {
if node == nil {
return 0
}
left, right := max(gain(node.Left), 0), max(gain(node.Right), 0)
best = max(best, node.Val+left+right) // the path that bends at node
return node.Val + max(left, right) // only one side continues upward
}
gain(root)
return best
}常见变体
这一站的题还会用到下面 2 种写法。展开看什么时候换用它,代码同样四种语言都跑过测试。
树上前缀和:回溯时撤销计数时间 O(n) · 空间 O(h)
数「向下的路径」里和为 target 的有几条,路径可以从任意结点开始:把根到当前结点的前缀和放进哈希表,像数组上的「和为 K 的子数组」一样查 pre - target;离开结点时把自己的前缀和计数减掉,别的分支才看不到它。LC 437 路径总和 III。
Python
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val, self.left, self.right = val, left, right
from collections import defaultdict
def path_sum_count(root, target: int) -> int:
"""Downward paths, starting and ending anywhere, whose values sum to target (LC 437)."""
seen = defaultdict(int) # prefix sums on the current root-to-node path
seen[0] = 1
count = 0
def dfs(node, pre: int) -> None:
nonlocal count
if not node:
return
pre += node.val
count += seen[pre - target] # a path ending here starts after an ancestor's prefix
seen[pre] += 1
dfs(node.left, pre)
dfs(node.right, pre)
seen[pre] -= 1 # leaving node: its prefix is off the path now
dfs(root, 0)
return countC++
struct TreeNode {
int val;
TreeNode *left, *right;
TreeNode(int v, TreeNode* l = nullptr, TreeNode* r = nullptr) : val(v), left(l), right(r) {}
};
// Downward paths, starting and ending anywhere, whose values sum to target (LC 437).
int pathSumCount(TreeNode* root, long long target) {
unordered_map<long long, int> seen{{0, 1}}; // prefix sums on the current root-to-node path
int count = 0;
function<void(TreeNode*, long long)> dfs = [&](TreeNode* node, long long pre) {
if (!node) return;
pre += node->val;
if (auto it = seen.find(pre - target); it != seen.end()) count += it->second; // starts after an ancestor's prefix
seen[pre]++;
dfs(node->left, pre);
dfs(node->right, pre);
seen[pre]--; // leaving node: its prefix is off the path now
};
dfs(root, 0);
return count;
}Java
class TreeNode {
int val;
TreeNode left, right;
TreeNode(int val) {
this.val = val;
}
TreeNode(int val, TreeNode left, TreeNode right) {
this.val = val;
this.left = left;
this.right = right;
}
}
class Solution {
private final Map<Long, Integer> seen = new HashMap<>(); // prefix sums on the current root-to-node path
private int count;
// Downward paths, starting and ending anywhere, whose values sum to target (LC 437).
int pathSumCount(TreeNode root, long target) {
seen.clear();
seen.put(0L, 1);
count = 0;
dfs(root, 0, target);
return count;
}
private void dfs(TreeNode node, long pre, long target) {
if (node == null) return;
pre += node.val;
count += seen.getOrDefault(pre - target, 0); // a path ending here starts after an ancestor's prefix
seen.merge(pre, 1, Integer::sum);
dfs(node.left, pre, target);
dfs(node.right, pre, target);
seen.merge(pre, -1, Integer::sum); // leaving node: its prefix is off the path now
}
}Go
type TreeNode struct {
Val int
Left, Right *TreeNode
}
// pathSumCount counts downward paths, starting and ending anywhere, that sum to target (LC 437).
func pathSumCount(root *TreeNode, target int) int {
seen := map[int]int{0: 1} // prefix sums on the current root-to-node path
count := 0
var dfs func(node *TreeNode, pre int)
dfs = func(node *TreeNode, pre int) {
if node == nil {
return
}
pre += node.Val
count += seen[pre-target] // a path ending here starts after an ancestor's prefix
seen[pre]++
dfs(node.Left, pre)
dfs(node.Right, pre)
seen[pre]-- // leaving node: its prefix is off the path now
}
dfs(root, 0)
return count
}最近公共祖先:两边都找到就是这里时间 O(n) · 递归深度 O(h)
问两个结点在哪里「汇合」:递归在左右子树里找 p 和 q,碰到其中一个就把它往上交;某个结点左右两边都交上来了,它就是最近公共祖先,否则把非空的那一边继续往上交。LC 236 二叉树的最近公共祖先,LC 1644、LC 1650 是它的变形。
Python
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val, self.left, self.right = val, left, right
def lowest_common_ancestor(root, p, q):
"""Deepest node with both p and q in its subtree, a node counting as its own descendant (LC 236)."""
if not root or root is p or root is q:
return root
left = lowest_common_ancestor(root.left, p, q)
right = lowest_common_ancestor(root.right, p, q)
if left and right: # p and q are on different sides: they meet here
return root
return left or rightC++
struct TreeNode {
int val;
TreeNode *left, *right;
TreeNode(int v, TreeNode* l = nullptr, TreeNode* r = nullptr) : val(v), left(l), right(r) {}
};
// Deepest node with both p and q in its subtree, a node counting as its own descendant (LC 236).
TreeNode* lowestCommonAncestor(TreeNode* root, TreeNode* p, TreeNode* q) {
if (!root || root == p || root == q) return root;
TreeNode* left = lowestCommonAncestor(root->left, p, q);
TreeNode* right = lowestCommonAncestor(root->right, p, q);
if (left && right) return root; // p and q are on different sides: they meet here
return left ? left : right;
}Java
class TreeNode {
int val;
TreeNode left, right;
TreeNode(int val) {
this.val = val;
}
TreeNode(int val, TreeNode left, TreeNode right) {
this.val = val;
this.left = left;
this.right = right;
}
}
class Solution {
// Deepest node with both p and q in its subtree, a node counting as its own descendant (LC 236).
TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q) {
if (root == null || root == p || root == q) return root;
TreeNode left = lowestCommonAncestor(root.left, p, q);
TreeNode right = lowestCommonAncestor(root.right, p, q);
if (left != null && right != null) return root; // p and q are on different sides: they meet here
return left != null ? left : right;
}
}Go
type TreeNode struct {
Val int
Left, Right *TreeNode
}
// lowestCommonAncestor returns the deepest node with both p and q in its subtree (LC 236).
func lowestCommonAncestor(root, p, q *TreeNode) *TreeNode {
if root == nil || root == p || root == q {
return root
}
left := lowestCommonAncestor(root.Left, p, q)
right := lowestCommonAncestor(root.Right, p, q)
if left != nil && right != nil { // p and q are on different sides: they meet here
return root
}
if left != nil {
return left
}
return right
}为什么对
- 每条路径都有唯一的最高点(离根最近的节点)。以每个节点为最高点算一次 left + node + right,就枚举了所有路径,全局最大值正确。
- 返回给父节点的只能是单侧链 node + max(left, right, 0):路径经过父节点后,下面就不能再分叉,否则不是一条简单路径。
- 负的贡献取 0,相当于不接这一侧,因为接上负数只会让路径更差。
30 秒口述稿
面试官让你讲思路时,按做法、为什么对、复杂度的顺序说。中英文各一版,可以对着读几遍。
树上最大路径和,我让递归函数返回从当前节点往下走的最大单侧链,负数当 0,不接。每条路径都有唯一的最高点,所以在每个节点把左链、自己、右链拼起来更新一次全局答案,就覆盖了所有路径。拐弯的路径只更新答案、不返回给父节点,因为经过父节点之后下面就不能再分叉。每个节点算一次,O(n)。For the maximum path sum, my recursion returns the best one-sided chain going down from the node, treating a negative chain as zero. Every path has a single highest node, so joining the left chain, the node and the right chain at every node and updating a global best covers all paths. The bent path only updates the answer and is never returned, because a path going up through the parent cannot fork below it. Each node is handled once, so it is O(n).自评一下
对照上面的答案给自己打个分。没掌握的题会按 1、3、7、21 天的间隔回来,到期的题在「我的进度 · 到期复习」里。 只存在这台设备上;登录后会合并到你的账号。
小测
3 道概念题和复杂度题。答过的题会和 OA 选择题练习一起按间隔回来复习。 去 OA 选择题练习
作答记录只保存在这台设备的浏览器里;登录后打开OA 选择题练习页,会存进账号。
模板练习
用上面的模板和变体出题:把打乱的行排回原序,把挖掉的边界条件填回去,或者在空白编辑器里整段默写,跑本站的测试并逐行对比。每次「换一题」都会重新出题。
下面几行被打乱了。点一行选中,再点另一行和它交换,排回模板原来的顺序;缩进是提示。
class TreeNode:
def __init__(self, val=0, left=None, right=None): return best分档练习
本站在 500 路里的 13 道题,按基础、核心、进阶分三档。每题附一句话思路,先自己想,想不出再看。
核心 5
| # | 题目 | 一句话思路 | 难度分 |
|---|---|---|---|
| 113 | 路径总和 II | 枚举所有满足目标和的根到叶路径,正确保存路径快照。 | |
| 129 | 求根节点到叶节点数字之和 | 向下传递十进制前缀值,在叶子处汇总结果。 | |
| 1448 | 统计二叉树中好节点的数目 | 传递路径上的最大值,判断当前节点是否满足祖先约束。 | |
| 236 | 二叉树的最近公共祖先 | 利用左右子树返回结果定位两个节点的最近公共祖先。 | |
| 1650 | 二叉树的最近公共祖先 III 🔒 | 借助父指针把最近公共祖先转化为两条祖先链的相交。 |
进阶 6
| # | 题目 | 一句话思路 | 难度分 |
|---|---|---|---|
| 437 | 路径总和 III | 在树DFS中维护路径前缀和频次,离开子树时撤销状态。 | |
| 124 | 二叉树中的最大路径和 | 求最大路径和,区分向父节点提供的贡献与全局最优路径。 | |
| 1644 | 二叉树的最近公共祖先 II 🔒 | 当目标节点可能不在树中时,增加存在性验证而非直接返回候选。 | |
| 1123 | 最深叶节点的最近公共祖先 | 一次后序遍历返回子树高度与最深叶子的公共祖先。 | |
| 863 | 二叉树中所有距离为 K 的结点 | 把父子关系视为无向图,从目标节点进行定距离BFS。 | |
| 1530 | 好叶子节点对的数量 | 向父节点汇总叶子距离分布,统计跨左右子树的合法叶子对。 |
本站 OA 真题 9
- Longest Path with Alternating Letters in TreeMicrosoft
- Longest Path With Different Adjacent CharactersMicrosoft
- Radio WavesSnowflake
- Largest Path Sum in Rooted Tree (Downward Only)Citadel
- Minimum Path Sum to Target in Binary TreeCitadel
- Get Minimum ServersCisco
- Disk Storage — palindromePaths on Rooted TreeUber
- Binary Tree: Subtree Sum, Maximum Path Sum, and Path NodesUber
- Graph Palindrome QueriesUber