1. 程式人生 > >動態規劃(DP)演算法

動態規劃(DP)演算法

    動態規劃相信大家都知道,動態規劃演算法也是新手在剛接觸演算法設計時很苦惱的問題,有時候覺得難以理解,但是真正理解之後,就會覺得動態規劃其實並沒有想象中那麼難。網上也有很多關於講解動態規劃的文章,大多都是敘述概念,講解原理,讓人覺得晦澀難懂,即使一時間看懂了,發現當自己做題的時候又會覺得無所適從。我覺得,理解演算法最重要的還是在於練習,只有通過自己練習,才可以更快地提升。話不多說,接下來,下面我就通過一個例子來一步一步講解動態規劃是怎樣使用的,只有知道怎樣使用,才能更好地理解,而不是一味地對概念和原理進行反覆琢磨。

    首先,我們看一下這道題(此題目來源於北大POJ):

    數字三角形(POJ1163)

    

    在上面的數字三角形中尋找一條從頂部到底邊的路徑,使得路徑上所經過的數字之和最大。路徑上的每一步都只能往左下或 右下走。只需要求出這個最大和即可,不必給出具體路徑。 三角形的行數大於1小於等於100,數字為 0 - 99

    輸入格式: 

   5      //表示三角形的行數    接下來輸入三角形

    7

    3   8

    8   1   0

    2   7   4   4

    4   5   2   6   5

    要求輸出最大和

    接下來,我們來分析一下解題思路:

    首先,肯定得用二維陣列來存放數字三角形

    然後我們用D( r, j) 來表示第r行第 j 個數字(r,j從1開始算)

    我們用MaxSum(r, j)表示從D(r,j)到底邊的各條路徑中,最佳路徑的數字之和。

    因此,此題的最終問題就變成了求 MaxSum(1,1)

    當我們看到這個題目的時候,首先想到的就是可以用簡單的遞迴來解題:

    D(r, j)出發,下一步只能走D(r+1,j)或者D(r+1, j+1)。故對於N行的三角形,我們可以寫出如下的遞迴式:   

if ( r == N)                
    MaxSum(r,j) = D(r,j)  
else      
    MaxSum( r, j) = Max{ MaxSum(r+1,j), MaxSum(r+1,j+1) } + D(r,j) 


    根據上面這個簡單的遞迴式,我們就可以很輕鬆地寫出完整的遞迴程式碼: 

#include <iostream>  
#include <algorithm> 
#define MAX 101  
using namespace std; 
int D[MAX][MAX];  
int n;  
int MaxSum(int i, int j){    
    if(i==n)  
        return D[i][j];    
    int x = MaxSum(i+1,j);    
    int y = MaxSum(i+1,j+1);    
    return max(x,y)+D[i][j];  
}
int main(){    
    int i,j;    
    cin >> n;    
    for(i=1;i<=n;i++)   
        for(j=1;j<=i;j++)        
            cin >> D[i][j];    
    cout << MaxSum(1,1) << endl;  
}      


    對於如上這段遞迴的程式碼,當我提交到POJ時,會顯示OLE

    

    對的,程式碼執行超時了,為什麼會超時呢?

    答案很簡單,因為我們重複計算了,當我們在進行遞迴時,計算機幫我們計算的過程如下圖:

    

    就拿第三行數字1來說,當我們計算從第2行的數字3開始的MaxSum時會計算出從1開始的MaxSum,當我們計算從第二行的數字8開始的MaxSum的時候又會計算一次從1開始的MaxSum,也就是說有重複計算。這樣就浪費了大量的時間。也就是說如果採用遞規的方法,深度遍歷每條路徑,存在大量重複計算。則時間複雜度為 2的n次方,對於 n = 100 行,肯定超時。 

    接下來,我們就要考慮如何進行改進,我們自然而然就可以想到如果每算出一個MaxSum(r,j)就儲存起來,下次用到其值的時候直接取用,則可免去重複計算。那麼可以用n方的時間複雜度完成計算。因為三角形的數字總數是 n(n+1)/2

    根據這個思路,我們就可以將上面的程式碼進行改進,使之成為記憶遞迴型的動態規劃程式: 

