子问题视角:分治与动态规划

树形 DP

它其实就是后序遍历 + 一点状态

树形 DP 听起来是个新东西,其实代码形状你早就写过: 就是二叉树的递归遍历, 只不过每个节点返回的不再是一个简单的值,而是一组状态。

function dfs(node) {
  if (!node) return /* base case */;
  const L = dfs(node.left);       // ⭐ 先拿到左右子树的答案
  const R = dfs(node.right);
  return /* 用 L、R 和 node.val 组合出本节点的状态 */;
}

⭐ 一定是后序(先递归左右,再处理自己)—— 因为「当前节点的最优解」必须建立在「子树的最优解」之上。 这就是子问题视角在树上的样子。

📌 树形 DP 天然不用管遍历顺序(递归帮你保证了子问题先算), 省掉了区间 DP里最容易错的那一环。 它的难点在别处。

⭐ 核心难点:返回值 ≠ 答案

这是树形 DP 唯一真正的坎,而且它在最经典的那道题上暴露得最彻底。

二叉树中的最大路径和(力扣 124):路径可以从任意节点开始、 任意节点结束,但不能重复经过节点。求路径上节点值之和的最大值。

关键在于「路径」在一个节点上有两种形态:

     a                        a
    / \                      / \
   b   c                    b   c

  经过 a 拐弯:b-a-c        向上延伸:只能选 b 或 c 中的一支
  (可以是答案)             (才能接到 a 的父节点上)

🚨 拐弯的那条路径不能往上传 —— 它已经用掉了 a 的两个方向, 父节点再接上来就会分叉,不是一条路径了。

⭐ 所以递归函数返回单边最大贡献,而答案在遍历过程中用全局变量更新:

function maxPathSum(root) {
  let best = -Infinity;

  const gain = (node) => {
    if (!node) return 0;

    const L = Math.max(0, gain(node.left));    // 🚨 负贡献剪掉,见下文
    const R = Math.max(0, gain(node.right));

    best = Math.max(best, node.val + L + R);   // ⭐ 答案:允许在这里拐弯
    return node.val + Math.max(L, R);          // ⭐ 返回:只能带一支往上走
  };

  gain(root);
  return best;
}

⚠️ 那两行的不对称就是这道题的全部:一个加 L + R,一个加 max(L, R)。

🚨 两种错法,都能通过力扣给的第一个示例

实测三种实现在同一批树上的结果:

用例                         正确   ①返回拐弯值   ②不剪负贡献
[1,2,3]                         6          6 ✅          6 ✅
[-10,9,20,null,null,15,7]      42         41 ❌         42 ✅
[-100,1,2]                      2        -97 ❌          2 ✅
[10,-5,-5]                     10         10 ✅          0 ❌
[2,-1]                          2          2 ✅          1 ❌

⚠️ 力扣的两个官方示例是第一、二行。 第一行两种错法都通过;第二行只抓到错法①。 错法②(不剪负贡献)用官方示例根本测不出来 —— 得自己造 [10,-5,-5]。

⭐ 而且这两种错法的暴露条件几乎不重叠。按节点值的符号分布各造 3000 棵随机树:

树里的节点值      错法① 暴露    错法② 暴露
有正有负             55.7%         34.0%
全是正数             61.4%          0.0%     ← 完全测不出来
全是负数             67.7%         41.8%

🚨 全正数的树上错法②一次都不暴露 —— 没有负贡献,Math.max(0, …) 剪不剪都一样。 而算法题的示例数据十有八九是正数。 📌 判据:凡是靠 Math.max(0, …) 剪枝的地方,自测必须专门放负数进去。

错法①:把拐弯的值当返回值

return node.val + L + R;   // ❌

这样返回的是「经过本节点拐弯的最大和」,父节点接上去就成了分叉。 症状是 [-100, 1, 2] 返回 -97 而不是 2 —— 它被迫把那个 -100 的根算进去了,因为它从头到尾只报告了根节点的值。

错法②:不剪负贡献

const L = gain(node.left);   // ❌ 少了 Math.max(0, ...)

子树贡献是负的时候,不接它比接它好。Math.max(0, ...) 表达的就是 「这一支我不要了」。

症状是 [10, -5, -5] 得到 0 而不是 10 —— 两个 -5 硬被加上,把根的 10 抵消掉了。

🚨 ⚠️ 注意 Math.max(0, ...) 只能用在贡献上,不能用在 best 上。 best 的初值必须是 -Infinity,不能是 0 —— 否则全负数的树(如 [-3]) 会返回 0,而正确答案是 -3。路径至少包含一个节点,不能为空。

同一个骨架的三道题

⭐ 认出这个模式之后,一批题就是同一段代码换个组合方式:

题 返回值(往上传) 答案(在过程中更新)
124 最大路径和 val + max(L, R) val + L + R
543 二叉树的直径 1 + max(L, R)(深度) L + R(边数)
104 最大深度 1 + max(L, R) 就是返回值本身
// 543 直径:和 124 是同一个骨架
function diameterOfBinaryTree(root) {
  let best = 0;
  const depth = (n) => {
    if (!n) return 0;
    const L = depth(n.left), R = depth(n.right);
    best = Math.max(best, L + R);        // ⭐ 答案:左深 + 右深
    return 1 + Math.max(L, R);           // ⭐ 返回:深度只能算一支
  };
  depth(root);
  return best;
}

