leetcode題解(動態規劃)

吳軍旗發表於2019-03-04

動態規劃本質依然是遞迴演算法,只不過是滿足特定條件的遞迴演算法;動態規劃是一個設計感比較強,藝術感比較強的一種演算法設計思想。

什麼是動態規劃

定義

將原問題拆解成若干子問題,同時儲存子問題的答案,使得每個子問題只求解一次,最終獲得原問題的答案。

paste image
  • 我們先解決小資料量的問題,之後層層遞推來解決更大資料量的問題,通常這個過程就叫做動態規劃。這個時間和記憶化搜尋的時間複雜度是相當的,不過動態規劃沒有遞迴的呼叫,不需要額外呼叫和棧空間。
  • 動態規劃是一個設計感比較強,藝術感比較強的一種演算法設計思想。

一個簡單例子


    #include <iostream>
    #include <ctime>
    
    using namespace std;
    
    int num = 0;
    
    int fib( int n ){
    
        num ++;
    
        if( n == 0 )
            return 0;
    
        if( n == 1 )
            return 1;
    
        return fib(n-1) + fib(n-2);
    }
    
    int main() {
    
        num = 0;
    
        int n = 42;
        time_t startTime = clock();
        int res = fib(n);
        time_t endTime = clock();
    
        cout<<"fib("<<n<<") = "<<res<<endl;
        cout<<"time : "<<double(endTime-startTime)/CLOCKS_PER_SEC<<" s"<<endl;
        cout<<"run function fib() "<<num<<"times."<<endl;
    
        return 0;
    }
複製程式碼

分析

通過計時我們會發現這個演算法很慢,為什麼這個解法效率這麼低呢?當我們需要計算fib(5)時,它的遞迴樹是:

leetcode題解(動態規劃)

從這個圖可以看出這裡面有大量的重複計算,我們怎樣避免呢,我們可以在程式的外面做一個陣列memo,其實memo[i]就記憶了第i個斐波那契數列。

    
    #include <iostream>
    #include <ctime>
    #include <vector>
    using namespace std;
    
    vector<int> memo;
    int num = 0;
    
    // 記憶化搜尋
    int fib( int n ){
    
        num ++;
    
        if( n == 0 )
            return 0;
    
        if( n == 1 )
            return 1;
    
        if( memo[n] == -1 )
            memo[n] = fib(n-1) + fib(n-2);
    
        return memo[n];
    }
    
    int main() {
    
        num = 0;
    
        int n = 42;
        memo = vector<int>(n+1,-1);
    
        time_t startTime = clock();
        int res = fib(n);
        time_t endTime = clock();
    
        cout<<"fib("<<n<<") = "<<res<<endl;
        cout<<"time : "<<double(endTime-startTime)/CLOCKS_PER_SEC<<" s"<<endl;
        cout<<"run function fib() "<<num<<"times."<<endl;
    
        return 0;
    }
    
複製程式碼

我們採用一個memo陣列來記憶,所以叫做記憶化搜尋。記憶化搜尋其實就是在遞迴的過程中新增計劃化,是一種自上向下的解決問題,我們假設基本的問題已經解決了,我們已經會求fib(n-1)和fib(n-2)了,那麼我們就能求第n個數了。

如果我們能自上而下解決問題,我們也能自下而上解決問題,只不過很多時候我們習慣於前者。


    #include <iostream>
    #include <ctime>
    #include <vector>
    using namespace std;
    
    // 動態規劃
    int fib( int n ){
    
        vector<int> memo(n+1, -1);
    
        memo[0] = 0;
        memo[1] = 1;
        for( int i = 2 ; i <= n ; i ++ )
            memo[i] = memo[i-1] + memo[i-2];
    
        return memo[n];
    }
    
    int main() {
    
        // 結果會溢位,這裡只看效能
        int n = 1000;
    
        time_t startTime = clock();
        int res = fib(n);
        time_t endTime = clock();
    
        cout<<"fib("<<n<<") = "<<res<<endl;
        cout<<"time : "<<double(endTime-startTime)/CLOCKS_PER_SEC<<" s"<<endl;
    
        return 0;
    }
    
複製程式碼

第一個動態規劃問題

leetcode 70. 爬樓梯

paste image

解題思路

我們來看一下遞迴的思路,把一個大的問題分解成小的問題。

paste image

程式碼實現(遞迴)

