Dynamic Programming

動態規劃

INDEX

  • 基本原理
  • 簡單例題
  • LIS
  • LCS
  • 背包問題

基本原理

基本原理

用表格紀錄programming

dp:用變動的表格來求解

比較像一種思考模式、手段

而不是演算法

基本概念

跟分治有點像

將問題分成子問題

接著合併、解決子問題

最後得到答案

基本概念

1.定義子問題

2.找到問題和子問題間的(遞迴)關係

3.找出計算的順序

並避免用遞迴計算

基本概念

要如何從子問題得到原本問題的答案?

首先子問題我們可以稱為狀態

看到題目的時候需要設計適合的狀態

接著要去想狀態間的關係(轉移方程)

ex:

A

B

+10

基本概念

接著依照順序

把問題解決

得到最後的答案

例題1 爬樓梯

有個小朋友要上樓梯

每次可以往上走1階或2階(往上)

有幾種可以到n階的方法

例題1 爬樓梯

首先

來設定狀態

我們設定dp[i]是走到第i格的方法數

因此最後要求的答案

要算出dp[n]的數值

例題1 爬樓梯

找出狀態間的關係

今天要走到第i階之前

要先走到i-1跟i-2階

因此dp[i]=dp[i-1]+dp[i-2]

例題1 爬樓梯

用遞迴式表示

\begin{cases} n\qquad \qquad \qquad \qquad \qquad \qquad \qquad if\ n<3\\ f(n)=f(n-1)+f(n-2)\qquad otherwise \end{cases}

發現在用遞迴的時候要相信呼叫完他會幫你算好

如果從小開始算的話?

一般人正常算費式數列應該都是這樣算的吧

其實就是費事數列

用到dp[i]時 dp[i-1],dp[i-2]的值已經算好了

例題1 爬樓梯

一開始要有個狀態

才能一直轉移上去

dp[1]=1;
dp[2]=2;

接著轉移

for(int i=3;i<=n;i++){
	dp[i]=dp[i-1]+dp[i-2];
}

答案

cout<<dp[n]<<'\n';

例題2 走格子

有一個m*n的表格

從左上角走到右下角

每一步只能往右或往下一格

有幾種走法

例題2

先定義子問題

dp[i][j]=走到(i,j)的方法數

接著是轉移式

由於只能往右或往下走

所以當i>1 && j>1的時候

這一格可以從上面或左邊走過來

答案:dp[n][m]

例題2

初始狀態
 

要把所有沒有上面或左邊的先手算完

也就是這兩排

而這兩排都只有一條路

所以都是1

這是轉移的方式

方法數是從上面和左邊走下來的總和

例題2

初始狀態

for(int i=1;i<=n;i++)dp[i][1]=1;
for(int i=1;i<=m;i++)dp[1][i]=1;

轉移式

for(int i=2;i<=n;i++){
	for(int j=2;j<=m;j++){
    	dp[i][j]=dp[i-1][j]+dp[i][j-1];
    }
}

答案

cout<<dp[n][m]<<'\n';

這幾題讓你們稍微感受一點點

dp的感覺

後面的題目會一步步建立起dp的思維模式

簡單例題

例題1 爬樓梯最小成本

跟一開始的費事數列有點像?

看到dp題目要怎麼開始想

狀態!

今天定義dp[i]是走到第i格最小成本

答案:dp[n]

要怎麼轉移?

例題1 爬樓梯最小成本

轉移式!

今天我在第i格

我能從我下面一格或兩格走來

那既然dp[i]是第i格的最小

dp[i-1],dp[i-2]就是i-1,i-2格的最小

那今天我們要接著走的應該要選小的繼續走

所以轉移式就出來了!

例題1 爬樓梯最小成本

轉移式!

dp[i]=min(dp[i-1],dp[i-2])+cost[i];

選小的(取min)、繼續走(+這格成本)

初始狀態

走第1格只有1個成本->cost[1]

走第2格 也能只有1個成本->cost[2]

dp[1]=cost[1];
dp[2]=cost[2];

答案

cout<<dp[n]<<'\n';

例題1 爬樓梯最小成本

每次轉移:O(1)

