cswork / 模板打开 500 路题单

第 31 站 · 动态规划

路径、树形与对抗状态

树形 DP:每个结点返回(选,不选) O(n)

周赛难度分 难度分 1954–难度分 2035· 中位 难度分 1995(2/6 道题有 zerotrac 周赛分)

打开 500 路题单 · 第 31 站 · 6 题
先学
学完去
没有后续站依赖它。
面试频次
2 家公司考过 · 面经出现 2 次TikTok / ByteDance 1、Waymo 1

先做这道

LC 403青蛙过河困难
卡住再看

到达同一石头但步长不同会影响后续可达性,训练识别缺失的状态维度。

要点

  1. 树形 DP 用后序遍历:先拿到左右子树的结果,再算当前结点,每个结点返回一个小元组。
  2. 按「当前结点选不选」拆状态,比在父结点里往下看两层更简单,也不会重复计算。
  3. 两人博弈题常用「当前先手比对手多拿多少」作为状态:dp[i][j] = max(nums[i] - dp[i+1][j], nums[j] - dp[i][j-1])。

易错点

  • 不返回元组,而是用「祖父 + 孙子」的写法又不做记忆化,会指数级重复计算。
  • 不选当前结点时,孩子也不一定要选,每个孩子都要取 max(选, 不选)。
  • 博弈题如果按「我的得分」建状态,往往还得再记对手的得分;用差值更省事。

看动画

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

交互演示 · 逐步观察

二叉树遍历 · 栈与队列

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

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

同一棵树,四种遍历顺序。节点下的 #k 是它在当前遍历里第几个输出。

始终成立深度优先的三种顺序只差在「输出根」排在左右子树的前、中、后;层序用队列,先进先出就是一层一层。

模板

选一种语言展开。四种写法实现的是同一个模板,每一份都编译运行过,并用测试数据核对了输出。看完用它重写热身题 LC 403。

Python
class TreeNode:
    def __init__(self, val=0, left=None, right=None):
        self.val, self.left, self.right = val, left, right


def rob_tree(root) -> int:
    """Largest sum of node values with no parent-child pair both taken."""

    def dfs(node) -> tuple[int, int]:  # (best if node is taken, best if it is not)
        if not node:
            return 0, 0
        lt, ls = dfs(node.left)
        rt, rs = dfs(node.right)
        take = node.val + ls + rs  # children must be skipped
        skip = max(lt, ls) + max(rt, rs)  # children are free to choose
        return take, skip

    return max(dfs(root))
C++
struct TreeNode {
    int val;
    TreeNode *left, *right;
    TreeNode(int v, TreeNode* l = nullptr, TreeNode* r = nullptr) : val(v), left(l), right(r) {}
};

// {best if node is taken, best if it is not}
pair<long long, long long> robDfs(TreeNode* node) {
    if (!node) return {0, 0};
    auto [lt, ls] = robDfs(node->left);
    auto [rt, rs] = robDfs(node->right);
    long long take = node->val + ls + rs;          // children must be skipped
    long long skip = max(lt, ls) + max(rt, rs);    // children are free to choose
    return {take, skip};
}

// Largest sum of node values with no parent-child pair both taken.
long long robTree(TreeNode* root) {
    auto [take, skip] = robDfs(root);
    return max(take, skip);
}
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 {
    // Largest sum of node values with no parent-child pair both taken.
    long robTree(TreeNode root) {
        long[] r = dfs(root);
        return Math.max(r[0], r[1]);
    }

    private long[] dfs(TreeNode node) { // {best if node is taken, best if it is not}
        if (node == null) return new long[] {0, 0};
        long[] l = dfs(node.left), r = dfs(node.right);
        long take = node.val + l[1] + r[1]; // children must be skipped
        long skip = Math.max(l[0], l[1]) + Math.max(r[0], r[1]); // children are free to choose
        return new long[] {take, skip};
    }
}
Go
type TreeNode struct {
	Val         int
	Left, Right *TreeNode
}

// robTree returns the largest sum of node values with no parent-child pair both taken.
func robTree(root *TreeNode) int {
	var dfs func(*TreeNode) (take, skip int)
	dfs = func(node *TreeNode) (int, int) {
		if node == nil {
			return 0, 0
		}
		lt, ls := dfs(node.Left)
		rt, rs := dfs(node.Right)
		take := node.Val + ls + rs        // children must be skipped
		skip := max(lt, ls) + max(rt, rs) // children are free to choose
		return take, skip
	}
	take, skip := dfs(root)
	return max(take, skip)
}

常见变体

这一站的题还会用到下面 2 种写法。展开看什么时候换用它,代码同样四种语言都跑过测试。

