Detailed dynamic planning (revised edition)

The general form of the dynamic programming problem is to find the best value . Dynamic programming is actually an optimization method of operations research, but it is used more in computer problems, such as letting you find the longest incremental subsequence, the minimum edit distance, and so on.

Since it is the most demanding value, what is the core issue? The core problem of solving dynamic programming is exhaustion . Because it requires the best value, we must list all feasible answers and find the best value among them.

Dynamic programming is as simple as that, is it all done? The dynamic programming problems I have seen are very difficult!

First of all, the exhaustion of dynamic programming is a bit special, because this type of problem has "overlapping sub-problems" . If it is violently exhausted, the efficiency will be extremely low. Therefore, a "memorandum" or "DP table" is needed to optimize the exhaustion process and avoid unnecessary Calculation.

Moreover, the dynamic programming problem must have an "optimal substructure" so that the maximum value of the original problem can be obtained through the maximum value of the subproblem.

In addition, although the core idea of ​​dynamic programming is to find the best value by exhaustion, the problem can be ever-changing. It is not an easy task to exhaust all feasible solutions. Only by listing the correct "state transition equation " can the exhaustion be correctly exhausted.

The above-mentioned overlapping sub-problems, optimal sub-structures, and state transition equations are the three elements of dynamic programming. The specific meaning will be explained in detail with examples, but in the actual algorithm problem, it is the most difficult to write the state transition equation . This is why many friends find the dynamic programming problem difficult. I will provide a thought that I have researched. Framework to help you think about the state transition equation:

Clear "state" -> define the meaning of dp array/function -> clear "choice" -> clear base case.

The following explains the basic principles of dynamic programming in detail through the Fibonacci sequence problem and the change problem. The former is mainly to let you understand what is the overlapping subproblem (the Fibonacci sequence is not strictly a dynamic programming problem), and the latter mainly focuses on how to list the state transition equations.

Readers do not dislike the simplicity of this example. Only simple examples can allow you to fully focus on the general ideas and techniques behind the algorithm, and not be inexplicable by those obscure details . For difficult examples, there are some in historical articles.

1. Fibonacci sequence

1. Violent recursion

The mathematical form of the Fibonacci sequence is recursive, and the code is like this:

int fib(int N) {
    if (N == 1 || N == 2) return 1;
    return fib(N - 1) + fib(N - 2);
}

Needless to say, school teachers seem to use this as an example when they talk about recursion. We also know that writing code like this is concise and easy to understand, but it is very inefficient. Where is the inefficiency? Assuming n = 20, please draw a recursive tree.

PS: Whenever you encounter a problem that requires recursion, it is best to draw a recursion tree. This is a great help for you to analyze the complexity of the algorithm and find the reasons for the inefficiency of the algorithm.

How to understand this recursive tree? That is to say f(20), if I want to calculate the original problem , I must first calculate the sub-problem f(19)sum f(18), and then to calculate f(19), I must first calculate the sub-problem f(18)sum f(17), and so on. When you finally encounter f(1)or f(2), if the result is known, the result can be returned directly, and the recursion tree no longer grows downward.

How to calculate the time complexity of the recursive algorithm? Multiply the number of sub-problems by the time required to solve a sub-problem.

The number of sub-problems is the total number of nodes in the recursive tree. Obviously the total number of binary tree nodes is at the exponential level, so the number of sub-problems is O(2^n).

The time to solve a sub-problem. In this algorithm, there is no loop, only f(n-1) + f(n-2) is an addition operation, and the time is O(1).

Therefore, the time complexity of this algorithm is O(2^n), exponential level, and explosion.

Observation recursive tree, apparently discovered the cause inefficient algorithm: there are a lot of double counting, for example, f(18)is counted twice, and you can see that with f(18)for the recursive tree root huge amount, counted more than once, it will be costly time. What's more, not only f(18)this node is repeatedly calculated, so this algorithm is extremely inefficient.

This is the first nature of the dynamic programming problem: overlapping subproblems . Next, we find a way to solve this problem.

2. Recursive solution with memo

If you clarify the problem, you have already solved half of the problem. Now that the time-consuming reason is repeated calculations, then we can create a "memo", and don't rush to return every time the answer to a certain sub-question is calculated. Write it down in the "memo" and return; each time you encounter a sub-question Check it in the "memorandum" first. If you find that you have solved the problem before, just use the answer directly instead of time-consuming calculations.