n次:O(n)

時間複雜度:

O(n)
#include<bits/stdc++.h>
#define maxn 100005
using namespace std;
int dp[maxn],cost[maxn];
int main(){
    int n;cin>>n;
    for(int i=1;i<=n;i++)cin>>cost[i];
    dp[1]=cost[1];
    dp[2]=cost[2];
    for(int i=3;i<=n;i++){
        dp[i]=min(dp[i-1],dp[i-2])+cost[i];
    }
    cout<<dp[n]<<'\n';
}

把上面題目刷完就能高中競程輕鬆一半

這個裸題蠻多的

都寫

dp多刷題就能比較抓得住感覺

基本每次北市賽、TOI、APCS基本都會考

例題2 frog1

每次可以1格或2格

但花費|h[i]-h[j]|

狀態!

dp[i]=跳到第i格最小花費

答案:dp[n]

接著要想轉移式

例題2 frog1

轉移式!

跟上一題想法差不多

要找前兩個中最小

但是要考慮他們的高度

因為跳過來也有花費

dp[i]=min(從前一格跳過來,從前兩格跳過來)

例題2 frog1

轉移式!

for(int i=3;i<=n;i++){
	dp[i]=min(dp[i-1]+abs(h[i-1]-h[i]),dp[i-2]+abs(h[i-2]-h[i]));
}

初始狀態

(dp[1]=0;)

dp[2]=abs(h[2]-h[1]);

答案

cout<<dp[n]<<'\n';

例題2 frog2

最多走k格

這題的想法應該不難

就使原本的2個轉移變成k個

狀態一樣

來想轉移式

轉移式

今天可以從前k個跳過來

那我希望走的就是最小的路徑

取min 然後算跳過來的成本

for(int i=k+1;i<=n;i++){
  for(int j=1;j<=k;j++){
  	dp[i]=min(dp[i],dp[i-j]+abs(h[i]-h[i-j]));
  }
}

每次都取一次min

看從哪一格成本比較低

例題2 frog2

初始狀態!

因為我們會用到前k個

所以要先手算好1~k

那1~k都可以1次跳

dp[i]=從第1格跳過來的成本

for(int i=1;i<=k;i++)dp[i]=abs(h[i]-h[1]);

例題2 frog2

CODE

#include<bits/stdc++.h>
#define maxn 100005
using namespace std;
int dp[maxn],h[maxn];
int main(){
    fill(dp,dp+maxn,1e9);
    int n,k;cin>>n>>k;
    for(int i=1;i<=n;i++)cin>>h[i];
    for(int i=1;i<=k;i++)dp[i]=abs(h[i]-h[1]);
    for(int i=k+1;i<=n;i++){
        for(int j=1;j<=k;j++){
            dp[i]=min(dp[i],dp[i-j]+abs(h[i]-h[i-j]));
        }
    }
    cout<<dp[n]<<'\n';
}

由於每次都取min

如果預設為0

全部都會是0

例題2 frog2

題目

p-6-6

#include<bits/stdc++.h>
using namespace std;
int main(){
    int n,m;
    cin>>n>>m;
    int g[205][205],dp[205][205];
    for(int i=1;i<=n;i++){
        for(int j=1;j<=m;j++){
            cin>>g[i][j];
        }
    }
    dp[1][1]=g[1][1];
    for(int i=1;i<=n;i++)dp[i][1]=dp[i-1][1]+g[i][1];
    for(int i=1;i<=m;i++)dp[1][i]=dp[1][i-1]+g[1][i];
    for(int i=2;i<=n;i++){
        for(int j=2;j<=m;j++){
            dp[i][j]=max(dp[i][j-1],dp[i-1][j])+g[i][j];
        }
    }
    cout<<dp[n][m];
}

p-6-2

#include<bits/stdc++.h>
using namespace std;
int main(){
    int n;
    cin>>n;
    int s[200005],dp[200005];
    for(int i=1;i<=n;i++){
        cin>>s[i];
    }
    dp[1]=s[1];
    dp[2]=max(dp[1],s[2]);
    for(int i=3;i<=n;i++){
        dp[i]=max(dp[i-1],dp[i-2]+s[i]);
    }
    cout<<dp[n];
}

