TLE[1]

講師自介

講師自介

  • 林禹喆
  • dc: Treeman
  • 資訊社教學
  • 因為想學演算法, 所以來當算法講師

Table

DP

dp intro

1D dp/application

Edit Distance/application

Knapsack

2D DP

Range dp

Bitmask dp

digit dp

dp on broken profiles

DP intro

Fibonacci Number

Task: 找到第N項的費式數列 \: \: \newline
Fibo: \:\: 1 \:\: 1 \:\: 2 \:\: 3 \:\: 5 \:\: 8 \:\: 13 \newline

Fibonacci Number

Task: 找到第N項的費式數列 \: \: \newline
Fibo: \:\: 1 \:\: 1 \:\: 2 \:\: 3 \:\: 5 \:\: 8 \:\: 13 \newline

Fibonacci Number

Task: 找到第N項的費式數列 \: \: \newline
Fibo: \:\: 1 \:\: 1 \:\: 2 \:\: 3 \:\: 5 \:\: 8 \:\: 13 \newline
第n項為n-1與n-2項之和 \: \: \newline fib(n) = fib(n-1) + fib(n-2)

遞迴解法:

int fib(int n)
{
	if (n == 0)
    	return 0;
    if (n == 1)
    	return 1;
        
    return fib(n-1) + fib(n-2);
}

遞迴解法:

int fib(int n)
{
	if (n == 0)
    	return 0;
    if (n == 1)
    	return 1;
        
    return fib(n-1) + fib(n-2);
}

fib(n)

fib(n-1)

fib(n-2)

fib(n-2)

fib(n-3)

fib(n-3)

fib(n-4)

可以想成是 \: \: \newline

注意到

fib(n)

fib(n-1)

fib(n-2)

fib(n-2)

fib(n-3)

fib(n-3)

fib(n-4)

遞迴中,會計算許多重複的值 (如: fib(n-2), fib(n-3) )

 這會降低程式效率 😨😨😨

有沒有辦法只計算一次?

注意到

fib(n)

fib(n-1)

fib(n-2)

fib(n-2)

fib(n-3)

fib(n-3)

fib(n-4)

遞迴中,會計算許多重複的值 (如: fib(n-2), fib(n-3) )

 這會降低程式效率 😨😨😨

有沒有辦法只計算一次?

我們可以利用 DP

Code:

#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const int MAXN = 114514 + 1919810 + 1234 + 67;
ll m[MAXN];

ll fib(int n)
{
	if (n == 0)
        return 0;
    if (n == 1)
        return 1;
        
	return m[n] = (m[n]? m[n] : fib(n-1) + fib(n-2));
}
int main() 
{
	int n;
    cin >> n;
    
    cout << fib(n);
}
# PRESENTING CODE

DP?

  • Dynamic Programming - 動態規劃

  • 利用問題重疊的特性解決大問題

  • DP解法有很多, 很多題目都有很創意的解法

DP?

  • Dynamic Programming - 動態規劃

  • 利用問題重疊的特性解決大問題

  • DP解法有很多, 很多題目都有很創意的解法

  • DP時該注意要如何存狀態&轉移式

例題:

給你 \: N \: \newline 求有幾個方式可以用一個骰子\newline 得到和為 \: N
\bullet \: 1 \leq N \leq 10^6
\bullet \: n = 1 \: \newline 可以從骰\: 1 \:得到\newline \bullet \: n = 2 \: \newline 可以從骰 \: 1+1 \: \newline 或骰 \: 2 \:得到\newline \bullet \: n = 3 \: \newline 可以從骰\: 1 + 1 + 1 \: \newline 或骰\: 2 + 1 \: \newline 或骰 \: 3 \:得到

想法

注意到\newline \: n = 3 \: 時 \: 1 + 1 + 1 \: 和 \: 2 + 1 \: \newline 其中的 \: 1 + 1 \: 和 \: 2 \: \newline 都是 \: n = 2 \:的答案 \newline 甚至 \: 3 + 0 \: 的 \: 0\newline 是 \: n = 0 \: 時的答案
注意到\newline 對於現在這個答案 \: n \: 可以從 \newline \bullet \:n-1 \: 的答案\: + 1 \newline \bullet \:n-2 \: 的答案\: + 2 \newline \bullet \:n-3 \: 的答案\: + 3 \newline ......... \newline \bullet \:n-6 \: 的答案\: + 6 \newline 得到

Code:

#include <bits/stdc++.h>
using namespace std;
long long saves[1000002];
 
