動態規劃(Dynamic programming)

sunhy2012發表於2024-07-25

什麼是動態規劃?

動態規劃(英語:Dynamic programming,簡稱DP),是一種在數學、管理科學、電腦科學經濟學和生物資訊學中使用的,透過把原問題分解為相對簡單的子問題的方式求解複雜問題的方法。動態規劃常常適用於有重疊子問題和最優子結構性質的問題。

以上定義來自維基百科,看定義感覺還是有點抽象。簡單來說,動態規劃其實就是,給定一個題,我們把它拆成一個個子問題,直到子問題可以直接解決。然後呢,把子問題答案儲存起來,以減重複計算。再根據子問題答案反推,得出原問題解的一種方法。

一般這些子問題很相似,可以透過函式關係式遞推出來。然後呢,動態規劃就致力於解決每個子問題一次,減重複計算,比如斐波那契數列就可以看做入門級的經典動態規劃問題。

動態規劃核心思想

動態規劃最核心的思想,就在於拆分子問題,記住過往,減少重複計算。

動態規劃的概念意義

動態規劃問世以來,在經濟管理、生產排程、工程技術和最優控制等方面得到了廣泛的應用。例如最短路線、庫管理、資源分配、裝置更新、排序、裝載等問題,用動態規劃方法比用其它方法求解更為方便。

雖然動態規劃主要用於求解以時間劃分階段的動態過程的最佳化問題,但是一些與時間無關的靜態規劃(如線性規劃、非線性規劃),只要人為地引進時間因素,把它視為多階段決策過程,也可以用動態規劃方法方便地求解。

動態規劃程式設計是對解最最佳化問題的一種途徑、一種方法,而不是一種特殊演算法。不像搜尋或數值計算那樣,具有一個標準的數學表示式和明確清晰的解題方法。動態規劃程式設計往往是針對一種最最佳化問題,由於各種問題的性質不同,確定最優解的條件也互不相同,因而動態規劃的設計方法對不同的問題,有各具特色的解題方法,而不存在一種萬能的動態規劃演算法,可以解決各類最最佳化問題。因此讀者在學習時,除了要對基本概念和方法正確理解外,必須具體問題具體分析處理,以豐富的想象力去建立模型,用創造性的技巧去求解。我們也可以透過對若干有代表性的問題的動態規劃演算法進行分析、討論,逐漸學會並掌握這一設計方法。

例如這道題就是動態規劃的應用

例如這道題就是動態規劃的應用

題目描述題目描述


棋盤上 AA 點有一個過河卒,需要走到目標 BB 點。卒行走的規則:可以向下、或者向右。同時在棋盤上 CC 點有一個對方的馬,該馬所在的點和所有跳躍一步可達的點稱為對方馬的控制點。因此稱之為“馬攔過河卒”。棋盤用座標表示,AA 點 (0, 0)(0,0)、BB 點 (n, m)(n,m),同樣馬的位置座標是需要給出的。現在要求你計算出卒從 A 點能夠到達 B 點的路徑的條數,假設馬的位置是固定不動的,並不是卒走一步馬走一步。現在要求你計算出卒從 A 點能夠到達 B 點的路徑的條數,假設馬的位置是固定不動的,並不是卒走一步馬走一步。

輸入格式

一行四個正整數,分別表示 BB 點座標和馬的座標。

輸出格式

一個整數,表示所有的路徑條數。

輸入 輸出

6 6 3 3 6

這是來自於 Chiaro 的題解

這道題初始位置是從 0 開始的,這樣不是很利於我們解題,所以不如暫且把這題裡涉及的座標統統 +1,那麼初始位置就從 (0,0)(0,0) 變成了 (1,1)(1,1)。

先考慮如果沒有任何馬的限制,卒子可以隨便向右向下走,那麼可以想到,一個卒子只能從 當前格子的左側格子 和 當前格子的上方格子 上走到當前格子。那麼假設從 (1,1)(1,1) 走到 當前格子的左側格子 的路徑條數是 xx,從 (1,1)(1,1) 走到 當前格子的上方格子 的路徑條數是 yy,那麼從 (1,1)(1,1) 走到當前格子的路徑條數就應該是 x+yx+y。