#include <iostream>  
#include <algorithm> 
using namespace std;
 
#define MAX 101
  
int D[MAX][MAX];    
int n;  
int maxSum[MAX][MAX];
 
int MaxSum(int i, int j){      
    if( maxSum[i][j] != -1 )         
        return maxSum[i][j];      
    if(i==n)   
        maxSum[i][j] = D[i][j];     
    else{    
        int x = MaxSum(i+1,j);       
        int y = MaxSum(i+1,j+1);       
        maxSum[i][j] = max(x,y)+ D[i][j];     
    }     
    return maxSum[i][j]; 
} 
int main(){    
    int i,j;    
    cin >> n;    
    for(i=1;i<=n;i++)   
        for(j=1;j<=i;j++) {       
            cin >> D[i][j];       
            maxSum[i][j] = -1;   
        }    
    cout << MaxSum(1,1) << endl; 
} 


    當我們提交如上程式碼時,結果就是一次AC

    

    雖然在短時間內就AC了。但是,我們並不能滿足於這樣的程式碼,因為遞迴總是需要使用大量堆疊上的空間,很容易造成棧溢位,我們現在就要考慮如何把遞迴轉換為遞推,讓我們一步一步來完成這個過程。

    我們首先需要計算的是最後一行,因此可以把最後一行直接寫出,如下圖:

    

    現在開始分析倒數第二行的每一個數,現分析數字2,2可以和最後一行4相加,也可以和最後一行的5相加,但是很顯然和5相加要更大一點,結果為7,我們此時就可以將7儲存起來,然後分析數字7,7可以和最後一行的5相加,也可以和最後一行的2相加,很顯然和5相加更大,結果為12,因此我們將12儲存起來。以此類推。。我們可以得到下面這張圖:

    

    然後按同樣的道理分析倒數第三行和倒數第四行,最後分析第一行,我們可以依次得到如下結果:

    

    

    上面的推導過程相信大家不難理解,理解之後我們就可以寫出如下的遞推型動態規劃程式: 

#include <iostream>  
#include <algorithm> 
using namespace std; 
 
#define MAX 101  
 
int D[MAX][MAX];   
int n;  
int maxSum[MAX][MAX]; 
int main(){    
    int i,j;    
    cin >> n;    
    for(i=1;i<=n;i++)   
        for(j=1;j<=i;j++)        
            cin >> D[i][j];   
    for( int i = 1;i <= n; ++ i )     
        maxSum[n][i] = D[n][i];   
    for( int i = n-1; i>= 1;  --i )     
        for( int j = 1; j <= i; ++j )         
            maxSum[i][j] = max(maxSum[i+1][j],maxSum[i+1][j+1]) + D[i][j];    
    cout << maxSum[1][1] << endl;  
} 


     我們的程式碼僅僅是這樣就夠了嗎?當然不是,我們仍然可以繼續優化,而這個優化當然是對於空間進行優化,其實完全沒必要用二維maxSum陣列儲存每一個MaxSum(r,j),只要從底層一行行向上遞推,那麼只要一維陣列maxSum[100]即可,即只要儲存一行的MaxSum值就可以。

     對於空間優化後的具體遞推過程如下:

    

    

    

    

    

    

    接下里的步驟就按上圖的過程一步一步推導就可以了。進一步考慮,我們甚至可以連maxSum陣列都可以不要,直接用D的第n行直接替代maxSum即可。但是這裡需要強調的是:雖然節省空間,但是時間複雜度還是不變的。

    依照上面的方式,我們可以寫出如下程式碼:    


#include <iostream>  
#include <algorithm> 
using namespace std; 
 
#define MAX 101  
 
int D[MAX][MAX];  
int n; 
int * maxSum; 
 