signed main() 
{
 
    int n;
    cin >> n;
    saves[0] = 1;
 
    for (int i=0; i <= n; i++) 
    {
        for (int j=1; j <= 6; j++) 
            if (i - j >= 0) saves[i] += saves[i-j];
            
        if (saves[i] >= (1e9 + 7)) saves[i] %= 1000000007;
    }
    cout << saves[n];
}
# PRESENTING CODE

通常DP

有bottom up, top down

兩種方法寫

有push dp, pull dp

兩種填表方式

有memoization, tabulation

兩種表

  • bottom up

  • 對於到達這個狀態之前的所有狀態都已經求出了答案, 因此可以直接從之前狀態球出現在答案
  • top down

  • 為了處理現在的狀態, 去求出這個狀態需要的子問題的答案再填這個答案
  • push dp

  • 對於現在的狀態, 把可以轉移到的所有狀態都處理完
  • pull dp

  • 對於現在的狀態, 從之前的狀態拿取現在的狀態需要的答案
  • memoization

  • 對於到達 N 所需要的狀態才存取
  • tabulation

  • 到達 N 之前的所有狀態都會存取

1D DP

例題:

有 \: N \: 個石頭 \newline 每個石頭都有一個高度 \: h_{i} \newline 從石頭 \: i \: 跳到石頭 \: j \: 會有 \: |h_{i} - h_{j}| \:的花費 \newline 青蛙原本在石頭 \: 1 \: 它可以往後跳\: 1, 2 \: 格\newline 問如何用最小的花費到達石頭 \: N
\bullet\: 1 \leq N \leq 10^5
\bullet\: 1 \leq h_{i} \leq 10^4
對於現在的石頭 \: k \newline 如果跳到 \: k+1 \: 或 \: k+2 \: 的石頭\newline 可以降低到達那個石頭的花費\newline 修改到達那個石頭的答案

想法

Code:

#include <bits/stdc++.h>
using namespace std;
const int INF = 1919810 + 114514 + 1234 + 67;

int main()
{
    cin.tie(nullptr)->sync_with_stdio(false);
    
    int n;
    cin >> n;
    
    vector<int> dp(n+10, INF);
    vector<int> saves(n+10);
    
    for (int i=1; i <= n; i++)
        cin >> saves[i];
        
    dp[1] = 0;
    for (int i=1; i <= n; i++)
    {
        if (i + 1 <= n) dp[i+1] = min(dp[i+1], dp[i] + abs(saves[i] - saves[i+1]));
        if (i + 2 <= n) dp[i+2] = min(dp[i+2], dp[i] + abs(saves[i] - saves[i+2]));
    }
    
    cout << dp[n];
}
# PRESENTING CODE

練習題:

2D DP

例題:

芋頭的\: N \:天暑假明天就開始了\newline 他對於每一天都有計劃\newline 它可以做A, B, C三件事情\newline 分別得到 a_{i}, b_{i}, c_{i} \: 滿足度 \newline 芋頭不能兩天都坐同一個事情 \newline 求暑假結束時芋頭的最大滿足度
\bullet \: 1 \leq N \leq 10^5
\bullet\: 1 \leq a_{i}, b_{i}, c_{i} \leq 10^4
首先注意到芋頭暑假結束時\newline 滿足度竟然沒有歸零 \newline 可以發現應該把芋頭丟進火鍋裡面\newline 😎😎😎

想法

想法

考慮對於每一天如果選的是A, B, C \newline 可以得到的最大值是什? \newline dp_{i, a} = a_{i} + max(dp_{i-1, b}, dp_{i-1, c})

Code:

#include <bits/stdc++.h>
using namespace std;

int main()
{
    cin.tie(nullptr)->sync_with_stdio(false);
    int n;
    cin >> n;
    vector<array<int, 3>> dp(n+1);
    for (int i = 1; i <= n; i++)
    {
        int a, b, c;
        cin >> a >> b >> c;
        dp[i][0] = max(dp[i-1][1] + a, dp[i-1][2] + a);
        dp[i][1] = max(dp[i-1][0] + b, dp[i-1][2] + b);
        dp[i][2] = max(dp[i-1][0] + c, dp[i-1][1] + c);
    }
    cout << max({dp[n][0], dp[n][1], dp[n][2]});
}
# PRESENTING CODE

練習題:

Edit Distance

例題:

給你兩個字串S1, S2 \newline 問你如果有以下三個操作\newline \bullet\:刪除一個字串的字 \newline \bullet\: 在一個字串插入一字 \newline \bullet\: 選取一個字串的字換成另一個字 \newline 問如果想把S1變成S2, 這種操作最少需要多少?
\bullet\:{1 \leq S1, S2 \leq 500}\newline
\bullet\: S_{i} = A \sim Z

想法

假設有\: S1 = LOVE, S2 = MOVIE\newline 則可以用雙指針理解接下來操作

想法

假設有\: S1 = LOVE, S2 = MOVIE\newline 則可以用雙指針理解接下來操作

L O V E

M O V I E

想法

假設有\: S1 = LOVE, S2 = MOVIE\newline 則可以用雙指針理解接下來操作

L O V E

M O V I E

接下來定義我們的操作方法: \newline \bullet\:如果我們要刪除字\newline 則操作\:+1\:且綠色指針向右移動一格 \newline \bullet\:如果我們要插入字\newline 則操作\:+1\:且藍色指針向右移動一格 \newline \bullet\:如果我們要改變字\newline 則操作\:+1\:且綠色指針和藍色指針皆向右移動一格 \newline

L O V E

M O V I E

L O V E

M O V I E

ops cnt: 0

M O V E

M O V I E

ops cnt: 1

M O V E

M O V I E

ops cnt: 1

M O V E

M O V I E

ops cnt: 1

M O V I E

M O V I E

ops cnt: 2

M O V I E

M O V I E

ops cnt: 2

我們無法知道當下最好的方法是選什麼操作 \newline 因此我們拉到二維矩陣想想看 \\
😡 M O V I E
L
O
V
E
😡 M O V I E
L
O
V
E
對於每一格\: dp_{i, j} \newline 若 s1[i] = s2[j]\:則 \: dp_{i, j} = dp_{i-1, j-1} \newline 否則 \: dp_{i, j} = min(dp_{i-1, j}, dp_{i-1, j-1}, dp_{i, j-1}) + 1
對於每一格\: dp_{i, j} \newline 若 s1[i] = s2[j]\:則 \: dp_{i, j} = dp_{i-1, j-1} \newline 否則 \: dp_{i, j} = min(dp_{i-1, j}, dp_{i-1, j-1}, dp_{i, j-1}) + 1

想想看這些分別代表雙指針做了那些操作

😡 M O V I E
0 1 2 3 4 5
L 1 1 2 3 4 5
O 2 2 1 2 3 4
V 3 3 2 1 2 2
E 4 4 3 2 2 2

Code:

#include <bits/stdc++.h>
using namespace std;
 
int main()
{
    string s1, s2; cin >> s1 >> s2;
    int n = s1.length(), m = s2.length();
 
    vector<vector<int>> dp(n+1, vector<int>(m+1));
 
    for (int i=0; i <= n; i++) dp[i][0] = i;
    for (int j=1; j <= m; j++) dp[0][j] = j;
    
    for (int i=1; i <= n; i++)
    {
        for (int j=1; j <= m; j++)
        {
            if (s1[i-1] == s2[j-1]) dp[i][j] = dp[i-1][j-1];
            else dp[i][j] = min({dp[i-1][j], dp[i][j-1], dp[i-1][j-1]}) + 1;
        }
    }
 
    cout << dp[n][m];
}
# PRESENTING CODE

例題:

給你兩個數字陣列 \newline 問它們最長的相等子序列是什麼
\bullet\:{1 \leq A, B \leq 1000}\newline
\bullet\: 1 \leq A_{i}, B_{i} \leq 10^9
如果你發現它就是上一題的改版你就對了

想法

至於如何拿到那個實際答案? \newline 從尾巴走\newline 看答案是從上面,左邊或左上走來就好了

想法

Code:

#include <bits/stdc++.h>
using namespace std;
 
int main()
{
    cin.tie(nullptr)->ios::sync_with_stdio(false);
 
    int n, m; cin >> n >> m;
 
    vector<int> arr1(n), arr2(m);
    for (auto &x : arr1) cin >> x; for (auto &x : arr2) cin >> x;
 
    vector<vector<int>> dp(n+1, vector<int>(m+1));
 
    for (int i=1; i <= n; i++)
    {
        for (int j=1; j <= m; j++)
        {
            if (arr1[i-1] == arr2[j-1]) dp[i][j] = dp[i-1][j-1] + 1;
            else dp[i][j] = max(dp[i-1][j], dp[i][j-1]);
        }
    }
    int &ans = dp[n][m];
    cout << ans << std::endl;
 
    int i = n, j = m; vector<int> res;
    while (ans != 0)
    {
        if (arr1[i-1] == arr2[j-1]) {res.push_back(arr1[i-1]); i--; j--; ans--;}
        else if(dp[i][j-1] > dp[i-1][j]) j--;
        else i--;
    }
    reverse(res.begin(), res.end()); for (auto &x : res) cout << x << ' ';
 
    
}
# PRESENTING CODE