博弈区间 DP:存「先手比后手多几分」时间 O(n²) · 空间 O(n)

两人轮流从两端拿、都走最优(预测赢家、石子游戏):dp[i][j] 存当前要走的人在 nums[i..j] 上能比对手多拿多少,拿走一端后对手面对剩下的区间,所以是 nums[i] − dp[i+1][j] 和 nums[j] − dp[i][j−1] 取大。只差一维,可以压成一行。LC 486 预测赢家、LC 877 石子游戏。

Python
def first_player_wins(nums: list[int]) -> bool:
    """Players take from either end in turn; does the first end with at least as much? (LC 486)"""
    n = len(nums)
    dp = nums[:]  # dp[j] for the current i: best (mover - opponent) on nums[i..j]; i == j: take it
    for i in range(n - 2, -1, -1):
        for j in range(i + 1, n):
            dp[j] = max(nums[i] - dp[j], nums[j] - dp[j - 1])  # take the left end, or the right
    return dp[n - 1] >= 0
C++
// Players take from either end in turn; does the first end with at least as much? (LC 486)
bool firstPlayerWins(const vector<int>& nums) {
    int n = nums.size();
    vector<int> dp = nums;  // dp[j] for the current i: best (mover - opponent) on nums[i..j]; i == j: take it
    for (int i = n - 2; i >= 0; i--)
        for (int j = i + 1; j < n; j++)
            dp[j] = max(nums[i] - dp[j], nums[j] - dp[j - 1]);  // take the left end, or the right
    return dp[n - 1] >= 0;
}
Java
class Solution {
    // Players take from either end in turn; does the first end with at least as much? (LC 486)
    boolean firstPlayerWins(int[] nums) {
        int n = nums.length;
        int[] dp = nums.clone(); // dp[j] for the current i: best (mover - opponent) on nums[i..j]; i == j: take it
        for (int i = n - 2; i >= 0; i--)
            for (int j = i + 1; j < n; j++)
                dp[j] = Math.max(nums[i] - dp[j], nums[j] - dp[j - 1]); // take the left end, or the right
        return dp[n - 1] >= 0;
    }
}
Go
// firstPlayerWins: players take from either end in turn; does the first end with at least as much? (LC 486)
func firstPlayerWins(nums []int) bool {
	n := len(nums)
	dp := append([]int(nil), nums...) // dp[j] for the current i: best (mover - opponent) on nums[i..j]
	for i := n - 2; i >= 0; i-- {
		for j := i + 1; j < n; j++ {
			dp[j] = max(nums[i]-dp[j], nums[j]-dp[j-1]) // take the left end, or the right
		}
	}
	return dp[n-1] >= 0
}
单调队列优化 DP:窗口里的最大 dp时间 O(n) · 空间 O(n)

转移是「从前面 k 个位置里挑 dp 最大的」(每次最多跳 k 步、带长度限制的子序列和)时,每步扫 k 个是 O(nk);用单调队列维护窗口内的最大 dp,降到 O(n)。LC 1696 跳跃游戏 VI;LC 1425 带限制的子序列和、LC 862 是同一个队列。

Python
from collections import deque


def max_jump_score(nums: list[int], k: int) -> int:
    """From index 0 to n - 1 in jumps of 1..k steps: the best sum of the values landed on (LC 1696)."""
    n = len(nums)
    dp = [0] * n  # dp[i]: best score landing on i
    dp[0] = nums[0]
    dq = deque([0])  # indexes in the last k, dp decreasing: the front is the best place to jump from
    for i in range(1, n):
        if dq[0] < i - k:
            dq.popleft()  # too far back to reach i
        dp[i] = dp[dq[0]] + nums[i]
        while dq and dp[dq[-1]] <= dp[i]:
            dq.pop()  # i is closer and no worse: they will never be the best again
        dq.append(i)
    return dp[-1]