int main(){    
    int i,j;    
    cin >> n;    
    for(i=1;i<=n;i++)   
        for(j=1;j<=i;j++)        
            cin >> D[i][j];   
    maxSum = D[n]; //maxSum指向第n行    
    for( int i = n-1; i>= 1;  --i )     
        for( int j = 1; j <= i; ++j )       
            maxSum[j] = max(maxSum[j],maxSum[j+1]) + D[i][j];    
    cout << maxSum[1] << endl;  
}


 

 

 

 

 

    接下來,我們就進行一下總結:

    遞迴到動規的一般轉化方法

    遞迴函式有n個引數,就定義一個n維的陣列,陣列的下標是遞迴函式引數的取值範圍,陣列元素的值是遞迴函式的返回值,這樣就可以從邊界值開始, 逐步填充陣列,相當於計算遞迴函式值的逆過程。

    動規解題的一般思路

    1. 將原問題分解為子問題

    把原問題分解為若干個子問題,子問題和原問題形式相同或類似,只不過規模變小了。子問題都解決,原問題即解決(數字三角形例)。
    子問題的解一旦求出就會被儲存,所以每個子問題只需求 解一次。
    2.確定狀態

    在用動態規劃解題時,我們往往將和子問題相關的各個變數的一組取值,稱之為一個“狀 態”。一個“狀態”對應於一個或多個子問題, 所謂某個“狀態”下的“值”,就是這個“狀 態”所對應的子問題的解。
    所有“狀態”的集合,構成問題的“狀態空間”。“狀態空間”的大小,與用動態規劃解決問題的時間複雜度直接相關。 在數字三角形的例子裡,一共有N×(N+1)/2個數字,所以這個問題的狀態空間裡一共就有N×(N+1)/2個狀態。
    整個問題的時間複雜度是狀態數目乘以計算每個狀態所需時間。在數字三角形裡每個“狀態”只需要經過一次,且在每個狀態上作計算所花的時間都是和N無關的常數。

    3.確定一些初始狀態(邊界狀態)的值

    以“數字三角形”為例,初始狀態就是底邊數字,值就是底邊數字值。

    4. 確定狀態轉移方程

     定義出什麼是“狀態”,以及在該“狀態”下的“值”後,就要找出不同的狀態之間如何遷移――即如何從一個或多個“值”已知的 “狀態”,求出另一個“狀態”的“值”(遞推型)。狀態的遷移可以用遞推公式表示,此遞推公式也可被稱作“狀態轉移方程”。

    數字三角形的狀態轉移方程:

    

    能用動規解決的問題的特點

    1) 問題具有最優子結構性質。如果問題的最優解所包含的 子問題的解也是最優的,我們就稱該問題具有最優子結 構性質。

    2) 無後效性。當前的若干個狀態值一旦確定,則此後過程的演變就只和這若干個狀態的值有關,和之前是採取哪種手段或經過哪條路徑演變到當前的這若干個狀態,沒有關係。

動態規劃是運籌學的一個分支,是求解決策過程最優化的數學方法。利用各個階段之間的關係,逐個求解,最終求得全域性最優解,需要確認原問題與子問題、動態規劃狀態、邊界狀態、邊界狀態結值、狀態轉移方程。

一、爬樓梯


You are climbing a stair case. It takes n steps to reach to the top.

Each time you can either climb 1 or 2 steps. In how many distinct ways can you climb to the top?

Note: Given n will be a positive integer.

Example 1:

Input: 2
Output: 2
Explanation: There are two ways to climb to the top.
1. 1 step + 1 step
2. 2 steps
Example 2:

Input: 3
Output: 3
Explanation: There are three ways to climb to the top.
1. 1 step + 1 step + 1 step
2. 1 step + 2 steps
3. 2 steps + 1 step
方法一:利用n個樓梯的步數,與n-1還有n-2之間的關係可以退出,f(n)==f(n-1)+f(n-2),相當於是直接考慮為n-1再上一步,和n-2直接上兩步,不能考慮n-2有兩種走法(一步一步,和一次兩步,一步一步的會和n-1中的重複,導致算多了),最後不斷的迭代直至可以n==1或者n==2,可以直接求出結果。

這個方法相當於是根據各個階段之間的關係,列出迭代關係,並且寫出臨界解,從而結束遞迴的過程,否則將一直遞迴下去(所有的遞迴都是如此,如果沒有邊界條件提前結束遞迴,遞迴將不會停止)