p-6-3

#include<bits/stdc++.h>
using namespace std;
#define int long long
int32_t main(){
    int n;
    cin>>n;
    int c[100005],dp[100005];
    for(int i=1;i<=n;i++){
        cin>>c[i];
    }
    dp[1]=c[1];
    dp[2]=c[2];
    dp[3]=c[3]+min(c[1],c[2]);
    for(int i=4;i<=n;i++){
        dp[i]=c[i]+min({dp[i-1],dp[i-2],dp[i-3]});
    }
    if(n!=1)cout<<min(dp[n],dp[n-1]);
    else cout<<c[1];
}

p-6-4

#include<bits/stdc++.h>
using namespace std;
int main(){
    int n,t,dp[2][200005];
    cin>>n>>t;
    vector<pair<int,int>>v;
    v.push_back({0,0});
    for(int i=0;i<n;i++){
        int a,b;
        cin>>a>>b;
        v.push_back({a,b});
    }
    dp[0][1]=(abs(t-v[1].first));
    dp[1][1]=(abs(t-v[1].second));
    for(int i=2;i<=n;i++){
        dp[0][i]=min(dp[0][i-1]+abs(v[i-1].first-v[i].first),dp[1][i-1]+abs(v[i-1].second-v[i].first));
        dp[1][i]=min(dp[0][i-1]+abs(v[i-1].first-v[i].second),dp[1][i-1]+abs(v[i-1].second-v[i].second));
    }
    cout<<min(dp[0][n],dp[1][n])<<'\n';
}

LIS

LIS?

LIS

Longest increasing subsequence

最長遞增子序列

子序列:之前枚舉有講過

取跟不取的那題

要找數列中

最長且符合遞增的子序列

LIS

舉例:

1 3 5 2 9

3 9

遞增子序列

1 5 2 9

不是遞增子序列

要找出最長的(可能不只一個)

LIS

1 3 5 2 9

其中一種最長的:1 3 5 9 

接下來要用dp的想法來解

有兩種做法

O(n^2)\\ O(n\ log\ n)

LIS

設定狀態

dp[i]是以第i項作為結尾的最長子序列長度

答案:dp[1~n]中最長的

接著要來想轉移式

每次都掃過前面的看能不能接

只求長度

LIS

由於前面dp[j]存的都是最長的子序列長度

每次往前找到如果比現在位置的值小就接看看

看怎麼接長度最長

for(int i=2;i<=n;i++){
  for(int j=1;j<i;j++){
    if(arr[i]>arr[j]){
      dp[i]=max(dp[i],dp[j]+1);
    }
  }
}

j每次往前掃

遇到可以接的 看看接上去會不會比原本大

CODE

#include<bits/stdc++.h>
#define maxn 200005
using namespace std;
int arr[maxn],dp[maxn];
int main(){
    int n;cin>>n;
    for(int i=1;i<=n;i++)cin>>arr[i];
    fill(dp,dp+maxn,1);
    for(int i=2;i<=n;i++){
        for(int j=1;j<i;j++){
            if(arr[i]>arr[j]){
                dp[i]=max(dp[i],dp[j]+1);
            }
        }
    }
    int ans=1;
    for(int i=1;i<=n;i++)ans=max(ans,dp[i]);
    cout<<ans<<'\n';
}

LIS

複雜度:

O(n^2)

很遺憾

這個方式過不了這一題的

看一下測資

2e5->n log n

接著用一點特殊的算法

LIS

Robinson–Schensted–Knuth algorithm

用的是貪心想法

直接來看怎麼做

利用binary search加速

如果聽不懂可以看這個

LIS

今天我們要來做一個LIS

如果a比陣列所有值大

那我們就可以放在最後

長度+1

要放入一個數字a

否則找一個它應該在的位置

將原有值替換掉

LIS

要放入一個數字2

ex

1 3 5 2 9

1 3 5

假如目前LIS做到第3

它沒比5大

找到適合它的位置

->把3換掉

LIS:1 2 5

LIS

要放入一個數字9

ex

1 3 5 2 9

