cswork / 模板打开 500 路题单

第 11 站 · 链表与树

树上路径与递归汇总

后序返回单边贡献,全局记录拐弯路径 时间 O(n) · 递归栈 O(h)

周赛难度分 难度分 1360–难度分 1746· 中位 难度分 1635(4/13 道题有 zerotrac 周赛分)

打开 500 路题单 · 第 11 站 · 13 题
先学
学完去
面试频次
5 家公司考过 · 面经出现 6 次Meta 2、Adobe 1、Capital One 1、Salesforce 1、TikTok / ByteDance 1

先做这道

LC 112路径总和简单
卡住再看

沿根到叶路径扣减目标和,明确必须终止于叶子。

要点

  1. 递归函数返回「从当前结点往下走的最好单边路径」,因为要继续往上接的路径只能选一边。
  2. 在当前结点拐弯的路径(左 + 根 + 右)只用来更新全局答案,不返回给父结点。
  3. 根到叶的路径题用「进入时加、离开时减」维护当前路径;任意起点的路径和用前缀和 + 哈希表。

易错点

  • 全局答案初始化成 0,所有值都是负数的树会得到错误的 0。
  • 子树贡献为负时要截成 0(这一边不接),否则会把答案拖低。
  • 把拐弯路径也返回给父结点,等于让一条路径分叉,结果会偏大。

看动画

一步一步播放这个模式在小例子上的执行过程。可以暂停、单步前进或后退,拖动进度条跳到任意一步。

交互演示 · 逐步观察

二叉树遍历 · 栈与队列

前序、中序、后序、层序遍历这棵树

换个输入
ABCDEFG
当前处理已发现 / 已计算答案
栈底 → 栈顶,或队首 → 队尾
空
遍历
前序
输出
—

同一棵树,四种遍历顺序。节点下的 #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 best
C++
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 count
C++
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 right
C++
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
}

为什么对

  1. 每条路径都有唯一的最高点(离根最近的节点)。以每个节点为最高点算一次 left + node + right,就枚举了所有路径,全局最大值正确。
  2. 返回给父节点的只能是单侧链 node + max(left, right, 0):路径经过父节点后,下面就不能再分叉,否则不是一条简单路径。
  3. 负的贡献取 0,相当于不接这一侧,因为接上负数只会让路径更差。

30 秒口述稿

面试官让你讲思路时,按做法、为什么对、复杂度的顺序说。中英文各一版,可以对着读几遍。

中文
树上最大路径和,我让递归函数返回从当前节点往下走的最大单侧链,负数当 0,不接。每条路径都有唯一的最高点,所以在每个节点把左链、自己、右链拼起来更新一次全局答案,就覆盖了所有路径。拐弯的路径只更新答案、不返回给父节点,因为经过父节点之后下面就不能再分叉。每个节点算一次,O(n)。
English
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 天的间隔回来,到期的题在「我的进度 · 到期复习」里。 只存在这台设备上;登录后会合并到你的账号。

对照要点(0/3)
这道题你会了吗?

小测

3 道概念题和复杂度题。答过的题会和 OA 选择题练习一起按间隔回来复习。 去 OA 选择题练习

3 道题
  1. 「二叉树中任意两点间的最大路径和」:递归函数返回给父节点的值应该是什么?(L、R 是左右孩子返回的值)
  2. 统计二叉树里和为 target 的向下路径数,常用做法是 DFS 时用哈希表记录根到当前节点路径上各前缀和出现的次数。为什么回溯(离开节点)时要把当前前缀和的计数减 1?
  3. 二叉树的直径(任意两节点间最长路径的边数)一定经过根节点吗?

作答记录只保存在这台设备的浏览器里;登录后打开OA 选择题练习页,会存进账号。

模板练习

用上面的模板和变体出题:把打乱的行排回原序,把挖掉的边界条件填回去,或者在空白编辑器里整段默写,跑本站的测试并逐行对比。每次「换一题」都会重新出题。

下面几行被打乱了。点一行选中,再点另一行和它交换,排回模板原来的顺序;缩进是提示。

class TreeNode:
    def __init__(self, val=0, left=None, right=None):
    return best

分档练习

本站在 500 路里的 13 道题,按基础、核心、进阶分三档。每题附一句话思路,先自己想,想不出再看。

基础 2

#题目一句话思路难度分
112路径总和沿根到叶路径扣减目标和,明确必须终止于叶子。
257二叉树的所有路径维护路径并生成根到叶字符串,练习状态复制与回退。

核心 5

#题目一句话思路难度分
113路径总和 II枚举所有满足目标和的根到叶路径,正确保存路径快照。
129求根节点到叶节点数字之和向下传递十进制前缀值,在叶子处汇总结果。
1448统计二叉树中好节点的数目传递路径上的最大值,判断当前节点是否满足祖先约束。难度分 1360
236二叉树的最近公共祖先利用左右子树返回结果定位两个节点的最近公共祖先。
1650二叉树的最近公共祖先 III 🔒借助父指针把最近公共祖先转化为两条祖先链的相交。

进阶 6

#题目一句话思路难度分
437路径总和 III在树DFS中维护路径前缀和频次,离开子树时撤销状态。
124二叉树中的最大路径和求最大路径和,区分向父节点提供的贡献与全局最优路径。
1644二叉树的最近公共祖先 II 🔒当目标节点可能不在树中时,增加存在性验证而非直接返回候选。
1123最深叶节点的最近公共祖先一次后序遍历返回子树高度与最深叶子的公共祖先。难度分 1607
863二叉树中所有距离为 K 的结点把父子关系视为无向图,从目标节点进行定距离BFS。难度分 1663
1530好叶子节点对的数量向父节点汇总叶子距离分布,统计跨左右子树的合法叶子对。难度分 1746