例題:

給一個整數陣列 A\newline 問最大的嚴格遞增子序列多長
\bullet\:{1 \leq A \leq 2\times10^5}\newline
\bullet\: 1 \leq a_{i} \leq 10^9
注意到這是上一題 \newline 所以把\:A\:排序再跟\: A \: 做LCS \newline

想法

注意到這是上一題 \newline 所以把\:A\:排序再跟\: A \: 做LCS \newline

想法

恭喜你獲得一個 \: TLE \: \\ 😡😡😡😡😡

想法

由於這題的正解講師不覺得是dp

 

所以這題當回家作業

😇😇😇

Knapsack

例題:

你有一個容量為\:W\:的背包與 \: N \: 個物品 \newline 每個物品有各自的重量W_{i} 與價值 V_{i}\newline \\[2ex] 問可裝進背包內物品的最大價值和
\bullet\: 1 \leq N \leq 1000 \\ \bullet\: 1 \leq W \leq 10^5 \\ \bullet\: 1 \leq h_{i}, v_{i} \leq 1000
對於每一個物品\\ 考慮其會不會改進當下重量的價值 \\ 如果會就對於當時狀態選取這個物品\\

想法

對於每一個物品\\ 考慮其會不會改進當下重量的價值 \\ 如果會就對於當時狀態選取這個物品\\

想法

我們使用狀態 \: dp_{i, j} \\ 表示考慮前i個物品且總重量等於j的狀態

想法

假設 \: N \: (物品數) = 2, \: W \: (背包總重量) = 5 \\ 物品A 價值: 5, 重量: 2 \\ 物品B 價值: 7, 重量: 3

想法

假設 \: N \: (物品數) = 2, \: W \: (背包總重量) = 5 \\ 物品A 價值: 5, 重量: 2 \\ 物品B 價值: 7, 重量: 3
dp
於是我們令dp陣列如下

想法

0
0
0
dp

物品A

價值: 5

重量: 2

物品B

價值: 7

重量: 3

當j = 0 時,總重量為0 \\表示沒有取任何物品,總價值必為0

想法

0 0 0 0 0 0
0
0
dp

物品A

價值: 5

重量: 2

物品B

價值: 7

重量: 3

當i = 0 時,總共考慮0個物品 \\也表示沒有取任何物品,總價值必為0

想法

0 0 0 0 0 0
0 0
0
dp

物品A

價值: 5

重量: 2

物品B

價值: 7

重量: 3

當i = 1, j = 1時,總價值為0 \\ 因為物品重量皆大於一

想法

0 0 0 0 0 0
0 0 5
0
dp

物品A

價值: 5

重量: 2

物品B

價值: 7

重量: 3

當i = 1, j = 2時,總重量剛好符合物品A \\ 總價值為 0 + 5 = 5

想法

0 0 0 0 0 0
0 0 5 5 5 5
0
dp

物品A

價值: 5

重量: 2

物品B

價值: 7

重量: 3

當i = 1, j = 3/4/5時,總重量符合物品A與B \\ 但因為目前我們只考慮物品A之前的物品 \\ 所以總價值皆為5

想法

0 0 0 0 0 0
0 0 5 5 5 5
0 0
dp

物品A

價值: 5

重量: 2

物品B

價值: 7

重量: 3

當i = 2, j = 1時,總價值為0 \\ 因為物品重量皆大於一

想法

0 0 0 0 0 0
0 0 5 5 5 5
0 0 5
dp

物品A

價值: 5

重量: 2

物品B

價值: 7

重量: 3

當i = 2, j = 2時,總重量剛好符合物品A \\ 總價值為 0 + 5 = 5

想法

0 0 0 0 0 0
0 0 5 5 5 5
0 0 5 7
dp

物品A

價值: 5

重量: 2

物品B

價值: 7

重量: 3

當i = 2, j = 3時,總重量同時符合物品A與物品B\\ 於是取較大價值的B \\ 總價值為 0 + 7 = 7

想法