這個時間複雜度是2^n相當於是一顆二叉樹來著,leetcode顯示time limit exceed

int climbStairs(int n) {
        if(n==1||n==2){
            return n;
        }
        return climbStairs(n-1)+climbStairs(n-2);    
   }


方法二:利用迭代來實現尾遞迴

由於方法一是利用了尾遞迴來實現演算法,考慮採用迭代來實現遞迴,並且遞迴本身演算法複雜度是要遠遠大於其對應的迭代迴圈演算法複雜度的,所以考慮利用迭代來減少時間複雜度。兩種方法的差別在於遞迴是從上往下算,迭代是從下往上算。

class Solution {
public:
    int climbStairs(int n) {
        vector<int>iteration(n+1,0); //initializition
        iteration[1]=1;
        iteration[2]=2;
        int i=3;
        while(i<n+1){
            iteration[i]=iteration[i-1]+iteration[i-2];
            i++;
        }
        return iteration[n];  
    }
};


時間複雜度是O(n),相比較於尾遞迴大大優化,leetcode顯示ac。

二、搶劫犯問題

You are a professional robber planning to rob houses along a street. Each house has a certain amount of money stashed, the only constraint stopping you from robbing each of them is that adjacent houses have security system connected and it will automatically contact the police if two adjacent houses were broken into on the same night.

Given a list of non-negative integers representing the amount of money of each house, determine the maximum amount of money you can rob tonight without alerting the police.

題目分析:這個題目的分析關鍵在於DP演算法中狀態轉移方程的求解,也就是求解是迭代關係,可以發現對於第i個房間而言,如果不搶劫第i個房間那麼就是i-1個的搶劫數目,如果搶劫第i個房間那麼就是一定不能搶劫i-1個房間,相當於是i-2個房間的搶劫數目,兩者時間的最大值即可,從而成功得出迭代關係。

注意自身在分析問題時的錯誤:

一:在無法的出迭代關係的情況下,沒有考慮根據題意堆可能的情況進行分類,注意兩個題目都是進行了分類的討論,才得以順利的得出迭代關係,並且盲目的理解為迭代關係一定是兩者之間的和,沒有考慮到最大值得情況。

二:在考慮迭代關係時一定要思考如何引入第i-1個和第i-2個問題的解,這道題就是通過分類討論,成功剝離出了i-1和i-2的情況;迭代關係的另一個要素是如何把i與i-1和i-2之間的關係找到

三:在考慮迭代關係時一定把i考慮成足夠大,因為在程式碼實現過程中i很小的情況是直接給出的,直接賦值的(對於有dp陣列而言),i很小的情況下只是考慮為邊界條件,作為迴圈的起始或者是迭代的結束。所以考慮迭代關係時一定不要具體化i而是直接假設i足夠大去考慮。

求取迭代關係的步驟:

1、根據題意分類討論,分類討論一定要達到引入i-1和i-2的解

2、挖掘i和i-1還有i-2之間的關係

3、邊界條件確認

方法一、使用迭代法

class Solution {
public:
    int rob(vector<int>& nums) {
        if(nums.empty()) return 0;
        if(nums.size()==1) return nums[0];
        vector<int>dp(nums.size(),0);
        dp[0]=nums[0];
        dp[1]=max(nums[0],nums[1]);
       for(int i=2;i<nums.size();i++){
            dp[i]=max(dp[i-2]+nums[i],dp[i-1]);
        }
        return dp[nums.size()-1];
    }
};


時間複雜度為O(n),執行時間3ms

方法二、使用遞迴演算法

class Solution {
public:
    int rob(vector<int>& nums) {
        int size=nums.size();
        if(size==0) return 0;
        if(size==1) return nums[0];
        if(size==2) return max(nums[0],nums[1]);
        vector<int>a1(nums.begin(),nums.end()-1);
        vector<int>a2(nums.begin(),nums.end()-2);
        return max(rob(a1),rob(a2)+nums[size-1]);
     
    }
};


