Explain in detail the dynamic programming problems of the series

After reading this article, you can go to Likou to win the following topics:

198. A house robbery

213. House Robbery II

337. House Robbery III

-----------

Some readers asked me privately how to do LeetCode "House Robber" series of questions (English version is called House Robber). I found that this series of topics are highly praised. They are more representative and skillful dynamic programming topics. Come today. Talk about this topic.

There are a total of three courses in the family and robbery series. The difficulty design is very reasonable and progressive. The first one is a relatively standard dynamic programming problem, while the second one incorporates the conditions of a circular array, and the third one is even more absolute. It combines the bottom-up and top-down solutions of dynamic programming with binary trees. I think it is very Inspiring. If you have never done it before, it is recommended to study.

Below, we start from the first analysis.

House Robber I

6147bbdf310af02edb4e2e39ee005905.jpg

public int rob(int[] nums);

The topic is easy to understand, and the characteristics of dynamic programming are obvious. As we summarized in the previous article "Detailed Explanation of Dynamic Programming", to solve the problem of dynamic programming is to find "status" and "choice", nothing more .

Imagine that you are the professional robber, walking through this row of houses from left to right. There are two choices in front of each house : grab or not.

If you grab this house, then you definitely can't grab the next house next door, you can only choose from the next house.

If you don't grab this house, then you can go to the next house and continue to make choices.

After you have walked through the last house, you don't have to grab it, and the money you can grab is obviously 0 ( base case ).

The above logic is very simple, in fact, the "state" and "choice" have been clarified: the index of the house in front of you is the state, and whether to grab or not to grab is the choice .

44518b0a94fb3984312ce557966ca429.jpeg

In the two choices, choose the larger result each time, and the final result is the most money you can grab:

// The main function 
public int rob(int[] nums) { 
    return dp(nums, 0); 
} 
// Return the maximum value that nums[start..] can grab 
private int dp(int[] nums, int start) { 
    if (start >= nums.length) { 
        return 0; 
    } 

    int res = Math.max( 
            // Don’t grab, go to the next house 
            dp(nums, start + 1),  
            // grab, go to the next house 
            nums[start ] + dp(nums, start + 2) 
        ); 
    return res; 
}

After clarifying the state transition, you can find that there start is an overlapping sub-problem for the same  location, as shown in the following figure:

267f2bb39fbad1ca422faedbe34c3c56.jpeg

Thieves have many options to get to this position. Wouldn't it be a waste of time if they enter recursion every time they get here? So there are overlapping sub-problems, which can be optimized with memo:

private int[] memo; 
// Main function 
public int rob(int[] nums) { 
    // Initialization memo 
    memo = new int[nums.length]; 
    Arrays.fill(memo, -1); 
    // The robber starts at 0 The house started to rob 
    return dp(nums, 0); 
} 

// Return the maximum value that dp[start..] can grab 
private int dp(int[] nums, int start) { 
    if (start >= nums.length) { 
        return 0; 
    } 
    // Avoid double calculation 
    if (memo[start] != -1) return memo[start]; 

    int res = Math.max(dp(nums, start + 1),  
                    nums[start] + dp( nums, start + 2)); 
    // Enter the memo 
    memo[start] = res; 
    return res; 
}

This is the top-down dynamic programming solution. We can also slightly modify it and write a bottom-up solution:

int rob(int[] nums) { 
    int n = nums.length; 
    // dp[i] = x means: 
    // start the robbery from the i-th house, and the maximum amount of money you can grab is x 
    // base case: dp [n] = 0 
    int[] dp = new int[n + 2]; 
    for (int i = n-1; i >= 0; i--) { 
        dp[i] = Math.max(dp[i + 1], nums[i] + dp[i + 2]); 
    } 
    return dp[0]; 
}

We also found that the state transition is only  dp[i] related to the two most recent states, so it can be further optimized to reduce the space complexity to O(1).

int rob(int[] nums) {
    int n = nums.length;
    // 记录 dp[i+1] 和 dp[i+2]
    int dp_i_1 = 0, dp_i_2 = 0;
    // 记录 dp[i]
    int dp_i = 0; 
    for (int i = n - 1; i >= 0; i--) {
        dp_i = Math.max(dp_i_1, nums[i] + dp_i_2);
        dp_i_2 = dp_i_1;
        dp_i_1 = dp_i;
    }
    return dp_i;
}

The above process has been explained in detail in our "Dynamic Planning Detailed Explanation", I believe everyone will be able to grasp it. I think it is very interesting that the follow up of this issue requires some clever adaptations based on our current thinking.

PS: I have written more than 100 original articles carefully , and I have hand-in-hand brushed with 200 buckle questions, all of which are published in  labuladong's algorithm cheat sheet , which is continuously updated . It is recommended to collect, brush the questions in the order of my articles , master various algorithm routines, and then cast them into the sea of ​​questions.

House Robber II

This question is basically the same as the first description. The robber still cannot rob neighboring houses. The input is still an array, but it tells you that these houses are not a row, but a circle .

In other words, the first house and the last house are now adjacent to each other and cannot be looted at the same time. For example, for an input array  nums=[2,3,2], the result returned by the algorithm should be 3 instead of 4, because the beginning and the end cannot be robbed at the same time.

It seems that this constraint should not be difficult to solve. We mentioned a solution to the circular array in the previous "Monotonic Stack Solving Next Greater Number", so how to deal with this problem?

First of all, the first and last rooms cannot be robbed at the same time, so there are only three possible situations: either none is robbed; or the first house is robbed and the last one is not robbed; or the last house is robbed and the first is not robbed.

be9fa34adb48b9b85e67f79719eb5c54.jpeg

That's simple. In these three cases, the kind of result is the biggest, which is the final answer! However, in fact, we don’t need to compare the three cases, just compare Case 2 and Case 3, because these two cases have a larger choice of houses than the case, and the amount of money in the house is non-negative, so there is a lot of choice. , The optimal decision result is certainly not small .

So just slightly modify the previous 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));
}

// 仅计算闭区间 [start,end] 的最优结果
int robRange(int[] nums, int start, int end) {
    int n = nums.length;
    int dp_i_1 = 0, dp_i_2 = 0;
    int dp_i = 0;
    for (int i = end; i >= start; i--) {
        dp_i = Math.max(dp_i_1, nums[i] + dp_i_2);
        dp_i_2 = dp_i_1;
        dp_i_1 = dp_i;
    }
    return dp_i;
}

At this point, the second question has also been resolved.

House Robber III

The third question managed to change his mind again. The robber discovered that the house he was facing was not a row, not a circle, but a binary tree! The house is at the node of the binary tree, and the two connected houses cannot be robbed at the same time. It is indeed a legendary crime of high intelligence:

9a1b4661c90d296a60d61921628717c2.jpg

The overall thinking has not changed at all. It is still the choice of grabbing or not grabbing, and going to the choice with greater profit. We can even write the code directly according to this routine:

Map<TreeNode, Integer> memo = new HashMap<>(); 
public int rob(TreeNode root) { 
    if (root == null) return 0; 
    // Use memo to eliminate overlapping sub-problems 
    if (memo.containsKey(root))  
        return memo.get(root); 
    // grab, and then go to the next home 
    int do_it = root.val 
        + (root.left == null?  
            0: rob(root.left.left) + rob(root.left.right )) 
        + (root.right == null?  
            0: rob(root.right.left) + rob(root.right.right)); 
    // Don't grab, then go to the next house 
    int not_do = rob(root.left) + rob(root.right); 

    int res = Math.max(do_it, not_do); 
    memo.put(root, res); 
    return res; 
}

This problem is solved, the time complexity is O(N), the N number of nodes.

PS: I have written more than 100 original articles carefully , and I have hand-in-hand brushed with 200 buckle questions, all of which are published in  labuladong's algorithm cheat sheet , which is continuously updated . It is recommended to collect, brush the questions in the order of my articles , master various algorithm routines, and then cast them into the sea of ​​questions.

But the clever point of this question is that there are more beautiful solutions. For example, the following is a solution I saw in the comment area:

int rob(TreeNode root) { 
    int[] res = dp(root); 
    return Math.max(res[0], res[1]); 
} 

/* returns an array of size 2 arr 
arr[0] means no If you grab root, the maximum amount of money you can get 
arr[1] represents the maximum amount of money you get if you grab root */ 
int[] dp(TreeNode root) { 
    if (root == null) 
        return new int[]{0, 0 }; 
    int[] left = dp(root.left); 
    int[] right = dp(root.right); 
    // If you grab, you can't grab the next home 
    int rob = root.val + left[0] + right[ 0]; 
    // Do not grab, the next home can be grabbed or not, depending on the size of the income 
    int not_rob = Math.max(left[0], left[1]) 
                + Math.max(right[0], right[1] ); 

    return new int[]{not_rob, rob}; 
}

The time complexity is O(N), and the space complexity is only the space required by the recursive function stack, without the extra space of the memo.

You see that his thinking is different from ours. He modified the definition of the recursive function and slightly modified the thinking to make the logic self-consistent, and still got the correct answer, and the code is more beautiful. This is a characteristic of the dynamic programming problem that we mentioned in the previous article "Different definitions produce different solutions".

In fact, this solution runs much faster than our solution, although the time complexity of the algorithm analysis level is the same. The reason is that this solution does not use additional memos, reducing the complexity of data operations, so the actual operating efficiency will be faster.


Guess you like

Origin blog.51cto.com/15064450/2570830