其實我們已經得到了一個動態規劃的轉移方程,設 f(i,j)f(i,j) 表示從 (1,1)(1,1) 格子走到當前格子的路徑條數,那麼根據上一段得到的結論,可以得到:

f(i,j) = f(i-1,j) + f(i,j-1)f(i,j)=f(i−1,j)+f(i,j−1)

(i,j)(i,j) 是當前格子,那麼 (i-1,j)(i−1,j) 就是 當前格子的上方格子,(i,j-1)(i,j−1) 就是 當前格子的左側格子。我們只需要從小到大依次列舉 ii 和 jj 就能獲得所有點的答案,可以想到,在這道題裡我們要求的答案就是 f(n,m)f(n,m)(因為 B 點的座標是(n,m)(n,m))。

當然如果只是按照這個公式推肯定不行,因為 ff 的初始數值都是 0,再怎麼推也都是 0,我們要讓 f(1,1)f(1,1) 能根據上面得到的式子推出答案是 1,這樣才能有有意義的結果。根據 f(1,1)=f(0,1)+f(1,0)f(1,1)=f(0,1)+f(1,0),我們只需要讓 f(1,0)=1f(1,0)=1 或者 f(0,1)=1f(0,1)=1 即可。

接下來考慮一下加入了 馬 這道題該怎麼做,假設 (x,y)(x,y) 這個點被馬攔住了,其實就是說這個點不能被卒子走到,那當我們列舉到這個點的時候,發現他被馬攔住了,那就直接跳過這個點,讓 f(x,y)=0f(x,y)=0 就行了。

具體寫程式碼的時候我們注意到在判斷一個點有沒有被馬攔住時,會用到 (i-2,j-1)(i−2,j−1) 和 (i-1,j-2)(i−1,j−2) 這兩個位置,那如果不把所有的點的座標都加上 2 (前面分析的時候只把所有的座標加上 1),就會因為陣列越界而 WA 掉一個點。

答案可能很大,所以記得開 long long。

#include<iostream>
#include<cstring>
#include<cstdio>
#include<algorithm>
#define ll long long
using namespace std;

const int fx[] = {0, -2, -1, 1, 2, 2, 1, -1, -2};
const int fy[] = {0, 1, 2, 2, 1, -1, -2, -2, -1};
//馬可以走到的位置

int bx, by, mx, my;
ll f[40][40];
bool s[40][40]; //判斷這個點有沒有馬攔住
int main(){
    scanf("%d%d%d%d", &bx, &by, &mx, &my);
    bx += 2; by += 2; mx += 2; my += 2;
    //座標+2以防越界
    f[2][1] = 1;//初始化
    s[mx][my] = 1;//標記馬的位置
    for(int i = 1; i <= 8; i++) s[mx + fx[i]][my + fy[i]] = 1;
    for(int i = 2; i <= bx; i++){
        for(int j = 2; j <= by; j++){
            if(s[i][j]) continue; // 如果被馬攔住就直接跳過
            f[i][j] = f[i - 1][j] + f[i][j - 1];
            //狀態轉移方程
        }
    }
    printf("%lld\n", f[bx][by]);
    return 0;
} 

考慮滾動陣列最佳化。

觀察轉移方程 :

f(i,j) = f(i-1,j) + f(i,j-1)f(i,j)=f(i−1,j)+f(i,j−1)

每一次轉移只需要提供 f(i-1,j)f(i−1,j) 和 f(i,j-1)f(i,j−1)。

即當前位置上方格子的答案與當前位置左邊的答案,也就是說,對於一次轉移,我們只需要用到橫座標是 ii 和橫座標是 i-1i−1 這兩行的答案,其他位置的答案已經是沒有用處的了,我們可以直接丟掉不管他們。

怎麼只保留第 ii 行和第 i-1i−1 行的答案呢?答案是取模(C++ 中的運算子 %)。

i\ %\ 2\ne (i-1)\ %\ 2i % 2=(i−1) % 2,所以我們把第一維的座標 ii 都取模 2 變成 i\ %\ 2i % 2,並且不斷覆蓋原來陣列裡存的答案,就成功做到只保留第 ii 行和第 i-1i−1 行的答案了。

