動態規劃算法解析與實戰

一、算法思想

          動態規劃算法的基礎是最優原理,它是用來解決貪婪算法和分治算法無法解決或者無法簡潔高效解決的算法,一般用於求解下列問題:(1)揹包問題 (2)最短路徑 等。

          動態規劃和貪婪算法一樣,都一個問題的求解都是分爲對很多問題的求解的綜合,問題最終的解是多次選擇的綜合結果,但是貪婪算法中,每次選擇最優解,之後不可撤回,但是動態規劃中需要考察一系列的抉擇,然後才能確定一個最優抉擇序列是否包含最優抉擇子序列,具體就通過下面的例子來說明:

1.1 最短路徑
  有下圖所示的單向圖,求從源點1到目的點5的最短路徑?


           如果是利用貪婪算法來進行求解,那麼第一步可以到達的點爲2,4,3。按照算法,到點3的距離是最短的,所以選擇走到點3,然後點3可以到達2,4,接下來走到點4,因爲到點4的距離最短,然後此時可以通過點4到達目的點5,最後選得的路徑是1->3->4->5,路徑長度爲7.雖然確實是最短路徑,但是可以看出,從1到3,從3到4,從4到5,整個過程是割裂開的,但是一個最短路徑是求的整個過程加起來的路徑長度最短,很多時候貪婪算法不一定適用,因爲它不能回退。

            如果是動態規劃,那麼要考慮的就是,首先選擇了從1到3這條路徑,之後要確定如何從3到5,如果3到5選擇的路徑不對,那麼就算1到3是最短的,整個結果依然會偏大,所以假設選擇了某個點x作爲最短路徑中的某個點,那麼接下來說選擇的從x到目的點的路徑必須是最短的,這就是包含最優抉擇序列的意思,因爲考慮這種情況:一個點都沒選的時候,此時就相當於要必須選擇出從源點到目的點的最短路徑,所以可以求出最短路徑。

1.2 0/1揹包問題


             有n個物品和一個容量爲c的揹包,從n個物品中選擇裝包的物品,每個物品有自己的重量w和價值p,要求算出如何在不超過揹包容量的情況下,要裝入物品的價值p最大。

             假設n=3,w=[100,14,10],c=116,p=[20,18,15],如果選擇裝入第一個物品,那麼問題轉變爲求解c=16,w[14,10],p=[18,15]的最優解,此時有兩種解法:裝入第一個或者裝入第二個,明顯看出因爲p1>p2,所以裝入第一個是此時的最優解,所以得到此條件下的最優結果爲[1,1,0],1代表裝入物品,從上可以看出,如果某一步所做的解不是此狀態下的最優解,那麼它肯定不是初始問題的最優解。也可以不裝入第一個物品,此時得到另外一個解[0,1,1],[0,1,1]是在不裝入物品1下的最優解。此時通過計算可以知道肯定是[1,1,0]是最好的結果,可是數據如果很大的時候,該怎麼判斷某個狀態之後,做出的解就是最優解呢,或者進入這個狀態之前所做的導致進入這個狀態的一個解也是最優解呢,因爲如果在某狀態下做出的某一個解不是最優解,那這個解也一定不是最初始答案的最優解。

               所以上述這種問題的核心就是一個,如何確定做的這一步是最優解的一個組成部分,動態規劃的方法是建立一個動態規劃遞歸方程,dp和貪婪算法不一樣,是可逆的,可以通過遞歸方程不斷進行迭代,不斷修正做出的選擇。

                例如1.2中提出的揹包問題,最優選擇序列是由最優子選擇序列構成的,我們假設f(i,y)表示揹包剩下的容量爲y,剩下物品是i、i+1....n的揹包問題的最優解。
                pn代表第n個物品的價值,wn代表第n個物品的重量,y代表剩餘容量。同一個等式寫兩遍,代表兩種不同的情況,視y的大小決定。那麼可以得到如下的等式:f(n,y)=pn (y>=wn))f(n,y)=0(0<=y<wn)