An array is generally used as this "memorandum", of course, you can also use a hash table (dictionary), the idea is the same.

int fib(int N) {
    if (N < 1) return 0;
    // 备忘录全初始化为 0
    vector<int> memo(N + 1, 0);
    // 初始化最简情况
    return helper(memo, N);
}

int helper(vector<int>& memo, int n) {
    // base case 
    if (n == 1 || n == 2) return 1;
    // 已经计算过
    if (memo[n] != 0) return memo[n];
    memo[n] = helper(memo, n - 1) + 
                helper(memo, n - 2);
    return memo[n];
}

Now, draw the recursion tree and you will know what the "memo" does:

In fact, the recursive algorithm with "memorandum" transforms a recursive tree with a huge amount of redundancy into a recursive graph without redundancy through "pruning", which greatly reduces the sub-problem (ie recursive The number of nodes in the graph).

How to calculate the time complexity of the recursive algorithm? Multiply the number of sub-problems by the time required to solve a sub-problem.

The number of sub-problems, i.e. the total number of nodes in the graph, since the algorithm calculates the redundancy does not exist, the sub-problem is f(1), f(2), f(3)... f(20), the number and size of the input is proportional to n = 20, the number of sub-problems O (n).

The time to solve a sub-problem is the same as above, there is no loop, and the time is O(1).

Therefore, the time complexity of this algorithm is O(n). Compared with violent algorithms, it is a dimensionality reduction attack.

So far, the efficiency of the recursive solution with memo has been the same as that of iterative dynamic programming. In fact, this solution is similar to the iterative dynamic programming idea, but this method is called "top-down" and dynamic programming is called "bottom-up".

What is "top down"? Note that we have just drawn recursive tree (or map), extending from top to bottom, are from a large-scale problems such as the original f(20), scale down gradually break down, until f(1)and f(2)bottom, layer by layer and then returns the answer, This is called "top-down".

What is "bottom-up"? Conversely, we directly start from the bottom, the simplest, f(1)and the smallest problem size and f(2)push upwards until we reach the answer we want f(20). This is the idea of ​​dynamic programming, which is why dynamic programming generally breaks away from recursion. The calculation is completed by loop iteration.

3. Iterative solution of dp array

With the inspiration of the "memo" in the previous step, we can separate this "memo" into a table, called DP table, it is not beautiful to complete the "bottom-up" calculation on this table!

int fib(int N) {
    vector<int> dp(N + 1, 0);
    // base case
    dp[1] = dp[2] = 1;
    for (int i = 3; i <= N; i++)
        dp[i] = dp[i - 1] + dp[i - 2];
    return dp[N];
}

It is easy to understand by drawing a picture, and you find that this DP table is very similar to the result of the previous "pruning", just the other way around. In fact, the "memorandum" in the recursive solution with memo is the DP table after the final completion, so the two solutions are actually the same. In most cases, the efficiency is basically the same.

Here, the introduction of the term "state transition equation" is actually a mathematical form that describes the structure of the problem:

Why is it called "state transition equation"? To sound high-end. You think of f(n) as a state n. This state n is transferred from the addition of state n-1 and state n-2. This is called state transition, and nothing more.

You will find all the operations in the above solutions, such as return f(n-1) + f(n-2), dp[i] = dp[i-1] + dp[i-2], and The initialization operations of the memo or DP table are all around the different expressions of this equation. It can be seen that the importance of listing the "state transition equation" is the core of solving the problem. It is easy to find that the state transition equation directly represents the violent solution.

Do not look down on the violent solution. The most difficult problem of dynamic programming is to write the state transition equation , which is the violent solution. The optimization method is nothing more than a memo or DP table, and there is no mystery.

At the end of this example, talk about a detail optimization. Careful readers will find that according to the state transition equation of the Fibonacci sequence, the current state is only related to the previous two states. In fact, there is no need for such a long DP table to store all the states. Just think of a way to store the previous ones. Two states will do. Therefore, it can be further optimized to reduce the space complexity to O(1):

int fib(int n) {
    if (n == 2 || n == 1) 
        return 1;
    int prev = 1, curr = 1;
    for (int i = 3; i <= n; i++) {
        int sum = prev + curr;
        prev = curr;
        curr = sum;
    }
    return curr;
}