1 2 5

接著LIS做到第4

它比5大

推到最後

LIS:1 2 5 9

長度=4

LIS

由於是遞增序列

是不是就有單調性

所以在找要怎麼替換的時候

可以快速找到

二分搜!

問題瞬間變簡單很多

LIS

CODE

#include<bits/stdc++.h>
#define maxn 200005
using namespace std;
int arr[maxn],dp[maxn];
vector<int>v;
int main(){
    int n;cin>>n;
    for(int i=1;i<=n;i++){
        cin>>arr[i];
    }
    for(int i=1;i<=n;i++){
        if(lower_bound(v.begin(),v.end(),arr[i])==v.end())v.push_back(arr[i]);
        else{
            auto it=lower_bound(v.begin(),v.end(),arr[i]);
            *it=arr[i];
        }
    }
    cout<<v.size();
}

利用二分搜

要注意 題目是嚴格遞增

複雜度:O(n log n)

LIS

最後vector裡面的並不是真正的LIS

要利用pos[]陣列來存

再用特殊的找法

詳情上面的連結有

題目

LCS

LCS

求最長共同子序列長度

並找一種輸出

ex

s1 : 5 7 9 3 1 2

s2 : 3 5 3 2 8

LCS=5 3 2

先來講長度

LCS

狀態

dp[i][j]=s1前i個跟s2前j個的LCS長度

答案=dp[n][m]

接著要來想轉移式

LCS

考慮兩種情況

1. s1[i]==s2[j]

2. s1[i]!=s2[j]

LCS長度是上面跟左邊格的mx

dp[i][j]=max(dp[i-1][j],dp[i][j-1])

則LCS長度+1

dp[i][j]=dp[i-1][j-1]+1

LCS

長度求完了 要怎麼找子序列

要來找怎麼轉移過來的

如果dp[i][j]是由dp[i-1][j-1]轉移過來的

代表s[i]==s[j]

用反推的

然後記錄怎麼來的

LCS

用一個pre陣列存起來

0 1 2分別是三種轉過來的路徑左上 左 上

最後用遞迴走回去並輸出

直接看code

CODE

#include<bits/stdc++.h>
#define maxn 1005
using namespace std;
int dp[maxn][maxn],pre[maxn][maxn];
vector<int>s1,s2;
void print(int i,int j){
    if(i==0 || j==0)return;
    if(pre[i][j]==1){
        print(i-1,j-1);
        cout<<s1[i]<<' ';
    }
    else if(!pre[i][j]){
        print(i,j-1);
    }
    else print(i-1,j);
    return;
}
int main(){
    int n,m;cin>>n>>m;
    s1.push_back(0);
    s2.push_back(0);
    for(int i=0;i<n;i++){
        int a;cin>>a;
        s1.push_back(a);
    }
    for(int i=0;i<m;i++){
        int a;cin>>a;
        s2.push_back(a);
    }
    for(int i=1;i<=n;i++){
        for(int j=1;j<=m;j++){
            if(s1[i]==s2[j]){
                    dp[i][j]=dp[i-1][j-1]+1;
                    pre[i][j]=1;
            }
            else{
                if(dp[i][j-1]>dp[i-1][j]){
                    dp[i][j]=dp[i][j-1];
                    pre[i][j]=0;
                }
                else{
                    dp[i][j]=dp[i-1][j];
                    pre[i][j]=2;
                }
            }
        }
    }
    cout<<dp[n][m]<<'\n';
    print(n,m);
}

背包問題

背包問題

有一個可以耐重W的背包,N個物品

每個物品有各自的wi,vi

求放進價值最大為多少?

0/1背包問題

暴力枚舉?->TLE

2^n

0/1背包問題

設定dp狀態

dp[i][j]是從前i個物品中,取到重量恰為j時的最大價值

答案=dp[n][m]

0/1背包問題

狀態轉移

從前i項選擇物品的最佳方案一定是

(有選第 i 項物品)或(沒有選第 i 項物品)

其中一種

1.假如今天有選第i項物品

那可以從前 i-1項 j-w[i] 項轉移過來 

2.假如沒有選