#include <iostream>
    #include <vector>
    
    using namespace std;
    
    // 記憶化搜尋
    class Solution {
    private:
        vector<int> memo;
    
        int calcWays(int n){
    
            if( n == 0 || n == 1)
                return 1;
    
            if( memo[n] == -1 )
                memo[n] = calcWays(n-1) + calcWays(n-2);
    
            return memo[n];
        }
    public:
        int climbStairs(int n) {
    
            memo = vector<int>(n+1,-1);
            return calcWays(n);
        }
    };
複製程式碼

程式碼實現(動態規劃)

我們會發現和上面斐波那契一樣,很輕易可以轉化為動態規劃解法。

#include <iostream>
    #include <vector>
    
    using namespace std;
    
    // 動態規劃
    class Solution {
    
    public:
        int climbStairs(int n) {
    
            vector<int> memo(n+1, -1);
            memo[0] = 1;
            memo[1] = 1;
    
            for ( int i = 2; i <= n; i++ ) {
                memo[i] = memo[i-1] + memo[i-2];
            }
    
            return memo[n];
        }
    };

複製程式碼

相似問題

  • leetcode 120
  • leetcode 64

發現重疊子問題

leetcode 343. 整數拆分

paste image

解題思路

paste image

對於一個問題如果沒有思路時,我們可以先考慮暴力解法。話句話說,我們使用什麼樣的方式,才能把正整數n的所有分割列舉出來,我們無法知道有幾重迴圈,通常我們需要使用遞迴的手段。
暴力解法:回溯遍歷將一個數做分割的所有可能性。O(2^n)

之所以遞迴樹存在,是因為它有最優子結構
通過求子問題的最優解,可以獲得原問題的最優解。

最優子結構

paste image
  • 通過求子問題的最優解, 可以獲得原問題的最優解

程式碼實現

實現1
    #include <iostream>
    #include <cassert>
    
    using namespace std;
    
    class Solution {
    private:
        int max3( int a , int b , int c ){
            return max( a , max(b,c) );
        }
    
        // 將n進行分割(至少分割兩部分), 可以獲得的最大乘積
        int breakInteger( int n ){
    
            if( n == 1 )
                return 1;
    
            int res = -1;
            for( int i = 1 ; i <= n-1 ; i ++ )
                res = max3( res , i*(n-i) , i * breakInteger(n-i) );
            return res;
        }
    public:
        int integerBreak(int n) {
            assert( n >= 1 );
            return breakInteger(n);
        }
    };
    
    
複製程式碼
實現2

它包含重疊子問題,下面是記憶化搜尋版本:

    
    class Solution {
    private:
        vector<int> memo;
    
        int max3( int a , int b , int c ){
            return max( a , max(b,c) );
        }
    
        // 將n進行分割(至少分割兩部分), 可以獲得的最大乘積
        int breakInteger( int n ){
    
            if( n == 1 )
                return 1;
    
            if( memo[n] != -1 )
                return memo[n];
    
            int res = -1;
            for( int i = 1 ; i <= n-1 ; i ++ )
                res = max3( res , i*(n-i) , i * breakInteger(n-i) );
            memo[n] = res;
            return res;
        }
    public:
        int integerBreak(int n) {
            assert( n >= 1 );
            memo = vector<int>(n+1, -1);
            return breakInteger(n);
        }
    };
    
複製程式碼
實現3 動態規劃

下面我們使用自底向上的方法,也就是動態規劃解決這個問題

    
    class Solution {
    
    private:
        int max3( int a , int b , int c ){
            return max(max(a,b),c);
        }
    public:
        int integerBreak(int n) {
    
            // memo[i] 表示將數字i分割(至少分割成兩部分)後得到的最大乘積
            vector<int> memo(n+1, -1);
    
            memo[1] = 1;
            for ( int i = 2; i <= n; i++ ) {
                // 求解memo[i]
                for ( int j = 1; j <= i-1; j++ ) {
                    // j + (i-j)
                    memo[i] = max3( memo[i], j*(i-j), j*memo[i-j] );
                }
            }
    
            return memo[n];
    
        }
    };
    
複製程式碼

相似問題

  • leetcode 279
  • leetcode 91
  • leetcode 62
  • leetcode 63

狀態的定義和狀態轉移

leetcode 198. 打家劫舍

paste image

狀態的定義

考慮偷取[x…n-1]範圍裡的房子(函式定義)

狀態的轉移

