淺入淺出 動態規劃
Intro to Dynamic Programming
Arvin Liu
前言
DP爆幹難
所以要好好專心看喔
大概是整個APCS最難的章節
複習遞迴 - N!
Factorial of N
求出 N!
給你一個數字 N,請輸出 N!
那還不簡單?
不就是基礎 for 迴圈嗎?
來想想看有沒有其他寫法!
int factorial(int N) {
int ans = 1;
for (int i=1; i<=N; i++)
ans *= i;
return ans;
}
C++
def factorial(N):
ans = 1
for i in range(1, N):
ans *= i
return ans
Python
求出 N!
給你一個數字 N,請輸出 N!
假設現在有一個函數:
如果不用 "!" 該怎麼寫?
求出 N!
給你一個數字 N,請輸出 N!
直接把它轉成程式?
跑跑看!
int factorial(int N) {
return N * factorial(N-1);
}
def factorial(N):
return N * factorial(N-1)
C++
Python
求出 N!
輸出結果 :
4
3
2
1
0
-1
-2
....
跑不完!
這裡就該停了!
#include <stdio.h>
int factorial(int N) {
printf("%d\n", N);
return N * factorial(N-1);
}
int main() {
printf("%d", factorial(4));
}
def factorial(N):
print(N)
return N * factorial(N-1)
print(factorial(4))
C++
Python
求出 N!
給你一個數字 N,請輸出 N!
這樣就可以了!
int factorial(int N) {
if (N == 0) return 1;
return N * factorial(N-1);
}
def factorial(N):
if N == 0: return 1
return N * factorial(N-1)
C++
Python
遞迴總結
Recursion - Review
怎麼寫遞迴?
-
定義遞迴函式要做什麼? 等於什麼?
- f(n) = n! 的數值
- 觀察如何將大函式用小函式完成?
- f(n) 跟 f(n-1) 有點關係
- 記得寫終止條件 (Base Case)
- f(0) = 1
- 思考的時候,不要從一開始往上推,例如 n=0 怎麼樣,n=1怎麼樣...
- 通常情況下,要逆向思考,習慣倒推。
- 例如 n 要怎麼用n-1表示,或者n-2表示..
記得寫終止條件!
N!遞迴的逐步過程
以 作為舉例
呼叫
呼叫
呼叫
回傳
回傳
回傳
回傳
實際例子可以參考另一個投影片
什麼是動態規劃?
Dynamic Programming
什麼是動態規劃?
就是有技巧的暴搜
技巧? 暴搜?
有技巧的暴搜
怎麼暴力搜尋?
暴搜比較好理解的其中一個方法就是遞迴。
技巧? 別急,我們慢慢來。
為什麼叫做動態規劃?
Dynamic Programming
為什麼叫做動態規劃?
Dynamic Programming
這是一個關於發明者Bellman的有趣故事...
*你在圖論會學到以他為名的演算法
Fibonacci Sequence
爬樓梯問題
東東爬樓梯 (zj d212)
東東爬階梯每次可以走一或兩階。
假設階梯有 n 階,那有幾種走法?
🐦
🐦
3 階有 3 種走法
4 階有 5 種走法
1+1+1+1
1+1+2
1+2+1
2+1+1
2+2
1+1+1
1+2
2+1
有點難?
東東爬樓梯 (zj d212)
- 函式定義:
- 如何拆解:
- Base Case:
f(n) = 走 n 階的可能數
f(n-1) f(n-2)
🐦
...
可以將到第n階的走法分成兩類
-
最後一步走1階的
- 最後一步走1階有幾種可能?
- f(n-1)
-
最後一步走2階的
- f(n-2)
+
東東爬階梯每次可以走一或兩階。
假設階梯有 n 階,那有幾種走法?
東東爬樓梯 (zj d212)
- 函式定義:
- 如何拆解:
- Base Case:
f(n) = 走 n 階的可能數
f(n-1) f(n-2)
+
輸入可能是 1 以後的正整數。
f(1) 可以用遞迴算出嗎?
f(2) 可以用遞迴算出嗎?
f(3) 可以用遞迴算出嗎?
⭕
❌
❌
f(1) = 1, f(2) = 2
東東爬階梯每次可以走一或兩階。
假設階梯有 n 階,那有幾種走法?
東東爬樓梯 (zj d212)
- 函式定義:
- 如何拆解:
- Base Case:
f(n) = 走 n 階的可能數
f(n-1) f(n-2)
+
f(1) = 1, f(2) = 2
東東爬階梯每次可以走一或兩階。
假設階梯有 n 階,那有幾種走法?
int fib(int N) {
if (N == 1) return 1;
if (N == 2) return 2;
return fib(N-1) + fib(N-2);
}
def fib(N):
if N == 1: return 1
if N == 2: return 2
return fib(N-1) + fib(N-2)
C++
Python
爬樓梯問題與費波那契數列
這就是鼎鼎大名的費波那契數列
fibonacci
- f(0) = 1
費波那契數列的遞迴過程
以 作為舉例
2
1
3
2
練習題!
- 如果題目改成東東每次可以走 1, 2, 3 階,遞迴會怎麼寫?
- 你會用迴圈寫這題嗎?
- 你覺得
- 遞迴版本的時間複雜度多少?
- 迴圈版本的時間複雜度多少?
- 如果題目改成東東每次可以走 k 階,遞迴會怎麼寫?
- Naive O(NK) Hint: 遞迴裡面會帶 for
- O(N) Hint: 區間和
組合數
Combination
00369 - Combinations (zj d134)
請問從 N 顆不同的球裡,
有幾種取出 M 顆球的方法?
有 4 顆相異的球
如果取三顆,共 4 種取法
如果取兩顆,共 6 種取法
00369 - Combinations (zj d134)
請問從 N 顆不同的球裡,
有幾種取出 M 顆球的方法?
有 4 顆相異的球
如果取三顆,共 4 種取法
- 函式定義:
- 如何拆解:
- Base Case:
這個也好難....
C(n, m) = N 取 M 的可能數
想想看可不可以跟
爬樓梯一樣拆Case!
00369 - Combinations (zj d134)
請問從 N 顆不同的球裡,
有幾種取出 M 顆球的方法?
有 4 顆相異的球
如果取三顆,共 4 種取法
- 函式定義:
- 如何拆解:
- Base Case:
取了最
後的球
沒取最
後的球
取了 後
需要再從三顆中取兩顆
放棄 後
需要再從三顆中取三顆
C(n, m) = N 取 M 的可能數
❌
00369 - Combinations (zj d134)
請問從 N 顆不同的球裡,
有幾種取出 M 顆球的方法?
有 n 顆相異的球
想要取 m 顆
- 函式定義:
- 如何拆解:
- Base Case:
取了最
後的球
沒取最
後的球
❌
...
還剩m顆要選
還剩m-1顆要選
C(n-1, m)
C(n-1, m-1)
+
C(n, m) = N 取 M 的可能數
放棄 後
需要再從n-1顆中取m顆
取了 後
需要再從n-1顆中取m-1顆
00369 - Combinations (zj d134)
請問從 N 顆不同的球裡,
有幾種取出 M 顆球的方法?
- 函式定義:
- 如何拆解:
- Base Case:
f(N,M) = N 取 M 的可能數
C(n-1, m)
C(n-1, m-1)
+
C(3, 2)
C(2, 1)
C(2, 2)
C(1, 0)
C(1, 1)
- m = 0 的時候,表示不取,答案為1
- m = n 的時候,表示全取,答案為1
- n 跟 m 的範圍是什麼,數值才合理?
- n 跟 m 減到哪裡該停?
想想看 C(3, 2) 的例子吧!
m = 0 或者 n = m 時為1
00369 - Combinations (zj d134)
請問從 N 顆不同的球裡,
有幾種取出 M 顆球的方法?
- 函式定義:
- 如何拆解:
- Base Case:
f(N,M) = N 取 M 的可能數
C(n-1, m)
C(n-1, m-1)
+
m = 0 或者 n = m 時為1
一個看似複雜的題目,遞迴程式卻非常少!
這就是遞迴的魅力之處 (?)
int C(int n, int m){
if (m == 0 || m == n) return 1;
return C(n-1, m-1) + C(n-1, m);
}
def C(n, m):
if m == 0 or m == n: return 1
return C(n-1, m) + C(n-1, m-1)
C++
Python
練習題!
- C(n, m) 其實是有公式的 (題目有給你)。
-
- 使用這個公式如果直接在 C++ 用 for 實作,其實會有些問題在,你知道為什麼嗎?
-
- 如果犧牲一些複雜度,也可以使用公式實作。不過你要怎麼繞過問題呢?
0/1 背包問題
0/1 Knapsack Problem
祖靈好孝順 ˋˇˊ (zj a587)
給你 N 個物品和物品的重量和價值,以及祖靈的背包的重量限制。
請問祖靈最多可以帶價值東西回家?
拿走什麼? | 總重 | 價值 |
---|---|---|
G+C+S | 15kg | $8 |
Y+C+S+B | 8kg | $15 |
... | ... | ... |
C
B
Y
G
S
祖靈好孝順 ˋˇˊ (zj a587)
給你 N 個物品和物品的重量和價值,以及祖靈的背包的重量限制。
請問祖靈最多可以帶價值東西回家?
- 函式定義:
f(n, W) = 前 n 個物品中,
限重 W 的情況下的最大價值
有點複雜... 試試看裸套題目定義
f(2, 5): 如果可以拿0~2號的物品,
背包限重為5,回傳所有可能中的的最大價值。
f(7, 10): 如果可以拿0~7號的物品,
背包限重為10,回傳所有可能中的最大價值。
祖靈好孝順 ˋˇˊ (zj a587)
給你 N 個物品和物品的重量和價值,以及祖靈的背包的重量限制。
請問祖靈最多可以帶價值東西回家?
- 函式定義:
- 如何拆解:
又卡住了 :( ...
其實背包問題跟 C(n,m) 很像!
都是 n 個物品裡面挑幾個東西出來
- C(n, m) 的限制是只能挑 m 個
- 背包問題的限制是挑出來的物品限重不能超過 W
f(n, W) = 前 n 個物品中,
限重 W 的情況下的最大價值
祖靈好孝順 ˋˇˊ (zj a587)
給你 N 個物品和物品的重量和價值,以及祖靈的背包的重量限制。
請問祖靈最多可以帶價值東西回家?
- 函式定義:
- 如何拆解:
又卡住了 :( ...
f(n, W) = 前 n 個物品中,
限重 W 的情況下的最大價值
有 n 顆相異的物品,
限重 W
取了最
後的
沒取最
後的
❌
...
💎
👑
📿
💎
💎
0/1 背包問題
有 n 顆相異的球
想要取 m 顆
取了最
後的球
沒取最
後的球
❌
...
C(n, m) 問題
祖靈好孝順 ˋˇˊ (zj a587)
給你 N 個物品和物品的重量和價值,以及祖靈的背包的重量限制。
請問祖靈最多可以帶價值東西回家?
- 函式定義:
- 如何拆解:
有 n 顆相異的物品,
限重 W
取了最
後的
沒取最
後的
❌
...
💎
👑
📿
💎
💎
放棄第 n 個物品,
當它不存在。
可以裝的重量剩
但多了價值
f(n, W) = 前 n 個物品中,
限重 W 的情況下的最大價值
背包還剩
背包還剩
祖靈好孝順 ˋˇˊ (zj a587)
給你 N 個物品和物品的重量和價值,以及祖靈的背包的重量限制。
請問祖靈最多可以帶價值東西回家?
- 函式定義:
- 如何拆解:
取了最
後的
💎
可以裝的重量剩
但多了價值
f(n, W) = 前 n 個物品中,
限重 W 的情況下的最大價值
背包還剩
你的背包 W
💎
背包剩下 W - w💎
那麼橘色的部分要怎麼放才可以最大化總價值呢?
不知道,所以我們用 f 地回來問。
祖靈好孝順 ˋˇˊ (zj a587)
給你 N 個物品和物品的重量和價值,以及祖靈的背包的重量限制。
請問祖靈最多可以帶價值東西回家?
- 函式定義:
- 如何拆解:
- Base Case :
f(n, W) = 前 n 個物品中,
限重 W 的情況下的最大價值
n 會一直往下減... 考慮第零號物品!
祖靈好孝順 ˋˇˊ (zj a587)
- 函式定義:
- 如何拆解:
- Base Case :
f(n, W) = 前 n 個物品中,限重 W 的情況下的最大價值
int f(int n, int w) {
if (n == 0)
return w >= W[n] ? V[n] : 0;
if (w >= W[n])
return max(
f(n-1, w),
f(n-1, w - W[n]) + V[n]
);
return f(n-1, w);
}
def f(n, w):
if n == 0:
return V[n] if w >= W[n] else 0
if w >= W[n]:
return max(
f(n-1, w),
f(n-1, w-W[n]) + V[n]
);
return f(n-1, w);
C++
Python
思考看看
- Base Case 其實有很多種寫法,可以想想看有沒有其他的寫法
- 假設定義函數 f(n, V) :n個物品裡面選價值為V的最低重量
例如 f(n,10)=3 表示選到價值為10的物品時,重量最低為3 )- 請問 DP 式該怎麼寫?
- 如何使用上題函數定義解決 0/1 背包問題?
- (無限背包問題) 如果每個物品都可以拿無限次,如何改遞迴式?
- (有限背包問題) 如果每個物品有其個數限制,如何改遞迴式?
- 有 Naive -> log 作法 -> 很漂亮的做法
記憶化
Memoization
記憶化 Memoization
剛剛的所有code
你知道嗎?
全都會TLE!
(其實是WA 因為我沒開long long)
DP是有技巧的暴搜
怎麼暴力搜尋?
暴搜比較好理解的其中一個方法就是遞迴。
- 技巧? 同樣的問題不算第二遍
- 這稱為 記憶化 Memoization
記憶化 Memoization
重複算有差嗎?我們以爬樓梯的題目來看
fib(3)一樣
卻會重算!
fib(4)
fib(3)
fib(2)
fib(2)
fib(1)
fib(5)
fib(3)
fib(2)
fib(1)
記憶化 Memoization
如何避免重算?
想像有個函數...
輸入 A
輸入 B
輸入 A
第一次碰到!
算出f(A)
第一次碰到!
算出f(B)
算過 A 了!
拿出之前算過的答案
你必須在第一次碰到的時候記錄起來!
記憶化 Memoization
- 如果沒算過,就算出答案,記錄起來。
- 如果有算過,就直接從記憶裡撈出來。
用一個方法可以記錄 「問題」 -> 「答案」
例如 陣列、unordered_map、dict 等等
long long dp[1000];
long long fib(int n){
if (n == 0 || n == 1)
return 1;
if (!dp[n])
dp[n] = fib(n-1) + fib(n-2);
return dp[n];
}
dp = {}
def fib(n):
if n == 0 or n == 1 :
return 1
if n not in dp:
dp[n] = fib(n-1) + fib(n-2)
return dp[n]
C++
Python
因為 fib 都會 > 0,所以我們可以用
dp[n] 是不是 0 來決定有沒有算過。
記憶化 Memoization
重複算有差嗎?我們以爬樓梯的題目來看
重複計算 (原本的code)
不重複計算 (記憶化)
f(n) = f(n-1) + f(n-2)
"大概"每多一個n,
就會多算一倍
算完 f(n-1) 時,
f(n-2) 已經算過了,不用重算
差超多
記憶化 Memoization
long long dp[1000];
long long f(int n){
if (n == 0 || n == 1)
return 1;
if (!dp[n])
dp[n] = f(n-1) + f(n-2);
return dp[n];
}
來用人腦跑一次看看吧!如果呼叫 f(4)...
n | 0 | 1 | 2 | 3 | 4 |
---|---|---|---|---|---|
f(n) | 1 | 1 | ? | ? | ? |
- | f(1) | f(2) | f(3) | ||
- | f(0) | f(1) | f(2) |
1
1
2
2
1
3
3
2
5
f(2) 不用再重新遞迴一次 f(1) 跟 f(0) 了!
東東爬樓梯 (zj d212) AC code
#include <stdio.h>
long long dp[1000];
long long f(int n){
if (n == 0 || n == 1)
return 1;
if (!dp[n])
dp[n] = f(n-1) + f(n-2);
return dp[n];
}
int main() {
int n;
while(~scanf("%d", &n)) {
printf("%lld\n", f(n));
}
}
00369 - Combinations (zj d134) AC code
#include <stdio.h>
unsigned long long dp[101][101];
unsigned long long C(int n, int m){
if (m == 0 || m == n) return 1;
if (!dp[n][m])
dp[n][m] = C(n-1, m-1) + C(n-1, m);
return dp[n][m];
}
int main() {
int n, m;
while(~scanf("%d%d", &n, &m) && n != 0 && m != 0) {
printf("%d things taken %d at a time is %llu exactly.\n", n, m, C(n, m));
}
}
如果問題有兩個參數呢? 像是 C(n, m) ?
那記憶化表格就開兩維!
那麼時間複雜度呢?
最多有 O(nm) 的狀態,算出每個狀態需要 O(1)
(轉移複雜度)
總複雜度 : O(nm) * O(1) = O(nm)!
祖靈好孝順 ˋˇˊ (zj a587) AC Code
#include <stdio.h>
#include <memory.h>
#include <algorithm>
int dp[101][10001];
int W[101], V[101];
int f(int n, int w) {
if (n == 0)
return 0;
if (!dp[n][w]) {
if (w >= W[n])
dp[n][w] = std::max(f(n-1, w - W[n]) + V[n], f(n-1, w));
else
dp[n][w] = f(n-1, w);
}
return dp[n][w];
}
int main() {
int n, w;
while(~scanf("%d", &n)) {
// 將dp陣列全部清空
memset(dp, 0, sizeof(dp));
for (int i=1; i<=n; i++)
scanf("%d%d", &W[i], &V[i]);
scanf("%d", &w);
printf("%d\n", f(n, w));
}
}
記憶化 Memoization
思考看看
- 請問背包問題 (祖靈好孝順 ˋˇˊ) 經過記憶化後的時間複雜度是多少?
- 我們確信問題的答案都大於零,所以才可以使用 !dp[n] 來表示沒有算過。
那麼如果答案可能是 0 怎麼辦?
- 如果參數的範圍很大,大到無法開陣列,但問題的種類數量很小 (DP為稀疏陣列),那怎麼使用記憶化?
(例如 dp[n] 的 n 有可能超大,但 dp[n] 不為0的值其實沒有那麼多,那要怎麼節省空間?)
DP兩流派
Top-down vs. Bottom-up
DP是有技巧的暴搜
怎麼暴力搜尋?
暴搜比較好理解的其中一個方法就是遞迴。
我遞迴苦手 :(
有沒有不是遞迴的方法?
東東爬樓梯 (zj d212)
東東爬階梯可以一次走一或兩階。
假設階梯有n階,那東東有幾種走法?
第n階答案 = 第n-1階答案 + 第n-2階答案
- 終止條件 / Base case: f(0) = 1, f(1) = 1
所以 ...
- f(2) = f(1) + f(0) = 2
- f(3) = f(2) + f(1) = 3
- f(4) = f(3) + f(2) = 5
好像可以用迴圈寫ㄟ?
東東爬樓梯 (zj d212)
東東爬階梯可以一次走一或兩階。
假設階梯有n階,那東東有幾種走法?
第n階答案 = 第n-1階答案 + 第n-2階答案
- 終止條件 / Base case: f(0) = 1, f(1) = 1
所以 ...
- f(2) = f(1) + f(0) = 2
- f(3) = f(2) + f(1) = 3
- f(4) = f(3) + f(2) = 5
好像可以用迴圈寫ㄟ?
#include <stdio.h>
int main() {
int n;
long long dp[100] = {0, 1};
for (int i=2; i<100; i++) {
dp[i] = dp[i-1] + dp[i-2];
}
while(~scanf("%d", &n)) {
printf("%lld\n", dp[n]);
}
}
Top-down & Buttom-up
DP主要兩大實做方法
Top-down
將大問題切成小問題,再將小問題切到 Base case。
Bottom-up
將 Base case堆成小答案,再慢慢堆成大答案
通常比較直觀。
(遞迴寫出來就結束了)
有可能不太直觀,還需要考慮堆答案的順序。
優化困難。
比較可以優化。
比較慢。(呼叫函數比較慢)
比較快。
Top-down & Buttom-up
Buttom-up 怎麼寫?
#include <stdio.h>
int main() {
int n;
long long dp[100] = {0, 1};
for (int i=2; i<100; i++) {
dp[i] = dp[i-1] + dp[i-2];
}
while(~scanf("%d", &n)) {
printf("%lld\n", dp[n]);
}
}
以東東爬階梯為例子
- 在用迴圈寫DP前,先寫好 base case。(或也可以寫在定義式上)
- 決定迴圈的順序,以及開頭結尾等等...
- 在內部寫上DP的定義式(或我們稱DP轉移)
00369 - Combinations (zj d134)
請問從 N 顆不同的球裡,
有幾種取出 M 顆球的方法?
(假設編號為1...n)
unsigned long long dp[101][101];
unsigned long long C(int n, int m){
if (m == 0 || m == n) return 1;
if (!dp[n][m])
dp[n][m] = C(n-1, m-1) + C(n-1, m);
return dp[n][m];
}
unsigned long long dp[101][101];
for (int n=1; n<101; n++) {
for (int m=0; m<=n; m++) {
if (m == 0 || n == m)
dp[n][m] = 1;
else
dp[n][m] = dp[n-1][m-1] + dp[n-1][m];
}
}
Top-down
Bottom-up
00369 - Combinations (zj d134)
請問從 N 顆不同的球裡,
有幾種取出 M 顆球的方法?
(假設編號為1...n)
unsigned long long dp[101][101];
for (int n=1; n<101; n++) {
for (int m=0; m<=n; m++) {
if (m == 0 || n == m)
dp[n][m] = 1;
else
dp[n][m] = dp[n-1][m-1] + dp[n-1][m];
}
}
思考看看填表格的順序吧!
0
1
2
3
4
1
2
3
4
5
1
1
1
2
1
1
3
3
1
4
6
1
5
10
1
4
1
5
5
1
10
int f(int n, int w) {
if (n == 0)
return 0;
if (!dp[n][w]) {
if (w >= W[n])
dp[n][w] = std::max(f(n-1, w - W[n]) + V[n], f(n-1, w));
else
dp[n][w] = f(n-1, w);
}
return dp[n][w];
}
for (int i=1; i<=n; i++) {
for (int j=0; j<=w; j++) {
if (j >= W[i])
dp[i][j] = std::max(dp[i-1][j-W[i]]+V[i], dp[i-1][j]);
else
dp[i][j] = dp[i-1][j];
}
}
祖靈好孝順 ˋˇˊ (zj a587)
給出 N 個物品和物品的重量和價值。
給出祖靈的背包的重量限制。
請問祖靈最多可以帶價值東西回家?
(假設編號為1...n)
Top-
down
Bottom-
up
* base case寫在memset裡面
祖靈好孝順 ˋˇˊ (zj a587)
給出 N 個物品和物品的重量和價值。
給出祖靈的背包的重量限制。
請問祖靈最多可以帶價值東西回家?
(假設編號為1...n)
接下來我們從bottom-up的0/1背包問題
開始進行優化吧!
滾動法
Rolling Optimization
滾動法 Rolling
我們來觀察一下用 Bottom-up 解決背包問題的時候,DP表的狀態。
編號 | 1 | 2 | 3 | 4 |
---|---|---|---|---|
重量 | 2 | 1 | 3 | 2 |
價值 | 3 | 2 | 4 | 2 |
N \ W | 0 | 1 | 2 | 3 | 4 | 5 |
---|---|---|---|---|---|---|
0 | 0 | 0 | 0 | 0 | 0 | 0 |
1 | 0 | 0 | 3 | 3 | 3 | 3 |
2 | 0 | 2 | 3 | 5 | 5 | 5 |
3 | 0 | 2 | 3 | 5 | 6 | 7 |
4 | 0 | 2 | 3 | 5 | 6 | 7 |
滾動法 Rolling
我們來觀察一下用 Bottom-up 解決背包問題的時候,DP表的狀態。
N \ W | 0 | 1 | 2 | 3 | 4 | 5 |
---|---|---|---|---|---|---|
0 | 0 | 0 | 0 | 0 | 0 | 0 |
1 | 0 | 0 | 3 | 3 | 3 | 3 |
2 | 0 | 2 | 3 | 5 | 5 | 5 |
3 | 0 | 2 | 3 | 5 | 6 | 7 |
4 | 0 | 2 | 3 | 5 | 6 | 7 |
我們每次算第 n 列,只會需要第 n-1 列的答案。
n-2 以前都用不到了,不覺得很浪費嗎?
滾動法 Rolling
如果我們計算某一列,我們只需要他的前一列的答案。
N \ W | 0 | 1 | 2 | 3 | 4 | 5 |
---|---|---|---|---|---|---|
0 | 0 | 0 | 0 | 0 | 0 | 0 |
1 | 0 | 0 | 3 | 3 | 3 | 3 |
0 | 2 | 3 | 5 | 5 | 5 | |
0 | 2 | 3 | 5 | 6 | 7 | |
0 | 2 | 3 | 5 | 6 | 7 |
這個"某一列",可以利用前兩列的空間來存。
0
0
1
我們可以使用 n % 2 來判斷現在是哪一列。
滾動法 Rolling
int pst=0, cur=1;
for (int i=1; i<=n; i++) {
for (int j=0; j<=w; j++) {
if (j >= W[i])
dp[cur][j] = std::max(dp[pst][j-W[i]]+V[i], dp[pst][j]);
else
dp[cur][j] = dp[pst][j];
}
cur = 1-cur;
pst = 1-pst;
}
或者用兩個變數輪替交換。這樣只需要開兩排空間!
x = 1 - x 這個式子:
如果 x == 0,那麼 x = 1。
如果 x == 1,那麼 x = 0。
這樣每一排做完的時候,0/1就會交換。
*也可以用 x ^= 1,你開心就好
思考看看
- 如果第n排的答案需要參照第n-1排以及第n-2排,這使得你必須滾動三排,那麼你該怎麼滾?
-
其實背包問題可以不用滾動,只開一個一維陣列。你有辦法想出來嗎?
-
提示1: dp[n][w] 總會有一個答案是 dp[n-1][w]。
-
提示2: for迴圈的順序非常重要
-
-
在背包問題中,每個物品都可以無限拿,該怎麼做呢?
-
在原本的背包問題是,兩層 for 迴圈是可以交換的。
那麼如果使用滾動法,這兩層還可以交換嗎?
動態規劃小結
動態規劃使用時機
- 問題具有最佳子結構。
講人話就是這個大問題可以透過小問題解決。
- 問題具有重複子問題。
講人話就是解決大問題的時候,
小問題會被問不只一次。
optimal substructure
overlapping subproblems
有這兩種性質的問題,就能動態規劃。
動態規劃使用時機
動態規劃條件: 最佳子結構 以及 重複子問題
給定一個正整數 n ,
請判斷 n 是否為質數
n 是不是質數無法由其他數是不是質數來判定。
沒有最佳子結構。
他不是個 DP 題。
*不過質數篩法好像勉強算個DP XD
動態規劃使用時機
動態規劃條件: 最佳子結構 以及 重複子問題
排序數列
排序大數列可以拆成排序兩個一半的數列,並且合併成一個排序的數列。
有最佳子結構。
但每個區間排序只會處理一次,
沒有重複子問題。
他不是個 DP 題,他是分治(D&C)題。
動態規劃的時間複雜度
如何分析一個動態規劃的時間複雜度?
- 分析有多少「狀態數」,就是你DP表格會怎麼開。
- 東東爬樓梯:O(n)
- C(n, m): O(nm)
- 0/1 背包問題:O(nW) 或者 O(nV)
- 分析一個遞迴內,時間複雜度是多少。這就叫做「轉移複雜度」 (不考慮呼叫遞迴)
- 上面三個問題轉移複雜度都是 O(1)
- 「狀態數」*「轉移複雜度」 = DP 的時間複雜度
- 東東爬樓梯: O(n) * O(1) = O(n)
- C(n, m):O(nm) * O(1) = O(nm)
- 0/1 背包問題:O(nW) 或 O(nV)
動態規劃的種類
- 狀態數量
- 如果狀態數量是 O(n),叫做 1D 狀態
- 如果狀態數量是 O(n^2), O(nm) 等兩個變數相乘,叫做 2D 狀態
-
轉移時間
- 如果轉移複雜度需要 O(1),叫做 0D 轉移
- 如果轉移複雜度是 O(n),叫做 1D 狀態
- 我們會用 「狀態」/「轉移」 描述你的 DP 種類。
- 東東爬樓梯:狀態 1D,轉移 0D,所以是 1D0D
- C(n, m):狀態 2D,轉移 0D,所以是 2D0D
- 背包問題:狀態 2D,轉移 0D,所以是 2D0D
各種排組問題
國小排列組合 (zj h997)
球不同,箱子不同
球相同,箱子不同
球不同,箱子相同
球相同,箱子相同
給定球數 n 跟箱子數 m,盒子可為空,求可能數:
隔板法:n 球 + (m-1) 隔板的排列組合
每個球都可以選不同箱子,所以...
可以參考這個
只能暴搜 DP
只能暴搜 DP
國小排列組合 (zj h997)
給定球數 n 跟箱子數 m,盒子可為空,
求「球不同,箱子相同」可能數:
n = 2,m = 2
ABC | |
---|---|
AB | C |
AC | B |
A | BC |
AB | |
---|---|
A | B |
n = 3,m = 2
國小排列組合 (zj h997)
給定球數 n 跟箱子數 m,盒子可為空,
求「球相同,箱子相同」可能數:
相當於 n 拆成 m 個數字的拆分數:
舉例來說,n = 5,有幾種拆法:
- 5
- 4 + 1
- 3 + 2
- 3 + 1 + 1
- 2 + 2 + 1
- 2 + 1 + 1 + 1
- 1 + 1 + 1 + 1 + 1
國小排列組合 (zj h997)
from functools import cache
@cache
def rec3(n, m):
if n == 1 or m == 1 or n == m:
return 1
if m > n:
return 0
return rec3(n-1, m-1) + (m) * rec3(n-1 ,m)
@cache
def rec4(n, m):
if m > n:
return 0
if n == 1 or m == 1 or n == m:
return 1
return rec4(n-m, m) + rec4(n-1, m-1)
for n in range(1, 10):
for m in range(1, 10):
print(rec4(n, m), end='\t')
print()
c = 0
for m in range(1, 11):
c += rec4(5, m)
print(c)
不同DP狀態的定義?
好的狀態,就會有簡單的轉移。
Critical Mass (zj a388)
Critical Mass (zj a388)
擲了n次硬幣,過程中出現連續3次正面的可能數為何?
嘗試想一下DP定義吧!
嘗試看看無腦定義狀態?
DP[n] = 連續三次正面的可能數
那麼 DP[n] 會包含著這些 case
你該怎麼寫?
n 個硬幣
x 個硬幣
➕
➕
➕
n-x-3 個硬幣
Critical Mass (zj a388)
擲了n次硬幣,過程中出現連續3次正面的可能數為何?
DP[n] = 連續三次正面的可能數
n 個硬幣
x 個硬幣
➕
➕
➕
n-x-3 個硬幣
- x = 0
- x = 1
會被重複算!
不行了...在討論下去沒完沒了,可能還要排容原理
➕
➕
➕
➕
4個硬幣
Critical Mass (zj a388)
擲了n次硬幣,過程中出現連續3次正面的可能數為何?
好像不能無腦定義狀態 :(
DP[n] = 沒有連續三次正面的可能數
n 個硬幣
排組教過你:如果正攻不行,就反攻
➖
n-1次,沒有連續三次正面
➕
➖
n-2次,沒有連續三次正面
➕
➕
➖
n-3次,沒有連續三次正面
➕
➕
➖
Critical Mass (zj a388)
好像不能無腦定義狀態 :(
DP[n] = 沒有連續三次正面的可能數
n 個硬幣
排組教過你:如果正攻不行,就反攻
➖
n-1次,沒有連續三次正面
➕
➖
n-2次,沒有連續三次正面
➕
➕
➖
n-3次,沒有連續三次正面
➕
➕
➖
DP[n-1]
DP[n-2]
DP[n-3]
擲了n次硬幣,過程中出現連續3次正面的可能數為何?
Critical Mass (zj a388)
擲了n次硬幣,過程中出現連續3次正面的可能數為何?
DP[n] = 沒有連續三次正面的可能數
n 個硬幣
➖
n-1次,沒有連續三次正面
➕
➖
n-2次,沒有連續三次正面
➕
➕
➖
n-3次,沒有連續三次正面
➕
➕
➖
DP[n-1]
DP[n-2]
DP[n-3]
答案 = 全部的可能 - 沒有連續三次正面
Critical Mass (zj a388)
擲了n次硬幣,過程中出現連續3次正面的可能數為何?
DP[n] = 沒有連續三次正面的可能數
答案 = 全部的可能 - 沒有連續三次正面
#include <stdio.h>
unsigned long long DP[1000] = {1, 2, 4, 7};
unsigned long long rec(int n) {
if (DP[n])
return DP[n];
return DP[n] = rec(n-1) + rec(n-2) + rec(n-3);
}
int main() {
int n;
while(~scanf("%d", &n) && n != 0) {
printf("%llu\n", (1 << (n)) - rec(n));
}
return 0;
}
Critical Mass (zj a388)
擲了n次硬幣,過程中出現連續3次正面的可能數為何?
好難... 有沒有其他作法...
定義 DP[n][k] =
沒有連續3次,且最後連續 k 個 ➕
n 個硬幣
➖
n-1次,沒有連續三次正面
➕
➖
n-2次,沒有連續三次正面
➕
➕
➖
n-3次,沒有連續三次正面
➕
➕
➖
如果分成 3 個 case 做呢?
Critical Mass (zj a388)
擲了n次硬幣,過程中出現連續3次正面的可能數為何?
定義 DP[n][k] = 沒有連續3次,且最後連續 k 個➕
不管是哪個Case,
加上一個➖都會在這裡。
➖
n-1次,沒有連續三次正面
➕
➖
n-2次,沒有連續三次正面
➕
➕
➖
n-3次,沒有連續三次正面
➕
➕
➖
最後是 ➖,再加上一個 ➕才會在這裡。
最後是➖➕,再加上一個 ➕ 才會在這裡。
DP的狀態定義
決定了你的轉移!
看你覺得怎麼寫最順,
有非常多種方法都可以算出答案。
Critical Mass (zj a388)
擲了n次硬幣,過程中出現連續3次正面的可能數為何?
其實正攻也可以!
DP[n][k][0] = n個硬幣,還沒有出現連續三次➕,最後有連續 k 個 ➕
DP[n][k][1] = n個硬幣,已經出現連續三次➕,最後有連續 k 個 ➕
你覺得這樣子的設計要怎麼轉移呢?
經典 2D/0D DP
DP經典問題
接下來列舉經典DP問題,
來想想看怎麼寫吧!
走格子問題
minimum-path-sum
Minimum Path Sum (leetcode 64)
給你一個二維地圖,從左上開始,
只能往右或往下地走到右下,
請問經過數字總和最小多少?
答案是: 7
Minimum Path Sum (leetcode 64)
給你一個二維地圖,從左上開始,
只能往右或往下地走到右下,
請問經過數字總和最小多少?
- 函式定義:
- 如何拆解:
- Base Case :
裸給:f(grid) = 答案
好像有點不切實際...
你還要切割陣列才可以把大問題變成小問題
答案要求 (0, 0) -> (n, m) 的最小值
直接將 (n, m) 當作是遞迴參數
f(grid, n, m) = 從 0, 0 走到 n, m 的最小值
grid 可傳可不傳。如果不傳要放global
Minimum Path Sum (leetcode 64)
給你一個二維地圖,從左上開始,
只能往右或往下地走到右下,
請問經過數字總和最小多少?
- 函式定義:
- 如何拆解:
- Base Case :
試試看走樓梯的拆法
在眾多 (0, 0) -> (n, m) 的路徑上,
可不可以分成幾類呢?
f(n, m) = 從 0, 0 走到 n, m 的最小值
Minimum Path Sum (leetcode 64)
給你一個二維地圖,從左上開始,
只能往右或往下地走到右下,
請問經過數字總和最小多少?
- 函式定義:
- 如何拆解:
- Base Case :
試試看走樓梯的拆法
在眾多 (0, 0) -> (n, m) 的路徑上,
可不可以分成幾類呢?
f(n, m) = 從 0, 0 走到 n, m 的最小值
0, 0
0, 1
0, 2
1, 0
1, 1
1, 2
2, 0
2, 1
2, 2
(0, 0) -> (n-1, m) -> (n, m)
(0, 0) -> (n, m-1) -> (n, m)
Minimum Path Sum (leetcode 64)
給你一個二維地圖,從左上開始,
只能往右或往下地走到右下,
請問經過數字總和最小多少?
- 函式定義:
- 如何拆解:
- Base Case :
在眾多 (0, 0) -> (n, m) 的路徑上,
可不可以分成幾類呢?
f(n, m) = 從 0, 0 走到 n, m 的最小值
0, 0
0, 1
0, 2
1, 0
1, 1
1, 2
2, 0
2, 1
2, 2
(0, 0) -> (n-1, m) -> (n, m)
(0, 0) -> (n, m-1) -> (n, m)
+ grid[n][m]
+ grid[n][m]
f(n-1, m)
f(n, m-1)
Minimum Path Sum (leetcode 64)
給你一個二維地圖,從左上開始,
只能往右或往下地走到右下,
請問經過數字總和最小多少?
- 函式定義:
- 如何拆解:
- Base Case :
f(n, m) = 從 0, 0 走到 n, m 的最小值
- 我們從右下開始遞迴,所以我們要確保:
- n 必須 >= 0
- m 必須 >= 0
- 一直遞迴下去,到哪就不能再遞迴了?
Minimum Path Sum (leetcode 64)
class Solution {
public:
int DP[200][200] = {0};
int rec(vector<vector<int>>& grid, int n, int m) {
if (n == 0 && m == 0)
return grid[0][0];
if (DP[n][m])
return DP[n][m];
if (n == 0)
DP[n][m] = rec(grid, n, m-1) + grid[n][m];
else if (m == 0)
DP[n][m] = rec(grid, n-1, m) + grid[n][m];
else
DP[n][m] = min(rec(grid, n, m-1), rec(grid, n-1, m)) + grid[n][m];
return DP[n][m];
}
int minPathSum(vector<vector<int>>& grid) {
return rec(grid, grid.size()-1, grid[0].size()-1);
}
};
試試看簡化條件吧!
如果我們在遞迴的時候出界,
並且在出界的時候回傳最大值呢?
Minimum Path Sum (leetcode 64)
class Solution {
public:
int DP[200][200] = {0};
int rec(vector<vector<int>>& grid, int n, int m) {
if (n == 0 && m == 0)
return grid[0][0];
if (n < 0 || m < 0)
return INT_MAX;
if (DP[n][m])
return DP[n][m];
return DP[n][m] = min(rec(grid, n, m-1), rec(grid, n-1, m)) + grid[n][m];
}
int minPathSum(vector<vector<int>>& grid) {
return rec(grid, grid.size()-1, grid[0].size()-1);
}
};
如果寫成 bottom-up 呢?
Minimum Path Sum (leetcode 64)
class Solution {
public:
int minPathSum(vector<vector<int>>& grid) {
vector<vector<int>> DP(grid);
for (int i=0; i<grid.size(); i++) {
for (int j=0; j<grid[0].size(); j++) {
if (i != 0 && j != 0)
DP[i][j] += min(DP[i-1][j], DP[i][j-1]);
else if (i != 0)
DP[i][j] += DP[i-1][j];
else if (j != 0)
DP[i][j] += DP[i][j-1];
}
}
return DP.back().back();
}
};
思考看看
- APCS 其實很喜歡考二維圖走路問題。因為題目概念簡單但頗有變化。
- 如果是左上到右下的路徑總數?
- 如果有格子是 -1 表示不能走,求路徑總數?
- 如果不規定起點跟終點,但只能往右往下,
求路徑最大值? (北市賽) - 如果可以往左往右往下,那麼路徑最大值?
(APCS 勇者修練) - 如果有兩個人從左上走到右下,如果兩個人踩到同一個格子答案只會算一次,求最大值?(北市賽)
- 如果格子皆為正數,從左上走到右下,可以朝四方向走,那麼最小值? (*這題不是DP)
最長共同子序列
Longest Common Subsequence (LCS)
10405 - LCS (zj c001)
什麼是子區間以及子序列?
- 子區間/子陣列 (subarray) :
有序且連續的部分。
- 子序列 (subsequence) :
有序但不一定連續的部分。
以 azbec 來說:
- zbe
- a
- ec
- abc
- ze
- 子區間都是子序列
題目求某字串的最大長度,並且同時是兩個字串的子序列。
給定兩個字串 S 和 T,請問 LCS(S, T) =?
最長共同子序列
10405 - LCS (zj c001)
給定兩個字串 S 和 T,請問 LCS(S, T) =?
範例 1:
a1b2c3d4e
zz1yy2xx3ww4vv
最長共同子序列
範例 2:
abcdgh
aedbhr
這兩個字串的唯一
LCS 為 1234,
因此答案為4。
這兩個字串有兩個 LCS,
分別是 "adh","abh"。但答案都是3。
- 為了方便,這題的LCS(S, T)都指LCS的長度,不是LCS本身
10405 - LCS (zj c001)
怎麼定義DP式子?
遇到不知道怎麼定義狀態的時候,就先這樣寫:
給定兩個字串 S 和 T,請問 LCS(S, T) =?
最長共同子序列
DP [????] = 題目要求的答案
所以在這題上:
DP [????] = LCS(S, T)
- 問號一定要跟 S 和 T 有關
- 希望有順序性,會比較好遞迴
10405 - LCS (zj c001)
怎麼定義DP式子?
給定兩個字串 S 和 T,請問 LCS(S, T) =?
最長共同子序列
那轉移式子呢?
也就是怎麼用小問題解決大問題呢?
好像很不好想也?我們回想一下之前的題目:
- 爬樓梯考量最後一步走 1 還是 2 階
- C(n, m) 考量要不要取最後一顆球 (第 n 顆球)
- 背包問題考量要不要取最後的一個物品
- 找硬幣考量最後一個硬幣是拿哪種硬幣
所以按照慣例我們考量兩個字串的最後一個字。
10405 - LCS (zj c001)
怎麼定義DP式子呢?
那轉移式子呢?
S[i] 跟 T[j] 匹配一定最好!
代表 LCS 尾巴一定是S[i]!
S = ......... X
T = .... X
S[i]
T[j]
T = .... Y
剩下的 LCS 會在哪呢?
在 S[0...i-1] 和 T[0...j-1] 之間
給定兩個字串 S 和 T,請問 LCS(S, T) =?
最長共同子序列
S[i] 不能跟 T[j] 匹配。
S[i] 跟 前面的 T 匹配
T[j] 沒人配,等同沒用。
同理,相反也是。
考量最後一個字
10405 - LCS (zj c001)
怎麼定義DP式子呢?
給定兩個字串 S 和 T,請問 LCS(S, T) =?
最長共同子序列
if
if
Base Case 呢?
考量一直遞迴會到哪個怪地方
10405 - LCS (zj c001)
箭頭表示答案是從哪裡得到的
給定兩個字串 S 和 T,請問 LCS(S, T) =?
最長共同子序列
如果用 Bottom-up 寫表格
就會是這樣。
- 圖中字串編號是從 1 開始數,
所以它的base case 是 i = 0 或 j = 0
10405 - LCS (zj c001)
string S1, S2;
int DP[1001][1001];
int rec(int i,int j){
if(i==-1 || j==-1)
return 0;
if(DP[i][j] != -1)
return DP[i][j];
if(S1[i] == S2[j])
return DP[i][j] = rec(i-1,j-1) + 1;
else
return DP[i][j] = max(rec(i-1,j),rec(i,j-1));
}
int main(){
while(cin>>S1>>S2){
memset(DP, -1, sizeof(DP));
cout << rec(S1.size()-1, S2.size()-1) << endl;
}
}
給定兩個字串 S 和 T,請問 LCS(S, T) =?
最長共同子序列
編輯距離
Edit Distance
1207 - AGTC (zj f507)
給定兩個字串 S 和 T,問 S 和 T 的編輯距離為何?
edit distance
你可以花費 1 個 cost 做以下三種操作
- 刪除任何一個字元
- 修改任何一個字元
- 增加任何一個字元
編輯距離:最少要花多少 cost 才可以讓 S = T?
AGTCTGACGC
AGTAAGTAGGC
3次修改,1次刪除:編輯距離為 4
1207 - AGTC (zj f507)
給定兩個字串 S 和 T,問 S 和 T 的編輯距離為何?
edit distance
同 LCS ,DP 定義就下成:
我們一樣來思考如何從最後一個字轉移吧!
你可以花費 1 個 cost 做以下三種操作
- 刪除任何一個字元
- 修改任何一個字元
1207 - AGTC (zj f507)
給定兩個字串 S 和 T,問 S 和 T 的編輯距離為何?
edit distance
- 刪除任何一個字元
- 修改任何一個字元
S[i] 跟 T[j] 匹配一定最好!
剩下的編輯距離?
在 S[0...i-1] 和 T[0...j-1] 之間
S[i] 不能跟 T[j] 匹配。
考量兩種操作:
修改:把 S[i] 修改成 T[j]
S = ......... X
T = .... X
S[i]
T[j]
T = .... Y
刪除:刪掉 S[i] 或者 刪掉 T[j]
1207 - AGTC (zj f507)
給定兩個字串 S 和 T,問 S 和 T 的編輯距離為何?
edit distance
修改:
刪除:
那麼 Base Case 呢?
S為空,那麼編輯距離就是 |T|。
T為空,那麼編輯距離就是 |S|。
一些練習題
Another LCS (zj a252)
給定三個字串,請問LCS的長度為何?
最常共同子序列
#include <iostream>
#include <algorithm>
#include <cstring>
using namespace std;
string S[3];
int DP[101][101][101];
int rec(int i, int j, int k){
if(i==-1 || j==-1 || k == -1)
return 0;
if(DP[i][j][k] != -1)
return DP[i][j][k];
if(S[0][i] == S[1][j] && S[1][j] == S[2][k])
return DP[i][j][k] = rec(i-1,j-1,k-1) + 1;
else
return DP[i][j][k] = max({rec(i-1,j,k),rec(i,j-1,k),rec(i,j,k-1)});
}
int main(){
while(cin>>S[0]>>S[1]>>S[2]){
memset(DP, -1, sizeof(DP));
cout << rec(S[0].size()-1, S[1].size()-1, S[2].size()-1) << endl;
}
}
00531 - Compromise (zj e682)
給輸出兩個字串的LCS字串為何。
(題目保證答案的 LCS 只有一個)
在DP時記錄這些箭頭,
算完之後由結尾往回走。
( 答案就是這些
斜線箭頭的組成 )
經典 xD/1D DP
找硬幣問題
Change-making Problem
Coin Change (leetcode 322)
給你一個國家的面額種類,
請問最少可以用多少個硬幣湊出 amount?
舉例來說:
-
台灣有 [1, 5, 10, 50, 100, 500, 1000]
- 如果你要找給別人 261 塊你會怎麼做?
1+1+1+1+1+1+1+1+1+1+1+1+1- 100 + 100 + 50 + 10 + 1
- 也就是優先使用大面額
Coin Change (leetcode 322)
但考慮比較奇怪的 case:
-
某個外國有的面額為 [1, 4, 5]
- 如果你要找給別人 8 塊你會怎麼做?
目標 8 塊
5 + (目標 3 塊)
4 + (目標 4 塊)
5 + 1 + 1 + 1
(四個硬幣)
4 + 4
(兩個硬幣)
給你一個國家的面額種類,
請問最少可以用多少個硬幣湊出 amount?
Coin Change (leetcode 322)
總結來說,這題只能爆搜。
也就是你幾乎必須試遍所有可能。
那不是很慢嗎?
給你一個國家的面額種類,
請問最少可以用多少個硬幣湊出 amount?
Coin Change (leetcode 322)
某個外國有的面額為 [1, 4, 5],要找 8 塊
目標 8 塊
怎麼定義DP式子?
最簡單的方法:
DP(???) = 答案
DP(8)
min
1 + DP(3)
1 + DP(4)
1 + DP(7)
5 + (目標 3 塊)
4 + (目標 4 塊)
1 + (目標 7 塊)
那 base case?
給你一個國家的面額種類,
請問最少可以用多少個硬幣湊出 amount?
DP(x) = 湊出 x 的最少硬幣
Coin Change (leetcode 322)
class Solution {
public:
int dp[10001] = {};
int coinChange(vector<int>& coins, int amount) {
// base case
if (amount == 0)
return 0;
// memoization
if (dp[amount])
return dp[amount];
int ans = -1;
for (int coin : coins) {
if (coin <= amount) {
int tmp = coinChange(coins, amount - coin);
// update when
// 1. tmp must has solution (!= -1)
// 2. no answer currently or tmp is better than ans
if (tmp != -1 && (ans == -1 || 1 + tmp < ans))
ans = 1 + tmp;
}
}
return dp[amount] = ans;
}
};
最長遞增子序列 - Part I
Longest Increasing Subsequence (LIS) - O(N^2)
給定一個陣列,問最長的嚴格遞增子序列長度為何?
嚴格遞增: 左邊的數 < 右邊的數
舉例來說:
如果數列為 [10,9,2,5,3,7,101,18]
則其中一個 LIS 為 2 3 7 18,因此答案為 4。
給定一個陣列,問最長的嚴格遞增子序列長度為何?
先來個最簡單的定義式吧
DP[n] = Ary[0...n] 的LIS長度
好像做不出轉移式?
因為左邊的數字 < 右邊的數字,而我們選了 Ary[n] 卻不知道左邊的數字的最佳解多少。
DP[n] = Ary[0...n] 選了第n個數字的LIS的長度
給定一個陣列,問最長的嚴格遞增子序列長度為何?
DP[n] = Ary[0...n] 選了第n個數字的LIS的長度
int rec(int n){
if(DP[n])
return DP[n];
int now = 0;
for(int i=0; i<n; i++){
if(ary[i] < ary[n])
now = max(rec(i), now);
}
return DP[n] = now + 1;
}
Base Case: 如果前面沒人比 Ary[n]還要小,則DP[n] = 1
給定一個陣列,問最長的嚴格遞增子序列長度為何?
嘗試分析看看複雜度吧!
狀態數量 * 轉移複雜度 =
有辦法變得更快嗎?
轉移其實有辦法可以做到 ,但好像有點困難....
二分搜優化
Longest Increasing Subsequence (LIS) - O(NlogN)
最長遞增子序列 - Part II
給定一個陣列,問最長的嚴格遞增子序列長度為何?
試試看其他DP定義吧!
定義 DP[n][i] = 對於Ary[0...n]內,
長度為 i 的 LIS 的最後一個元素 (取最小)
int lengthOfLIS(vector<int>& nums) {
vector<int> DP(nums.size(), INT_MAX);
int ans = (nums.size() != 0);
for (int i=0; i<nums.size(); i++) {
for (int j=ans; j>=0; j--) {
if (j == 0 || DP[j-1] < nums[i]) {
DP[j] = min(DP[j], nums[i]);
ans = max(ans, j+1);
}
}
}
return ans;
}
最後像背包問題壓成一維就可以了。
最後像背包問題壓成一維就可以了。
我們來觀察一下DP表的狀態
輸入:
10
1 7 1 5 3 10 4 2 6 8
===========================
DP表格:
1
1 7
1 7
1 5
1 3
1 3 10
1 3 4
1 2 4
1 2 4 6
1 2 4 6 8
每次只會改到一個數字,
改在比 Ary[n] 還要小的數字的位置的後面。
想想看為甚麼?
int lengthOfLIS(vector<int>& nums) {
vector<int> DP(nums.size(), INT_MAX);
int ans = (nums.size() != 0);
for (int i=0; i<nums.size(); i++) {
int j = lower_bound(DP.begin(), DP.end(), nums[i]) - DP.begin();
DP[j] = nums[i];
ans = max(ans, j+1);
}
return ans;
}
每次只會改到一個數字,
改在比 Ary[n] 還要小的數字的位置的後面。
複雜度: O(nlogn)
=> 通過 lower_bound 查找
思考看看
- 求出其中一個 LIS 該怎麼寫?
- 求出LIS的個數。(leetcode 683)
- LCS 其實可以使用 LIS 來實作,想想看怎麼寫。
- 考慮如果LCS的兩個字串的每個字元都不重複的話
- 那重複的該怎麼做?
區間DP
精緻的DP狀態!
好的狀態,就會有簡單的轉移。
4. 合併成本 (zj m934)
4. 合併成本 (zj m934)
(APCS 2024/1 第四題)
給定一個序列,每次可選兩相鄰數 u,v 合併成 u+v,需要花費 |u-v|。
問合併成一個數字的最小花費?
(APCS 2024/1 第四題)
要DP好像有點困難... 不知道狀態怎麼設計...
不管是哪一種合併方法,
總會有最後一次怎麼合併吧?
就像背包問題總會有最後一個選的物品一樣
給定一個序列,每次可選兩相鄰數 u,v 合併成 u+v,需要花費 |u-v|。
問合併成一個數字的最小花費?
4. 合併成本 (zj m934)
(APCS 2024/1 第四題)
最後一次怎麼合併長甚麼樣呢?
給定一個序列,每次可選兩相鄰數 u,v 合併成 u+v,需要花費 |u-v|。
問合併成一個數字的最小花費?
他們都源自於一個區間
因為只能相鄰才可以合併
我們就可以利用區間來定義狀態
DP[l, r] = 區間 [l, r) 的最小成本
4. 合併成本 (zj m934)
(APCS 2024/1 第四題)
給定一個序列,每次可選兩相鄰數 u,v 合併成 u+v,需要花費 |u-v|。
問合併成一個數字的最小花費?
DP[l, r] = 區間 [l, r) 的最小成本
轉移式呢?
想想看最後一個答案 DP[0, n]
的所有轉移長什麼樣子?
4. 合併成本 (zj m934)
(APCS 2024/1 第四題)
給定一個序列,每次可選兩相鄰數 u,v 合併成 u+v,需要花費 |u-v|。
問合併成一個數字的最小花費?
DP[l, r] = 區間 [l, r) 的最小成本
轉移式呢?
想想看最後一個答案 DP[0, n]
的所有轉移長什麼樣子?
用 for 迴圈決定切割點
如果切割點在 k,那麼...
左邊的數字加總 -
右邊的數字加總 = 這次合併的成本
合併左邊的成本
合併右邊的成本
4. 合併成本 (zj m934)
(APCS 2024/1 第四題)
#include <bits/stdc++.h>
using namespace std;
int ary[100], pre_sum[100], n;
int dp[101][101];
pair<int, int> rec(int l, int r) {
if (l+1 == r) return {0, ary[l]};
int merged = ary[l] + rec(l+1, r).second;
if (dp[l][r]) return {dp[l][r], merged};
dp[l][r] = INT_MAX;
for (int k=l+1; k<r; k++){
auto L = rec(l, k), R = rec(k, r);
dp[l][r] = min(dp[l][r], L.first + R.first + abs(L.second - R.second));
}
return {dp[l][r], merged};
}
int main() {
scanf("%d", &n);
for (int i=0; i<n; i++) {
scanf("%d", &ary[i]);
}
printf("%d\n", rec(0, n).first);
return 0;
}
* 區間和你可以寫個前綴和算,
但我很懶所以直接用遞迴算。
(first = 最小cost, second = 區間和)
4. 合併成本 (zj m934)
結合其他技巧的DP!
有的時候,DP 轉移也會需要其他技術!
Minimum Substring Partition (leetcode 3144)
給一字串 S,問最少能將將 S 分成幾段使得每段字串都是「平衡」的。
我們定義「平衡」表示該字串內每個字的出現次數皆一樣。
無腦下 DP 定義:
轉移呢?
想想看最後一切是在哪裡?
不知道?那就枚舉找!
- 想想看最後一切是在哪裡?
不知道?那就枚舉找!
最後一切,如果平衡
最後一切,如果平衡
最後一切,如果平衡
...
...
最後一切,如果平衡
...
...
- 想想看最後一切是在哪裡?
最後一切,如果 平衡
但是我們要怎麼知道一個子字串是不是平衡的呢?
我們先想想看簡單版的題目吧?
所以轉移式如果用數學寫出來的話就是:
給一字串 S,判斷 S 是否平衡
你可能會這樣寫:
Counting Table
對於每一種字母都跑過一遍,
算出出現的個數。
字母有幾種
有沒有更方便,更快的做法呢?
給一字串 S,判斷 S 是否平衡
Counting Table
A
B
D
C
C
A
D
B
字母 | A | B | C | D |
---|---|---|---|---|
出現次數 |
0
+1
+1
0
+1
+1
0
+1
+1
0
+1
+1
再檢查全部出現過的字母是不是都是同一個次數,就可以了!
有了S字串後,每次再後面多加一個字元,
並且每次都要判斷這個時候是不是平衡的。
每次加,只需要讓 Table 該元素 + 1 ...
那怎麼 O(1) 的檢查呢?
- Hint: 你可以多記兩個變數
- 目前最多出現幾次?
- 目前出現幾種?
最大數字 * 出現的種類 = N
接著我們再更進階一點...
如果每次加,每次重新判斷 ...
給一字串 S,問最少能將將 S 分成幾段使得每段字串都是「平衡」的。
我們定義「平衡」表示該字串內每個字的出現次數皆一樣。
對於 n 來說,我們需要知道 ...
是不是平衡的
這跟之前講的有關係嗎?
知道了 DP 就結束了!
給一字串 S,問最少能將將 S 分成幾段使得每段字串都是「平衡」的。
是不是平衡的
每次再 S 後面多加一個字元,並且每次都要判斷這個時候是不是平衡的。
所以從 S[n] 往回做就可以了!
int minimumSubstringsInPartition(string s) {
vector<int> dp(s.length(), 1001);
dp[0] = 1;
for (int i=1; i<s.length(); i++) {
int table[26] = {0}, uniq = 0;
// 找分界點 (j,從後面往前找)
for (int j=i; j>=0; --j) {
int idx = s[j]-'a';
// 更新頻率表
table[idx]++;
// 判斷是不是多一個種類
uniq += (table[idx] == 1);
// 判斷是不是平衡
if (table[idx]*uniq == i-j+1)
// 因為沒有 dp[-1],所以 j=0 要特判
dp[i] = min(dp[i], j==0 ? 1 : dp[j-1]+1);
}
}
return dp.back();
}
給一字串 S,問最少能將將 S 分成幾段使得每段字串都是「平衡」的。
我們定義「平衡」表示該字串內每個字的出現次數皆一樣。
精緻的DP轉移!
有的時候,轉移也很有技巧!
4. 美食博覽會 (zj g278)
4. 美食博覽會 (zj g278)
(APCS 2021/9 第四題)
給定一個序列以及整數k。
請找出如何選出k個不重疊區間,且各個區間內無重複數字,使得區間覆蓋最廣。
範例測資:
5 1
1 2 1 3 1
10 3
1 7 1 3 1 4 4 2 7 4
答案分別為 3, 8
4. 美食博覽會 (zj g278)
(APCS 2021/9 第四題)
給定一個序列以及整數k。
請找出如何選出k個不重疊區間,且各個區間內無重複數字,使得區間覆蓋最廣。
好像有點困難?
只要完成 k = 1 就可以五級分了!
先想想看 k = 1 怎麼做吧!
也就是選一個最大區間,這個區間沒有重複數字。
4. 美食博覽會 (zj g278)
(APCS 2021/9 第四題)
請找出1個最大的不重疊區間。
想想看 DP 可不可以解決吧!
先來個無腦 DP 定義:
DP[n] = 以 A[n] 結尾的最大區間 (的開頭)
DP[n] =
0
0
0
2
2
3
2
2
4
1
5
1
DP[n]的區間 = DP[n-1]的區間 + A[n],但是要扣掉有A[n]地方!
4. 美食博覽會 (zj g278)
(APCS 2021/9 第四題)
給定一個序列以及整數k。
請找出如何選出k個不重疊區間,且各個區間內無重複數字,使得區間覆蓋最廣。
回來原本題目,我們還是來無腦決定狀態
= 前n個數字中,
選了k個區間覆蓋最多的值
恩... 轉移呢?
好像只要最大化我們最後一個選的區間就好?
那就是 k=1 的 DP!
為什麼?
4. 美食博覽會 (zj g278)
(APCS 2021/9 第四題)
給定一個序列以及整數k。
請找出如何選出k個不重疊區間,且各個區間內無重複數字,使得區間覆蓋最廣。
那 DP[n][k] 呢?
k=1 的 case 我們換個名字 L:
= 前n個數字中,選了k個區間覆蓋最多的值
好像有點複雜...
4. 美食博覽會 (zj g278)
(APCS 2021/9 第四題)
給定一個序列以及整數k。
請找出如何選出k個不重疊區間,且各個區間內無重複數字,使得區間覆蓋最廣。
取了第n個值當答案
不取第n個值當答案
最後一個區間:
好像就是等於
從選了 k-1 個區間推導:
= 前n個數字中,選了k個區間覆蓋最多的值
不吃第n個攤位的解
吃了第n個攤位的最佳解
4. 美食博覽會 (zj g278)
(APCS 2021/9 第四題)
#include <stdio.h>
#include <algorithm>
using namespace std;
#define MAXN 1000005
int prv[MAXN], tmp[MAXN];
int dp[MAXN][21];
int main() {
int n, k, x, ans=0, L=0;
scanf("%d%d", &n, &k);
for (int i=1; i<=n; i++) {
scanf("%d", &x);
prv[x] = tmp[x];
tmp[x] = i;
L = max(L, prv[x]+1);
// 選 1 個區間的解 ~ 選 k 個區間的解
for (int j=1; j<=k; j++) {
dp[i][j] = max(dp[i-1][j], dp[L-1][j-1] + i - L + 1);
ans = max(ans, dp[i][j]);
}
}
printf("%d\n", ans);
return 0;
}
我們只會用到L[n-1],
所以其實不用開一個L陣列。
DP小結
DP 小結
什麼時候使用DP?
- 可以爆搜題目的時候,並且狀態可能會重複。
- 通常都是最佳化答案 (背包問題),或者計算個數。
DP 的流派 ?
- Top-down: 遞迴 + 記憶化 (Memoization)
- Bottom-up: 用迴圈疊出答案
DP 的流程?
- 狀態設計,好的狀態會讓你DP好寫很多。
- 狀態轉移,思考你要怎麼將問題由大變小。
- 如果有,記得寫 Base case
- 考慮優化,有時候轉移複雜度太高,可能有魔法可以優化。
DP 小結
題目名稱 | 題目簡介 |
---|---|
東東爬樓梯 | 一次可以走 1、2 階,走到 n 階的可能數 |
Combination | n 個物品選 m 個的選法 |
0 / 1背包問題 | 每個物品都有價值跟重量,求限定重量下的最高價值選法 |
找硬幣問題 | 給定幣值的種類,用最少的硬幣數量找 n 元 |
最長共同子序列 (LCS) | 問兩個字串的最長共同子序列 |
編輯距離 (edit distance) | 問兩個字串要改或刪幾個字,才可以讓它們相等 |
最長遞增子序列 (LIS) | 問一個陣列的最長遞增子序列 |
Critical Mass | 擲 n 次硬幣,有連續三次正面的可能數 |
合併成本 | 兩相鄰數可以合併,問合併成一個數字的最小成本 |
最小字串切割 | 給一個字串,問最少可以切成幾個皆為平衡的子字串 |
美食博覽會 | 選 k 個不相交的數字不重複區間,使得涵蓋範圍最大 |
我們在這章節上的題目總覽
DP 小結
怎麼設計狀態?
- 大多時候,DP[???] = 題目問的答案。
- 這個 ??? 是什麼,就要靠你想像了。
- 常見的樣式:
- 看到第幾個數字
- 走樓梯問題的 n
- 題目給的限制
- 背包的限重 W
- 美食博覽會的區間個數 k
- 一些狀態,可以分成不同的 Case
- 例如 Critical Mass 的後面有幾個 +
- 看到第幾個數字
- 有時也可以考慮反解:
- 例如 Critical Mass (連續三個+) 的題目
- 有些題目的狀態就真的很 ... 魔幻,這就真的要靠你想像了。
DP 小結
怎麼設計轉移?
- 考慮所有轉移時需要的狀態。
- 你可以劃出枚舉樹,可能會幫你思考。
- 背包問題:考量「限定重量的最大價值」或者「限定價值的最小重量」
- Critical Mass:考量「最後一次連續出現了多少個 + 」。
- 有時候這個考量是需要用迴圈搜尋的,例如合併成本的切割點
- 如果找不到合適的,可以考慮 n -> n-1。
- 例如 LCS,LIS,裴波那契數列。
- 有些轉移式非常的困難 ( 例如 DP 的各種優化,這個就要吃你的想像力跟經驗了 ;( )
DP 小結
怎麼練習DP?
多多練習?- 靠多看題目來培養狀態設計的感覺。
- 可以去 Leetcode 的 DP 找找看!
可以不用寫 code,想想看怎麼寫就好- 可以從 easy,medium 開始練習
- 可以去 Leetcode 的 DP 找找看!
- DP 的路很長很長 ...
- 建議你至少每周都寫個幾題 DP
DP 練習題 - 1
題目名稱 | 來源 | 備註 |
---|---|---|
Min Cost Climbing Stairs | Leetcode 746 | 爬樓梯變形題 |
Triangle | Leetcode 120 | 巴斯卡三角變形題 |
Target Sum | Leetcode 494 | 類似背包的遞迴 |
禮物分配 Equal Subset |
Leetcode 416 Zj d890 |
99年北市賽 背包變形題 |
Unique Paths | Leetcode 62 | 排列組合經典題 |
Unique Paths II | Leetcode 63 | 排列組合經典題 |
burst-balloons | Leetcode 312 | 合併成本類似題 |
DP 練習題 - 2
題目名稱 | 來源 | 備註 |
---|---|---|
Min Path Cost in a Grid | Leetcode 2304 | 2D/1D 裸題 |
Maximal Square | Leetcode 221 | 經典題,轉移很酷 |
Combination Sum IV | Leetcode 377 |
經典題,數字拆分變形 |
DP 練習題 - 3
題目名稱 | 來源 | 備註 |
---|---|---|
House Robber | Leetcode 198 | 經典題 |
House Robber II | Leetcode 213 | 上一題的微變形 |
House Robber III | Leetcode 337 | 樹 DP |
Min Path Cost in a Grid | Leetcode 2304 | 2D/1D 裸題 |
burst-balloons | Leetcode 312 | 合併成本類似題 |
APCS DP 考古題
APCS 幾乎每兩次考一題!
DP 小結
接下來我們來開始更難的DP吧!
沒有,我好懶得教後面的,數學太多了
前綴和 + DP
https://leetcode.com/problems/maximum-value-of-k-coins-from-piles/
位元DP
位元運算
要提到位元DP前,我們要先熟悉位元運算
bit operation
你知道 && 和 & 的差別嗎?
5 & 6 = ?
同理可以應用在 ^, | 上。
另外, ! 的位元運算是 ~
位元運算
bit operation
給定一個數字n,請枚舉所有選法 (2^n)
來個簡單題吧!
這一看不就是遞迴題嗎?
0
1
01
2
02
12
012
3
03
13
013
23
023
123
0123
4
04
14
014
24
024
124
0124
34
034
134
0134
234
0234
1234
01234
#include <stdio.h>
int main() {
int n = 5;
for (int i=0; i<(1<<n); i++) {
for (int j=0; j<n; j++) {
if (i & (1 << j))
printf("%d", j);
}
printf("\n");
}
}
* 1<<n = 2^n
位元運算
bit operation
給定一個數字n,請枚舉所有選法 (2^n)
來個簡單題吧!
這一看不就是遞迴題嗎?
0
1
01
2
02
12
012
3
03
13
013
23
023
123
0123
4
04
14
014
24
024
124
0124
34
034
134
0134
234
0234
1234
01234
00000
10000
10000
11000
10000
10100
11000
11100
10000
10010
10100
10110
11000
11010
11100
11110
10000
10001
10010
10011
10100
10101
10110
10111
11000
11001
11010
11011
11100
11101
11110
11111
位元DP
接著我們來做做看位元DP的題目吧,
單調對列優化
有限間距 LCS (zj b478)
給定兩個字串,並且限制子序列的每個元素間距必須 <= k,那麼請問LCS的長度為何?
Hint: 你可能需要會做固定範圍的2維區間最大值
斜率優化
Convex Hull Optimization / 凸包優化
四邊形優化
Quadrilateral Inequality Optimization
Aliens 優化
Dynamic Programming
By Arvin Liu
Dynamic Programming
Introduction to dynamic programming
- 732