House Robber系列问题

House Robber I

Reference: https://leetcode.com/problems/house-robber-iii/discuss/79330/Step-by-step-tackling-of-the-problem
https://leetcode.com/problems/house-robber/discuss/156523/From-good-to-great.-How-to-approach-most-of-DP-problems

How to approach a DP problem

  1. Find recursive relation
  2. Recursive (top-down)
  3. Recursive + memo (top-down)
  4. Iterative + memo (bottom-up)
  5. Iterative + N variables (bottom-up)

Step 1. Figure out recursive relation.
A robber has 2 options: a) rob current house i; b) don’t rob current house.
If an option “a” is selected it means she can’t rob previous i-1 house but can safely proceed to the one before previous i-2 and gets all cumulative loot that follows.
If an option “b” is selected the robber gets all the possible loot from robbery of i-1 and all the following buildings.
So it boils down to calculating what is more profitable:

  • robbery of current house + loot from houses before the previous
  • loot from the previous house robbery and any loot captured before that
rob(i) = Math.max( rob(i - 2) + currentHouseValue, rob(i - 1) )

Step 2. Recursive (top-down)
Converting the recurrent relation from Step 1 shound’t be very hard.

public int rob(int[] nums) {
    return rob(nums, nums.length - 1);
}

private int rob(int[] nums, int index) {
    if(index < 0){
        return 0;
    }
    return Math.max(rob(nums, index-2)+ nums[index], rob(nums, index-1));
}

This algorithm will process the same index multiple times and it needs improvement. Time complexity: [to fill]

Step 3. Recursive + memo (top-down).

int[] memo;
public int rob(int[] nums) {
    memo = new int[nums.length + 1];
    Arrays.fill(memo, -1);
    return rob(nums, nums.length - 1);
}

private int rob(int[] nums, int index) {
    if(index < 0){
        return 0;
    }
    if(momo[index] >= 0){
        return memo[index];
    }
    int ans = Math.max(rob(nums, index-2)+nums[index], rob(nums, index - 1));
    memo[index] = ans;
    return ans;
}

Much better, this should run in O(n) time. Space complexity is O(n) as well, because of the recursion stack, let’s try to get rid of it.

Step 4. Iterative + memo (bottom-up)

public int rob(int[] nums) {
    if(nums.length == 0) {
        return 0;
    }
    
    int memo[] = new int[nums.length+1];
    memo[0] = 0;
    memo[1] = nums[0];
    for(int i = 1;i<nums.length; i++){
        int val = nums[i];
        memo[i+1] = Math.max(memo[i], memo[i-1] + val);
    }
    return memo[nums.length];
}

Step 5. Iterative + 2 variables (bottom-up)
We can notice that in the previous step we use only memo[i] and memo[i-1], so going just 2 steps back. We can hold them in 2 variables instead. This optimization is met in Fibonacci sequence creation and some other problems [to paste links].

// the order is : prev2, prev1, num ....
public int rob(int[] nums) {
    if(nums.length == 0){
        return 0;
    }
    int prev1 = 0;
    int prev2 = 0;
    for(int num : nums) {
        int tmp = prev1;
        prev1 = Math.max(prev2 + num, prev1);
        prev2 = tmp;
    }
    return prev1;
}

House Robber II

The first house is the neighbor of the last one.

// 两趟动规,抢第一家不抢最后一家  抢最后一家不抢第一家
public int rob(int[] nums) {
    if(nums == null || nums.length == 0) {
        return 0;
    }
    if(nums.length == 1) {
        return nums[0];
    }
    if(nums.length == 2) {
        return Math.max(nums[0], nums[1]);
    }
    return Math.max(maxRobber(nums, 0, nums.length-2), maxRobber(nums, 1, nums.length-1));
}

public static int maxRobber(int[] nums, int begin, int end) {
    int[] dp = new int[end - begin + 1];
    dp[0] = nums[begin];
    dp[1] = Math.max(nums[begin], nums[begin+1]);

    for(int i = 2,j = begin + 2;i< end - begin + 1 && j <= end;i++,j++) {
        dp[i] = Math.max(dp[i-2] + nums[j], dp[i-1]);
    }
    return dp[nums.length - 2];
}

House Robber III

Step I – Think naively

At first glance, the problem exhibits the feature of “optimal substructure”: if we want to rob maximum amount of money from current binary tree (rooted at root), we surely hope that we can do the same to its left and right subtrees.

So going along this line, let’s define the function rob(root) which will return the maximum amount of money that we can rob for the binary tree rooted at root; the key now is to construct the solution to the original problem from solutions to its subproblems, i.e., how to get rob(root) from rob(root.left), rob(root.right), ... etc.