可以從前i-1項 j 項轉移

0/1背包問題

code

    for(int i=1;i<=n;i++){
        for(int j=0;j<=m;j++){
            if(j-w[i]<0){
                dp[i][j]=dp[i-1][j];
                continue;
            }
            dp[i][j]=max(dp[i-1][j],dp[i-1][j-w[i]]+v[i]);
        }
    }

如果拿了第i 重量超過j就不能拿

沒選                   有選

code不難 但想法要理解一下

0/1背包問題

實作

今天轉移的時候

最多只會用到 [i-1][j] [i-1][j-w[i]]

在這些之前的都不會用到

所以可以用滾動方式優化

0/1背包問題

dp[i%2][j]=max(dp[(i+1)%2][j-w[i]]+v[i], dp[(i+1)%2][j];

只用兩排陣列解決

其實可以用一維陣列解決?

空間複雜度少很多

0/1背包問題

將j由m到0跑

for(int i=1;i<=n;i++){
  for(int j=m;j>=0;j--){
  	if(j-w[i]<0)continue;
  	dp[j]=max(dp[j],dp[j-w[i]]+v[i]);
  }
}

當要更新dp[j]的時候

使用左邊的dp[j-w[i]] 和 這格的dp[j]

都還沒跑第i次(是i-1次的計算結果)

所以不會出錯

0/1背包問題

Code

#include<bits/stdc++.h>
#define maxn 105
using namespace std;
long long w[maxn],v[maxn],dp[100005];
int main(){
    int n,m;cin>>n>>m;
    for(int i=1;i<=n;i++)cin>>w[i]>>v[i];
    for(int i=1;i<=n;i++){
        for(int j=m;j>=0;j--){
            if(j-w[i]<0)continue;
            dp[j]=max(dp[j],dp[j-w[i]]+v[i]);
        }
    }
    cout<<dp[m];
}

我們是透過從二維一路優化過來

而不是直接只記一維的做法

否則遇到變形題就沒了

0/1背包問題

如果W很大?

1e9 -> [n][w] = 1e9*100 -> (MLE)

想想看其他種定狀態的方法

改成用價值來定

0/1背包問題

狀態

dp[i][j]=從前i項物品

取到總價值恰為j的最小重量

答案=當dp[i][j]<=m時的最大j

接著要來想轉移式

0/1背包問題

轉移式

dp[i][j] = dp[i-1][j-v[i]]+w[i]  選擇第i項

dp[i][j] = dp[i-1][j]                 不選擇第i項

我們希望在裝同價值物品的情況下

重量越少越好

所以取min

0/1背包問題

for(int i=1;i<=n;i++){
 	for(int j=0;j<=sum;j++){
      if(j-v[i]<0){
        dp[i][j]=dp[i-1][j];
        continue;
      }
      dp[i][j]=min(dp[i-1][j],dp[i-1][j-v[i]]+w[i]);
      if(dp[i][j]<=m)ans=max(ans,j);
	}
}
    

價值不能<0

這樣其實還不完整 思考一下如果前i-1項都沒拿->重量=0 這樣第i取min時 就會一直不拿

(全部都不拿重量最小)

0/1背包問題

當i=0 j=0 dp[i][j]=0

但i=0 j>0時 不可能發生 所以沒取的時候我們把dp[0][1~mx]重量設inf 極大的數字

這樣在轉移的時候 才能正確找到有取且重量最小的

0/1背包問題

CODE

#include<bits/stdc++.h>
#define ll long long
#define maxn 105
using namespace std;
ll dp[maxn][100005],v[maxn],w[maxn],sum;
int main(){
    int n,m;cin>>n>>m;
    for(int i=1;i<=n;i++){
        cin>>w[i]>>v[i];
        sum+=v[i];
    }
    fill(dp[0]+1,dp[0]+sum+5,1e9);
    int ans=0;
    for(int i=1;i<=n;i++){
        for(int j=0;j<=sum;j++){
            if(j-v[i]<0){
                dp[i][j]=dp[i-1][j];
                continue;
            }
            dp[i][j]=min(dp[i-1][j],dp[i-1][j-v[i]]+w[i]);
            if(dp[i][j]<=m)ans=max(ans,j);
        }
    }
    cout<<ans<<'\n';
}

j的範圍

最多有可能全部選

所以是0~sum(v[1~n])

無限背包問題

有一個可以耐重W的背包,N個物品

每種可以拿任意數量

每個物品有各自的wi,vi

求放進價值最大為多少?

定義狀態

無限背包問題

狀態

dp[i][j] = 從前i個物品中,取到重量恰為j時的最大價值

跟0/1背包一模一樣

但轉移式要怎麼想

想法:

不取這一項or這一項再取一個

其中一種

 

無限背包問題

轉移

dp[i][j] = max(dp[i-1][j], dp[i-1][j-w[i]]+v[i], dp[i-1][j-w[i]*2]+2*v[i], ....., dp[i-1][j-w[i]*k]+v[i]

且w[i]*k<=j

那經過整理:

dp[i][j] = max(dp[i-1][j], dp[i][j-w[i]]+v[i])

相當於再拿一次第i項

無限背包問題

#include<bits/stdc++.h>
#define maxn 1005
#define ll long long
using namespace std;
ll w[maxn],v[maxn],dp[maxn][maxn];
int main(){
    int n,m;cin>>n>>m;
    for(int i=1;i<=n;i++)cin>>w[i];
    for(int i=1;i<=n;i++)cin>>v[i];
    for(int i=1;i<=n;i++){
        for(int j=0;j<=m;j++){
            if(j-w[i]<0){
                dp[i][j]=dp[i-1][j];
                continue;
            }
            dp[i][j]=max(dp[i-1][j],dp[i][j-w[i]]+v[i]);
        }
    }
    cout<<dp[n][m]<<'\n';
}

CODE

只有轉移式的部分有改

無限背包問題

可以發現只會用到這一排跟上一排

->可以滾動

也可以用一維來寫

j從0~m跑

需要用到上面那格跟這一輪更新過的j-w[i] 按照0~m的順序剛好不會覆蓋到

無限背包問題

空間優化CODE

#include<bits/stdc++.h>
#define maxn 1005
#define ll long long
using namespace std;
ll w[maxn],v[maxn],dp[maxn];
int main(){
    int n,m;cin>>n>>m;
    for(int i=1;i<=n;i++)cin>>w[i];
    for(int i=1;i<=n;i++)cin>>v[i];
    for(int i=1;i<=n;i++){
        for(int j=0;j<=m;j++){
            if(j-w[i]<0)continue;
            dp[j]=max(dp[j],dp[j-w[i]]+v[i]);
        }
    }
    cout<<dp[m]<<'\n';
}

有限背包問題

有一個可以耐重W的背包,N個物品

每種可以拿ki

每個物品有各自的wi,vi

求放進價值最大為多少?

把每個物品數量當成不同物品

然後做0/1背包

O(NWK)

K為重複數量最大值

有限背包問題

O(NW\ log(k))\ \ \ \ O(NW)

的作法

今天我們教這個 另外的要用到dp優化的內容

來設定狀態

有限背包問題

狀態

跟無限背包很像

dp[i][j] = max(dp[i-1][j], dp[i-1][j-k*w[i]]+k*v[i]

1<=k<=重複數量

這樣轉移所花的時間差不多

想想怎麼優化

想想看

今天如果要組合出0到49

需要幾個數字?

49個1?

二進位!

有限背包

我們可以把k[i]分成{1,2,4,8,2ᵖ,q}

其中p為使           不大於k[i]的最大整數

2^{p+1}

ex.21 可以分成{1,2,4,8,6}

為甚麼這樣可以湊出所有種組合?

x<               則一定可以用{1,2,4,8,2ᵖ}湊出來

2^{p+1}

x>               則先取q,一定可以用{1,2,4,8,2ᵖ}湊出來

2^{p+1}

(剩下x-q<           ) 

2^{p+1}

有限背包

最多分出log(k[i])+1堆

那再用0/1背包

就能取到所有組合

複雜度O(N*W*log(k[i]))

背包

一些背包問題的題目

去刷題吧

課程到這邊

Made with Slides.com