第 31 站 · 动态规划
路径、树形与对抗状态
树形 DP:每个结点返回(选,不选) O(n)
- 学完去
- 没有后续站依赖它。
- 面试频次
- 2 家公司考过 · 面经出现 2 次TikTok / ByteDance 1、Waymo 1
先做这道
LC 403青蛙过河卡住再看
到达同一石头但步长不同会影响后续可达性,训练识别缺失的状态维度。
要点
- 树形 DP 用后序遍历:先拿到左右子树的结果,再算当前结点,每个结点返回一个小元组。
- 按「当前结点选不选」拆状态,比在父结点里往下看两层更简单,也不会重复计算。
- 两人博弈题常用「当前先手比对手多拿多少」作为状态:dp[i][j] = max(nums[i] - dp[i+1][j], nums[j] - dp[i][j-1])。
易错点
- 不返回元组,而是用「祖父 + 孙子」的写法又不做记忆化,会指数级重复计算。
- 不选当前结点时,孩子也不一定要选,每个孩子都要取 max(选, 不选)。
- 博弈题如果按「我的得分」建状态,往往还得再记对手的得分;用差值更省事。
看动画
一步一步播放这个模式在小例子上的执行过程。可以暂停、单步前进或后退,拖动进度条跳到任意一步。
二叉树遍历 · 栈与队列
前序、中序、后序、层序遍历这棵树
换个输入
- 遍历
- 前序
- 输出
- —
同一棵树,四种遍历顺序。节点下的 #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] >= 0C++
// 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]
}为什么对
- 按「当前节点选或不选」拆状态后,子树之间互不影响:选了当前节点,孩子必须不选;不选当前节点,每个孩子在两种状态里自由取较大者。两种情况覆盖全部方案。
- 后序遍历保证算当前节点时两个孩子的结果都已就绪,每个节点只算一次,O(n)。
- 博弈用分差: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)。双人博弈我用当前玩家能领先多少分做状态,拿走一端后对手面对的是同样的子问题,减去它的结果即可。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 天的间隔回来,到期的题在「我的进度 · 到期复习」里。 只存在这台设备上;登录后会合并到你的账号。
小测
3 道概念题和复杂度题。答过的题会和 OA 选择题练习一起按间隔回来复习。 去 OA 选择题练习
作答记录只保存在这台设备的浏览器里;登录后打开OA 选择题练习页,会存进账号。
模板练习
用上面的模板和变体出题:把打乱的行排回原序,把挖掉的边界条件填回去,或者在空白编辑器里整段默写,跑本站的测试并逐行对比。每次「换一题」都会重新出题。
下面几行被打乱了。点一行选中,再点另一行和它交换,排回模板原来的顺序;缩进是提示。
class TreeNode:
return max(dfs(root))分档练习
本站在 500 路里的 6 道题,按基础、核心、进阶分三档。每题附一句话思路,先自己想,想不出再看。
核心 1
| # | 题目 | 一句话思路 | 难度分 |
|---|---|---|---|
| 337 | 打家劫舍 III | 在树上为每个节点返回选与不选两种收益,通过后序遍历组合孩子结果。 |
进阶 5
本站 OA 真题 12
- Minimize VariationAmazon
- Maximum Number of Moves with Same ResultMicrosoft
- Get Process TimeLinkedIn
- Longest String ChainLinkedIn
- Can You Count the Bit Strings?Snowflake
- Get Minimum TimeSalesforce
- Maximize Sum of Processed TimesSalesforce
- Increasing Paths, part 1Hudson River Trading
- Increasing Paths, part 2Hudson River Trading
- Get Minimum CostThe D. E. Shaw Group