📌 只有 104 最大深度这种「答案就是返回值」的题,才不需要全局变量。 一旦答案可能出现在某个中间节点而不是根节点,就必须分开。

打家劫舍 III:一个节点带多个状态

力扣 337:树上每个节点有值,相邻的两个节点不能同时选,求最大和。

这里每个节点有两种状态:偷 或 不偷。 所以返回值不是一个数,而是一个二元组:

function rob(root) {
  // 返回 [不偷本节点的最大值, 偷本节点的最大值]
  const dfs = (n) => {
    if (!n) return [0, 0];
    const [lNo, lYes] = dfs(n.left);
    const [rNo, rYes] = dfs(n.right);
    return [
      Math.max(lNo, lYes) + Math.max(rNo, rYes),   // 不偷:孩子随意
      n.val + lNo + rNo,                            // 🚨 偷:孩子必须不偷
    ];
  };
  return Math.max(...dfs(root));
}

⭐ 返回二元组,一次遍历搞定。 这是树形 DP 的通用手法: 状态多了就多返回几个分量,而不是多跑几遍。

⚠️ 朴素递归慢在哪:多做了三千倍的函数调用

很多人的第一版是「偷了就跳过孩子、直接从孙子继续」:

// ❌ 没有记忆化:孙子层被重复计算
const rob = (n) => {
  if (!n) return 0;
  let take = n.val;
  if (n.left)  take += rob(n.left.left)  + rob(n.left.right);
  if (n.right) take += rob(n.right.left) + rob(n.right.right);
  return Math.max(take, rob(n.left) + rob(n.right));
};

它是对的,但每个节点被算了很多遍。实测满二叉树,看调用次数而不是看耗时:

节点数      朴素递归调用次数    二元组调用次数    调用次数之比
 16383          14,463,795          32,767          441×
 65535         151,466,803         131,071        1,156×
262143       1,586,180,915         524,287        3,025×

⭐ 调用次数是纯结构性的(跟节点值无关),所以这三行完全可复现。 二元组版的次数还有个精确公式:节点数 × 2 + 1(每个节点一次,加上所有空指针)。

🚨 26 万个节点,朴素版做了 15.9 亿次函数调用 —— 平均每个节点 6051 次。 这正是动规框架里说的重叠子问题: rob(孙子) 既被「偷儿子」这条路算,又被「不偷儿子」那条路算。

⚠️ 别去记「快多少倍」这个数。 耗时那一列里,朴素版是稳的 (26 万节点约 5 秒),但二元组版只要两三毫秒 —— 大数除以极小数, 商完全被 JIT 预热主导:

262143 节点   朴素 5081 ms   二元组 3.3 ms(冷启动)→ 1546×
                             二元组 2.8 ms(预热后)→ 1811×
16383 节点    朴素   44 ms   二元组 1.5 ms(冷启动)→   29×
                             二元组 0.2 ms(预热后)→  230×

🚨 同一台机器、同一份代码,16383 那一行的倍数在 29× 和 230× 之间摇摆。 ⭐ 所以这类对比要报调用次数比(441×/1156×/3025×,确定性), 耗时只用来说明「一个五秒、一个三毫秒」这个量级差别。

📌 两种修法:加一个 Map 备忘录(记忆化搜索), 或者像上面那样改状态定义、返回二元组(等价于自底向上递推)。 ⭐ 后者更快也更短 —— 树形 DP 里几乎总是选它。

模板

function treeDP(root) {
  let ans = /* 全局答案的初值 */ -Infinity;

  const dfs = (node) => {
    if (!node) return /* base case:注意是 0 还是 -Infinity 还是 [0,0] */;

    const L = dfs(node.left);
    const R = dfs(node.right);

    ans = /* 用 L、R、node 组合出「以本节点为最高点」的答案,更新全局 */;

    return /* 本节点能往上提供的状态 */;
  };

  dfs(root);
  return ans;
}

写之前先回答三个问题:

  1. 每个节点需要几个状态? 一个 → 返回数值;多个 → 返回数组/对象
  2. 答案会出现在中间节点吗? 会 → 需要全局变量;只在根 → 直接返回
  3. base case 是 0 还是 -Infinity? 空节点「贡献 0」用 0; 「不存在的方案」用 -Infinity

一般的树(不只是二叉树)

上面都是二叉树。换成多叉树或图上的树, 骨架一样,只是把「左右孩子」换成「遍历邻接表」:

const dfs = (u, parent) => {
  let acc = /* 初值 */;
  for (const v of graph[u]) {
    if (v === parent) continue;      // 🚨 无向图存树时,必须排除回到父节点
    const sub = dfs(v, u);
    acc = /* 累积 */;
  }
  return acc;
};

⚠️ 那个 if (v === parent) continue 是无向图存树时最容易漏的一行 —— 漏了会在父子之间来回递归,直接栈溢出。

下一步

区间 DP 的状态是「一段区间」,树形 DP 的状态是「一个节点」。 还有一类题,状态既不是位置也不是节点,而是你此刻处于哪个身份 —— 持有股票 / 不持有、刚卖出 / 冷冻期。那就是 状态机 DP。

练习

勾选记录做过哪些,0 / 7 道。进度只存在这台设备的浏览器里;同一道题在别篇勾过,这里也会显示已做。