和f(n,y)=max(f(i+1,y),f(i+1,y-wi)+pi) (y>=wi) f(n,y)=f(i+1,y)(0<=y<wi)
                上述兩個等式的意思是,當剩餘容量y大於當前物品i的重量時,那麼最優解就是取這個物品和不取這個物品兩種情況的最大值,如果剩餘容量y小於當前物品i的重量,那麼直接就不能放下當前物品。根據最優序列是由最優子序列構成這個結論,可以得到f的遞歸式子,當遞歸到n=1的時候就是揹包問題最初始時最優解的值,這裏就是遞歸的出口,符合遞歸的定律,遞歸必須要有一個可以結束的點。明顯f(n,y)是可以通過f(n,y)=max(f(i+1,y),f(i+1,y-wi)+pi)(y>=wi) f(n,y)=f(i+1,y)(0<=y<wi)遞歸求得的.

                 以1.2作爲例子舉例,如果要求f(3,116),那麼可得f(3,116)=max(f(2,116),f(2,16)+20),由公式可知f(2,116)=max(f(1,116),f(1,102)+18)、f(2,16)=max(f(1,16),f(1,2)+18),同理求得f(1,116)=15,f(1,102)=15,f(1,16)=15,f(1,2)=0.
那麼可以算出f(3,116)=max(max(15,15+18),max(15,0+18)+20)=max(33,38)=38。得出最優解是38.然後可以由3個f(n,y)(n等於1,2,3)之間的關係判斷出最終結果爲[1,1,0],1代表裝入揹包,0代表不裝入揹包。
                由上面的計算過程可以看出,無論之前的選擇是什麼,接下來的選擇一定是當前狀態下的最大值,也就是最優選擇,這就是最優原則,它代表了一個最優選擇序列一定由最優選擇子序列所構成的,所以應用動態規劃就一定要證明該問題適用最優原則,然後就建立動態規劃的遞歸方程組,然後通過不斷迭代遞歸求解該遞歸方程組,然後由最優解以及各個不同的f(n,y)之間的關係可以求出最優解的組成,上述那個簡單的例子中並沒有一些重複的計算,但是在複雜的動態規劃問題中是存在很多重複的計算的,如果不能避免這些重複的計算,dp的複雜度也會很高.

                 例如有類似於例1.2的題目,但是數據是n=4,w=[20,20,14,10],c=100,p=[20,20,18,15],在計算f(4,100)=max(f(3,100),f(3,80)+20)中,f(3,100)後續會計算f(2,80),而f(3,80)後續也會計算f(2,80),如果不能很好的避免這些重複計算,動態規劃的優越性就蕩然無存,下面就用幾個具體的例子的dp解法來進行實戰。

二、實際應用


2.1 0/1揹包問題


 (1)遞歸求解
  在第一大節中所說的函數f求解方程如下所示:

int f(int i,int thecapacity)
    {
        if(i==numberofobjects)//遍歷到了最後一個物品,這很明顯也是遞歸的出口。
            return (thecapacity<weight[numberofobjects]?0:profit(numberofobjects));
            if(thecapacity<weight[i])//如果容量不夠,那麼不能將物品i放入揹包,否則是下一種情況。
            return f(i+1,thecapacity);
            return max(f(i+1,thecapacity),f(i+1,thecapacity-weight[i])+profit[i]);
    }


            其中的numberofobjects代表的是物品的個數,i代表目前遍歷到了第幾個物品,thecapacity代表的是揹包剩下的容量,weight數組代表的是每個物品的重量,profit數組代表的是每個物品的價值,其中profit和weight還有thecapacity是全局變量,上述代碼的時間複雜度是O(2^n)。
          上述這種問題在一個實際的例子當中時,比如n=5,p=[6,3,5,4,6],w=[2,2,6,5,4],c=10的時候,就會很明顯的出現第一大節中提到的重複計算,具體可看下一圖:


             上圖中的波浪線所標註的就是出現重複計算的部分,其實只要在上圖樹中只要兩個節點高度相同,並且剩餘容量相同,那麼它們所有的後續計算都是重複計算,一般爲了避免這種重複計算,採用的方式都是建立一個dp數組,該數組用來存儲計算過的f(n,y)的值,具體可以看下面這種無重複計算的f函數:


   

 int f(int i,int thecapacity)
    {
        if(dp[i][thecapacity]>=0)

            return dp[i][thecapacity];//若已經計算過就不用再次計算了
        if(i==numberofobjects)//遍歷到了最後一個物品,這很明顯也是遞歸的出口。
        {
            dp[i][thecapacity]=(thecapacity<weight[numberofobjects]?0:profit(numberofobjects));
                return dp[i][thecapacity];
        }
            
            if(thecapacity<weight[i])//如果容量不夠,那麼不能將物品i放入揹包,否則是下一種情況。
                 dp[i][thecapacity]=f(i+1,thecapacity);
            else//容量足夠,那麼選擇兩種情況裏面的最大值
                dp[i][thecapacity]=max(f(i+1,thecapacity),f(i+1,thecapacity-weight[i])+profit[i]);
        return dp[i][thecapacity];
    }



             時間複雜度已經降低到了O(cn)(c表示揹包容量,n表示物品個數),其中依然有numberofobjects代表的是物品的個數,i代表目前遍歷到了第幾個物品,thecapacity代表的是揹包剩下的容量,weight數組代表的是每個物品的重量,profit數組代表的是每個物品的價值,其中profit和weight還有thecapacity是全局變量,多增加了一個全局變量dp,它的定義應當是vector<vector<int>>dp(n+1,vector<int>(thecapacity+1,-1)),-1代表這個值未曾計算過,最後返回的是dp.back().back(),也就是返回dp[n][c]。


2.2最長子序列

              下面的代碼是求最長嚴格上升子序列(元素之間不需要連續),dp在這種求最長某種限制的子序列,或者說求最大之類的情況下,都是比較適用的,比如下面這個題,具體的就在代碼裏面看吧。

#code

    

#include<iostream>
    #include<vector>
    #include<algorithm>
    using namespace std;
    int lengthOfLIS(vector<int>& nums) {
        if(nums.size()==0)
            return 0;
        vector<int>res(nums.size(),1);//建立dp數組,因爲最少包含一個元素,所以最小值肯定是爲1,所以初值置爲1.
        for(int i=1;i<nums.size();++i)
        {
            for(int j=0;j<i;++j)//如果num[i]大於num[j],代表num[j]可以接在num[i]後面成爲一個上升子序列。
            {
                if(nums[i]>nums[j])
                    res[i]=max(res[i],res[j]+1);
            }
        }
        sort(res.begin(),res.end());//排序找出最大值,也就是最長的子序列的長度。
        return res.back();
    }
    int main()
    {
        long n;
        cin>>n;
        vector<int>nums(n,0);
        while(n--)//構造需要處理的數組
        {
            int temp=0;
            cin>>temp;
            nums[n]=temp;
        }
        
        cout<<lengthOfLIS(nums);//輸出最長嚴格上升子序列的長度
        system("pause");
        return 0;
    
    }



運行的結果如下所示:


輸入數組爲[4,2,1,2,4,1,6,8,9],可以知道它的最長嚴格上升子序列爲[1,2,4,6,8,9],符合輸出答案的長度爲6.


2.3求最大值

             愛玩遊戲的小J,小J給每個遊戲標上一個成就值,同時估算了完成這些遊戲所需要的時間,現在他只有X天時間,而遊戲一旦開始玩,至少需要玩一天才能夠停下來,那麼他玩完的遊戲的成就值之和最大能達到多少呢?

            雖然是遊戲,但其實就是一個0/1揹包問題,完成這個遊戲等同於放入揹包,x天等同於揹包容量,所以依然是一樣的辦法。