Some people may ask, why is the "optimal substructure" another important feature of dynamic programming not involved? It will be covered below. The example of Fibonacci sequence is not strictly speaking dynamic programming, because it does not involve seeking the most value, the above is intended to demonstrate the spiraling process of algorithm design.

Next, look at the second example, the problem of collecting change.

2. The problem of collecting change

Look under the topic: you kdenominations of coins in denominations of c1, c2 ... ckunlimited number of each coin, to give a total amount amount, ask you at least need a few coins Couchu this amount, if not impossible Couchu, the algorithm returns - 1 . The function signature of the algorithm is as follows:

// coins 中是可选硬币面值,amount 是目标金额
int coinChange(int[] coins, int amount);

For example k = 3, the face value is 1, 2, 5, and the total amount amount = 11. Then at least 3 coins are needed to make up, that is, 11 = 5 + 5 + 1.

How do you think computers should solve this problem? Obviously, it is to list all the possible ways to collect coins, and then look for the minimum number of coins required.

1. Violent recursion

First of all, this problem is a dynamic programming problem because it has an "optimal substructure". To conform to the "optimal substructure", the subproblems must be independent of each other . What is mutual independence? You definitely don't want to see mathematical proofs, I will use an intuitive example to explain.

For example, your original question is to get the highest total score on the test, then your sub-question is to get the highest score in the Chinese test and the highest score in the math test... In order to get the highest score in each course, you have to put the corresponding Get the highest score for multiple-choice questions, and get the highest score for fill-in-the-blank questions... Of course, in the end, you get a perfect score for each course, which is the highest total score.

Get the correct result: the highest total score is the total score. Because this process conforms to the optimal substructure, the sub-questions of "each subject to the highest test" are independent of each other and do not interfere with each other.

However, if you add a condition: your language scores and mathematics scores will restrict each other, one will trade each other. In this case, obviously the highest total score you can get will not reach the total score, and you will get the wrong result according to the idea just now. Because the sub-problems are not independent, the Chinese and mathematics performance cannot be optimal at the same time, so the optimal sub-structure is destroyed.

Going back to the question of picking up change, why does it fit the optimal substructure? For example, if you want to find amount = 11the minimum number of coins (original question), if you know amount = 10the minimum number of coins collected (sub-question), you only need to add one to the answer to the sub-question (choose another coin with a face value of 1). The answer to the original question is because there is no limit to the number of coins, and there is no mutual restriction between the sub-questions, and they are independent of each other.

So, now that we know that this is a dynamic programming problem, we must think about how to list the correct state transition equation .

First determine the "state" , that is, the variable that changes in the original problem and sub-problems. Since the number of coins is unlimited, the only state is the target amount amount.

Then determine dpthe definition of the function: The function dp(n) indicates that the current target amount is nthat at least dp(n)one coin is needed to make up the amount.

Then determine the "choice" and choose the best , that is, for each state, what choices can be made to change the current state. Specific to this question, no matter what the target amount is, the choice is coinsto select a coin from the denomination list , and then the target amount will be reduced:

# 伪码框架
def coinChange(coins: List[int], amount: int):
    # 定义:要凑出金额 n,至少要 dp(n) 个硬币
    def dp(n):
        # 做选择,需要硬币最少的那个结果就是答案
        for coin in coins:
            res = min(res, 1 + dp(n - coin))
        return res
    # 我们要求目标金额是 amount
    return dp(amount)

Finally, the base case is clarified . Obviously, when the target amount is 0, the number of coins required is 0; when the target amount is less than 0, there is no solution, and -1 is returned:

def coinChange(coins: List[int], amount: int):

    def dp(n):
        # base case
        if n == 0: return 0
        if n < 0: return -1
        # 求最小值,所以初始化为正无穷
        res = float('INF')
        for coin in coins:
            subproblem = dp(n - coin)
            # 子问题无解,跳过
            if subproblem == -1: continue
            res = min(res, 1 + subproblem)

        return res if res != float('INF') else -1

    return dp(amount)

At this point, the state transition equation has actually been completed. The above algorithm is already a violent solution. The mathematical form of the above code is the state transition equation:

At this point, the problem is actually solved, but we need to eliminate the overlapping sub-problems. For example amount = 11, coins = {1,2,5}, draw a recursive tree to see:

Time complexity analysis: total number of sub-problems x time to solve each sub-problem .

