咱們以前的課程當中接觸了最基礎的動態規劃。
動態規劃最重要的就是找到一個狀態和狀態轉移方程。
除此以外,動態規劃問題分析中還有一些重要性質,如:重疊子問題、最優子結構、無後效性等。c++
最優子結構 的概念:
1)若是問題的一個最優解包含了子問題的最優解,則該問題具備最優子結構。當一個問題具備最優子結構的時候,咱們就可能要用到動態規劃(貪心策略也是有可能適用的)。算法
2)尋找最優子結構時,能夠遵循一種共同的模式:數組
3)最優子結構在問題域中以兩種方式變化:函數
在裝配線調度問題中,一個最優解只使用了一個子問題,可是,爲肯定一個最優解,咱們必須考慮兩種選擇。優化
4)動態規劃與貪心算法的區別spa
重疊子問題 的概念:
適用於動態規劃求解的最優化問題必須具備的第二個要素是子問題的空間要「很小」,也就是用來解原問題的遞歸算法能夠反覆的解一樣的子問題,而不是總在產生新的子問題。
好比:狀態 \(i\) 的求解和狀態 \(i-1\) 有關,狀態 \(i-1\) 的求解和狀態 \(i-2\) 有關,那麼當咱們計算獲得狀態 \(i\) 的時候,咱們就能夠用 \(f[i]\) 來表示狀態 \(i\) ,那麼當我下一次須要用到狀態 \(i\) 的時候,我直接返回 \(f[i]\) 便可。code
無後效性 的概念:
某階段的狀態一旦肯定,則此後過程的演變再也不受此前各類狀態及決策的影響,簡單的說,就是「將來與過去無關」,當前的狀態是此前歷史的一個完整總結,此前的歷史只能經過當前的狀態去影響過程將來的演變。具體地說,若是一個問題被劃分各個階段以後,階段I中的狀態只能由階段I-1中的狀態經過狀態轉移方程得來,與其它狀態沒有關係,特別是與未發生的狀態沒有關係。從圖論的角度去考慮,若是把這個問題中的狀態定義成圖中的頂點,兩個狀態之間的轉移定義爲邊,轉移過程當中的權值增量定義爲邊的權值,則構成一個有向無環加權圖,所以,這個圖能夠進行「拓撲排序」,至少能夠按它們拓撲排序的順序去劃分階段。blog
咱們在這篇講義中主要講解最基礎的線性DP記憶記憶化解法。
其實咱們能夠發現,若是一個搜索解法添加上了記憶化,那麼他就解決了「最優子結構」和「重疊子問題」,就變成了一個遞歸版本的動態規劃了。排序
說明:
接下來的例題當中咱們會講解這些問題的for循環解法和記憶化搜索寫法。
雖然for循環寫法在咱們這節課當中寫起來更方便且很好理解,可是但願同窗們務必瞭解並掌握 記憶化搜索 寫法,由於咱們接下來的幾節課程會與記憶化搜索有很是重要的關係。遞歸
題目大意:
給你一個長度爲 \(n\) 的數列 \(a_1,a_2, \cdots , a_n\) ,請你求出它的最長上升子序列的長度。
最長上升子序列:在不交換順序的狀況從序列 \(a\) 中選出一些元素(子序列不須要連續)使得前一個元素必然比後一個元素小。對應的最長的上升子序列就是最長上升子序列。
咱們通常簡稱「最長上升子序列」爲 LIS(Longest Increasing Subsequence)。
解題思路:
設狀態 \(f[i]\) 表示以 \(a_i\) 結尾(而且包含 \(a_i\))的最長上升子序列長度,則:
首先咱們定義數組和一些必要的變量:
int n, a[1010], f[1010], ans;
其中:
而後咱們處理輸入:
cin >> n; for (int i = 1; i <= n; i ++) cin >> a[i];
而後,咱們演示一下用for循環的方式實現求解 \(f[1]\) 到 \(f[n]\):
for (int i = 1; i <= n; i ++) { f[i] = 1; for (int j = 1; j < i; j ++) { if (a[j] < a[i]) { f[i] = max(f[i], f[j]+1); } } }
而後咱們的答案就是 \(f[i]\) 的最大值:
for (int i = 1; i <= n; i ++) ans = max(ans, f[i]); cout << ans << endl;
那麼,使用搜索+記憶化的方式怎麼實現呢?以下:
int dfs(int i) { if (f[i]) return f[i]; f[i] = 1; for (int j = 1; j < i; j ++) if (a[j] < a[i]) f[i] = max(f[i], dfs(j)+1); return f[i]; }
記憶化搜索 又被稱爲 備忘錄 ,而咱們這裏的備忘錄就是咱們的 \(f[i]\) 。
dfs(i)
是第一次被調用,\(f[i]=0\),會執行一系列的計算;dfs(i)
不是第一次被調用,則必然 \(f[i] \gt 0\),因此 dfs(i)
會直接返回 \(f[i]\) 的值,這樣就避免了子問題的重讀計算。因此我在函數的最開始就進行了判斷:
若是 \(f[i]\) 不爲零,則直接返回 \(f[i]\)。
不然再進行計算。
而後,咱們在能夠經過以下方式計算答案:
for (int i = 1; i <= n; i ++) ans = max(ans, dfs(i)); cout << ans << endl;
通常形式的完整代碼:
#include <bits/stdc++.h> using namespace std; int n, a[1010], f[1010], ans; int main() { cin >> n; for (int i = 1; i <= n; i ++) cin >> a[i]; for (int i = 1; i <= n; i ++) { f[i] = 1; for (int j = 1; j < i; j ++) { if (a[j] < a[i]) f[i] = max(f[i], f[j]+1); } } for (int i = 1; i <= n; i ++) ans = max(ans, f[i]); cout << ans << endl; return 0; }
記憶化搜索形式的完整代碼:
#include <bits/stdc++.h> using namespace std; int n, a[1010], f[1010], ans; int dfs(int i) { if (f[i]) return f[i]; f[i] = 1; for (int j = 1; j < i; j ++) if (a[j] < a[i]) f[i] = max(f[i], dfs(j)+1); return f[i]; } int main() { cin >> n; for (int i = 1; i <= n; i ++) cin >> a[i]; for (int i = 1; i <= n; i ++) ans = max(ans, dfs(i)); cout << ans << endl; return 0; }
題目大意:
咱們能夠把「字段」理解爲「連續子序列」。
最大字段和問題就是求解全部連續子序列的和當中最大的那個和。
解題思路:
首先咱們定義狀態 \(f[i]\) 表示以 \(a[i]\) 結尾(而且包含\(a[i]\))的最大字段和。
那麼咱們能夠獲得狀態轉移方程:
\(f[i] = \max(f[i-1], 0) + a[i]\)
首先咱們初始化及輸入的部分以下(座標從\(1\)到\(n\)):
int n, a[1010], f[1010], ans; cin >> n; for (int i = 1; i <= n; i ++) cin >> a[i];
而後以通常方式求解的方式以下:
for (int i = 1; i <= n; i ++) f[i] = max(f[i-1], 0) + a[i];
而後咱們的答案就是全部 \(f[i]\) 的最大值:
for (int i = 1; i <= n; i ++) ans = max(ans, f[i]); cout << ans << endl;
遞歸形式,咱們一樣開一個函數 dfs(i)
用於返回 \(f[i]\) 的值。
可是這裏咱們沒法經過 \(f[i]\) 的值肯定 \(f[i]\) 是否已經求出來,因此我再開一個bool類型的 \(vis\) 數組,經過 \(vis[i]\) 來判斷 \(f[i]\) 是否已經求過了。
bool vis[1010];
記憶化搜索實現以下:
int dfs(int i) { if (i == 0) return 0; // 邊界條件 if (vis[i]) return f[i]; vis[i] = true; return f[i] = max(dfs(i-1), 0) + a[i]; }
注意:搜索/遞歸必定要注意邊界條件。
而後,答案的求解方式1以下:
ans = dfs(1); for (int i = 2; i <= n; i ++) ans = max(ans, dfs(i)); cout << ans << endl;
答案的另外一種求解方式以下:
dfs(n); ans = f[1]; for (int i = 2; i <= n; i ++) ans = max(ans, f[i]); cout << ans << endl;
有沒有發現,在這裏我就調用了一次 \(dfs(n)\) ,全部的 \(f[i](1 \le i \le n)\) 的值就都求出來了呢。
由於我在第一次求 \(dfs(n)\) 的時候,會調用 \(dfs(n-1)\) ,而第一次 \(dfs(n-1)\) 會調用 \(dfs(n-2)\) ,……,第一次 \(dfs(2)\) 會調用 \(dfs(1)\)。
因此調用一下 \(dfs(n)\) ,我就把全部的 \(f[i]\) 都求出來了。
通常形式的完整實現代碼以下:
#include <bits/stdc++.h> using namespace std; int n, a[1010], f[1010], ans; int main() { cin >> n; for (int i = 1; i <= n; i ++) cin >> a[i]; for (int i = 1; i <= n; i ++) f[i] = max(f[i-1], 0) + a[i]; for (int i = 1; i <= n; i ++) ans = max(ans, f[i]); cout << ans << endl; return 0; }
記憶化搜索的完整實現代碼以下:
#include <bits/stdc++.h> using namespace std; int n, a[1010], f[1010], ans; bool vis[1010]; int dfs(int i) { if (i == 0) return 0; // 邊界條件 if (vis[i]) return f[i]; vis[i] = true; return f[i] = max(dfs(i-1), 0) + a[i]; } int main() { cin >> n; for (int i = 1; i <= n; i ++) cin >> a[i]; // ans = dfs(1); // for (int i = 2; i <= n; i ++) // ans = max(ans, dfs(i)); dfs(n); ans = f[1]; for (int i = 1; i <= n; i ++) ans = max(ans, f[i]); cout << ans << endl; return 0; }
題目大意:
有以下所示的數塔,要求從頂層走到底層,若每一步只能走到相鄰的結點,則通過的結點的數字之和最大是多少?
解題思路:
首先咱們作一些假設:總共有 \(n\) 行,最上面的那行是第 \(1\) 行,最下面的那行是第 \(n\) 行,咱們用 \((i,j)\) 來表示第 \(i\) 行第 \(j\) 個格子,用 \(a[i][j]\) 表示 \((i,j)\) 格子存放的值。
咱們能夠發現,從 \((i,j)\) 往下走走到最底層的最大數字和與從最下面的格子往上走走到 \((i,j)\) 的最大數字和是相等的。
因此咱們能夠把問題變成:求從最底下任意一個格子往上走走到 (1,1) 的最大數字和。
能夠發現,通過這樣一下思惟的轉換,咱們就把一個「自頂向上」的問題轉換成了一個「自底向上」的問題。
(請好好體會 「自頂向下」 和 「自底向上」 這兩個概念,由於咱們這道題接下來還會在另外一個情景中討論這兩個概念)
咱們能夠發現,除了最底層(第 \(i\) 層)是直接走到的意外,上層的全部 \((i,j)\) 不是從 \((i+1,j)\) 走上來的,就是從 \((i+1, j+1)\) 走上來的。
因此咱們不妨設 \(f[i][j]\) 表示從最底層任意一個位置往上走走到 \((i,j)\) 位置的最大數字和。
能夠推導出:
在推導的過程當中,記得從 \(n\) 到 \(1\) 遍歷 \(i\) ,由於高層的狀態要先經過低層的狀態推導出來。
通常形式的主要實現代碼段以下:
for (int i = n; i >= 1; i --) { // 自底向上 for (int j = 1; j <= n; j ++) { if (i == n) f[i][j] = a[i][j]; else f[i][j] = max(f[i+1][j], f[i+1][j+1]) + a[i][j]; } }
能夠發現,咱們採用通常形式的寫法,是先求解較低層的轉態,而後經過低層的轉態推導出高層的狀態,因此咱們也說這種實現思想是 自底向上 的。
講完通常形式的實現方式,咱們再來說解使用 記憶化搜索 的形式進行求解的實現方式。
咱們一樣仍是要定義一個狀態 \(f[i][j]\) 表示從最底層任何一個位置走到 \((i,j)\) 的最大數字和(和上面的描述同樣)。
可是咱們不是以上面的通常形式來求解 \(f[i][j]\) ,而是開一個函數 dfs(int i, int j)
來求解 \(f[i][j]\)。
那麼,咱們怎麼樣來進行 記憶化 :即:判斷當前的 \(f[i][j]\) 已經訪問過呢?
由於一開始 \(f[i][j]\) 均爲 \(0\),若是全部的數塔中的元素 \(a[i][j]\) 均 \(\gt 0\) ,那麼 \(f[i][j]\) 一旦求過則 \(f[i][j]\) 必然也 \(\gt 0\)。
可是若是 \(a[i][j] \ge 0\)(即 \(a[i][j]\) 能夠爲 \(0\))或者 \(a[i][j]\) 能夠是負數的狀況下,咱們就不能靠 \(f[i][j]\) 是否爲 \(0\) 來判斷 \((i,j)\) 這個格子有沒有訪問過了( 仔細思考一下爲何 )。
因此最靠譜,最不容易錯的方式就是跟採用跟例2同樣的方式,開一個二維 \(vis\) 數組, 用 \(vis[i][j]\) 來標識 \((i, j)\) 是否訪問過。
記憶化搜索形式的主要代碼片斷以下:
int dfs(int i, int j) { // dfs(i,j)用於計算並返回f[i][j]的值 if (vis[i][j]) return f[i][j]; vis[i][j] = true; if (i == n) // 邊界條件——最底層 return f[i][j] = a[i][j]; return f[i][j] = max(dfs(i+1, j), dfs(i+1, j+1)) + a[i][j]; }
通常形式的完整代碼以下:
#include <bits/stdc++.h> using namespace std; const int maxn = 101; int n, a[maxn][maxn], f[maxn][maxn]; int main() { cin >> n; for (int i = 1; i <= n; i ++) for (int j = 1; j <= i; j ++) cin >> a[i][j]; for (int i = n; i >= 1; i --) { // 自底向上 for (int j = 1; j <= n; j ++) { if (i == n) f[i][j] = a[i][j]; else f[i][j] = max(f[i+1][j], f[i+1][j+1]) + a[i][j]; } } cout << f[1][1] << endl; return 0; }
記憶化搜索的完整代碼以下:
#include <bits/stdc++.h> using namespace std; const int maxn = 101; int n, a[maxn][maxn], f[maxn][maxn]; bool vis[maxn][maxn]; int dfs(int i, int j) { // dfs(i,j)用於計算並返回f[i][j]的值 if (vis[i][j]) return f[i][j]; vis[i][j] = true; if (i == n) // 邊界條件——最底層 return f[i][j] = a[i][j]; return f[i][j] = max(dfs(i+1, j), dfs(i+1, j+1)) + a[i][j]; } int main() { cin >> n; for (int i = 1; i <= n; i ++) for (int j = 1; j <= i; j ++) cin >> a[i][j]; cout << dfs(1, 1) << endl; return 0; }