#code

    

#include<iostream>
    #include<vector>
    #include<algorithm>
    #include<map>
    using namespace std;
    int value(vector<pair<int,int>>&a,int day)
    {
        vector<int>p(day+1,0);//可以看出,其實不一定要用二維數組和遞歸也能做dp,不過把vector裏面的元素換成了pair格式,因爲要存耗時天數和成就值。
        for (int k = 0; k<a.size();++k)//可以看出複雜度是c*n
        {
            for(int i=day;i>=0;--i)
            {
                if(a[k].second<=i)//第k個遊戲可以完成,那麼有兩種可能。
                    p[i]=max(p[i],p[i-a[k].second]+a[k].first);
            }
        }//雙重循環用第二個循環代替了一個單獨存儲剩餘天數的數據,所以可以不用遞歸。
        return p.back();
    
    }
    int main()
    {
        int num=0;
        cin>>num;
        vector<int>res;
        while(num--)
        {
            int game_num=0;
            cin>>game_num;
            int day_num=0;
            cin>>day_num;
            vector<pair<int,int>>game__num;
            while(game_num--)
            {    
                pair<int,int> temp;
                cin>>temp.first;
                cin>>temp.second;
                game__num.push_back(temp);
            }//輸入測試用例
            int value_num=value(game__num,day_num);//進行dp運算
            res.push_back(value_num);
        }
        for(int i=0;i<res.size();++i)
        {
            cout<<res[i]<<endl;
        }
        system("pause");
        return 0;
         
    }


輸出結果如下所示:


輸入:
第一行輸入case數T,對於每個case,第一行輸入遊戲的數目N,總時間X。從第二行到第N+1行輸入遊戲的成就值Ai,所需要的時間Bi。
輸出:
對每個case輸出一行,成就值之和的最大值。

第一個例子輸入的是2天的情況下,一個遊戲的價值10,耗時1天,一個遊戲耗時2天,成就20,明顯結果是20.
第二個例子輸入的是4天的情況下,一個遊戲的價值10,耗時2天,一個遊戲的價值18,耗時3天,一個遊戲的價值10,耗時2天,結果爲20,是最大值。

2.4求最大值(依然是一個求最大值的問題,但是多了一個約束條件,相當於約束條件多一點的揹包問題)


             維克多博士創造了一個裂變反應堆,可取用處於液體狀態的放射性物質。反應堆的容量是V加侖。他有N瓶的放射性液體,每個都有一定的質量和一定的體積。當液體倒入反應堆時,也產生一些單位的能量。現在,維克多想要將能量輸出最大化。但是,有一個限制條件。他研究了原子元素的物理知識和歷史,認識到反應堆內放射性液體的總量不能超過特定的臨界質量M,否則反應就會失控,並引發劇烈的爆炸。寫一個算法,幫助他從反應堆獲得最大的能量,而不會讓他丟掉性命。
輸入:

該函數/方法的輸入包括六個參數------
reactorCap,一個整數,表示反應堆的容量(V);
numberOfRadLiquid,一個整數,表示現有小瓶的數量(N);
criticalMass,一個整數,表示反應堆的最大臨界質量(M);
volumes,一個整數列表,按順序表示N份放射性液體的體積;
masses,一個整數列表,按順序表示N份放射性液體的質量;
energies,一個整數列表,按順序表示N份放射性液體產生的能量。
輸出:
返回一個整數,表示可給定的約束條件下從反應堆中產生的最大能量。
示例:
輸入:
reactorCap=100,numberOfRadLiquid=5,criticalMass=15,volumes=[50,40,30,20,10],masses=[1,2,3,9,5],energies=[300,480,270,200,180]
輸出:
960
解釋:
選擇1、2、5號瓶中的液體,產生的能量=300+480+180=960.


