第八章:动态规划高级——区间DP、树形DP与经典例题

动态规划学到这儿,大家基本套路都熟了。但说实话,面试里真正卡人的,往往是那些「状态定义不直观」的题目。今天聊的区间DP和树形DP,就是这类问题的典型代表。

我个人习惯把区间DP叫做「小区间推大区间」的玩法。而树形DP,说白了就是在树上做递归决策。这两个东西看着唬人,其实核心思想没变——还是那个「最优子结构 + 重叠子问题」的老配方。

8.1 区间DP:从小区间开始,慢慢长大

区间DP,顾名思义,状态是一个区间。比如 dp[i][j] 表示从第 i 个元素到第 j 个元素这个区间的最优解。怎么转移?通常是在区间里找一个分割点 k,把大区间拆成两个小区间,然后合并结果。

我在项目中遇到过类似场景:要计算一段文本里最长的对称子串。当时第一反应是暴力枚举,但数据量一大就扛不住了。后来换成区间DP,复杂度从 O(n³) 降到 O(n²),效果立竿见影。

8.1.1 经典例题:最长回文子串

题目很简单:给你一个字符串 s,找出其中最长的回文子串。回文就是正着读反着读都一样,比如 "aba"、"abba"。

嗯,这里要注意:回文子串有两种情况——奇数长度和偶数长度。区间DP可以统一处理。

状态定义:

dp[i][j] 表示子串 s[i..j] 是否为回文串(true/false)。

转移方程:

if s[i] == s[j]:
    if j - i <= 2:          // 长度小于等于3,直接是回文
        dp[i][j] = true
    else:
        dp[i][j] = dp[i+1][j-1]  // 取决于内部子串
else:
    dp[i][j] = false

代码实现:

public String longestPalindrome(String s) {
    int n = s.length();
    boolean[][] dp = new boolean[n][n];
    int start = 0, maxLen = 1;

    // 所有长度为1的子串都是回文
    for (int i = 0; i < n; i++) dp[i][i] = true;

    // 按长度从小到大枚举
    for (int len = 2; len <= n; len++) {
        for (int i = 0; i + len - 1 < n; i++) {
            int j = i + len - 1;
            if (s.charAt(i) == s.charAt(j)) {
                if (len <= 3) {
                    dp[i][j] = true;
                } else {
                    dp[i][j] = dp[i+1][j-1];
                }
            }
            // 更新答案
            if (dp[i][j] && len > maxLen) {
                start = i;
                maxLen = len;
            }
        }
    }
    return s.substring(start, start + maxLen);
}
小技巧: 区间DP的遍历顺序很关键。一定要先枚举区间长度,再枚举起点。如果先枚举起点再枚举终点,你会发现需要的子状态还没算出来。
避坑指南: 我曾经在写转移方程时忘了处理长度小于等于3的情况,结果 "aa" 这种长度为2的回文一直判不对。记住:长度1、2、3的子串,只要两端相等就是回文,不需要看内部。

8.2 树形DP:在树上做决策

树形DP,就是把DP搬到了树上。你想想看,树的结构天然适合递归——每个节点的问题可以交给它的子节点去解决。状态通常定义成 dp[node][状态],表示以 node 为根的子树在某种状态下的最优解。

我个人觉得树形DP最妙的地方在于:它把「选或不选」这种二维决策,和树的层次结构完美结合在了一起。

8.2.1 经典例题:打家劫舍III

题目背景:小偷又来了。这次不是一排房子,而是一棵二叉树。每个节点代表一个房子,里面有现金。如果两个直接相连的房子同时被偷,会触发警报。问最多能偷多少钱。

说白了,这就是一个「相邻节点不能同时选」的问题,只不过从线性结构变成了树形结构。

状态定义:

每个节点有两个状态:

  • dp[node][0]:不偷 node 时,以 node 为根的子树能偷到的最大值
  • dp[node][1]:偷 node 时,以 node 为根的子树能偷到的最大值

转移方程:

// 不偷当前节点:子节点可偷可不偷,取大的
dp[node][0] = max(dp[left][0], dp[left][1]) + max(dp[right][0], dp[right][1])

// 偷当前节点:子节点都不能偷
dp[node][1] = node.val + dp[left][0] + dp[right][0]

代码实现:

public int rob(TreeNode root) {
    int[] result = dfs(root);
    return Math.max(result[0], result[1]);
}

private int[] dfs(TreeNode node) {
    if (node == null) return new int[]{0, 0};

    int[] left = dfs(node.left);
    int[] right = dfs(node.right);

    // 不偷当前节点
    int notRob = Math.max(left[0], left[1]) + Math.max(right[0], right[1]);
    // 偷当前节点
    int rob = node.val + left[0] + right[0];

    return new int[]{notRob, rob};
}
核心要点: 树形DP的返回值通常是一个数组或结构体,包含多个状态值。后序遍历保证了子节点的结果先算出来,父节点直接拿来用就行。
我的习惯: 写树形DP时,我一般先画一棵小树,手动推一遍状态值。比如一个三层二叉树,从叶子往上推,看看每个节点的两个状态怎么算。走一遍流程,代码基本不会写错。

8.3 知识体系总览

下面这张图总结了区间DP和树形DP的核心要点。我建议你把它存下来,刷题前看一眼,思路会清晰很多。

动态规划高级:知识体系总览 区间DP 核心思想: 小区间 → 大区间,枚举分割点 状态定义: dp[i][j]:区间[i, j]的最优解 遍历顺序: 先枚举长度,再枚举起点 经典例题: • 最长回文子串 • 石子合并 • 戳气球 树形DP 核心思想: 后序遍历,子节点推父节点 状态定义: dp[node][状态]:子树最优解 遍历顺序: DFS后序遍历(递归) 经典例题: • 打家劫舍III • 树的最大独立集 • 树的直径 共同点:最优子结构 + 重叠子问题 → 状态转移

8.4 总结与避坑

区间DP和树形DP,说白了就是两种特殊的DP场景。区间DP的关键在于「枚举长度」和「枚举分割点」的顺序;树形DP的关键在于「后序遍历」和「多状态返回」。

最后分享几个我踩过的坑:

  • 区间DP的初始化: 长度1的区间一定要先初始化好,否则后面转移会出问题。
  • 树形DP的递归边界: 空节点返回 [0, 0] 是标准写法,别漏了。
  • 状态数组的维度: 区间DP常用二维数组,树形DP常用一维数组+状态枚举。别搞混了。

嗯,这一章的内容就到这儿。记住:多画图、多手动推状态,比死记硬背代码管用得多。


公众号:蓝海资料掘金营,微信 deep3321