0 0 0 0 0 0
0 0 5 5 5 5
0 0 5 7 7
dp

物品A

價值: 5

重量: 2

物品B

價值: 7

重量: 3

當i = 2, j = 4時,同i = 2, j = 3

想法

0 0 0 0 0 0
0 0 5 5 5 5
0 0 5 7 7 12
dp

物品A

價值: 5

重量: 2

物品B

價值: 7

重量: 3

當i = 2, j = 5時,總重量剛好可以同時容納A與B \\ 總價值為 5 + 7 = 12 \\ dp_{2, 5} = dp_{2, 2} + val[B]

想法

根據上面的推論可以得知 \\ 狀態(i, j)與狀態(i-1, j-w_{i})有關
我們可推導出轉移式 \\ dp_{i, j} = max(dp_{i, j}, dp_{i-1,j-w_{i}} + v_{i})

Code:

#include <bits/stdc++.h>
using namespace std;

int main() {
    int books, max_weight;
    cin >> books >> max_weight;
 
    vector<pair<int, int>> book_info(books);
 
    for (auto& book : book_info) cin >> book.first;
    for (auto& book  : book_info) cin >> book.second;
 
    vector <vector <int>> dp(books+1, vector <int>(max_weight + 1, 0));
 
    for (int i=1; i <= books; i++) {
        int price = book_info[i-1].first;
        int page = book_info[i-1].second;
 
        for (int j=0; j <= max_weight; j++) 
            if (j >= price) dp[i][j] = max(dp[i-1][j], dp[i-1][j-price] + page);
            else dp[i][j] = dp[i-1][j];
 
    }
    cout << dp.back().back();
}
# PRESENTING CODE
注意到我們只需要上面那一層的答案 \\ 因此這裡可以用一個叫\:rolling \:dp\:的小技巧 \\ 它可以讓\:code\:看起來整潔一點\\ 也可以節省記憶體

想法

Code:

#include <bits/stdc++.h>
using namespace std;

int main()
{
    cin.tie(nullptr)->sync_with_stdio(false);

    int n, x;
    cin >> n >> x;

    int dp[x+1];
    memset(dp, 0, sizeof(dp));

    vector<array<int, 2>> saves(n);
    for (int i=0; i < n; i++)
        cin >> saves[i][0];
    for (int i=0; i < n; i++)
        cin >> saves[i][1];
    
    for (int i=0; i < n; i++)
        for (int j=x; j >= saves[i][0]; j--)
            dp[j] = max(dp[j], dp[j - saves[i][0]] + saves[i][1]);

    cout << dp[x];
}
# PRESENTING CODE

練習題:

Range DP

例題:

有 \: N \:個大小為\: S_{i} \:的史萊姆 \\ 問如果把全部史萊姆融合需要的最低花費 \\ 融合:選兩個相鄰的史萊姆 \\ 融合花費=S_{i} + S_{j}, |i-j| = 1
\bullet\:{1 \leq N \leq 400}\newline
\bullet\: 1 \leq S_{i} \leq 10^9
看到\: N \leq 400 \\ 所以可以唬爛複雜度應該是\mathcal{O}(n^3) \\[2ex]然後對於每一個狀態\: dp_{i, j} \\ 定義他的意思為:\\ 融合\: i \sim j\: 之間的史萊姆的最小值 \\[2ex]可以推得以下公式: \\ dp_{i, j} = min(dp_{i, j}, dp_{i, k} + dp_{k+1, j} + i \sim j 的總大小)

想法

Code:

#include <bits/stdc++.h>
using namespace std;
using ll = long long;

const int MAXN = 401;
ll dp[MAXN][MAXN];
ll saves[MAXN][MAXN];

int main()
{
    cin.tie(nullptr)->sync_with_stdio(false);
    memset(dp, 0x3f, sizeof(dp));

    int n;
    cin >> n;

    for (int i=1; i <= n; i++)
        cin >> saves[i][i], dp[i][i] = 0;
    
    for (int i=1; i <= n; i++)
        for (int j=i+1; j <= n; j++)
            saves[i][j] = saves[i][j-1] + saves[j][j];


    for (int l=1; l < n; l++)
        for (int i=1, j = i + l; j <= n ; i++, j++)
            for (int k=i; k < j; k++)
                dp[i][j] = min(dp[i][j], dp[i][k] + dp[k+1][j] + saves[i][j]);

    cout << dp[1][n];
}
# PRESENTING CODE

練習題:

DP

By treeman667

DP

  • 64