#思路
            利用動態規劃解決這道題時,使用一個函數f來表示最大能量,方程爲f(v,m,n), 這個方程的解表示n個小瓶在約束條件最大質量m,最大容量v下可以獲得的最大能量,那麼很顯然f(v,m,n)=max(f(v,m,n),f(v,m,n-1),f(v-v(k),m-m(k),n)),利用一個二維數組表示在某個體積和質量的約束下可獲得的最大能量。


#code

    

#include <iostream>
    #include <vector>
    #include <map>
    #include <algorithm>
    using namespace std;
    int maxenergy(int reactorCap, int numofliq, int maxmass, vector<int> volumes, vector<int> mass, vector<int> energies){
        int res;
        vector<vector<int>> p(maxmass + 1, vector<int>(reactorCap + 1, 0));//以容量和質量的最大值建一個二維數組表示質量爲i,體積爲j時可以獲得的最大能量,然後對狀態(i,j)進行更新,從後往前更新。
        for (int k = 0; k < mass.size(); ++k)
        {
            for (int i = p.size() - 1; i>0; --i)//不用等於0,因爲體積或者質量某一個等於0,都無法放下新瓶子。
            {
                for (int j = p[i].size() - 1; j>0; --j)
                {
                    if (mass[k] <= i && volumes[k] <= j)//如果mass[k]小於目前的質量i,volumes[k]小於目前的容量j,說明可以裝入第k個瓶子,這裏有很多冗餘計算。
                        p[i][j] = max(p[i][j], (p[i - mass[k]][j - volumes[k]] + energies[k]));//計算最大值,由前面說的那個狀態轉移方程來計算最大值。
                        //如果我裝下這個瓶子,會導致我之前一些瓶子裝不下,那麼就要判斷兩者到底誰更大,選擇更大的。
                }
            }
            for(int i=0;i<p.size();++i)
            {
                for(int j=0;j<p[i].size();++j)
                {cout<<p[i][j]<<" ";}
                cout<<endl;
            }
        }
    
        res = p.back().back();//最後一個一定是最大的,因爲最後一維纔是考慮了所有的瓶子的。
        for(int i=0;i<p.size();++i)
        {
            for(int j=0;j<p[i].size();++j)
            {cout<<p[i][j]<<" ";}
            cout<<endl;
        }
        return res;
    }
    
    int main(){
        int reactorCap ;
        cout<<"please intput reactorCap: ";
        cin>>reactorCap;
        int numberOfRadLiquid  ;
        cout<<"please intput numberOfRadLiquid: ";
        cin>>numberOfRadLiquid;
        int maxmass ;
        cout<<"please intput maxmass: ";
        cin>>maxmass;
        vector<int>vol;
        vector<int>mass;
        vector<int>ener;
        for(int i=0;i<numberOfRadLiquid;i++)
        {
            int temp;
            cout<<"please intput vol["<<i<<"]:";
            cin>>temp;
            vol.push_back(temp);
            
        }
        for(int i=0;i<numberOfRadLiquid;i++)
        {
            int temp;
            
            cout<<"please intput mass["<<i<<"]:";
            cin>>temp;
            mass.push_back(temp);
            
            
        }
        for(int i=0;i<numberOfRadLiquid;i++)
        {
            int temp;
           
            cout<<"please intput ener["<<i<<"]:";
            cin>>temp;
            ener.push_back(temp);
            
        }
        int res = maxenergy(reactorCap, numberOfRadLiquid, maxmass, vol, mass, ener);
        cout<<res<<endl;;
        system("pause");
        return 0;
    }


運行結果如下所示:

             可以從上面兩張圖看出,隨着不斷加入新的液體,最大值也在不斷的更新,並且是在滿足這個二維數組的緯度下的更新,這個二維數組的緯度其實就是題目的約束條件。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章