眾所周知,x\ %\ 2x % 2 可以在程式碼中寫成更快的運算方式 i\ &\ 1i & 1。

如果 xx 是偶數,那麼 x\ &\ 1=0x & 1=0,如果 xx 是奇數,那麼 x\ &\ 1=1x & 1=1。

那麼新的轉移方程就可以變成:

f(0,1)=1f(0,1)=1

f(i\ &\ 1,j)=f((i-1)\ &\ 1,j)+f(i\ &\ 1,j-1)f(i & 1,j)=f((i−1) & 1,j)+f(i & 1,j−1)

f((i-1)\ &\ 1,j)f((i−1) & 1,j) 就是當前位置上邊格子的答案。

f(i\ &\ 1,j-1)f(i & 1,j−1) 就是當前位置左邊的答案。

這樣 , 陣列第一維是不是就可以壓成 2 了呢?

另外 , 因為是滾動陣列 , 所以如果當前位置被馬攔住了一定要記住清零。

程式碼 :

#include<iostream>
#include<cstring>
#include<cstdio>
#include<algorithm>
#define ll long long
using namespace std;

const int fx[] = {0, -2, -1, 1, 2, 2, 1, -1, -2};
const int fy[] = {0, 1, 2, 2, 1, -1, -2, -2, -1};
int bx, by, mx, my;
ll f[2][40];    //第一維大小為 2 就好
bool s[40][40];

int main(){
    scanf("%d%d%d%d", &bx, &by, &mx, &my);
    bx += 2; by += 2; mx += 2; my += 2;
    f[1][2] = 1; //初始化
    s[mx][my] = 1;
    for(int i = 1; i <= 8; i++) s[mx + fx[i]][my + fy[i]] = 1;
    for(int i = 2; i <= bx; i++){
        for(int j = 2; j <= by; j++){
            if(s[i][j]){
                f[i & 1][j] = 0; //被馬攔住了記住清零
                continue;
            }
            f[i & 1][j] = f[(i - 1) & 1][j] + f[i & 1][j - 1]; 
            //新的狀態轉移方程
        }
    }
    printf("%lld\n", f[bx & 1][by]);
    //輸出的時候第一維也要按位與一下
    return 0;
} 

好的那繼續來看看能不能再最佳化。

唯一再有點最佳化空間的地方就是那個大小為 2 的第一維了,那麼為什麼我們去不掉這個 2 呢?

因為狀態轉移的時候需要一個 f(i-1,j)f(i−1,j),所以必須要多開一維。

那麼我們如果最佳化掉了這裡,當然就不再需要二維陣列了。

觀察我們能發現 , 這個 f(i-1,j)f(i−1,j) 與當前位置的 f(i,j)f(i,j) 的第二維一樣 , 都是 j , 而第一維只是差了 1。

我們考慮直接去掉第一維,來看這個狀態轉移方程 :

f(j) = f(j) + f(j-1)f(j)=f(j)+f(j−1)

是不是就把陣列變成一維了呢?但是如何解釋這個方程?

f(j)+f(j-1)f(j)+f(j−1) 裡面,f(j-1)f(j−1) 就是前面方程裡的 f(i,j-1)f(i,j−1)。

至於 f(j)f(j) , 因為還沒有被更新過 , 所以答案仍然儲存的是上次求出的答案 , 即 f(i-1,j)f(i−1,j)。

這樣 , 就把二維陣列成功變成了一維陣列。

程式碼 :

#include<iostream>
#include<cstdio>
#include<cstring>
#include<algorithm>
#define ll long long
using namespace std;

// 快速讀入
template <class I>
inline void read(I &num){
    num = 0; char c = getchar(), up = c;
    while(!isdigit(c)) up = c, c = getchar();
    while(isdigit(c)) num = (num << 1) + (num << 3) + (c ^ '0'), c = getchar();
    up == '-' ? num = -num : 0; return;
}
template <class I>
inline void read(I &a, I &b) {read(a); read(b);}
template <class I>
inline void read(I &a, I &b, I &c) {read(a); read(b); read(c);}