f(0) = max{ v(0) + f(2), v(1) + f(3), v(2) + f(4), … , v(n-3) + f(n-1), v(n-2), v(n-1)}(狀態轉移方程)

解題思路

leetcode題解(動態規劃)

首先依然是如果沒有思路的話,先考慮暴力解法。檢查所有的房子,對每個組合,檢查是否有相鄰的房子,如果沒有,記錄其價值,找最大值。O((2^n)*n)

注意其中對狀態的定義:
考慮偷取[x…n-1]範圍裡的房子(函式的定義)

根據對狀態的定義,決定狀態的轉移:
f(0) = max{ v(0) + f(2), v(1) + f(3), v(2) + f(4), … , v(n-3) + f(n-1), v(n-2), v(n-1)}(狀態轉移方程)

實際上我們的遞迴函式就是在實現狀態轉移。

198. House Robber

實現程式碼

    class Solution {
    private:
        // memo[i] 表示考慮搶劫 nums[i...n) 所能獲得的最大收益
        vector<int> memo;
    
        // 考慮搶劫nums[index...nums.size())這個範圍的所有房子
        int tryRob( vector<int> &nums, int index){
    
            if( index >= nums.size() )
                return 0;
    
            if( memo[index] != -1 )
                return memo[index];
    
            int res = 0;
            for( int i = index ; i < nums.size() ; i ++ )
                res = max(res, nums[i] + tryRob(nums, i+2));
            memo[index] = res;
            return res;
        }
    public:
        int rob(vector<int>& nums) {
    
            memo = vector<int>(nums.size(), -1);
            return tryRob(nums, 0);
        }
    };
    
複製程式碼

動態規劃解法

    
    class Solution {
    
    public:
        int rob(vector<int>& nums) {
    
            int n = nums.size();
    
            if( n == 0 ) {
                return 0;
            }
    
            // memo[i] 表示考慮搶劫 nums[i...n) 所能獲得的最大收益
            vector<int> memo(n, 0);
            memo[n-1] = nums[n-1];
            for( int i = n-2 ; i >= 0 ; i -- ) {
                for (int j = i; j < n; j++) {
                    memo[i] = max(memo[i], nums[j] + (j + 2 < n ? memo[j + 2] : 0) );
                }
            }
    
            return memo[0];
        }
    };
    
複製程式碼

狀態的另一種定義

我們所強調的是對於動態規劃來說,我們要清晰自己對狀態的定義,在我們之前的定義我們是去考慮偷取[x…n-1]範圍裡的房子(函式的定義)。對於同樣的問題,很多時候我們可以設立不同的狀態得到同樣正確的答案。

改變對狀態的定義:
考慮偷取[0…x]範圍裡的房子(函式的定義)。實現如下:

記憶化搜尋程式碼實現

class Solution {

private:
    vector<int> memo;
    //考慮偷取[0..x]範圍裡的房子
    int tryRob(vector<int>&nums, int index){
        if (index < 0){
            return 0;
        }
        
        if (memo[index] != -1){
            return memo[index];
        }
        
        int res = 0;
        for( int i = index; i >= 0; i--){
            res = max(res, nums[i] + tryRob(nums, i - 2));
        }
        memo[index] = res;
        return res;
    }

public:
    
    int rob(vector<int>& nums) {
        int n = nums.size();
        memo = vector<int>(n + 1, -1);
        if (n == 0){
            return 0;
        }
        
        return tryRob(nums, n-1);
    }
};

複製程式碼

動態規劃程式碼實現

class Solution {

public:
    
    //考慮偷取[0..x]範圍裡的房子
    int rob(vector<int>& nums) {
        int n = nums.size();
        vector<int> memo(n, -1);
        
        if (n == 0){
            return 0;
        }
        
        memo[0] = nums[0];
        
        for(int i = 1; i < n; i++){
            for(int j = i; j >= 0; j --){
                memo[i] = max(memo[i], nums[j] + (j-2 >= 0? memo[j-2]: 0));
            }
        }
        
        return memo[n-1];
        
    }
};
複製程式碼

相似問題

  • leetcode 213
  • leetcode 337
  • leetcode 309

————————-華麗的分割線——————–

看完的朋友可以點個喜歡/關注,您的支援是對我最大的鼓勵。

個人部落格番茄技術小棧掘金主頁

想了解更多,歡迎關注我的微信公眾號:番茄技術小棧

番茄技術小棧

相關文章