小偷又发现了一个新的可行窃的地区。这个地区只有一个入口,我们称之为 root 。
除了 root 之外,每栋房子有且只有一个“父“房子与之相连。一番侦察之后,聪明的小偷意识到“这个地方的所有房屋的排列类似于一棵二叉树”。 如果 两个直接相连的房子在同一天晚上被打劫 ,房屋将自动报警。
给定二叉树的 root 。返回 在不触动警报的情况下 ,小偷能够盗取的最高金额 。
题解
递归 (非重点)
思路
打不打劫根节点,会影响打劫一棵树的收益:
打劫根节点,则不能打劫左右子节点,但是能打劫左右子节点的四个子树(如果有)。
不打劫根节点,则能打劫左子节点和右子节点,收益是打劫左右子树的收益之和。
const rob = (root) => { // 打劫以root为根节点的子树的最大收益
if (root == null) {
return 0;
}
// 打劫包括根节点的收益,保底是root.val
let robIncludeRoot = root.val;
if (root.left) {
robIncludeRoot += rob(root.left.left) + rob(root.left.right);
}
if (root.right) {
robIncludeRoot += rob(root.right.left) + rob(root.right.right);
}
// 打劫不包括根节点的收益
const robExcludeRoot = rob(root.left) + rob(root.right);
// 二者取其大
return Math.max(robIncludeRoot, robExcludeRoot);
};
记忆化递归
刚才哪里做了重复计算?
我们计算了 root 的四个孙子子树,又计算了 root 的左右子树,而后者会把 root 的孙子子树重复计算一遍。
我们可以把计算过的结果存到 map。下次遇到相同的子问题时直接拿过来用,不用做重复的计算。
观察上面发现,左右子树的两个状态影响当前子树的两个状态,但别的子树的状态影响不了。
因此没必要用 map 记录每一个子树的状态,递归总是子调用的解返回给父调用,所以只需在每次递归中用两个变量,存当前子问题的两个状态,返回出来给父调用即可。
class Solution {
public int rob(TreeNode root) {
int[] res = dfs(root);
return Math.max(res[0],res[1]);
}
public int[] dfs(TreeNode root){
//base case
if(root==null) return new int[]{0,0};
//dp[root][0]:打劫以 root 为根节点的子树,并且不打劫 root 节点的最大收益。
//dp[root][1]:打劫以 root 为根节点的子树,并且打劫 root 节点的最大收益。
int[] left = dfs(root.left);
int[] right = dfs(root.right);
int selected = root.val + left[0]+right[0];
int notSelected = Math.max(left[0],left[1]) + Math.max(right[0],right[1]);
return new int[]{notSelected,selected};
}
}
复盘
树形DP不像常规DP那样在迭代中“填表格”,而是在递归遍历中“填表格”。
这道题的二维矩阵定义其实是:
dp[root][0]:打劫以 root 为根节点的子树,并且不打劫 root 节点的最大收益。
dp[root][1]:打劫以 root 为根节点的子树,并且打劫 root 节点的最大收益。
在分析时,注意 root 节点和子节点相互冲突的关系。
base case是 dp[null节点][0]=0、dp[null节点][1]=0,遍历到 null 时,返回这两个状态。
随着递归的出栈,子调用向上返回,子问题的解不断被填入到这张表格中。
最后求出了填上了我们需要的 dp[ROOT][0] 和 dp[ROOT][1]。