const int fx[] = {0, -2, -1, 1, 2, 2, 1, -1, -2};
const int fy[] = {0, 1, 2, 2, 1, -1, -2, -2, -1};

int bx, by, mx, my;
ll f[40];   //這次只需要一維陣列啦
bool s[40][40];

int main(){
    read(bx, by); read(mx, my);
    bx += 2; by += 2; mx += 2; my += 2;
    f[2] = 1;   //初始化
    s[mx][my] = 1;
    for(int i = 1; i <= 8; i++) s[mx + fx[i]][my + fy[i]] = 1;
    for(int i = 2; i <= bx; i++){
        for(int j = 2; j <= by; j++){
            if(s[i][j]){
                f[j] = 0; // 還是別忘了清零
                continue;
            }
            f[j] += f[j - 1];
            //全新的 簡潔的狀態轉移方程
        }
    }
    printf("%lld\n", f[by]);
    return 0;
} 

這時可能就有同學說了,f 陣列是變成一維了,但是你的 s 陣列還是二維的啊你個騙子!

至於去掉 s 陣列的方法,其實還是很多的。

首先有比較暴力的方法,我們直接去掉 s 陣列,然後對於當前位置 (x,y)(x,y),我們列舉被馬攔住的那 8 個點,如果其中有一個點的位置和他的位置是一樣的,那麼這個位置就是不合法的了。這個方法可行,但是我們把本來是 O(n^2)O(n2) 小常數的做法加了一個 8 倍常數。如果把範圍開大到 n\leq 2\times 10^4n≤2×104,那麼這個做法可能會被卡。

有沒有別的方法呢?下面可能會用到這個知識點:切比雪夫距離。

我們注意到,被馬攔住的位置到馬的切比雪夫距離一定是2,也就是說,他們都分佈於下圖這個正方形上,那我們就成功縮小了列舉範圍:只有噹噹前這個點 (x,y)(x,y) 到馬的切比雪夫距離是 2 時,才進行 8 個點的列舉,那麼複雜度大概就是 O(n^2+16\times 8)O(n2+16×8)(原諒我用這種不正確的方法書寫複雜度),常數很小.
image

但是還能有更好的方法,那就是加上曼哈頓距離:我們可以發現,這些被馬攔住的位置同時到馬的曼哈頓距離也一定為 3。

藍色是曼哈頓距離為 3 的位置,紅色是切比距離 - OI Wiki雪夫距離為 2 的位置,交點是被馬攔住的位置,且被馬攔住的位置一定是交點,也就是說,這是個充要條件。
所以對於每個點我們只需要算一下他到馬的切比雪夫距離和曼哈頓距離即可,這個計算都是 O(1)O(1) 的,且常數很小。

#include <cmath>
#include <cctype>
#include <cstdio>
#include <cstdlib>
#include <iostream>
#include <algorithm>
#define ll long long

inline int read(){
    int num = 0; char c = getchar();
    while(!isdigit(c)) c = getchar();
    while(isdigit(c)) num = (num << 1) + (num << 3) + (c ^ '0'), c = getchar();
    return num;
}

int bx, by, mx, my;
ll f[30];

inline bool check(int x, int y) {
    if(x == mx && y == my) return 1;
    return (std::abs(mx - x) + std::abs(my - y) == 3) && (std::max ((std::abs(mx - x)), std::abs(my - y)) == 2);
}

int main(){
    bx = read() + 2, by = read() + 2, mx = read() + 2, my = read() + 2;
    f[2] = 1;
    for(int i = 2; i <= bx; i++){
        for(int j = 2; j <= by; j++){
            if(check(i, j)){
                f[j] = 0;
                continue;
            }
            f[j] += f[j - 1];
        }
    }
    printf("%lld\n", f[by]);
    return 0;
} 

至此,我們成功將一個時間複雜度和空間複雜度為 O(n^2)O(n2) 的演算法,最佳化到了時間複雜度 O(n^2)O(n2),空間複雜度 O(n)O(n),雖然對於這道題而言沒有任何的意義,但是或許能在做其他難題的時候啟發我們一點思路,總歸是沒有壞處的。

相關文章