Apparently the analyses above suggest a recursive solution. And for recursion, it’s always worthwhile figuring out the following two properties:

  1. Termination condition: when do we know the answer to rob(root) without any calculation? Of course when the tree is empty ---- we’ve got nothing to rob so the amount of money is zero.
  2. Recurrence relation: i.e., how to get rob(root) from rob(root.left), rob(root.right), ... etc. From the point of view of the tree root, there are only two scenarios at the end: root is robbed or is not. If it is, due to the constraint that “we cannot rob any two directly-linked houses”, the next level of subtrees that are available would be the four “grandchild-subtrees” (root.left.left, root.left.right, root.right.left, root.right.right). However if root is not robbed, the next level of available subtrees would just be the two “child-subtrees” (root.left, root.right). We only need to choose the scenario which yields the larger amount of money.

Here is the program for the ideas above:

public int rob(TreeNode root) {
    if (root == null) return 0;
    
    int val = 0;
    
    if (root.left != null) {
        val += rob(root.left.left) + rob(root.left.right);
    }
    
    if (root.right != null) {
        val += rob(root.right.left) + rob(root.right.right);
    }
    
    return Math.max(val + root.val, rob(root.left) + rob(root.right));
}

However the solution runs very slowly (1186 ms) and barely got accepted (the time complexity turns out to be exponential, see my comments below).

Step II – Think one step further

In step I, we only considered the aspect of “optimal substructure”, but think little about the possibilities of overlapping of the subproblems. For example, to obtain rob(root), we need rob(root.left), rob(root.right), rob(root.left.left), rob(root.left.right), rob(root.right.left), rob(root.right.right); but to get rob(root.left), we also need rob(root.left.left), rob(root.left.right), similarly for rob(root.right). The naive solution above computed these subproblems repeatedly, which resulted in bad time performance. Now if you recall the two conditions for dynamic programming: “optimal substructure” + “overlapping of subproblems”, we actually have a DP problem. A naive way to implement DP here is to use a hash map to record the results for visited subtrees.

And here is the improved solution:

public int rob(TreeNode root) {
    return robSub(root, new HashMap<>());
}

private int robSub(TreeNode root, Map<TreeNode, Integer> map) {
    if (root == null) return 0;
    if (map.containsKey(root)) return map.get(root);
    
    int val = 0;
    
    if (root.left != null) {
        val += robSub(root.left.left, map) + robSub(root.left.right, map);
    }
    
    if (root.right != null) {
        val += robSub(root.right.left, map) + robSub(root.right.right, map);
    }
    
    val = Math.max(val + root.val, robSub(root.left, map) + robSub(root.right, map));
    map.put(root, val);
    
    return val;
}

The runtime is sharply reduced to 9 ms, at the expense of O(n) space cost (n is the total number of nodes; stack cost for recursion is not counted).

Step III – Think one step back

In step I, we defined our problem as rob(root), which will yield the maximum amount of money that can be robbed of the binary tree rooted at root. This leads to the DP problem summarized in step II.

Now let’s take one step back and ask why we have overlapping subproblems. If you trace all the way back to the beginning, you’ll find the answer lies in the way how we have defined rob(root). As I mentioned, for each tree root, there are two scenarios: it is robbed or is not. rob(root) does not distinguish between these two cases, so “information is lost as the recursion goes deeper and deeper”, which results in repeated subproblems.

If we were able to maintain the information about the two scenarios for each tree root, let’s see how it plays out. Redefine rob(root) as a new function which will return an array of two elements, the first element of which denotes the maximum amount of money that can be robbed if root is not robbed, while the second element signifies the maximum amount of money robbed if it is robbed.

Let’s relate rob(root) to rob(root.left) and rob(root.right)..., etc. For the 1st element of rob(root), we only need to sum up the larger elements of rob(root.left) and rob(root.right), respectively, since root is not robbed and we are free to rob its left and right subtrees. For the 2nd element of rob(root), however, we only need to add up the 1st elements of rob(root.left) and rob(root.right), respectively, plus the value robbed from root itself, since in this case it’s guaranteed that we cannot rob the nodes of root.leftand root.right.

As you can see, by keeping track of the information of both scenarios, we decoupled the subproblems and the solution essentially boiled down to a greedy one. Here is the program:

//跟聚会人气有区别: 这里不一定是偷一层然后上下两层都不可以偷,比如可以同时偷root的左右节点和root的兄弟节点
public int rob(TreeNode root) {
    int[] res = robSub(root);
    return Math.max(res[0], res[1]);
}

private int[] robSub(TreeNode root) {
    if (root == null) return new int[2];
    
    int[] left = robSub(root.left);
    int[] right = robSub(root.right);
    int[] res = new int[2];

    res[0] = Math.max(left[0], left[1]) + Math.max(right[0], right[1]);
    res[1] = root.val + left[0] + right[0];
    
    return res;
}

你可能感兴趣的:(算法与数据结构)