C++
// From index 0 to n - 1 in jumps of 1..k steps: the best sum of the values landed on (LC 1696).
long long maxJumpScore(const vector<int>& nums, int k) {
    int n = nums.size();
    vector<long long> dp(n);  // dp[i]: best score landing on i
    dp[0] = nums[0];
    deque<int> dq{0};  // indexes in the last k, dp decreasing: the front is the best place to jump from
    for (int i = 1; i < n; i++) {
        if (dq.front() < i - k) dq.pop_front();  // too far back to reach i
        dp[i] = dp[dq.front()] + nums[i];
        while (!dq.empty() && dp[dq.back()] <= dp[i]) dq.pop_back();  // i is closer and no worse
        dq.push_back(i);
    }
    return dp[n - 1];
}
Java
class Solution {
    // From index 0 to n - 1 in jumps of 1..k steps: the best sum of the values landed on (LC 1696).
    long maxJumpScore(int[] nums, int k) {
        int n = nums.length;
        long[] dp = new long[n]; // dp[i]: best score landing on i
        dp[0] = nums[0];
        Deque<Integer> dq = new ArrayDeque<>(List.of(0)); // indexes in the last k, dp decreasing
        for (int i = 1; i < n; i++) {
            if (dq.peekFirst() < i - k) dq.pollFirst(); // too far back to reach i
            dp[i] = dp[dq.peekFirst()] + nums[i];
            while (!dq.isEmpty() && dp[dq.peekLast()] <= dp[i]) dq.pollLast(); // i is closer and no worse
            dq.offerLast(i);
        }
        return dp[n - 1];
    }
}
Go
// maxJumpScore: from index 0 to n - 1 in jumps of 1..k steps, the best sum of the values landed on (LC 1696).
func maxJumpScore(nums []int, k int) int {
	n := len(nums)
	dp := make([]int, n) // dp[i]: best score landing on i
	dp[0] = nums[0]
	dq := []int{0} // indexes in the last k, dp decreasing: the front is the best place to jump from
	for i := 1; i < n; i++ {
		if dq[0] < i-k {
			dq = dq[1:] // too far back to reach i
		}
		dp[i] = dp[dq[0]] + nums[i]
		for len(dq) > 0 && dp[dq[len(dq)-1]] <= dp[i] {
			dq = dq[:len(dq)-1] // i is closer and no worse
		}
		dq = append(dq, i)
	}
	return dp[n-1]
}

为什么对

  1. 按「当前节点选或不选」拆状态后,子树之间互不影响:选了当前节点,孩子必须不选;不选当前节点,每个孩子在两种状态里自由取较大者。两种情况覆盖全部方案。
  2. 后序遍历保证算当前节点时两个孩子的结果都已就绪,每个节点只算一次,O(n)。
  3. 博弈用分差:dp[i][j] 表示当前玩家在 nums[i..j] 上最多能领先对手多少分。拿走一端后,对手面对的子区间是同样的问题,所以 dp[i][j] = max(nums[i] − dp[i+1][j], nums[j] − dp[i][j−1])。

30 秒口述稿

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

中文
树形 DP 我用后序遍历,每个节点返回一个二元组:选它时的最优值和不选它时的最优值。选了它,两个孩子都只能不选;不选它,每个孩子在两种里取更大的。这样子树互不干扰,每个节点算一次,O(n)。双人博弈我用当前玩家能领先多少分做状态,拿走一端后对手面对的是同样的子问题,减去它的结果即可。
English
For tree DP I go post-order and each node returns a pair: the best value if it is taken and if it is skipped. If it is taken both children must be skipped; if not, each child contributes the larger of its two values. Subtrees stay independent and each node is computed once, so it is O(n). For two-player games I use how far the player to move can finish ahead, because after taking one end the opponent faces the same subproblem and I subtract its result.

自评一下

对照上面的答案给自己打个分。没掌握的题会按 1、3、7、21 天的间隔回来,到期的题在「我的进度 · 到期复习」里。 只存在这台设备上;登录后会合并到你的账号。

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

小测

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

3 道题
  1. 两人轮流从数组两端取数,都按最优策略。区间 DP 写作 dp[i][j] = max(a[i] - dp[i+1][j], a[j] - dp[i][j-1])。dp[i][j] 表示什么?
  2. 「矩阵中的最长递增路径」用带记忆化的 DFS 求解,为什么不需要 visited 数组防止走回头路?
  3. 下面代码的时间复杂度是?(n = len(a),取最紧的界)
    from functools import lru_cache
    
    def first_player_margin(a):
        @lru_cache(maxsize=None)
        def go(i, j):
            if i > j:
                return 0
            return max(a[i] - go(i + 1, j), a[j] - go(i, j - 1))
    
        return go(0, len(a) - 1)

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

模板练习

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

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

class TreeNode:

    return max(dfs(root))

分档练习

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

核心 1

#题目一句话思路难度分
337打家劫舍 III在树上为每个节点返回选与不选两种收益,通过后序遍历组合孩子结果。

进阶 5

#题目一句话思路难度分
403青蛙过河到达同一石头但步长不同会影响后续可达性,训练识别缺失的状态维度。
1696跳跃游戏 VI从窗口最大转移推导单调队列优化,正确过期索引而不是比较原数组值。难度分 1954
486预测赢家用净胜分表达双方最优选择,避免把对手当成固定贪心策略。
375猜数字大小 II区分最坏情况成本与期望成本,用minimax解释二分中点不一定最便宜。
1140石子游戏 II把可取数量上限加入对抗状态,用后缀和转换双方收益并记忆化。难度分 2035