動態規劃——入門
動態規劃(入門)
在之前貪心演算法的部分,有一部分問題使用貪心演算法無法求得最優解,但是使用動態規劃就可以求出最優解。
在現實生活中,有一類活動的過程,這個過程可以分為若干個有聯絡的過程,每一個階段都需要作出決策,從而使得整個活動達到最好的效果。各個階段的決策依賴於當前的狀態同時又影響著以後的發展,而每個階段的決策一般來說和時間有關,每一次決策會引起狀態的變化,一個決策序列就是這樣動態產生的,有動態的含義。
故動態規劃實際上就是解決多階段決策最優化的過程的方法。
舉例來說,假定從\(A\)到\(D\)需要鋪設管道,其中要經過兩級中轉站,連線上的數字表示距離,如下圖所示,應該選擇什麼路線,使得總路線最短?
按照之前學過的貪心策略,從\(A\)出發,每次選擇距離最短的路徑,直到\(D\),得出的答案為\(A->B1->C3->D\),總距離為\(7\)。但是實際上的最短路徑為\(A->B1->C1->D\),長度和為\(6\)。錯誤的原因在於貪心求解的問題是子問題相互獨立,沒有聯絡的,僅僅有先後順序,合起來正好是原問題。而上面的最短路徑的各個子問題都是有關聯的,用貪心求解是錯誤的。需要使用動態規劃。接下來從兩個例項來說明動態規劃的特點。
引例、數塔
給你一個數字三角形,要求找出從第一層到最後一層的路徑,使得所經過的權值和最大。
三角形如下
7 3 8 8 1 0 2 7 4 4 4 5 2 6 5
我們首先將這個大的三角形拆分為最小單位,如下
顯然,最大路徑權值和為\(7(2+5)\),如果是三層呢?
是否可以參考\(2\)層的做法,如果只考慮上面兩層,那麼最大的路徑應該是\(8\)加上\(2\)和\(7\)中大的那個路徑。
但是\(2\)和\(7\)分別都有子路徑,剛才的分析,\(2\)的子路徑中最優的為\(2+5=7\),而\(7\)的子路徑中最優的為\(7+5 = 12\)。迴歸到這個\(3\)層的數塔中,如果中間那層已經是最優的路徑了(包含其子路徑)也就是\(7\)和\(12\),那麼就和\(2\)層的問題相同了。
按照上面的方式推導,如果沒有數塔中從下往上每一個數字都是到達當前數字的最優路徑,那麼往上推導時,只需要考慮\(2\)
其實剛才的分析過程中,通過子問題的最優解,推匯出整個問題的最優解,並且每個子問題之間有聯絡。其實這就是最優子結構(問題的最優解包含子問題的最優解)。
具體實現可以通過陣列\(d[i][j]\)表示第\(i\)層\(j\)列這個點到最後一行的最優解,顯然根據上面的分析,我們的子問題是重疊的。可以有如下遞推式
\(d[i] [j] = a[i, j] + max { d[i+1] [j], d[i+1] [j+1] }\)
每一層的取值,只和下一層有關,只需要考慮這一層是從\(d[i+1] [j]\)還是\(d[i+1] [j+1]\)走過來的即可,不需要關心\(d[i+1] [j+1]\)和\(d[i+1] [j]\)是怎麼得到的。這就是無後效性。
程式設計可以從底部向上推導,也可以從上往下推導。或者採用記憶化搜尋等方式。
參考程式
#include <iostream>
#include <algorithm>
#define N 1005
using namespace std;
int a[N][N];
int main () {
ios::sync_with_stdio(0);
int n;
cin >> n;
for (int i = 1; i <= n; i++)
for (int j = 1; j <= i; j++)
cin >> a[i][j];
for (int i = n - 1; i >= 1; i--)
for (int j = 1; j <= i; j++)
a[i][j] += max(a[i+1][j], a[i+1][j+1]);
cout << a[1][1];
return 0;
}
小結
動態規劃的一些基本概念
- 階段:根據時空順序對問題求解劃分階段
- 狀態:描述事物的性質,取決於你如何去思考這個問題的性質特點。對問題求解狀態的描述是分階段的
- 決策:是一種狀態轉移的選擇,從當前狀態轉移到下一狀態
- 狀態轉移方程:數學公式描述與狀態有關的演變規律
動態規劃的一個模型、三大特點
- 多階段決策最優解模型
- 最優子結構
- 問題的最優解包含子問題的最優解。可以通過子問題的最優解推匯出問題的最優解,也可以理解成後面階段的狀態可以通過前面的狀態推匯出來
- 無後效性
- 在推導後面階段狀態的時候,我們只關心前面階段的狀態值,不關心這個狀態是怎麼一步步推匯出來的
- 某階段狀態一旦確定,就不受之後階段的決策影響
- 重複子問題
- 不同的決策序列,到達某個相同的階段時,可能會產生重複的狀態。
例題
例1、導彈攔截
某國為了防禦敵國的導彈襲擊,發展出一種導彈攔截系統。但是這種導彈攔截系統有一個缺陷:雖然它的第一發炮彈能夠到達任意的高度,但是以後每一發炮彈都不能高於前一發的高度。某天,雷達捕捉到敵國的導彈來襲。由於該系統還在試用階段,所以只有一套系統,因此有可能不能攔截所有的導彈。
輸入導彈依次飛來的高度(雷達給出的高度資料是\(≤50000\)的正整數),計算這套系統最多能攔截多少導彈。
【輸入格式】
\(1\)行,若干個整數(個數\(≤100\))
【輸出格式】
\(1\)行,表示這套系統最多能攔截多少導彈。
【輸入樣例】
389 207 155 300 299 170 158 65
【輸出樣例】
6
該問題的本質是尋找一個最長不下降子序列(該子序列可以不連續)。
那麼劃分階段可以根據導彈來,第幾個導彈就是第幾個階段
假定,要攔截當前飛來的導彈,那麼需要找到此前飛來的導彈中,已經被攔截的數量最多的系統,來進行攔截,並且要保證當前的導彈高度不超過這個系統的最後一枚導彈高度。
可以定義\(d[i]\)表示從\(1\)到\(i\)枚導彈中,攔截\(a[i]\)導彈的系統最多能攔截的導彈數量,滿足最優子結構。\(d[i]\)這個狀態明顯應該從\(1\)到\(i-1\)枚導彈中大於等於\(a[i]\)且\(d[j]\)的值最大的狀態轉移過來,滿足無後效性。
參考程式
#include <iostream>
#include <algorithm>
#include <cstdio>
#define N 105
using namespace std;
int main () {
int a[N], d[N], ans = 0;
int n = 1;
while (scanf ("%d", &a[n]) != EOF) {
d[n] = 1;
n ++;
}
n --;
for (int i = 1; i <= n; i++) {
for (int j = 1; j < i; j++) {
if (a[i] <= a[j] && d[j] + 1 > d[i]) {
d[i] = d[j] + 1;
if (d[i] > ans)
ans = d[i];
}
}
}
printf ("%d", ans);
return 0;
}
例2、挖地雷
在一個地圖上有\(N\)個地窖\((N≤20)\),每個地窖中埋有一定數量的地雷。同時,給出地窖之間的連線路徑。當地窖及其連線的資料給出之後,某人可以從任一處開始挖地雷,然後可以沿著指出的連線往下挖(僅能選擇一條路徑),當無連線時挖地雷工作結束。設計一個挖地雷的方案,使某人能挖到最多的地雷。
【輸入格式】
有若干行。
第\(1\)行只有一個數字,表示地窖的個數\(N\)。
第\(2\)行有\(N\)個數,分別表示每個地窖中的地雷個數。
第\(3\)行至第\(N+1\)行表示地窖之間的連線情況:
第\(3\)行有\(n-1\)個數(\(0\)或\(1\)),表示第一個地窖至第\(2\)個、第\(3\)個、…、第\(n\)個地窖有否路徑連線。如第\(3\)行為\(11000…0\),則表示第\(1\)個地窖至第\(2\)個地窖有路徑,至第\(3\)個地窖有路徑,至第\(4\)個地窖、第\(5\)個、…、第\(n\)個地窖沒有路徑。
第\(4\)行有\(n-2\)個數,表示第二個地窖至第\(3\)個、第\(4\)個、…、第\(n\)個地窖有否路徑連線。
… …
第\(n+1\)行有\(1\)個數,表示第\(n-1\)個地窖至第\(n\)個地窖有否路徑連線。(為\(0\)表示沒有路徑,為\(1\)表示有路徑)。
【輸出格式】
有兩行
第一行表示挖得最多地雷時的挖地雷的順序,各地窖序號間以一個空格分隔,不得有多餘的空格。
第二行只有一個數,表示能挖到的最多地雷數。
【輸入樣例】
5
10 8 4 7 6
1 1 1 0
0 0 0
1 1
1
【輸出樣例】
1 3 4 5
27
注意路徑是單向路徑。
用\(f[i]\)表示到挖到\(f[i]\)這個地窖,能夠挖到的最多地雷數量。答案就在f序列中最大的那個值上。
狀態轉移方程如下:
\(f[i] = f[j] + a[i] {1<=j <i \,\,\, and \,\,\, f[j]+a[i] > f[i]}\)
翻譯過來就是找到能夠通往當前地窖中的地窖,並且地雷數量最多的地窖,將地雷數轉移過來。前提是之前的地窖也都滿足這個最優子結構。
參考程式
#include <iostream>
#include <cstring>
#define N 25
using namespace std;
int a[N], f[N], p[N], g[N][N];
void put_seq (int x) {
if (x == p[x]) {
cout << x << " ";
return;
}
put_seq(p[x]);
cout << x << " ";
}
int main () {
memset(p, -1, sizeof(p));
int n, mx = 1;
cin >> n;
for (int i = 1; i <= n; i++) {
cin >> a[i];
f[i] = a[i];
if (f[i] > f[mx])
mx = i;
}
for (int i = 1; i < n; i++)
for (int j = i+1; j <= n; j++)
cin >> g[i][j];
for (int i = 1; i <= n; i++) p[i] = i;
for (int i = 2; i <= n; i++) {
for (int j = 1; j < i; j ++) {
if (g[j][i] && f[j]+a[i] > f[i]) {
f[i] = f[j] + a[i];
p[i] = j;
if (f[i] > f[mx])
mx = i;
}
}
}
put_seq(mx);
cout << "\n" << f[mx];
return 0;
}
總結
動態規劃的核心思想就是記錄結果再利用,從而避免子問題重複計算,採用空間換取時間效率。
一般來說,解決動態規劃問題有\(4\)個步驟
- 刻畫一個最優解的結構特徵。
- 找出狀態轉移方程。
- 計算最優解的值,通常採用自底向上的方法。
- 利用計算出的資訊構造一個最優解。
動態規劃類的問題包含兩個重要要素
- 最優子結構
- 重疊的子問題