【以下內容僅為本人在學習中的所感所想,本人水平有限目前尚處學習階段,如有錯誤及不妥之處還請各位大佬指正,請諒解,謝謝!】
引言
前一篇文章(有關動態規劃 - PaperHammer - 部落格園 (cnblogs.com))我們探討了動態規劃及其分析方法,但在做題或面試時往往會需要我們對空間或時間進行優化,尤其是對空間的優化尤為常見。動態規劃本就難度較大,對於尚處學習階段的同學,能寫出來就算很不錯了。在此,本人將分享我在做題時的想法與見解,並和大家一起學習新的知識,如有錯誤或更好的思想還請各位大佬留言指正,謝謝!
複雜度分析
複雜度分為時間複雜度與空間複雜度,其不代表程式正真執行時間與所佔空間,只反映在執行時間或佔用空間隨資料規模增大的變化趨勢,以最高階的變化趨勢來分別代表整個程式的時空複雜度。
【注:本文重點非複雜度的介紹,相關詳細內容將在其他文章中介解釋】
(一)空間
空間複雜度取決於我們用來記錄之前資料的儲存結構。一般地,在DP中常見的空間複雜度有O(N)與O(N2)或者更大,但一般不超過O(N3),通常我們需要對其指數至少減小1或降階,以達到相應的需求。減少的關鍵思想在於,判斷我們所儲存過的資料是否會被再次訪問,是否可以不用一直儲存。通常採用畫表的方式進行判斷。
(二)時間
時間複雜度取決於迴圈結構,而迴圈結構又取決於推匯出的狀態轉移方程。一般地,常見的時間複雜度為O(N),O(N2)或者更大,但一般不超過O(N3),通常我們需要對其指數至少減小1或降階。其關鍵思想在於,一是對某些特殊情況進行判斷,使得直接在迴圈中不進行操作,通常採用邏輯推理方式進行;二是利用某些資料結構和數學理論進行優化。一般地,當我們寫出狀態轉移方程後,就可得出時間複雜度。
優化思路
空間(減少儲存結構規模)
(1) 減少變數總數
(2) 減少不必要的資料的儲存
時間(減少迴圈次數)
(1)優化變換狀態(從變數的角度,排除無意義或可省去的部分)
(2)選擇適當的規劃方向(從情況的角度,多種方法對比)
(3)四邊形不等式與優化決策單調
第一部分 空間優化
上一篇文章中我們講到了定變數的過程,我們採用陣列的方式儲存資訊,每個維度的索引代表一個變數,不難發現:變數的數目往往決定了我們儲存結構的規模且規模呈次方級增長,雖然空間與時間比起來不那麼“寶貴”,現在的裝置運存至少也有10 G,但必要的優化還是需要,我們不能因為足夠就開始浪費。
題:01揹包
【注:在此不重複該題目及方程推導過程,如有需要,請轉至上一篇文章(有關動態規劃 - PaperHammer - 部落格園 (cnblogs.com))】
方程:f [ i ][ v ] = max(f [ i ][ v ], f [ i-1 ][ v-w[i] ] + c[i]);
或f [ i ] [ j ] = max(f [ i-1 ][ j ], f [ i-1] [ j-w[i] ] + c[i]);
常見的有上述兩種形式的方程,其原理是一致的。第一個方程的解釋不再贅述;第二個方程中i表示前i件物品,j表示當前揹包的容量,f[ i ][ j ]表示在前i件物品中不超過容量j時的最大價值。
例:
輸入格式
第一行兩個整數 N,V 用空格隔開,分別表示物品數量和揹包容積。
接下來有兩行,第一行為 wi,第二行為ci 用空格隔開,分別表示第 i 件物品所佔的空間和價值。
輸入樣例
4 20
8 9 5 2
5 6 7 3
輸出樣例
16
(1)減少不必要的資料的儲存
針對第二個方程(第一個類似),我們給出利用上面的例子列出f[ i ][ j ]所每個索引下所對應的值。可以發現i只來源於i-1,即當前狀態下的i僅前一次有關,與前兩次、前三次均無關。
從表中的資料也可以看出,第i行的資料只依賴於第i-1行的值,因此我們只需用兩個一維陣列儲存第i-1行和第i行這兩行的值即可,其中f1[ i ]代表f[ i-1 ][ j ],f2[ i ]表示f[ i ][ j ]。
所以第二個方程可以轉化為:f2[ j ] = max(f2[ j ], f1[ j-w[ i ] ] + c[ i ]);
此時,空間複雜度從原來的O(N2)降為了O(N)。
for(int i = 1; i <= n; i++){ for(int j = 1; j <= v; j++){ f2[j] = f1[j]; if(j-c[i] >= 0) f2[j] = max(f2[j], f1[j-w[i]]+c[i]); } for(int k = 1; k <= v; k++) f1[k] = f2[k]; } cout << f2[v] << endl;
(2)減少變數數
但在許多面試題中往往需要我們進行原地修改,那就意味著我們只能使用一個一維陣列。同樣這道題也可以進行原地修改。但現在的迴圈順序是容量從小到大的情況,如果原地修改會出現覆蓋資料的情況
我們來分析一下原因:
當我們使用二維陣列時,紅色代表i-1行儲存的資訊,藍色代表當前第i行的值,該值受到所對應的上一行值的影響。如果我們換成一維陣列,從幾何意義上其可以反映為:
1.初始狀態:整行都為上一次所儲存的值
2.開始更新:根據方程可知,每一個新值受上一個值的影響,即需要上一個值的存在。如果我們依舊從小到大進行更新,那麼首先就覆蓋了第一個值。之後就會導致我們並沒有按照方程原本的意思:根據上一個值計算結果,反而是用新值計算結果,故結果不正確。
所以我們需要將原本正向的更新改為反向,這樣就能避免該問題。
for(int i = 1 ;i<=n; i++) { for(int j = v; j>=0 ;j--) if(j-w[i]>=0) f[j] = max(f[j],f[j-w[i]]+c[i]); } cout << f[v] << endl;
第二部分 時間優化
時間往往是我們在程式設計中更看中的一點,不論是競賽還是面試,很多情況下都需要對時間的把控。一般來說動態規劃在暴力的基礎上已經對時間有了很好的優化,但依然可以再優化下面還是通過例子來講述這兩種方法。
題:傳紙條
來源:P1006 [NOIP2008 提高組] 傳紙條 - 洛谷 | 電腦科學教育新生態 (luogu.com.cn)
將題目抽象化後可翻譯為:從點(1,1)走到(m,n)再從(m,n)回到(1,1)方向只能向下(上)或向右(左),即起點在左上角,終點在右下角。且每個點只能走一次,每個點上有一個正數,返回所有在兩次行走的可行路徑中的數值的最大和。
往返路徑且往返過程等價,不妨將其看為從同一點出發的兩條路徑。利用上一篇文章的方法步驟,把每次選擇怎麼走作為一個子問題,並且總是以最大和為基礎進行操作,每次選擇處理相同。
對於第一條路上的點(i,j)有兩種選擇方式;
對於第二條路上的點(k,l)有兩種選擇方式;
選擇方式的兩種理解:(從左邊到達,從上邊到達)/(向右走,向下走)
由於二者等價所以合併後對於點(i,j,k,l)有四種選擇方式
不難推出方程:f[ i ][ j ][ k ][ l ] = max( f[ i ][ j-1 ][ k-1 ][ l ] , f[ i-1 ][ j ][ k ][ l-1 ] , f[ i ][ j-1 ][ k ][ l-1 ] , f[ i-1 ][ j ][ k-1 ][ l ] ) + value[ i ][ j ] + value[ k ][ l ];為了避免重複每次特判即可,詳細內容見文末附錄。
根據上述方程可以得到此時的時間複雜度為O(N2*M2),理論上只能承受N,M<=50的資料量,甚至更小。
for (int i = 1; i <= n; i++) for (int j = 1; j <= m; j++) for (int p = 1; p <= n; p++) for (int q = 1; q <= m; q++) { f[i][j][p][q] = max(max(f[i - 1][j][p - 1][q], f[i - 1][j][p][q - 1]), max(f[i][j - 1][p - 1][q], f[i][j - 1][p][q - 1])) + g[i][j] + g[p][q]; if (i == p && j == q)f[i][j][p][q] -= g[i][j]; //去重,只需判斷第二次走的點是否被第一次所走過,因為兩條路經本身不會經過自己走過的點,只可能兩條路相交 }
(1)優化狀態(重新構建變數的意義,改進狀態表示)
三維DP:剛剛提到,兩個路徑的行進是完全等價的,每條路徑在每個點只有兩種選擇,且它們是同步進行的,所以可以得到當前總步數steps = i + j = k + l;利用這一點,我們列舉當前走過的步數,同時列舉第一條路徑和第二條路徑當前的橫座標或者縱座標,f[k][i][j]表示走了k步,第一條路徑走到第i行,第二條路徑走到第j行的最大價值,於是方程轉變為:f[ k ][ i ][ j ] = max ( f[ k-1 ][ i ][ j ], f[ k-1 ][ i-1 ][ j-1 ], f[ k-1 ][ i ][ j-1 ], f[ k-1 ][ i-1 ][ j ] ) + value[ i ][ k-i+1 ] + a[ j ][ k-j+1 ];
此時,方程時間複雜度為O(N2 * (N+M))
for (int k = 1; k <= n + m-1; k++) for (int i = 1; i <= n; i++) for (int j = 1; j <= n; j++) { if ( k-i + 1 < 1 || k - j + 1 < 1) //判斷縱座標的合法性,不合法就跳過 continue; f[k][i][j] = max(max(f[k - 1][i][j], f[k - 1][i - 1][j - 1]), max(f[k - 1][i][j - 1], f[k - 1][i - 1][j])) + g[i][k - i + 1] + g[j][k - j + 1]; if (i == j) f[k][i][j] -= g[i][k - i + 1]; }
二維DP:沿用01揹包的優化思想,當前狀態與前一個狀態有關,所以我們原地修改,反向更新,即可再降一維。
for(int k=3;k<=n+m;k++) for(int i=n;i>=1;i--) for(int p=n;p>i;p--) f[i][p]=max(max(f[i][p],f[i-1][p-1]),max(f[i-1][p],f[i][p-1]))+g[i][k-i]+g[p][k-p];
(2)選擇適當的規劃方向
動態規劃的規劃方向主要有兩種:順推和逆推。有些情況下,不同的規劃方向,時間效率有所不同。一般地,若初始狀態確定,目標狀態不確定,“從因到果”,則應考慮採用順推;反之,若目標狀態確定,而初始狀態不確定,“從果到因”,就應該考慮採用逆推。若初始狀態和目標狀態都已確定,一般情況下順推和逆推都可以選用。但是,我們還可以使用類似雙指標一樣的思想,進行雙向規劃,即雙執行緒。
題:傳紙條——三維DP
其實,剛才的三維DP已經體現了雙執行緒的思想,即同時進行,本質就是對四維DP的優化
【注:下方內容,即第(3)點在此僅作為補充說明,本人尚未對其深入瞭解與研究,僅供參考】
(3)四邊形不等式與優化決策單調
該方法實際上是基於數學理論推導而來。
有關四邊形不等式,它可以針對形如f[ i, j ] = min/max{ m( i, k-1 ), m( k, j) } + w( i, j ) (i ≤ k ≤ j)的方程進行降階優化。
補充說明:
- a. 區間包含的單調性:對於i ≤ i' < j ≤ j',有w( i' , j ) ≤ w( i , j' ),稱w具有區間包含的單調性,即保號性,區間小則對應值就小。
- b. 四邊形不等式:對於i ≤ i' < j ≤ j',有w( i , j ) + w( i' , j' ) ≤ w( i' , j ) + w( i , j' ),稱函式w滿足四邊形不等式,即小區間與大區間之和<=交錯區間和。
- c. 定理一:如果上述w同時滿足區間包含單調性和四邊形不等式,那麼m也滿足四邊形不等式性質。
- d. 定理二:如m滿足四邊形不等式,則s( i , j )單調,即s( i , j ) ≤ s( i , j+1 ) ≤ s( i+1 , j+1 )。其中,s表示m去最優值時的下標。
【注:相關證明過程在此不提供】
由此,我們可以得到:s[ i , j-1 ] ≤ s[ i , j ] ≤ s[ i+1 , j ];
所以上述方程轉變為:f[ i , j ] = min/max{ m[ i , k ] + m[ k , j ]} (s[ i , j-1 ] ≤ k ≤ s[ i+1 , j ]);
此時,時間複雜度從O(N3)減小為O(N2)
總結
動態規劃的優化還有許多型別,本人目前僅瞭解以上內容,每一種優化思想都有獨特的地方,但這些思想往往十分複雜讓人望而生畏,只有通過平時一道題一道題的磨礪,才能逐漸領悟。眾所周知,寫出程式碼不難,難的是如何對現有程式碼進行優化,當我們能夠對我們所寫過的程式碼提供一份優化方案時,相信我們的水平一定上了一個臺階,讓我們一起努力,加油!
【感謝您可以抽出時間閱讀到這裡,內容可能會有許多不妥之處;受限於水平,許多地方可能存在錯誤,還請各位大佬留言指正,請見諒,謝謝!】
#附文中所提到的2個題目的程式碼(僅提供文中提到的部分方案,可滿足絕大多數時的要求)
(1)01揹包
#include <bits/stdc++.h> using namespace std; int n,v,c[102],w[102],f[100000]; int main() { cin >> n >> v; for(int i = 1; i <= n; i++) cin >> w[i]; for(int i = 1; i <= n; i++) cin >> c[i]; for(int i = 1; i <= n; i++) { for(int j = 1; j <= v; j++) { if(j - w[i] >= 0) f[j] = max(f[j], f[j - w[i]] + c[i]); } } cout<<f[v]<<endl; return 0; }
(2)傳紙條(三維DP)
#include<bits/stdc++.h> using namespace std; int n, m, g[54][54], f[108][54][54]; int main() { ios::sync_with_stdio(false); cin >> n >> m; for (int i = 1; i <= n; i++) for (int j = 1; j <= m; j++) cin >> g[i][j]; for (int k = 1; k <= n + m-1; k++) for (int i = 1; i <= n; i++) for (int j = 1; j <= n; j++) { if ( k-i + 1 < 1 || k - j + 1 < 1) //判斷縱座標的合法性,不合法就跳過 continue; f[k][i][j] = max(max(f[k - 1][i][j], f[k - 1][i - 1][j - 1]), max(f[k - 1][i][j - 1], f[k - 1][i - 1][j])) + g[i][k - i + 1] + g[j][k - j + 1]; if (i == j) f[k][i][j] -= g[i][k - i + 1]; } cout << f[n + m - 1][n][n] << endl; return 0; }
(3)傳紙條(二維DP)
#include<bits/stdc++.h> using namespace std; int n, m, g[54][54], f[108][108]; int main() { ios::sync_with_stdio(false); cin >> n >> m; for (int i = 1; i <= n; i++) for (int j = 1; j <= m; j++) cin >> g[i][j]; for(int k=3;k<=n+m;k++) for(int i=n;i>=1;i--) for(int p=n;p>i;p--) f[i][p]=max(max(f[i][p],f[i-1][p-1]),max(f[i-1][p],f[i][p-1]))+g[i][k-i]+g[p][k-p]; cout << f[n - 1][n] << endl; return 0; }