The total number of sub-problems is the number of recursive tree nodes. This is hard to see. It is O(n^k). In short, it is exponential. Each sub-problem contains a for loop with a complexity of O(k). So the total time complexity is O(k * n^k), exponential level.

2. Recursion with memo

With only a few modifications, the sub-problems can be eliminated through the memo:

def coinChange(coins: List[int], amount: int):
    # 备忘录
    memo = dict()
    def dp(n):
        # 查备忘录,避免重复计算
        if n in memo: return memo[n]

        if n == 0: return 0
        if n < 0: return -1
        res = float('INF')
        for coin in coins:
            subproblem = dp(n - coin)
            if subproblem == -1: continue
            res = min(res, 1 + subproblem)

        # 记入备忘录
        memo[n] = res if res != float('INF') else -1
        return memo[n]

    return dp(amount)

Without drawing the picture, it is obvious that the "memorandum" greatly reduces the number of sub-questions and completely eliminates the redundancy of sub-questions, so the total number of sub-questions will not exceed the amount of money n, that is, the number of sub-questions is O(n). The time to process a sub-problem remains the same, still O(k), so the total time complexity is O(kn).

3. Iterative solution of dp array

Of course, we can also use dp table from bottom to top to eliminate overlapping sub-problems. dpThe definition of the array is dpsimilar to the function just now , and the definition is the same:

dp[i] = xSaid that when the target amount of itime, at least xcoins .

int coinChange(vector<int>& coins, int amount) {
    // 数组大小为 amount + 1,初始值也为 amount + 1
    vector<int> dp(amount + 1, amount + 1);
    // base case
    dp[0] = 0;
    for (int i = 0; i < dp.size(); i++) {
        // 内层 for 在求所有子问题 + 1 的最小值
        for (int coin : coins) {
            // 子问题无解,跳过
            if (i - coin < 0) continue;
            dp[i] = min(dp[i], 1 + dp[i - coin]);
        }
    }
    return (dp[amount] == amount + 1) ? -1 : dp[amount];
}

PS: Why is the dparray initialized to amount + 1? Because amountthe number of coins that make up the amount can only be equal to amount(all coins with a face value of 1 yuan), initializing to amount + 1is equivalent to initializing to positive infinity, which is convenient for taking the minimum value later.

Third, the final summary

The first Fibonacci sequence problem explained how to optimize the recursive tree through the "memorandum" or "dp table" method, and made it clear that the two methods are essentially the same, but top-down and self- The bottom-up is different.

The second problem of collecting change shows how to determine the "state transition equation" in a streamlined manner. As long as the violent recursive solution is written through the state transition equation, the rest is to optimize the recursion tree and eliminate the overlapping sub-problems.

If you don't know much about dynamic programming and you can still see here, I really have to applaud you. I believe you have mastered the design skills of this algorithm.

In fact, there are no tricks or tricks for computer to solve problems. Its only solution is to exhaustively exhaust all possibilities. Algorithm design is nothing more than thinking about "how to exhaustively" and then pursuing "how to exhaustively intelligently".

To list the dynamic transfer equation is to solve the "how to exhaust" problem. The reason why it is difficult is that many exhaustive lists need to be implemented recursively, and secondly, because the solution space of some problems is complicated, it is not so easy to exhaustively complete.

The memorandum and DP table are pursuing "how to exhaustively wisely". The idea of ​​using space for time is the only way to reduce the complexity of time. In addition, let me ask, what can I do?

PS:

This article was written by Xiaohui's friend labuladong, and his new book "labuladong's algorithm cheat sheet" has been published, congratulations!

               

At the stage of sample draft, I read this book and wrote a recommendation.

 

This book takes you through the algorithm problems hand by hand, full color, more than 400 pages, and a lot of dry goods. The author clearly explained many classic algorithmic topics in easy-to-understand language. Many of the topics are often encountered during interviews with well-known companies. After thorough understanding, it will definitely increase your chances of getting offers from major companies.

 

The author himself took the offer from a major domestic manufacturer, and it was an offer harvester. There are always algorithm problems, and only the routines are popular. He summed up the routines from his years of experience in algorithm problem solving, which is very practical. This book can definitely help you clear the obstacles in the algorithm road. Dangdang still has discounts in these two days, so hurry up and buy a copy if you need it:

Guess you like

Origin blog.csdn.net/bjweimengshu/article/details/110943983