How difficult is dynamic programming?
Dynamic planning is a term borrowed from other industries.
It's probably the first thing meaning into several stages , then between the stage transfer achieve our goals. Since there are usually multiple directions of transfer, is required at this time to decide which transfer direction
The thing to be solved by dynamic programming is usually to complete a specific goal, and this goal is often the optimal solution. and:
- There can be transitions between stages, which is called dynamics.
- Achieve a feasible solution (target phase) need to constantly shift, how to transfer it to achieve optimal solution ? This is called planning.
Each stage is abstracted as a state (represented by circles), and transitions between states (represented by arrows) may occur. You can draw a picture similar to the following:
decision sequence should we make to make the result optimal? In other words, how each state should select the next specific state, and finally reach the target state. This is the problem of dynamic programming research.
In fact, will not consider subsequent decisions for each decision, but only consider the previous state. speaking, it is actually short-sighted thinking of taking one step at a time. Why can this short-sightedness be used to solve the optimal solution? that is because:
- We all possible transitions of and 1608109e1f3d13, and finally picked an optimal solution.
- No backwardness (we will talk about this later, let’s sell it first)
And if you don't simulate all the possibilities, but go straight to an optimal solution, it is a greedy algorithm.
That's right, dynamic programming is to find the optimal solution from the beginning. But sometimes the way you can find something else the total number of other programs, this is actually byproduct dynamic programming .
Okay, let's split the dynamic programming into two parts to explain separately. Perhaps you probably know what dynamic programming is. But this will not help you to do the problem. What exactly is dynamic programming on the algorithm?
Algorithmically, dynamic programming and look-up table recursion (also called recursion) 1608109e1f3dd6 have many similarities. I suggest you start with memoization and recursion. This article also starts with memoization and recursion, and then explains the dynamic programming step by step.
Memoized recursion
So what is recursion? What is a look-up table (memorization)? Let's take a closer look.
What is recursion?
Recursion refers to the method in which calls the function itself a function.
recursion usually decomposes the problem into 1608109e1f3e60 similar sub-problems with a reduced scale. When the sub-problems are abbreviated to the ordinary, we can directly know its solution. Then the original problem can be solved by establishing the connection (transfer) between the recursive functions.
Is it a bit like divide and conquer? Divide and conquer refers to dividing the problem into multiples, and then combining the multiple solutions into one. This is not what it means here.
To solve a problem using recursion, there must be a recursive termination condition (the infiniteness of the algorithm), which means that the recursion will gradually shrink to the normal size.
Although the following code is also recursive, it is not an effective algorithm because it cannot end:
def f(x):
return x + f(x - 1)
Unless outside intervention, the above code will execute forever without stopping.
So more cases should be:
def f(n):
if n == 1: return 1
return n + f(n - 1)
Using recursion can usually make the code shorter and sometimes more readable. Using recursion in the algorithm can be very simply complete some functions that are not easy to implement with loops, such as the left, middle and right order traversal of a binary tree.
Recursion is widely used in algorithms, including functional programming, which is becoming increasingly popular.
Recursion has a high status in functional programming. There are no loops in pure functional programming, only recursion.
In fact, in addition to the implementation of recursion through function call itself in coding. We can also define recursive data structures. For example, the well-known trees, linked lists, etc. are all recursive data structures.
Node {
value: any; // 当前节点的值
children: Array<Node>; // 指向其儿子
}
. It can be seen that children is the collection class of Node, which is a 1608109e1f4041 recursive data structure .
Not just ordinary recursive functions
recursion mentioned in this article actually refers to the special recursive function , which satisfies the following conditions on the ordinary recursive function:
- Recursive function does not depend on external variables
- Recursive function does not change external variables
What is the use of meeting these two conditions? This is because we need the given parameters of the function, and its return value is also deterministic. So that we can memorize. Regarding memoization, we will talk about it later.
If you understand functional programming, in fact, the recursion here is strictly speaking the functional programming . It doesn't matter if you don't understand, the recursive function here is actually the function mathematics.
Let's review the functions in mathematics:
在一个变化过程中,假设有两个变量 x、y,如果对于任意一个 x 都有唯一确定的一个 y 和它对应,那么就称 x 是自变量,y 是 x 的函数。x 的取值范围叫做这个函数的定义域,相应 y 的取值范围叫做函数的值域 。
And this article refers to this kind of function in mathematics.
For example, the recursive function above:
def f(x):
if x == 1: return 1
return x + f(x - 1)
- x is the independent variable, and the set of all possible return values of x is the domain.
- f(x) is the function.
- The set of all possible return values of f(x) is the range.
There can also be multiple arguments, and there can be multiple parameters corresponding to the recursive function, such as f(x1, x2, x3).
Describing problems through functions and describing the relationship between problems through the calling relationship of functions is the core content of memoization recursion.
Every dynamic programming problem can actually be abstracted as a mathematical function. The set of arguments of this function is all the values of the question, and the range is all the possible answers to the question. Our goal is actually to fill the content of this function so that a given argument x can be uniquely mapped to a value y. (Of course there may be multiple independent variables, and there may be multiple corresponding recursive function parameters)
Solving the dynamic programming problem can be seen as filling the black box of the function, so that the number in the domain is correctly mapped to the value domain.
Recursion is not an algorithm, it is a programming method corresponding to iteration. It's just that we usually use recursion to resolve the problem. For example, we define a recursive function f(n), and use f(n) to describe the problem. It is the same as using ordinary dynamic programming f[n] to describe the problem, where f is an array of dp.
What is memoization?
In order for everyone to better understand the content of this section, let's cut through an example:
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 steps plus the number of stages to n-th - 1 The number of steps.
Recursive code:
function climbStairs(n) {
if (n === 1) return 1;
if (n === 2) return 2;
return climbStairs(n - 1) + climbStairs(n - 2);
}
We use a recursive tree to intuitively feel the following (each circle represents a sub-problem):
Red indicates repeated calculations. That is, Fib(N-2) and Fib(N-3) have been calculated twice, in fact, it is enough to calculate once. For example, if the value of Fib(N-2) is calculated for the first time, when Fib(N-2) needs to be calculated again next time, the result of the last calculation can be directly returned. The reason why we can do this is the mentioned above. Our recursive function is a function in mathematics. That is to say, if the parameter is certain, the return value will not change , so if you encounter the same parameter next time, we can the last calculated value returned directly, without having to recalculate . The time saved in this way is equivalent to the number of overlapping subproblems.
For this problem, it was originally necessary to calculate $2^n$ times, but if memoization is used, it only needs to be calculated n times, which is so magical.
In the code, we can use a hashtable to cache intermediate calculation results, thereby eliminating unnecessary calculations.
We use memoization to modify the above code:
memo = {}
def climbStairs(n):
if n == 1:return 1
if n == 2: return 2
if n in memo: return memo[n]
ans = func(n - 1) + func(n-2)
memo[n] = ans
return ans
climbStairs(10)
Here I use a memo to store the return value of the recursive function, where key is the parameter and value is the return value of the recursive function.
The form of key is (x, y), which represents a primitive ancestor. Usually there are multiple parameters of dynamic programming, we can use the way of ancestors to memorize. Or it can also take the form of a multi-dimensional array. For the above figure, it can be represented by a two-dimensional array.
You can remove and add the code memo to feel memory of role.
summary
The advantage of using recursive functions is that the logic is simple and clear, but the disadvantage is that too deep calls will cause stack overflow. Here I have listed several algorithmic questions, these algorithmic questions can be easily written using recursion:
- Recursively implement sum
- Traversal of binary tree
- Stairs problem
- Tower of Hanoi
- Yanghui triangle
In recursion if has repeated calculations (we call overlapping sub-problems, which will be discussed below), it is one of the powerful signals to solve the problem using memoized recursion (or dynamic programming). It can be seen that the core of dynamic programming is to use memorization to eliminate the calculation of repeated sub-problems. If the scale of this repeated sub-problem is exponential or higher, then the benefits of memorizing recursive (or dynamic programming) will be very large. Big.
In order to eliminate this double calculation, we can use the look-up table method. That is, while recursively, use a "record table" (such as a hash table or an array) to record the situation we have calculated. When the next time we encounter it, if it has been calculated before, then just return directly, so as to avoid duplication Calculation. dynamic programming to be discussed below is actually the same as the "record table" here, .
If you are new to recursion, I suggest you practice recursion first and then look back. A simple way to practice recursion is to change all the iterations you write into recursive form. For example, if you write a program whose function is "output a string in reverse order", it will be very easy to write it out using iteration, so can you use recursion to write it out? Through such exercises, you can gradually adapt to using recursion to write programs.
When you have adapted to recursion, let us continue to learn dynamic programming!
Dynamic programming
After talking about so much recursion and memoization, it is finally time for our protagonist to debut.
Basic concepts of dynamic programming
Let us first learn the two most important concepts of dynamic programming: optimal substructure and no aftereffect.
among them:
- No aftereffect determines whether it can be solved using dynamic programming.
- The optimal substructure determines the specific solution.
Optimal substructure
Dynamic programming is often applicable to problems with overlapping sub-problems and optimal sub-structure properties. I talked about overlapping sub-problems earlier, so what is the optimal sub-structure? This is the definition I found from Wikipedia:
如果问题的最优解所包含的子问题的解也是最优的,我们就称该问题具有最优子结构性质(即满足最优化原理)。最优子结构性质为动态规划算法解决问题提供了重要线索。
For example: if the score in the test is defined as f, then this question can be broken down into sub-questions such as language, mathematics, and English. Obviously, when the sub-problem is optimal, the solution to the big problem of total score is also optimal.
Another example is the 01 backpack problem: define f(weights, values, capicity). If we want to ask for the optimal solution of f([1,2,3], [2,2,4], 10). We can divide it into the following sub-problems:
Put the third item into the backpack, which is f([1,2], [2,2], 10)
- And
does not pack the third item into the backpack, which is f([1,2,3], [2,2,4], 9)
Obviously these two issues are still complicated, and we need to further disassemble them. However, this is not about dismantling.
The original problem f([1,2,3], [2,2,4], 10) is equal to the maximum value of the above two sub-problems. Only when the two sub-problems are optimal , the whole is optimal, because the sub-problems will not affect each other.
No aftereffect
That is, once the solution of the sub-problem is determined, it will not change, and will not be affected by the decision-making of the larger problem that contains it.
Continue with the above two examples.
- High mathematics test cannot affect English (in reality, it may affect English, for example, if the time is fixed, the input of English is more, and other subjects are less).
- In the backpack problem, f([1,2,3], [2,2,4], 10) chooses whether to take the third item, it should not affect whether to take the previous item. For example, the title stipulates that after the third item is taken, the value of the second item will become lower or higher). This situation does not satisfy the lack of backwardness.
Three elements of dynamic programming
State definition
What is the central point of dynamic programming? If you let me say it, it is defines the state .
The first step in dynamic programming is to define the state. Once you have defined the state, you can draw a recursive tree, focus on the optimal substructure and write the transfer equation. That's why I said that the state definition is the core of dynamic programming, and the state of dynamic programming problems is really not easy to see.
But once you can define the state, you can draw a recursive tree along the way, and focus on the optimal substructure after drawing the recursive tree. But the premise of being able to draw a recursive tree is: to divide the problem, the professional point is to define the state. How can we define the state?
Fortunately, the definition of state has its own unique routines. For example, the status of a string is usually dp[i] which means that the string s ends with i.... For example, the status of two strings, usually dpi means that the string s1 ends with i and s2 ends with j....
In other words, the definition of state usually has different routines, and everyone can learn and summarize in the process of doing the questions. But there are so many kinds of routines, so how do you get it done?
To be honest, you can only practice more and summarize the routines during the practice. For specific routines, refer to the following 1608109e2008d8 dynamic programming question type part of the content. After that, you can think about the general state definition direction for different question types.
two examples
Regarding the definition of state, it is so important that I put it as the core of dynamic programming. Therefore, I think it is necessary to cite a few examples to illustrate. I will directly dynamic programming topic and tell you about them.
The first question: "5. The longest palindrome substring" is of medium difficulty
给你一个字符串 s,找到 s 中最长的回文子串。
示例 1:
输入:s = "babad"
输出:"bab"
解释:"aba" 同样是符合题意的答案。
示例 2:
输入:s = "cbbd"
输出:"bb"
示例 3:
输入:s = "a"
输出:"a"
示例 4:
输入:s = "ac"
输出:"a"
提示:
1 <= s.length <= 1000
s 仅由数字和英文字母(大写和/或小写)组成
parameter of 1608109e200a17 is a string. Then we need to convert it into a smaller sub-problem. That is undoubtedly the problem of shortening the string. The critical condition should also be an empty string or a character.
therefore:
- One way to define the state is f(s1), which means the longest palindrome substring of string s1, where s1 is the substring of string s in the title, then the answer is f(s).
- Since the smaller scale means that the string becomes shorter, we can also use two variables to describe the string, which actually saves the cost of developing the string. The two variables can be starting point index + substring length , ending point index + substring length , or starting point coordinates + ending point coordinates . As you like, here I will use start point coordinates + end point coordinates . Then the state definition is f(start, end), which means the longest palindrome substring of the substring s[start:end+1], then the answer is f(0, len(s)-1)
s[start:end+1] refers to a continuous substring that contains s[start] but not s[end+1].
This is undoubtedly a way to define the state, but once we define it like this, we will find that the state transition equation will become difficult to determine (in fact, many dynamic programming have this problem, such as the longest ascending subsequence problem). So how do you define state? I will continue to complete this problem later in the state transition equation. Let's look at the next question first.
The second question: "10. Regular Expression Matching" is difficult and difficult
给你一个字符串 s 和一个字符规律 p,请你来实现一个支持 '.' 和 '*' 的正则表达式匹配。
'.' 匹配任意单个字符
'*' 匹配零个或多个前面的那一个元素
所谓匹配,是要涵盖 整个 字符串 s的,而不是部分字符串。
示例 1:
输入:s = "aa" p = "a"
输出:false
解释:"a" 无法匹配 "aa" 整个字符串。
示例 2:
输入:s = "aa" p = "a*"
输出:true
解释:因为 '*' 代表可以匹配零个或多个前面的那一个元素, 在这里前面的元素就是 'a'。因此,字符串 "aa" 可被视为 'a' 重复了一次。
示例 3:
输入:s = "ab" p = ".*"
输出:true
解释:".*" 表示可匹配零个或多个('*')任意字符('.')。
示例 4:
输入:s = "aab" p = "c*a*b"
输出:true
解释:因为 '*' 表示零个或多个,这里 'c' 为 0 个, 'a' 被重复一次。因此可以匹配字符串 "aab"。
示例 5:
输入:s = "mississippi" p = "mis*is*p*."
输出:false
提示:
0 <= s.length <= 20
0 <= p.length <= 30
s 可能为空,且只包含从 a-z 的小写字母。
p 可能为空,且只包含从 a-z 的小写字母,以及字符 . 和 *。
保证每次出现字符 * 时,前面都匹配到有效的字符
There are two input parameters for this question, one is s and the other is p. Following the above ideas, we have two ways of defining states.
- One way to define the state is f(s1, p1), which means whether p1 can match the string s1, where s1 is the substring of the string s in the title, and p1 is the substring of the string p in the title, then The answer is f(s, p).
- The other is f(s_start, s_end, p_start, p_end), which means whether the substring p1[p_start:p_end+1] can match the string s[s_start:s_end+1], then the answer is f(0, len( s)-1, 0, len(p)-1)
In fact, for this question, we can also use a simpler state definition method, but the basic ideas are the same. I'm still selling a key point, and I will reveal the transfer equation later.
After finishing the state definition, you will find that the time and space complexity becomes obvious. This is why I have repeatedly emphasized that state definition is the core of dynamic programming.
How is the time and space complexity obvious?
First of all, space complexity. I just said that dynamic programming is actually a violent method of looking up tables. Therefore, the space complexity of dynamic programming is based on the size of the table. To be more blunt is the size of the hash table memo above. memo is basically the number of states. What is the number of states? Doesn't it depend on how your state is defined? For example, f(s1, p1) above. What is the status? Obviously, it is the Cartesian product of the value range of each parameter. All possible values of s1 have len(s) kinds, and all possible values of p1 have len(p) kinds, so the total state size is len(s) * len(p). The space complexity is $O(m * n)$, where m and n are the sizes of s and p, respectively.
I said that the base of space complexity is the number of states, and state compression is not considered here for now.
The second is time complexity. Time complexity is more difficult to say. But since we anyway enumerate all state , therefore time complexity is the total number of states backing . Based on the above state definition method, the time complexity base is $O(m * n)$.
If you enumerate each state and need to calculate with each character of s, the time complexity is $O(m^2 * n)$.
Taking the example of climbing stairs above, we define the state f(n) to represent the number of ways to reach the nth step, then the total number of states is n, and the space complexity and time complexity are the base of $n$. (Still not considering rolling array optimization)
another example: 1608109e200c40 62. Different paths
一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为“Start” )。
机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为“Finish”)。
问总共有多少条不同的路径?
This question is very similar to the stairs above, except that it has changed from one dimension to two dimensions. I call it two-dimensional staircase climbing . There are many similar questions about changing the skin, and you will gradually realize it.
For this question, I define the state as f(i, j), which means the total number of paths the robot takes to reach the point (i, j). Then the total number of states is the Cartesian product of the values of i and j, which is m*n.
In general, the space and time complexity of dynamic programming is the number of , and the number of states is usually the Cartesian product of parameters, which is determined by the no backwardness of dynamic programming.
critical condition is the easiest
When you define the state, there are three things left:
- Critical condition
- State transition equation
- Enumeration status
In the stair climbing problem explained above, if we use f(n) to indicate how many ways there are to climb n steps, then:
f(1) 与 f(2) 就是【边界】
f(n) = f(n-1) + f(n-2) 就是【状态转移公式】
Let me express it in the form of dynamic programming:
dp[0] 与 dp[1] 就是【边界】
dp[n] = dp[n - 1] + dp[n - 2] 就是【状态转移方程】
It can be seen how similar the memoized recursion and dynamic programming are.
In fact, the critical conditions are relatively simple. You only have to write a few more questions to get a sense of it. The difficulty is to find the state transition equation and enumerate the state. Both of these core points are built on the basis that has abstracted the state For example, the issue of stairs, if we use the f (n) n climb stairs How many ways, then f (1), f (2 ), ... that is, each independent state .
After getting the definition of state, let's look at the state transition equation.
State transition equation
The state of the current stage in dynamic programming is often the result of the state of the previous stage and the decision of the previous stage. There are two keywords here, namely:
- Last stage status
- Decision in the previous stage
In other words, if the state s[k] and decision choice(s[k]) of the k-th stage are given, the state s[k+1] of the k+1-th stage is also completely determined, which is expressed by the formula: : S[k] + choice(s[k]) -> s[k+1], this is the state transition equation. It should be noted that there may be more than one choice, so there will be more than one state s[k+1] at each stage.
Continuing with the above stair climbing problem, the stair climbing problem must come from n-1 or n-2 for the nth step, so the number of nth steps is the number of n-1 steps on Add the number of n-1 steps.
The above understanding is the core, it is our state transition equation, expressed in code is f(n) = f(n - 1) + f(n - 2)
.
The actual operation process may be as intuitive as climbing stairs, and it is not difficult for us to think of it. It may also be hidden deep or too high in dimension. If you really can't think of it, you can try to draw a picture to open up your mind. This is also the method when I first learned dynamic programming. When you get up to the volume of the questions, your sense of the question will come, and you don't need to draw pictures at that time.
For example, we define the state equation, according to which we define the initial state and the target state. Then focus on the optimal substructure, think about how each state expands so that it gets closer and closer to the target state .
As shown below:
The theory is almost like this first, and then a few actual combat to digest.
ok, next is the decryption link. We didn't talk about the transfer equation in the above two questions, we will fill it up here.
The first question: "5. The longest palindrome substring" is of medium difficulty. Our two state definitions above are not good, but I can a little bit on the basis of the above to make the transfer equation very easy to write. This technique is reflected in many dynamic topics, such as the longest ascending subsequence, etc. requires everyone to master .
Taking the f(start, end) mentioned above, the meaning is the longest palindrome substring of the substring s[start:end+1]. The way of expression is unchanged, but the meaning becomes the longest palindrome substring of the substring s[start:end+1], and must contain start and end . After this definition, in fact, we don't need to define the return value of f(start, end) as the length, but just a Boolean value. If it returns true, the longest palindrome substring is end-start + 1, otherwise it is 0.
So the transfer equation can be written as:
f(i,j)=f(i+1,j−1) and s[i] == s[j]
The second question: "10. Regular Expression Matching" is difficult.
Taking the f(s_start, s_end, p_start, p_end) we analyzed, the meaning is whether the substring p1[p_start:p_end+1] can match the string s[s_start:s_end+1].
In fact, we can define a simpler way, that is, f(s_end, p_end), which means whether the substring p1[:p_end+1] can match the string s[:s_end+1]. In other words, the fixed starting point is index 0, which is also a , please be sure to master it.
So the transfer equation can be written as:
- If p[j] is a lowercase letter, whether it matches depends on whether s[i] is equal to p[j]:
$$ f(i,j)=\left\{ \begin{aligned} f(i-1, j-1) & & s[i] == p[j] \\ false & & s[i] != p[j] \\ \end{aligned} \right. $$
- if p[j] =='.', it must match:
f(i,j)=f(i-1,j−1)
- if p[j] =='*', which means p can match the j−1 character of s any number of times:
$$ f(i,j)=\left\{ \begin{aligned} f(i-1, j) & & match & & 1+ & & times \\ f(i, j - 2) & & match & & 0 & & time \\ \end{aligned} \right. $$
Believe that you can analyze it to this point, it is not difficult to write the code. For the specific code, please refer to my force button solution warehouse , so I won’t talk about it here.
Did you notice? I have used the above mathematical formulas to describe all state transition equations. Yes, all transfer equations can be described in this way. I suggest that you for every dynamic programming problem at . At first, you may find it annoying. But believe me, if you persevere, you will find yourself gradually becoming stronger. It's as if I strongly recommend that you analyze the complexity of every question. Dynamic programming must not only understand the transfer equation, but also write it out completely in mathematical formulas like me.
Do you find it troublesome to write the state transition equation? Here I will introduce you a little trick, that is to use latex, latex grammar can easily write such a formula. In addition, Xifa also thoughtfully wrote the to generate the dynamic programming transfer equation formula with one key to help everyone generate the prosecution office as quickly as possible. Plug-in address: https://leetcode-pp.github.io/leetcode-cheat/?tab=solution-template
There is really no panacea for the state transition equation. Different problems have different solutions. The state transition equation is also the most difficult and critical point in solving dynamic programming problems. You must practice more to improve the sense of the problem. Next, we look at is not so difficult, but more novice doubt question - how to enumerate the state .
Of course, there may be more than one state transition equation, and the corresponding efficiencies of different transition equations may be quite different. This is a relatively metaphysical topic, and you need to understand it in the process of doing the problem.
How to enumerate states
The key to enumerating states is how to enumerate states so that they are not repeated.
- If it is a one-dimensional state, then we can use a layer of loop to get it.
for i in range(1, n + 1):
pass
- If it is a two-dimensional state, then we can use a two-layer loop to get it done.
for i in range(1, m + 1):
for j in range(1, n + 1):
pass
- 。。。
But the actual operation process has many details such as:
- Do I enumerate the one-dimensional state first on the left or on the right? (Traversal from left to right or from right to left)
- In the two-dimensional state, do I first enumerate the upper left or the upper right, or the lower left or the lower right?
- The positional relationship between the inner loop and the outer loop (can it be interchanged)
- 。。。
In fact, this thing is related to many factors, it is difficult to summarize a law, and I think it is completely unnecessary to summarize the law.
But here I still summarize a key point, that is:
- If you do not use the scrolling array technique , then the traversal order depends on the state transition equation. such as:
for i in range(1, n + 1):
dp[i] = dp[i - 1] + 1
Then we need to traverse from left to right, the reason is very simple, because dp[i] depends on dp[i-1], so when calculating dp[i], dp[i-1] needs to be already calculated.
The same is true for the two-dimensional one, you can try it.
- If you use the scrolling array technique , you can traverse any way, but the meaning of different traversal is usually not different. For example, I compressed the two-dimensional to one-dimensional:
for i in range(1, n + 1):
for j in range(1, n + 1):
dp[j] = dp[j - 1] + 1;
This is okay. dp[j-1] actually refers to the dpi before compression
and:
for i in range(1, n + 1):
# 倒着遍历
for j in range(n, 0, -1):
dp[j] = dp[j - 1] + 1;
This is also possible. But dp[j-1] actually refers to dpi-1 before compression. Therefore, what kind of traversal method is used in practice depends on the topic. I specially wrote a [complete knapsack problem] routine problem (1449. The digital cost and the maximum number of the target value article, through a specific example to tell you what is the actual difference between different traversals, I strongly recommend that you take a look, and Give it a three-in-one.
- Regarding the inside and outside circulation, the principle is similar to the above.
This is more subtle, you can refer to this article to understand 0518.coin-change-2 .
summary
How to determine the critical condition is usually relatively simple, and you can quickly master it by doing a few more questions.
Regarding how to determine the state transition equation, this is actually more difficult. Fortunately, these routines are relatively strong, such as the state of a string, usually dp[i] means that the string s ends with i.... For example, the status of two strings, usually dpi means that the string s1 ends with i and s2 ends with j.... In this way, when you encounter a new problem, you can apply it. If you can't find it, draw the picture honestly and observe continuously to improve the sense of the problem.
Regarding how to enumerate the state, if there is no scrolling array, then how to enumerate can be determined according to the transition equation. If you use a rolling array, you should pay attention to the dp correspondence between after compression and before compression.
Dynamic programming VS memoized recursion
Above, we cleverly solved the problem of climbing stairs with the problem of memory recursion. So how does dynamic programming solve this problem?
The answer is also "look up the table". The dp table we usually write is a table. In fact, this dp table is no different from the memo above.
In the dp table we generally write, array usually corresponds to the memoized recursive function parameter, and the value corresponds to the return value of the recursive function.
It seems that no ideological difference between the two, , the only difference is the writing 1608109e20141d? ? That's right. However, this difference in writing will also bring about some other related differences, which we will talk about later.
If the above problem of climbing stairs, using dynamic programming, what is the code like? Let's take a look:
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];
}
It doesn't matter if you don't know it now, we will slightly modify the recursive code . In fact, change the name of the function:
function dp(n) {
if (n === 1) return 1;
if (n === 2) return 2;
return dp(n - 1) + dp(n - 2);
}
After such a change. Let's compare dp[n] and dp(n). Does this make sense? In fact, the difference between them is that recursively uses the call stack to enumerate the state, while dynamic programming uses iterative enumeration.
If multiple dimensions are required for enumeration, then iterative enumeration can also be used inside memoized recursion, such as the longest ascending subsequence problem.
If the table look-up process of dynamic programming is drawn as a picture, it is like this:
The dotted line represents the look-up process
Scrolling array optimization
We do not need to use a one-dimensional array to climb the stairs, but use two variables to achieve it, and the space complexity is O(1). Code:
function climbStairs(n) {
if (n === 1) return 1;
if (n === 2) return 2;
let a = 1;
let b = 2;
let temp;
for (let i = 3; i <= n; i++) {
temp = a + b;
a = b;
b = temp;
}
return temp;
}
in the state transition equation of the stairs climbing problem is only related to the first two states , so only these two need to be stored. There are many such tricky methods for dynamic programming problems. This technique is called rolling arrays.
This problem is the simplest problem in dynamic programming, because it only involves the change of a single factor. If multiple factors are involved, it is more complicated, such as the famous backpack problem, gold mining problem, etc.
For a single factor, we only need a one-dimensional array at most. For the knapsack problem, we need a two-dimensional array and other higher latitudes.
Answer the above question: Memoized recursion and dynamic programming are no different except for one using recursion and the other using iteration. What is the difference between the two? I think the biggest difference is that memoized recursion cannot use rolling array optimization (you can try it with the stairs above), and the overhead of memoizing the call stack is relatively large (the complexity remains the same, you can think that the space complexity constant item is larger ), but it's hardly TLE or MLE. Therefore, my suggestion is to memorize it directly if there is no space for optimization requirements, otherwise use iterative dp .
To emphasize again:
- If we say that recursion is to reverse the result of the problem, until the problem size is reduced to normal. Then dynamic programming is to start from the usual and gradually expand the scale to the optimal substructure.
- Memoized recursion is not fundamentally different from dynamic programming. They are all enumerated states, and are gradually derived and solved according to the direct connection of the states.
- Dynamic programming performance is usually better. On the one hand is the stack overhead of recursion, on the other hand is the technique of scrolling arrays.
Basic types of dynamic programming
- Backpack DP (for this, we have opened a special topic)
- Interval DP
Interval dynamic programming is an extension of linear dynamic programming. When dividing the problem in stages, it has a great relationship with the order of the elements in the stage and which elements from the previous stage are merged. Let the state $f(i,j)$ denote the maximum value that can be obtained by combining all the elements from the subscript position $i$ to $j$, then $f(i,j)=\max\{f(i ,k)+f(k+1,j)+cost\}$, where $cost$ is the cost of combining these two sets of elements.
Features of interval DP:
merge : That is to integrate two or more parts, of course, it can also be reversed;
feature : can decompose the problem into a form that can be combined in pairs;
: Set the optimal value for the entire problem, enumerate the merge points, decompose the problem into two parts, and finally merge the optimal values of the two parts to obtain the optimal value of the original problem.
Two questions are recommended:
- 877. Stone Game
- 312. Poke the balloon
- Pressure DP
Regarding the pressure DP, please refer to an article I wrote before: What is the pressure DP? This problem solution takes you to get started
- Digital DP
Digital DP is usually this: Given a closed interval, so that you find in this section meet certain conditions total number of.
Recommend a question Increasing-Digits
- Count DP and Probability DP
I won't say more about these two. Because there are no rules.
The count DP is listed for two reasons:
- Let everyone know that there is indeed this question type.
- Counting is a by-product of dynamic programming.
The probability DP is special. The state transition formula of the probability DP generally refers to will transfer from a certain state to , which is more like an expected calculation, so it is also called an expected DP.
For more topic types and recommended topics, see the learning route of the brush-quest plugin. Plug-in acquisition method: Official account force deduction plus reply plug-in.
When to use memoized recursion?
- It is convenient to use memoized recursion when traversing from both ends of the array at the same time, which is actually the range DP (range dp). For example, the game of stone, and the question https://binarysearch.com/problems/Make-a-Palindrome-by-Inserting-Characters
If the interval dp, your traversal method probably needs to be like this:
class Solution:
def solve(self, s):
n = len(s)
dp = [[0] * n for _ in range(n)]
# 右边界倒序遍历
for i in range(n - 1, -1, -1):
# 左边界正序遍历
for j in range(i + 1, n):
# do something
return dp[0][m-1] # 一般都是使用这个区间作为答案
If you use memoized recursion, you don't need to consider the traversal method.
Code:
class Solution:
def solve(self, s):
@lru_cache(None)
def helper(l, r):
if l >= r:
return 0
if s[l] == s[r]:
return helper(l + 1, r - 1)
return 1 + min(helper(l + 1, r), helper(l, r - 1))
return helper(0, len(s) - 1)
- chooses more discrete, it is better to use memoized recursion. For example, the horse goes on the chessboard.
Then when do you not need to memoize recursion? The answer is nothing else. Because ordinary dp table has an important function, this function recursion cannot be replaced, that is, 1608109e201caa rolling array optimization . If you need to optimize the space, you must use dp table.
Warm-up start
The theoretical knowledge is almost there, let's try a question.
Let's practice with a very classic backpack problem.
Subject: 322. Change
给定不同面额的硬币 coins 和一个总金额 amount。编写一个函数来计算可以凑成总金额所需的最少的硬币个数。如果没有任何一种硬币组合能组成总金额,返回 -1。
你可以认为每种硬币的数量是无限的。
示例 1:
输入:coins = [1, 2, 5], amount = 11
输出:3
解释:11 = 5 + 5 + 1
There are two parameters for this question, one is coins and the other is amount.
We can define the state as f(i, j), which means the minimum number of coins needed to find j yuan with the first i items of coins. Then the answer is f(len(coins)-1, amount).
Based on the principle of combination, all selection states of coins are $2^n$. The total number of states is the Cartesian product of the values of i and j, which is 2^len(coins) * (amount + 1).
The minus 1 is due to the existence of 0 yuan.
With this clear, what we need to consider is how to transfer the state, that is, how to transfer from normal to f(len(coins)-1, amount).
How to determine the state transition equation? we need:
- Focus on the best substructure
- Make a choice, take the optimal solution in the choice (if it is counting dp, then count)
For this question, we have two choices:
- Select coins[i]
- Do not choose coins[i]
This is undoubtedly complete. Except that only one each of the coins is performed in selection and non-selection , so that the number of states is already a $ 2 ^ n $, where n is the length of the coins.
If only this is the case, the enumeration will definitely time out, because the number of states is already at the exponential level.
The core of this question is that the choice of coins[i] is not that important. important for 1608109e201f3e is actually how much the selected coins are .
Therefore, we can define f(i, j) to indicate that the first i items of coins are selected (do not care how to choose), and the minimum number of coins required to form j yuan.
For example, such as coins = [1,2,3]. So although choosing [1,2] and choosing [3] are different, we don't care at all. Because there is no difference between the two, we still pick whoever contributes the most to the result.
Taking coins = [1,2,3], amount = 6, we can draw the following recursive tree.
(Picture from https://leetcode.com/problems/coin-change/solution/ )
Therefore, the transfer equation is min(dp[i][j], dp[i-1][j - coins[j]] + 1)
, which means: min (do not choose coins[j], choose coins[j]) The minimum number of coins required.
The formula is:
$$ dp[i]=\left\{ \begin{aligned} min(dp[i][j], dp[i-1][j - coins[j]] + 1) & & j >= coins[j] \\ amount + 1 & & j < coins[j] \\ \end{aligned} \right. $$
amount means no solution. Because the denominations of the coins are all positive integers, it is impossible to have a solution that requires amount + 1 coin.
code
Memoized recursion:
class Solution:
def coinChange(self, coins: List[int], amount: int) -> int:
@lru_cache(None)
def dfs(amount):
if amount < 0: return float('inf')
if amount == 0: return 0
ans = float('inf')
for coin in coins:
ans = min(ans, 1 + dfs(amount - coin))
return ans
ans = dfs(amount)
return -1 if ans == float('inf') else ans
Two-dimensional dp:
class Solution:
def coinChange(self, coins: List[int], amount: int) -> int:
if amount < 0:
return - 1
dp = [[amount + 1 for _ in range(len(coins) + 1)]
for _ in range(amount + 1)]
# 初始化第一行为0,其他为最大值(也就是amount + 1)
for j in range(len(coins) + 1):
dp[0][j] = 0
for i in range(1, amount + 1):
for j in range(1, len(coins) + 1):
if i - coins[j - 1] >= 0:
dp[i][j] = min(
dp[i][j - 1], dp[i - coins[j - 1]][j] + 1)
else:
dp[i][j] = dp[i][j - 1]
return -1 if dp[-1][-1] == amount + 1 else dp[-1][-1]
dpi depends on dp[i][j - 1]
and dp[i - coins[j - 1]][j] + 1)
This is an optimized signal, we can optimize it to one dimension.
One-dimensional dp (rolling array optimization):
class Solution:
def coinChange(self, coins: List[int], amount: int) -> int:
dp = [amount + 1] * (amount + 1)
dp[0] = 0
for j in range(len(coins)):
for i in range(1, amount + 1):
if i >= coins[j]:
dp[i] = min(dp[i], dp[i - coins[j]] + 1)
return -1 if dp[-1] == amount + 1 else dp[-1]
Recommended practice questions
Finally, I recommend a few questions to everyone, and I suggest that you use memoized recursion and dynamic programming to solve them. If you use dynamic programming, use rolling arrays to optimize space as much as possible.
- 0091.decode-ways
- 0139.word-break
- 0198.house-robber
- 0309.best-time-to-buy-and-sell-stock-with-cooldown
- 0322.coin-change
- 0416.partition-equal-subset-sum
- 0518.coin-change-2
to sum up
This article summarizes two commonly used methods in algorithms-recursion and dynamic programming. If you are recursive, you can practice with the topic of the tree. If you are dynamic programming, you can use all the recommendations I recommend above, and then consider the dynamic programming label of Likou.
When you learn dynamic programming in the early stage, you can try to solve it with memoization first. Then transform it into dynamic programming, so that you will feel it when you practice it a few times. After that, you can practice scrolling the array. This technique is very useful and relatively simple.
The core of dynamic planning is to define the state. After the state is defined, everything else is a matter of course.
The difficulty of dynamic programming is that enumerates all states (not but not missing) 1608109e202381 and find the state transition equation .
reference
- oi-wiki-dp This information is recommended for everyone to study, it is very comprehensive. It's just more suitable for people with a certain foundation. You can eat it with this handout.
In addition, you can go LeetCode explore the recursive the I in an interactive learning.
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。