可以發現這種方法再次出現了time limit exceed,時間複雜度是O(2^n),以後不用再考慮遞迴的DP演算法了,直接使用迭代,時間複雜度降低很多。

三、最大子段和

Given an integer array nums, find the contiguous subarray (containing at least one number) which has the largest sum and return its sum.
注意自身在分析問題時的錯誤:

       在分析問題的時候沒有靈活變通,直接考慮前i個連續子陣列陣列的最大值,將無法進行分類討論,無法得到遞迴關係,並且在考慮遞迴關係時也是直接考慮了i與i-1還有i-2之間的關係,其實可以考慮為i與i-1的關係即可,只要是一種可以迭代出所有情況的關係即可。在不能夠得出迭代關係的時候需要變通的考慮,改變dp陣列的意義,不需要一步到位,只要保證可以通過dp陣列得到最後的結果即可。

code:dp陣列表示的是以第i個元素結尾的連續子陣列的最大值,最後再尋找dp的最大值

class Solution {
public:
    int maxSubArray(vector<int>& nums) {
        int size=nums.size();
        vector<int>dp(size,0);
        dp[0]=nums[0];
        for(int i=1;i<size;i++){
            if(dp[i-1]>0) dp[i]=dp[i-1]+nums[i];
            else dp[i]=nums[i];
        }
        int max1=dp[0];
        for(int i=1;i<size;i++){
            max1=max(max1,dp[i]);
        }
        return max1;
    }
};


從第四題開始之後的題目都會較為複雜的情況

四、找零錢和

You are given coins of different denominations and a total amount of money amount. Write a function to compute the fewest number of coins that you need to make up that amount. If that amount of money cannot be made up by any combination of the coins, return -1.

Example 1:
coins = [1, 2, 5], amount = 11
return 3 (11 = 5 + 5 + 1)

Example 2:
coins = [2], amount = 3
return -1.

Note:
You may assume that you have an infinite number of each kind of coin.

注:這道題自己還是沒有能夠理解到迭代關係的思想,可以再看一下solution,此時實在無法理解
注意自身在分析問題時的錯誤:(假設已經理解了迭代關係)

一、這個迭代關係比較特殊,並不是i-1,i-2之類的,而是會變化的,隨著coins的不同會發生改變,所以需要對coins進行遍歷,在迴圈中加入條件判斷順便很好地解決了是否會越界的問題。

....沒有理解這道題果然寫不出總結

class Solution {
public:
    int coinChange(vector<int>& coins, int amount) {
        vector<int>dp(amount+1,amount+1);
        dp[0]=0;
        for(int i=1;i<=amount;i++){
            for(int m:coins){
                if(m<=i) dp[i]=min(dp[i],dp[i-m]+1);
            }            
        }
        if(dp.back()==amount+1) return -1;
            else return dp.back();
    }
};


五、三角形

Given a triangle, find the minimum path sum from top to bottom. Each step you may move to adjacent numbers on the row below.

For example, given the following triangle

[
     [2],
    [3,4],
   [6,5,7],
  [4,1,8,3]
]
The minimum path sum from top to bottom is 11 (i.e., 2 + 3 + 5 + 1 = 11).這道題目類似於第三題,都是對dp陣列的意義進行轉換,從而以退為進解決問題,這道題目是自己獨立完成的,所以就不寫心得了,直接上程式碼
 

class Solution {
public:
    int minimumTotal(vector<vector<int>>& triangle) {
        int size=triangle.size();
        vector<vector<int>>dp(size,vector<int>(size,INT_MAX));
        dp[0][0]=triangle[0][0];
        for(int i=1;i<size;i++){
            for(int j=0;j<triangle[i].size();j++){
                if(j==0) dp[i][j]=dp[i-1][j]+triangle[i][j];
                if(j==triangle[i].size()-1) dp[i][j]=dp[i-1][j-1]+triangle[i][j];
                if(j!=0&&j!=triangle[i].size()-1) dp[i][j]=min(dp[i-1][j-1]+triangle[i][j],dp[i-1][j]+triangle[i][j]);              
            }
        }
        return *min_element(dp[size-1].begin(),dp[size-1].end());
    }
};