打家劫舍题目总结

·  阅读 41

基本介绍

打家劫舍系列总共有三道,难度设计非常合理,层层递进。第一道是比较标准的动态规划问题,而第二道融入了环形数组的条件,第三道更绝,让盗贼在二叉树上打劫,这就是传说中的高智商犯罪吧。。。

下面,我们从第一道开始分析

198. 打家劫舍

image.png

public int rob(int[] nums);
复制代码

由于不能偷窃连续的房屋,我们自然想到,隔一个偷一间显然是一个不错的选择。那是不是,直接计算所有奇数项的和,以及所有偶数项的和,取最大值就可以了呢?并没有这么简单。例如,如果是[2,7,1,3,9],很明显,偷2,1,9或者7,3都不是最佳选择,偷7,9才是。

这里的关键是,对于三个连续的房屋2,7,1,由于跟后面的9都隔开了,所以我们可以选择偷2,1,也可以直接选择偷7。这就需要分情况讨论了。所以我们发现,从最后往前倒推,最后一间屋n,有偷和不偷两种选择:

  • 如果偷,那么前一间屋n-1一定没有偷,我们考虑n-2之前的最优选择,加上n就可以了;
  • 如果不偷,那么n-1之前的最优选择,就是当前的最优选择。

所以,这明显是一个动态规划的问题。

解决动态规划问题就是找「状态」和「选择」,仅此而已

假想你就是这个专业强盗,从左到右走过这一排房子,在每间房子前都有两种选择:抢或者不抢。

  • 如果你抢了这间房子,那么你肯定不能抢相邻的下一间房子了,只能从下下间房子开始做选择。

  • 如果你不抢这间房子,那么你可以走到下一间房子前,继续做选择。

当你走过了最后一间房子后,你就没得抢了,能抢到的钱显然是 0(base case)。

以上的逻辑很简单吧,其实已经明确了「状态」和「选择」:你面前房子的索引就是状态,抢和不抢就是选择

image.png

在两个选择中,每次都选更大的结果,最后得到的就是最多能抢到的 money:

我们可以将前n个房屋能偷到的最大金额,保存到状态数组dp。前i个房屋能够偷到的最大金额,就是dp[i]。

可以得到状态转移方程:

dp[i] = Math.max(dp[i-1], dp[i-2] + nums[i])

代码如下:

class Solution {
    public int rob(int[] nums) {
        int n = nums.length;
        if(n == 1) {
            return nums[0];
        }
        //定义状态,
        int[] dp = new int[n];
        dp[0] = nums[0];
        dp[1] = Math.max(dp[0], nums[1]);
        for(int i = 2; i < n; i++){
            dp[i] = Math.max(dp[i-1], dp[i-2] + nums[i]);
        }
        return dp[n-1];
    }
}
复制代码

上述方法使用了数组存储结果。我们通过状态方程可以发现,每间房屋的最高总金额,只和该房屋的前两间房屋的最高总金额相关。因此只要存储之前两间房屋的最高金额就可以了。

class Solution {
    public int rob(int[] nums) {
        int n = nums.length;
        if(n == 1) {
            return nums[0];
        }
        //定义状态,
        int pre1 = nums[0];
        int pre2 = Math.max(pre1, nums[1]);
        for(int i = 2; i < n; i++){
            int temp = Math.max(pre2, pre1 + nums[i]);
            pre1 = pre2;
            pre2 = temp;
        }
        return pre2;
    }
}
复制代码

213. 打家劫舍 II

image.png

这道题目和第一道描述基本一样,强盗依然不能抢劫相邻的房子,输入依然是一个数组,但是告诉你这些房子不是一排,而是围成了一个圈

也就是说,现在第一间房子和最后一间房子也相当于是相邻的,不能同时抢。比如说输入数组nums=[2,3,2],算法返回的结果应该是 3 而不是 4,因为开头和结尾不能同时被抢。

首先,首尾房间不能同时被抢,那么只可能有三种不同情况:要么都不被抢;要么第一间房子被抢最后一间不抢;要么最后一间房子被抢第一间不抢。

image.png

那就简单了啊,这三种情况,哪种的结果最大,就是最终答案呗!不过,其实我们不需要比较三种情况,只要比较情况二和情况三就行了, 因为这两种情况对于房子的选择余地比情况一大呀,房子里的钱数都是非负数,所以选择余地大,最优决策结果肯定不会小

所以只需对之前的解法稍作修改即可:

class Solution {
    public int rob(int[] nums) {
        int n = nums.length;
        if(n == 1) {
            return nums[0];
        }
        return Math.max(robRange(nums, 0, n-2), robRange(nums, 1, n-1));
    }

    public int robRange(int[] nums, int start, int end) {       
        if(start == end) {
            return nums[start];
        }
        //定义状态,
        int pre1 = nums[start];
        int pre2 = Math.max(pre1, nums[start + 1]);
        for(int i = start + 2; i <= end; i++){
            int temp = Math.max(pre2, pre1 + nums[i]);
            pre1 = pre2;
            pre2 = temp;
        }
        return pre2;
    }
}
复制代码

337. 打家劫舍 III

image.png

image.png

第三题又想法设法地变花样了,此强盗发现现在面对的房子不是一排,不是一圈,而是一棵二叉树!房子在二叉树的节点上,相连的两个房子不能同时被抢劫:

解法一、暴力递归 - 最优子结构

在解法一和解法二中,我们使用爷爷、两个孩子、4 个孙子来说明问题 首先来定义这个问题的状态,爷爷节点获取到最大的偷取的钱数呢

首先要明确相邻的节点不能偷,也就是爷爷选择偷,儿子就不能偷了,但是孙子可以偷,二叉树只有左右两个孩子,一个爷爷最多 2 个儿子,4 个孙子

根据以上条件,我们可以得出单个节点的钱该怎么算?

4 个孙子偷的钱 + 爷爷的钱 VS 两个儿子偷的钱 哪个组合钱多,就当做当前节点能偷的最大钱数。这就是动态规划里面的最优子结构

由于是二叉树,这里可以选择计算所有子节点

  • 4 个孙子投的钱加上爷爷的钱如下

    int method1 = root.val + rob(root.left.left) + rob(root.left.right) + rob(root.right.left) + rob(root.right.right)

  • 两个儿子偷的钱如下

    int method2 = rob(root.left) + rob(root.right);

  • 挑选一个钱数多的方案则

    int result = Math.max(method1, method2);

将上述方案写成代码如下

public int rob(TreeNode root) {
    if (root == null) {
        return 0;
    }

    int money = root.val;
    
    if (root.left != null) {
        //当前节点加上左边孙子节点的钱
        money += (rob(root.left.left) + rob(root.left.right));
    }

    if (root.right != null) {
        //当前节点加上右边孙子节点的钱
        money += (rob(root.right.left) + rob(root.right.right));
    }

    return Math.max(money, rob(root.left) + rob(root.right));
}
复制代码

但是这种方式提交会出现超时。

解法二、记忆化 - 解决重复子问题

针对解法一种速度太慢的问题,经过分析其实现,我们发现爷爷在计算自己能偷多少钱的时候,同时计算了 4 个孙子能偷多少钱,也计算了 2 个儿子能偷多少钱。这样在儿子当爷爷时,就会产生重复计算一遍孙子节点。

于是乎我们发现了一个动态规划的关键优化点

重复子问题

我们这一步针对重复子问题进行优化,我们在做斐波那契数列时,使用的优化方案是记忆化,但是之前的问题都是使用数组解决的,把每次计算的结果都存起来,下次如果再来计算,就从缓存中取,不再计算了,这样就保证每个数字只计算一次。

由于二叉树不适合拿数组当缓存,我们这次使用哈希表来存储结果,TreeNode 当做 key,能偷的钱当做 value

解法一加上记忆化优化后代码如下:

public int rob(TreeNode root) {
    HashMap<TreeNode, Integer> memo = new HashMap<>();
    return robInternal(root, memo);
}

public int robInternal(TreeNode root, HashMap<TreeNode, Integer> memo) {
    if (root == null) {
        return 0;
    }
    
    if (memo.containsKey(root)) {
        return memo.get(root);
    }
    
    int money = root.val;

    if (root.left != null) {
        money += (robInternal(root.left.left, memo) + robInternal(root.left.right, memo));
    }
    if (root.right != null) {
        money += (robInternal(root.right.left, memo) + robInternal(root.right.right, memo));
    }
    int result = Math.max(money, robInternal(root.left, memo) + robInternal(root.right, memo));
    memo.put(root, result);
    return result;
}
复制代码

说到这里,肯定会有人对上面的最优子结构有疑问,我选择一个儿子和另外两个孙子不行吗

因为另外两个孙子偷钱的情况已经包含在另外一个儿子中、所以儿子能偷到的最大值肯定大于两个孙子,所以这种情况可以去掉了

这样,打家劫舍系列问题就全部解决了,其实也没多难吧?

分类:
后端
标签:
分类:
后端
标签: