1

title:
tags: [algorithm, dynamic programming]
date: 2021-05-18
categories:

  • [Dynamic Programming]

In the dynamic programming topic repeatedly emphasized that first learns recursion, then learns .

The reasons have been fully explained, and I believe you all understand. If you don’t understand, I strongly recommend that you read that article first.

Although many friends who read my article know to learn recursion first, some fans still ask me: 160a37b1b6cc5a "The conversion of memoization recursion to dynamic programming always makes mistakes. What should I do if I don’t? Are there any points? ”

Today I will answer this question from fans.

recursion into dynamic programming, but it may not be particularly detailed. Today we will try 160a37b1b6cc79 to refine a wave of .

Let's still take the classic stair climbing as an example to tell you a little bit of basic knowledge. Next, I will take you to solve a more complicated problem.

<!-- more -->

Climb the stairs

Title description

A person can only climb 1 or 2 steps at a time. Assuming there are n steps, how many different ways does this person have to climb stairs?

Ideas

Since n-th stage must be stepped from n - 1 step or stage n - 2 steps to the stage , so the number of steps is the n-th stage to the n - 1 stages plus the number of steps to the n - 1 The number of steps.

Memoize recursive code:

const memo = {};
function climbStairs(n) {
  if (n === 1) return 1;
  if (n === 2) return 2;
  if (n in memo) return memo[n];
  ans = climbStairs(n - 1) + climbStairs(n - 2);
  memo[n] = ans;
  return ans;
}

climbStairs(10);

First of all, to make it easier to see the relationship, let's change the name of memo and replace memo with dp:

const dp = {};
function climbStairs(n) {
  if (n === 1) return 1;
  if (n === 2) return 2;
  if (n in dp) return dp[n];
  ans = climbStairs(n - 1) + climbStairs(n - 2);
  dp[n] = ans;
  return ans;
}

climbStairs(10);

Nothing else happened, but the name was changed.

So how can this memoized recursive code be transformed into dynamic programming? Here I have summarized three steps, according to these three steps, many recursively can be easily transformed into dynamic programming.

1. Create a dp array based on the memoized recursive input parameters

In the topic of dynamic programming, Xifa also mentioned that dynamic programming is state. The time complexity of the dynamic programming problem is the number of states, and the space complexity is the number of states if the rolling array optimization is not considered. What is the number of states? Isn't it the Cartesian product of the range of values for each state? The state corresponds exactly to the recursive input parameter 160a37b1b6cdaa.

Corresponding to this question, it is clear that the state is currently at which level. Then there are n states. Therefore, it is good to open up a one-dimensional array of length n.

I use from to represent the memoized recursive code before the transformation, and to represent the dynamic programming code after the transformation. (The same below, no more details)

from:

dp = {};
function climbStairs(n) {}

to:

function climbStairs(n) {
  const dp = new Array(n);
}

2. Fill the initial value of the dp array with the return value of the memoized recursive leaf node

If you simulate the execution process of the above dp function, you will find: if n == 1 return 1 and if n == 2 return 2 , which correspond to the leaf nodes of the recursive tree. These two lines of code they reach the leaf node. Next, combine the results according to the return value of the sub-dp function, which is a typical post-order traversal .

蓝色表示叶子节点

If it is transformed into iterative, how to do it? A simple idea is to simulate the process of returning from the recursive stack from the leaf node. That's right, the dynamic programming is 160a37b1b6ce77. From the leaf node to the end of the root node, This is why memoized recursion is usually called top-down, and dynamic programming is called bottom-up . The bottom and top here can be regarded as the leaves and roots of the recursive tree.

Know the essential difference between memory recursion and dynamic programming. Next, we fill and initialize, and the logic of filling is to memorize the recursive leaf node return part.

from:

const dp = {};
function climbStairs(n) {
  if (n == 1) return 1;
  if (n == 2) return 2;
}

to:

function climbStairs(n) {
  const dp = new Array(n);
  dp[0] = 1;
  dp[1] = 2;
}
The length of dp is n and the index range is [0,n-1], so dp[n-1] corresponds to memoized recursive dp(n). Therefore, dp[0] = 1 is equivalent to the above if n == 1: return 1. If you want the two to correspond exactly, the array length is set to n + 1, and the array index 0 is not used.

3. Enumerate the Cartesian product and copy the main logic

  1. if (xxx in dp) return dp[xxx] delete this code
  2. Change the recursive function f(xxx, yyy, ...) to dpxxx[....], the corresponding question is to change climbStairs(n) to dp[n]
  3. Change recursion to iteration. For example, in this question, climbStairs(n) recursively calls climbStairs(n-1) and climbStairs(n-2) every time, calling a total of n times. What we have to do is iterative simulation. For example, if it is called n times, we use a layer of loop to simulate execution n times. If there are two parameters, it will be a two-level loop, three parameters will be a three-level loop, and so on.

from:

const dp = {};
function climbStairs(n) {
  // ...
  if (n in dp) return dp[n];
  ans = climbStairs(n - 1) + climbStairs(n - 2);
  dp[n] = ans;
  return ans;
}

to:

function climbStairs(n) {
  // ...
  // 这个循环其实就是咱上面提到的状态的笛卡尔积。由于这道题就一个状态,枚举一层就好了。如果状态有两个,那么笛卡尔积就可以用两层循环搞定。至于谁在外层循环谁在内层循环,请看我的动态规划专题。
  for (let i = 2; i < n; i++) {
    dp[i] = dp[i - 1] + dp[i - 2];
  }
  return dp[dp.length - 1];
}

Combining the results of the above steps can transform the original memoization recursion into dynamic programming.

Complete code:

function climbStairs(n) {
  if (n == 1) return 1;
  const dp = new Array(n);
  dp[0] = 1;
  dp[1] = 2;

  for (let i = 2; i < n; i++) {
    dp[i] = dp[i - 1] + dp[i - 2];
  }
  return dp[dp.length - 1];
}

Some people may think this question is too simple. It's actually a bit simple. And I also admit that more difficult to rewrite 160a37b1b6cfdd, what kind of memoization recursion is better to write, and it is more troublesome to change to dynamic programming. I also talked to you about dynamic programming, and students who are not clear about it.

As far as I know, if dynamic programming can pass, can pass most recursion. There are some extreme cases where memory recursion cannot pass: that is, there are too many test cases, and there are more test cases with a large amount of data. This is because the timeout judgment of Likou is the sum of the time used for multiple test cases, rather than calculating the time separately.

Next, I will give a slightly more difficult example (this example must use dynamic programming to pass, memoization recursion will time out). Let you familiarize yourself with the routine I gave you above.

1824. Minimum number of side jumps

Title description

给你一个长度为  n  的  3 跑道道路  ,它总共包含  n + 1  个   点  ,编号为  0  到  n 。一只青蛙从  0  号点第二条跑道   出发  ,它想要跳到点  n  处。然而道路上可能有一些障碍。

给你一个长度为 n + 1  的数组  obstacles ,其中  obstacles[i] (取值范围从 0 到 3)表示在点 i  处的  obstacles[i]  跑道上有一个障碍。如果  obstacles[i] == 0 ,那么点  i  处没有障碍。任何一个点的三条跑道中   最多有一个   障碍。

比方说,如果  obstacles[2] == 1 ,那么说明在点 2 处跑道 1 有障碍。
这只青蛙从点 i  跳到点 i + 1  且跑道不变的前提是点 i + 1  的同一跑道上没有障碍。为了躲避障碍,这只青蛙也可以在   同一个   点处   侧跳   到 另外一条   跑道(这两条跑道可以不相邻),但前提是跳过去的跑道该点处没有障碍。

比方说,这只青蛙可以从点 3 处的跑道 3 跳到点 3 处的跑道 1 。
这只青蛙从点 0 处跑道 2  出发,并想到达点 n  处的 任一跑道 ,请你返回 最少侧跳次数  。

注意:点 0  处和点 n  处的任一跑道都不会有障碍。

示例 1:

输入:obstacles = [0,1,2,3,0]
输出:2
解释:最优方案如上图箭头所示。总共有 2 次侧跳(红色箭头)。
注意,这只青蛙只有当侧跳时才可以跳过障碍(如上图点 2 处所示)。
示例 2:

输入:obstacles = [0,1,1,3,3,0]
输出:0
解释:跑道 2 没有任何障碍,所以不需要任何侧跳。
示例 3:

输入:obstacles = [0,2,1,0,3,0]
输出:2
解释:最优方案如上图所示。总共有 2 次侧跳。

提示:

obstacles.length == n + 1
1 <= n <= 5 \* 105
0 <= obstacles[i] <= 3
obstacles[0] == obstacles[n] == 0

Ideas

Is this frog jumping horizontally repeatedly? ?

Explain this topic a little bit.

  • If there are no obstacles in a position behind the current runway, in this case, the horizontal jump will not be better than the straight horizontal jump. We should greedily jump directly (not horizontal jump) over. This is because in the worst case, we can and then jump horizontally, which is the same as jumping horizontally and then horizontally.
  • If there is an obstacle at a position behind the current runway, we need to jump to a channel without obstacles, and the horizontal jump counter + 1 at the same time.

Finally, select all the horizontal jumps that reach the end point with the least number of times. The corresponding recursive tree has the smallest counter when reaching the leaf node.

Use dp(pos, line) to represent the minimum number of horizontal jumps required to the end point It is not difficult to write the following memoized recursive code.

Since this article mainly talks about dynamic programming of memoized recursive transformation, the details of this question will not be introduced much, just look at the code.

Let's look at the code:


class Solution:
    def minSideJumps(self, obstacles: List[int]) -> int:
        dp = {}
        def f(pos, line):
            if (pos, line) in dp: return dp[(pos, line)]
            if pos == len(obstacles) - 1:
                return 0
            # 贪心地平跳
            if obstacles[pos + 1] != line:
                ans = f(pos + 1, line)
                dp[(pos, line)] = ans
                return ans
            ans = float("inf")
            for nxt in [1, 2, 3]:
                if nxt != line and obstacles[pos] != nxt:
                    ans = min(ans, 1 +f(pos, nxt))
            dp[(pos, line)] = ans
            return ans

        return f(0, 2)

The memoization recursion will time out for this problem, and it needs to use dynamic programming. So how to transform ta into dynamic programming?

Still use the above formula.

1. Create a dp array based on the memoized recursive input parameters

The above recursive function is dp(pos, line), and the state is the formal parameter. Therefore, it is necessary to create a two-dimensional array of m * n, where m and n are the size of the set of value ranges of pos and line respectively. The value range of line is actually [1,3]. In order to facilitate index correspondence, Xifa decided to waste a space this time. Since this question is to seek the minimum, it is fine to initialize to infinity.

from:

class Solution:
    def minSideJumps(self, obstacles: List[int]) -> int:
        dp = {}
        def f(pos, line):
            # ...

        return f(0, 2)

to:

class Solution:
    def minSideJumps(self, obstacles: List[int]) -> int:
        dp = [[float("inf")] * 4 for _ in range(len(obstacles))]
        # ...
        return min(dp[-1])

2. Fill the initial value of the dp array with the return value of the memoized recursive leaf node

Not much to say, just go to the code.

from:

class Solution:
    def minSideJumps(self, obstacles: List[int]) -> int:
        dp = {}
        def f(pos, line):
            if pos == len(obstacles) - 1:
                return 0
            # ...

        return f(0, 2)

to:

class Solution:
    def minSideJumps(self, obstacles: List[int]) -> int:
        dp = [[float("inf")] * 4 for _ in range(len(obstacles))]
        dp[0] = [0, 1, 0, 1]
        # ...
        return min(dp[-1])

3. Enumerate the Cartesian product and copy the main logic

How does this question enumerate states? Of course it is the Cartesian product of the enumeration state. It's simple, just a few layers of cycles.

Upload the code.

class Solution:
    def minSideJumps(self, obstacles: List[int]) -> int:
        dp = [[float("inf")] * 4 for _ in range(len(obstacles))]
        dp[0] = [0, 1, 0, 1]
        for pos in range(1, len(obstacles)):
            for line in range(1, 4):
                # ...
        return min(dp[-1])

The next step is to copy and paste the main logic of memoized recursion.

from:

class Solution:
    def minSideJumps(self, obstacles: List[int]) -> int:
        dp = {}
        def f(pos, line):
            # ...
            # 贪心地平跳
            if obstacles[pos + 1] != line:
                ans = f(pos + 1, line)
                dp[(pos, line)] = ans
                return ans
            ans = float("inf")
            for nxt in [1, 2, 3]:
                if nxt != line and obstacles[pos] != nxt:
                    ans = min(ans, 1 +f(pos, nxt))
            dp[(pos, line)] = ans
            return ans

        return f(0, 2)

to:

class Solution:
    def minSideJumps(self, obstacles: List[int]) -> int:
        dp = [[float("inf")] * 4 for _ in range(len(obstacles))]
        dp[0] = [0, 1, 0, 1]
        for pos in range(1, len(obstacles)):
            for line in range(1, 4):
                if obstacles[pos - 1] != line: # 由于自底向上,因此是和 pos - 1 建立联系,而不是 pos + 1
                    dp[pos][line] = min(dp[pos][line], dp[pos - 1][line])
                else:
                    for nxt in range(1, 4):
                        if nxt != line and obstacles[pos] != nxt:
                            dp[pos][line] = min(dp[pos][line], 1 + dp[pos][nxt])

        return min(dp[-1])

It can be seen that I basically copied the main logic and changed it slightly. The basic reason for the change is:

  • It used to be a recursive function, so return needs to be removed, such as changing to continue. You can't let the function return directly, but continue to enumerate the next state.
  • It used to be dp[(pos, line)] = ans and now it is changed to fill in the two-dimensional dp array that we had originally created above.

Do you think this is over?

Then you are wrong. There is a reason for choosing this question. If this question is submitted directly, an error will be reported, which is a wrong answer (WA).

What I want to tell you here is: Because we use iteration to simulate the recursive process, we use the Cartesian product multi-layer loop to enumerate the state, and the main logic part is the state transition equation, and the sequence of the transition equation writing and enumeration Closely related.

It is not difficult to see from the code: for this question, we use an enumeration from small to large, and dppos only depends on dppos-1 and dppos.

The key to the problem is nxt. For example, when dp2 is processed, d2 depends on the value of dp2, but in fact dp2 is not processed.

Therefore, there is a problem with the line of code in the above dynamic programming:

dp[pos][line] = min(dp[pos][line], 1 + dp[pos][nxt])

Because when traverses to dppos, it is possible that dppos has not been calculated (not enumerated), and this is a bug.

So why is it okay to memoize recursion?

it's actually really easy. Sub-problems recursive function inside are not computed , leaf nodes and then to start to return up after calculate, but process of return and in fact iteration is similar.

For example, the recursive tree of f(0,2) in this question is probably like this, in which the dotted line mark may not be reachable.

递归树

When the recursion from f (0, 2) to f (0, 1) or f (0, 3) the time, are not computed, and therefore does not matter, code continue to extend to the direction of the leaf node to reach the leaf After the node returns, all the child nodes must have been calculated, and the next process is very similar to the ordinary iteration .

For example, f(0,2) recursively to f(0,3), f(0,3) will continue to recurse downward to know the leaf node, and then return upwards. When it returns to f(0,2) again, f( 0,3) must have been calculated.

The image point is: f(0,2) is a leader, tell his subordinates f(0,3), I want xxxx, I don’t care how to achieve it, if you have it, give it to me (memorization), no Find a way to get it (recursively). Anyway, you get it out for me and send it to me anyway.

And if you use iterative dynamic programming, you can directly give me (memorization) if you have it, it's easy to do it. The key is that it is not easy to find a way to obtain (recursively) if not, at least a similar loop is needed to complete it, right?

How to solve this problem?

It's very simple. Each time only depends on the calculated state .

For this question, although dppos may not be calculated, then dppos-1 must be calculated because dppos-1 has already been calculated in the previous main loop.

But is it logical to change directly to dppos-1? This requires a specific analysis of specific issues. For this question, it is possible to write this way.

This is because the logic here is If there is an obstacle in front of the current track, then we cannot come from the previous position of the current track, but can only choose to jump across the other two tracks.

I drew a sketch. Where X represents the obstacle, O represents the current position, and the number represents the sequence in time, first jump 1 and then 2. . .

-XO
---
---

Here, the following two situations are actually equivalent:

Case 1 (that is, the case of dppos above):

-X2
--1
---

Case 2 (that is, the case of dppos-1 above):

-X3
-12
---

It can be seen that the two are the same. Don't understand? Look more and think more.

In summary, we can change dppos to dppos-1 without any problems. If you encounter other problems, you can take a similar approach and analyze a wave.

Complete code:

class Solution:
    def minSideJumps(self, obstacles: List[int]) -> int:
        dp = [[float("inf")] * 4 for _ in range(len(obstacles))]
        dp[0] = [0, 1, 0, 1]
        for pos in range(1, len(obstacles)):
            for line in range(1, 4):
                if obstacles[pos - 1] != line: # 由于自底向上,因此是和 pos - 1 建立联系,而不是 pos + 1
                    dp[pos][line] = min(dp[pos][line], dp[pos - 1][line])
                else:
                    for nxt in range(1, 4):
                        if nxt != line and obstacles[pos] != nxt:
                            dp[pos][line] = min(dp[pos][line], 1 + dp[pos-1][nxt])

        return min(dp[-1])

Strike while the iron is hot and come again

Let's take another example, 1866. There are exactly K sticks that can see the number of .

Ideas

Memoize the recursive code directly:

class Solution:
    def rearrangeSticks(self, n: int, k: int) -> int:
        @lru_cache(None)
        def dp(i, j):
            if i == 0 and j != 0: return 0
            if i == 0 and j == 0: return 1
            return (dp(i - 1, j - 1) + dp(i - 1, j) * (i - 1)) % (10**9 + 7)
        return dp(n, k) % (10**9 + 7)

We don't care what the question is, how the code comes from. Suppose we have already written this code. So how to transform it into dynamic programming? Continue to apply the trilogy.

1. Create a dp array based on the memoized recursive input parameters

Since the value of i [0-n] is n + 1 in total, the value of j is [0-k] and there are k + 1 in total. So just initialize a two-dimensional array.

dp = [[0] * (k+1) for _ in range(n+1)]

2. Fill the initial value of the dp array with the return value of the memoized recursive leaf node

Since i == 0 and j == 0 are 1, so just write dp0 = 1 directly.

dp = [[0] * (k+1) for _ in range(n+1)]
dp[0][0] = 1

3. Enumerate the Cartesian product and copy the main logic

Just a two-level loop enumerating all the combinations of i and j.

dp = [[0] * (k+1) for _ in range(n+1)]
dp[0][0] = 1

for i in range(1, n + 1):
    for j in range(1, min(k, i) + 1):
        # ...
return dp[-1][-1]

Finally, the main logic is copied and completed.

For example: change return xxx to dp parameter one = xxx and other minor details.

The final code is:

class Solution:
    def rearrangeSticks(self, n: int, k: int) -> int:
        dp = [[0] * (k+1) for _ in range(n+1)]
        dp[0][0] = 1

        for i in range(1, n + 1):
            for j in range(1, min(k, i) + 1):
                dp[i][j] = dp[i-1][j-1]
                if i - 1 >= j:
                    dp[i][j] += dp[i-1][j] * (i - 1)
                dp[i][j] %= 10**9 + 7
        return dp[-1][-1]

to sum up

Some memoization recursion is more difficult to rewrite. Under what circumstances memoization recursion is better to write, it is more troublesome to change to dynamic programming. I also talked about dynamic programming to you, and students who are not clear about it.

The reason why I recommend that you start with memoization recursion is because in many cases memoization is simple to write and has high fault tolerance (think of the frog jump example above). This is because memoized recursion is always a post-order traversal, and will only go up when it reaches the leaf node. The upward calculation process is similar to iterative dynamic programming. Or you can think of iterative dynamic programming as simulating the recursive process .

What we have to do is to learn some easy-to-modify methods, and then try to use memoization recursion as much as possible in the face of difficulties. As far as I know, if dynamic programming can be done, most memoized recursion can be done. There is an extreme case that memory recursion cannot pass: there are too many test cases, and there are more test cases with a large amount of data. This is because the timeout judgment of Likou is the sum of the time used for multiple test cases, rather than calculating the time separately.

To transform memoization recursion into dynamic programming, you can refer to these three steps of mine:

  1. Create a dp array based on the memoized recursive input parameters
  2. Fill the initial value of the dp array with the return value of the memoized recursive leaf node
  3. Enumerate the Cartesian product and copy the main logic

Another thing to note is that the determination of the state transition equation is closely related to the direction of enumeration, although the details of different topics vary greatly. But we only need to firmly grasp one principle, that is: never use the uncalculated state, but only use the calculated state .


lucifer
5.3k 声望4.6k 粉丝