所以要好好專心看喔
DP 是整個APCS最難的章節
Factorial of 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!
直接把它轉成程式?
跑跑看!
int factorial(int N) {
return N * factorial(N-1);
}
def factorial(N):
return N * factorial(N-1)
C++
Python
輸出結果 :
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!
這樣就可以了!
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
以 作為舉例
呼叫
呼叫
呼叫
回傳
回傳
回傳
回傳
實際例子可以參考另一個投影片
Dynamic Programming
技巧? 暴搜?
暴搜比較好理解的其中一個方法就是遞迴。
技巧? 別急,我們慢慢來。
Dynamic Programming
Dynamic Programming
這是一個關於發明者Bellman的有趣故事...
*你在圖論會學到以他為名的演算法
Fibonacci Sequence
東東爬階梯每次可以走一或兩階。
假設階梯有 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
有點難?
f(n) = 走 n 階的可能數
f(n-1) f(n-2)
🐦
...
+
東東爬階梯每次可以走一或兩階。
假設階梯有 n 階,那有幾種走法?
f(n) = 走 n 階的可能數
f(n-1) f(n-2)
+
輸入可能是 1 以後的正整數。
f(1) 可以用遞迴算出嗎?
f(2) 可以用遞迴算出嗎?
f(3) 可以用遞迴算出嗎?
⭕
❌
❌
f(1) = 1, f(2) = 2
東東爬階梯每次可以走一或兩階。
假設階梯有 n 階,那有幾種走法?
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
以 作為舉例
2
1
3
2
Combination
請問從 n 顆不同的球裡,
有幾種取出 m 顆球的方法?
有 4 顆相異的球
如果取三顆,共 4 種取法
如果取兩顆,共 6 種取法
有 4 顆相異的球
如果取三顆,共 4 種取法
這個也好難....
C(n, m) = n 取 m 的可能數
想想看可不可以跟
爬樓梯一樣拆Case!
請問從 n 顆不同的球裡,
有幾種取出 m 顆球的方法?
有 4 顆相異的球
如果取三顆,共 4 種取法
取了最
後的球
沒取最
後的球
取了 後
需要再從三顆中取兩顆
放棄 後
需要再從三顆中取三顆
C(n, m) = n 取 m 的可能數
❌
請問從 n 顆不同的球裡,
有幾種取出 m 顆球的方法?
請問從 n 顆不同的球裡,
有幾種取出 m 顆球的方法?
有 n 顆相異的球
想要取 m 顆
取了最
後的球
沒取最
後的球
❌
...
還剩m顆要選
還剩m-1顆要選
C(n-1, m)
C(n-1, m-1)
+
C(n, m) = n 取 m 的可能數
放棄 後
需要再從n-1顆中取m顆
取了 後
需要再從n-1顆中取m-1顆
C(n-1, m)
C(n-1, m-1)
+
C(3, 2)
C(2, 1)
C(2, 2)
C(1, 0)
C(1, 1)
想想看 C(3, 2) 的例子吧!
m = 0 或者 n = m 時為1
C(n, m) = 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
請問從 n 顆不同的球裡,
有幾種取出 m 顆球的方法?
C(n, m) = n 取 m 的可能數
0/1 Knapsack Problem
給你 N 個物品和物品的重量和價值,以及祖靈的背包的重量限制。
請問祖靈最多可以帶價值東西回家?
拿走什麼? | 總重 | 價值 |
---|---|---|
G+C+S | 15kg | $8 |
Y+C+S+B | 8kg | $15 |
... | ... | ... |
C
B
Y
G
S
給你 N 個物品和物品的重量和價值,以及祖靈的背包的重量限制。
問祖靈可以帶走的最高價值?
f(n, W) = 前 n 個物品中,
限重 W 的情況下的最大價值
有點複雜... 試試看裸套題目定義
f(2, 5): 如果可以拿0~2號的物品,
背包限重為5,回傳所有可能中的最大價值。
f(7, 10): 如果可以拿0~7號的物品,
背包限重為10,回傳所有可能中的最大價值。
又卡住了 :( ...
其實背包問題跟 C(n, m) 很像!
都是 n 個物品裡面挑幾個東西出來
f(n, W) = 前 n 個物品中,
限重 W 的情況下的最大價值
給你 N 個物品和物品的重量和價值,以及祖靈的背包的重量限制。
問祖靈可以帶走的最高價值?
又卡住了 :( ...
f(n, W) = 前 n 個物品中,
限重 W 的情況下的最大價值
有 n 顆相異的物品,
限重 W
取了最
後的
沒取最
後的
❌
...
💎
👑
📿
💎
💎
0/1 背包問題
有 n 顆相異的球
想要取 m 顆
取了最
後的球
沒取最
後的球
❌
...
C(n, m) 問題
給你 N 個物品和物品的重量和價值,以及祖靈的背包的重量限制。
問祖靈可以帶走的最高價值?
有 n 顆相異的物品,
限重 W
取了最
後的
沒取最
後的
❌
...
💎
👑
📿
💎
💎
放棄第 n 個物品,
當它不存在。
可以裝的重量少
但多了價值
f(n, W) = 前 n 個物品中,
限重 W 的情況下的最大價值
背包還剩
背包還剩
給你 N 個物品和物品的重量和價值,以及祖靈的背包的重量限制。
問祖靈可以帶走的最高價值?
取了最
後的
💎
可以裝的重量少
但多了價值
f(n, W) = 前 n 個物品中,
限重 W 的情況下的最大價值
背包還剩
你的背包 W
💎
背包剩下 W - w💎
那麼橘色的部分怎麼放才可以
最大化總價值呢?
不知道,所以我們用 f 遞迴來問。
給你 N 個物品和物品的重量和價值,以及祖靈的背包的重量限制。
問祖靈可以帶走的最高價值?
f(n, W) = 前 n 個物品中,
限重 W 的情況下的最大價值
n 會一直往下減... 考慮第零號物品!
給你 N 個物品和物品的重量和價值,以及祖靈的背包的重量限制。
問祖靈可以帶走的最高價值?
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
Memoization
(其實是WA 因為我沒開long long)
暴搜比較好理解的其中一個方法就是遞迴。
重複算有差嗎?我們以爬樓梯的題目來看
如何避免重算?
想像有個函數...
輸入 A
輸入 B
輸入 A
第一次碰到!
算出f(A)
第一次碰到!
算出f(B)
算過 A 了!
拿出之前算過的答案
你必須在第一次碰到的時候記錄起來!
用一個方法可以記錄 「問題」 -> 「答案」
例如 陣列、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 來決定有沒有算過。
重複算有差嗎?我們以爬樓梯的題目來看
重複計算 (原本的code)
不重複計算 (記憶化)
f(n) = f(n-1) + f(n-2)
"大概"每多一個n,
就會多算一倍
算完 f(n-1) 時,
f(n-2) 已經算過了,不用重算
差超多
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) 了!
#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));
}
}
#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)!
#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));
}
}
from functools import lru_cache
1. 先 import lru_cache
2. 對你的遞迴函式前面加上這行
@lru_cache(None)
那麼你接下來的東西都會被記憶化。
@lru_cache(None)
def C(n, m):
if m == 0 or m == n: return 1
return C(n-1, m) + C(n-1, m-1)
像這樣:
棧溢出 / 堆疊溢出
在函式裡宣告的變數都會在stack空間。 (包括main)
當你呼叫非常非常非常多的函式...
stack
警戒線
超出stack警戒線就會導致程式執行錯誤。
(所以是Runtime Error)
stack
警戒線
extern int main2(void) __asm__ ("main2");
int main2() {
run();
exit(0);
}
int main() {
// 跟heap借256MB
int size = 256 << 20;
char *p = (char *)malloc(size) + size;
__asm__ __volatile__(
"movq %0, %%rsp\n"
"pushq $exit\n"
"jmp main2\n"
:: "r"(p));
}
#include <cstdlib>
Python 在要求 Stack 空間的次數
本身就有限制 (預設1000次)
1. 調開這個限制
import sys
sys.setrecursionlimit(10000000)
但這個時候有可能會 RE,
因為記憶體要求過高。
這個時候你就必須要:
2. 先從小 Case 開始跑,提前記憶化。
這樣子就不會一次遞迴過深了。
2. 先從小 Case 開始跑,提前記憶化。
這樣子就不會一次遞迴過深了。
def fib(N):
if N == 1: return 1
if N == 2: return 2
return fib(N-1) + fib(N-2)
呼叫 f(1000) ? 這樣一次遞迴的深度就會是 1000
以 fib() 做舉例:
但是 ... 如果我們先呼叫 f(500),再呼叫 f(1000)
f(500) 不會繼續遞迴,
因為已經被你記憶化了。
Top-down vs. Bottom-up
暴搜比較好理解的其中一個方法就是遞迴。
我遞迴苦手 :(
有沒有不是遞迴的方法?
東東爬階梯可以一次走一或兩階。
假設階梯有n階,那東東有幾種走法?
所以 ...
好像可以用迴圈寫ㄟ?
東東爬階梯可以一次走一或兩階。
假設階梯有n階,那東東有幾種走法?
所以 ...
好像可以用迴圈寫ㄟ?
#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。
將 Base case 堆成小答案,再將小答案慢慢堆成大答案。
通常比較直觀。
(遞迴寫出來就結束了)
有可能不太直觀,還需要考慮堆答案的順序。
優化困難。
比較可以優化。
比較慢。(呼叫函數比較慢)
比較快。
基本上你可以把 Bottom-up 想成
「小問題全都已經被記憶化」的 Top-down
#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]);
}
}
把 Bottom-up 想成
「小問題全都已經被記憶化」
的 Top-down
請問從 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];
}
}
請問從 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] = 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] = max(dp[i-1][j-W[i]]+V[i], dp[i-1][j]);
else
dp[i][j] = dp[i-1][j];
}
}
給出 N 個物品和物品的重量和價值。
給出祖靈的背包的重量限制。
請問祖靈最多可以帶價值東西回家?
(假設編號為1...n)
* base case寫在memset裡面
編號 | 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 |
怕你沒辦法 Get 到,所以放個表給你 Get 一下
Rolling Optimization
我們來觀察一下用 Bottom-up 解決 0/1 背包問題的時候,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 |
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 以前都用不到了,不覺得很浪費嗎?
我們來觀察一下用 Bottom-up 解決 0/1 背包問題的時候,DP表的狀態。
如果我們計算某一列,我們只需要他的前一列的答案。
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 來判斷現在是哪一列。
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,你開心就好
Rolling Optimization II
我們再觀察一下,當你填某一格答案的時候,你只需要左上角一排的答案。
編號 | 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 |
有沒有一種走訪順序,可以讓空間只開 W 就好呢?
先由右往左,再由上往下。
編號 | 1 | 2 | 3 |
---|---|---|---|
重量 | 2 | 1 | 3 |
價值 | 3 | 2 | 4 |
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 |
不用存,用不到
先由右往左,在由上往下。
你會發現綠色的可以被壓成一排。
那麼陣列會長怎麼樣呢?
先由右往左,在由上往下。
你會發現綠色的可以被壓成一排。
那麼陣列會長怎麼樣呢?
DP[0] | DP[1] | DP[2] | ... | DP[W-1] | DP[W] |
---|---|---|---|---|---|
f(n-1, 0) | f(n-1, 1) | f(n-1, 2) | ... | f(n-1, W-1) | f(n-1, W) |
... |
f(n, W)
f(n, W-1)
f(n, 2)
f(n, 0)
f(n, 1)
#include <iostream>
#include <algorithm>
#include <vector>
using namespace std;
int main() {
int n, w;
while(~scanf("%d", &n)) {
vector<int> W(n), V(n);
for (int i=0; i<n; i++)
scanf("%d%d", &W[i], &V[i]);
scanf("%d", &w);
vector<int> DP(w+1, 0);
for (int i=0; i<n; i++) {
for (int j=w; j>=W[i]; j--) {
DP[j] = max(DP[j], DP[j-W[i]]+V[i]);
}
}
printf("%d\n", *max_element(DP.begin(), DP.end()));
}
return 0;
}
C++
import sys
def main():
def line_generator():
for word in sys.stdin.read().split():
yield int(word)
G = line_generator()
for N in G:
items = [(next(G), next(G)) for _ in range(N)]
W = next(G)
DP = [0] * (W+1)
for w, v in items:
for cur_W in range(W, w-1, -1):
DP[cur_W] = max(DP[cur_W], DP[cur_W - w]+v)
print(max(DP))
main()
Python
背包問題是一個典型的選擇類找最佳解問題。
這就代表著順序是沒有關係的。
你可以改成這樣思考:
加上一個物品後,最佳解可以怎麼轉換?
這樣就不用從
top-down -> bottom-up -> 滾動 這樣思考了。
前 n-1 個東西的最佳解
DP[0] = rec(n-1, 0)
DP[1] = rec(n-1, 1)
DP[2] = rec(n-1, 2)
....
前 n 個東西的最佳解
DP[0] = rec(n, 0)
DP[1] = rec(n, 1)
DP[2] = rec(n, 2)
....
在0/1背包問題中,轉移式子是這樣的:
在無限背包問題中,轉移式子是這樣的:
也就是你算 (n, W) 的時候,你需要兩個東西:
無限背包怎麼壓成一維呢?
DP Answer Recovery
在 0/1 背包問題中...
我們是求出限重 W 的最高價值
但我們卻不知道怎麼取才有這個最高價值。
編號 | 1 | 2 | 3 | 4 |
---|---|---|---|---|
重量 | 2 | 1 | 3 | 2 |
價值 | 3 | 2 | 4 | 2 |
我們根據之前的算法可以得出:
W=5,最高價值為 7。
但是要怎麼選才可以到達7呢?
編號 | 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 |
答案為 DP[4][5],那麼其實你知道 DP[4][5] 是怎麼被算出來的。
觀察一下答案是怎麼被 max 出來的?接著依序往回走
4號
2號
1號
3號
1號
解答選法
// 正常解背包問題
vector<int> W(n+1), V(n+1);
for (int i=1; i<=n; i++)
scanf("%d%d", &W[i], &V[i]);
scanf("%d", &w);
for (int i=1; i<=n; i++) {
for (int j=0; j<=w; j++) {
if (j >= W[i])
DP[i][j] = max(DP[i-1][j], DP[i-1][j-W[i]]+V[i]);
else
DP[i][j] = DP[i-1][j];
}
}
printf("%d\n", DP[n][w]);
// 還原解答
int cur_i = n, cur_j = w;
do {
if (DP[cur_i][cur_j] ==
DP[cur_i-1][cur_j-W[cur_i]] + V[cur_i]) {
printf("Choose %d Item\n", cur_i);
cur_j -= W[cur_i];
}
cur_i -= 1;
} while (cur_i != 0);
C++
Python
DP = [[0] * (W+1) for _ in range(N)]
for i, (w, v) in enumerate(items):
for cur_W in range(W+1):
if cur_W >= w:
DP[i][cur_W] = max(DP[i-1][cur_W], DP[i-1][cur_W-w]+v)
else:
DP[i][cur_W] = DP[i-1][cur_W]
print(DP[-1][-1])
cur_i, cur_j = N-1, W
while cur_i != -1:
w, v = items[cur_i]
if DP[cur_i][cur_j] == DP[cur_i-1][cur_j-w]+v:
print(f"Select Item {cur_i}")
cur_j -= w
cur_i -= 1
以背包問題做舉例:
編號 | 1 | 2 | 3 | 4 |
---|---|---|---|---|
重量 | 2 | 1 | 3 | 2 |
價值 | 3 | 2 | 4 | 2 |
找硬幣問題 Coin Change (leetcode 322)
舉例來說:
給定某個國家的面額種類,
請問最少可以用多少個硬幣湊出 amount?
但考慮比較奇怪的 case:
目標 8 塊
5 + (目標 3 塊)
4 + (目標 4 塊)
5 + 1 + 1 + 1
(四個硬幣)
4 + 4
(兩個硬幣)
給定某個國家的面額種類,
請問最少可以用多少個硬幣湊出 amount?
總結來說,這題只能暴搜。
也就是你幾乎要試遍所有可能。
那不是很慢嗎?
給定某個國家的面額種類,
請問最少可以用多少個硬幣湊出 amount?
某怪國面額為 [1, 4, 5],要找 8 塊
目標 8 塊
min
5 + (目標 3 塊)
4 + (目標 4 塊)
1 + (目標 7 塊)
給定某個國家的面額種類,
請問最少可以用多少個硬幣湊出 amount?
某怪國面額為 [1, 4, 5],要找 8 塊
目標 8 塊
min
5 + (目標 3 塊)
4 + (目標 4 塊)
1 + (目標 7 塊)
給定某個國家的面額種類,
請問最少可以用多少個硬幣湊出 amount?
這樣的確可以解出題目,
不過有沒有其他種看法呢?
其實這題就是多重背包問題。
所以我們嘗試用背包問題的定義法吧!
所以我們嘗試用背包問題的定義!
像背包問題一樣,把面額當成是背包問題的物品!
背包問題怎麼分 Case 的呢?
有 n 顆相異的物品,
限重 W
取了最
後的
沒取最
後的
❌
...
💎
👑
📿
💎
💎
有 n 個面額,
要求 amount
取了最
後的
沒取最
後的面額
❌
...
5
4
1
5
5
for i : 1->n
for x : 0->amount
if (x >= C[i])
DP[i][x] = min(DP[i-1][x], 1+DP[i][x-C[i]])
else
DP[i][x] = DP[i-1][x]
自己試試用滾動優化成空間複雜度 O(amount)!
所以我們嘗試用背包問題的定義!
int dp[10001] = {};
int coinChange(vector<int>& coins, int amount) {
if (amount == 0)
return 0;
if (dp[amount])
return dp[amount];
int ans = -1;
for (int coin : coins) {
if (coin <= amount) {
int res = coinChange(coins, amount - coin);
if (res == -1)
continue;
if (ans == -1 || res + 1 < ans)
ans = res + 1;
}
}
return dp[amount] = ans;
}
C++ : Top-down
int coinChange(vector<int>& coins, int amount) {
vector<int> DP(amount+1, 10001);
DP[0] = 0;
for (int coin : coins)
for (int i=coin; i<=amount; i++)
DP[i] = min(DP[i], DP[i-coin]+1);
return DP[amount] > 10000 ? -1 : DP[amount];
}
C++ : Bottom-up
def coinChange(self, coins, amount):
DP = [0] + [inf] * (amount)
for coin in coins:
for i in range(coin, amount+1):
DP[i] = min(DP[i-coin]+1, DP[i])
return DP[-1] if DP[-1] != inf else -1
Python: Top-down
def __init__(self):
self.DP = {}
def coinChange(self, coins, amount):
if amount == 0:
return 0
if amount not in self.DP:
ans = -1
for coin in coins:
if amount >= coin:
res = self.coinChange(coins, amount-coin)
if res == -1:
continue
if ans == -1 or res + 1 < ans:
ans = res + 1
self.DP[amount] = ans
return self.DP[amount]
Python: Bottom-up
發現了嗎?
明明是同一題,卻有截然不同的 DP 寫法!
有怎麼樣的狀態,就會有相應的轉移
好的狀態就會有好寫的轉移。
如何分析一個動態規劃的時間複雜度?
如何分析一個動態規劃的時間複雜度?
!!!!!!!!!!!!!!!!!!!!!!!!
optimal substructure
overlapping subproblems
有這兩種性質的問題,就能動態規劃。
動態規劃條件: 最佳子結構 以及 重複子問題
給定一個正整數 n ,
請判斷 n 是否為質數
n 是不是質數無法由其他數是不是質數來判定。
沒有最佳子結構。
他不是個 DP 題。
*不過質數篩法好像勉強算個DP XD
動態規劃條件: 最佳子結構 以及 重複子問題
排序數列
排序大數列可以拆成排序兩個一半的數列,並且合併成一個排序的數列。
有最佳子結構。
但每個區間排序只會處理一次,
沒有重複子問題。
他不是個 DP 題,他是分治(D&C)題。
什麼時候使用DP?
DP 的流派 ?
DP 的流程?
題目名稱 | 來源 | 備註 |
---|---|---|
Climbing Stairs | Leetcode 70 | 等於爬樓梯 |
Fibonacci Number | Leetcode 509 | 等於爬樓梯 |
Min Cost Climbing Stairs | Leetcode 746 | 爬樓梯變形題 |
House Robber | Leetcode 198 | 上題,但不能選相鄰 |
House Robber II | Leetcode 213 | 上題,但環狀 |
Min Cost for Tickets | Leetcode 983 | 爬樓梯+二分搜尋 |
Triangle | Leetcode 120 | 巴斯卡三角變形題 |
題目名稱 | 來源 | 備註 |
---|---|---|
Target Sum | Leetcode 494 | 類似背包的遞迴 |
Coin Change II | Leetcode 518 | Coin Change 的可能數 |
禮物分配 Equal Subset |
Zj d890 Leetcode 416 |
99年北市賽 背包變形題 |
Split Array with Same Average | Leetcode 805 | 巴斯卡三角變形題 |
置物櫃分配 | APCS 2018 / 10 - 4 | zj 範圍怪怪的, 直接做 0/1 背包就可以了 |
Partition to K Equal Sum | Leetcode 698 | 可暴搜可背包 |
我們說動態規劃是...
可是可以暴搜的題目 ... 這不是很多嗎?
你答對了,所以 ...
動態規劃的題目五花八門,非常多種
動態規劃的題目五花八門,非常多種
接下來我們會嘗試列舉各種題型,
並且選出其中的經典題。
因為動態規劃很重要,題型也很多,
接下來就感受 DP 的神奇魔力吧!
其實高一的排列組合就有上過!
不信嗎?我們來看看這題
教學影片: Youtube
給定一個有障礙物的二維地圖,
以左上角當起點,以右下角為終點,
並且每次只能往右或往下走,請問有幾種走法?
答案是: 2
1
1
1
1
1
1
2
做法好像真的一樣?
到底什麼是「加法原理」?
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
裸給:f(grid) = 答案
單純給 grid 很難繼續往下拆解。
能不能把更多資訊帶入參數,
讓我們方便做拆解呢?
接下來如何拆解呢?
給定一個有障礙物的二維地圖,
以左上角當起點,以右下角為終點,
並且每次只能往右或往下走,請問有幾種走法?
在走樓梯中,我們將「走到 n 階」
的所有可能,可以拆成兩類
在這題中,在「從 (0, 0) → (n, m) 」
的所有可能,可不可以也拆成兩類呢?
f(n, m) = 從 0, 0 走到 n, m 的走法數
給定一個有障礙物的二維地圖,
以左上角當起點,以右下角為終點,
並且每次只能往右或往下走,請問有幾種走法?
(最後一步是1階還是2階)
0, 0
0, 1
0, 2
1, 0
1, 1
1, 2
2, 0
2, 1
2, 2
f(n, m) = 從 0, 0 走到 n, m 的走法數
f(n-1, m) + f(n, m-1)
0, 0
0, 1
0, 2
1, 0
1, 1
1, 2
2, 0
2, 1
2, 2
等等,這題不是有障礙物嗎?
怎麼寫在 Base Case 呢?
給定一個有障礙物的二維地圖,
以左上角當起點,以右下角為終點,
並且每次只能往右或往下走,請問有幾種走法?
int dp[100][100] = {};
bool visit[100][100] = {};
int f(vector<vector<int>> &grid, int n, int m) {
if (n < 0 || m < 0) return 0;
if (grid[n][m]) return 0;
if (n == 0 && m == 0) return 1;
if (visit[n][m]) return dp[n][m];
visit[n][m] = true;
dp[n][m] = f(grid, n-1, m) + f(grid, n, m-1);
return dp[n][m];
}
int uniquePathsWithObstacles(vector<vector<int>>& grid) {
memset(dp, 0, sizeof(dp));
memset(visit, 0, sizeof(visit));
return f(grid, grid.size()-1, grid[0].size()-1);
}
dp = {}
def f(n, m):
if (n, m) in dp:
return dp[n, m]
if n < 0 or m < 0:
return 0
if n == 0 and m == 0:
return 1
dp[n, m] = f(n-1, m) + f(n, m-1)
return dp[n, m]
for i, row in enumerate(obstacleGrid):
for j, cell in enumerate(row):
if cell:
dp[i, j] = 0
return f(i, j)
C++
Python
可是這跟高中教的方法不一樣啊 ... ?
其實,高中教的方法就是 Bottom-up。
Top-down Solution
Bottom-up Solution
Bottom-up Solution
int n = grid.size(), m = grid[0].size();
vector<vector<int>> dp(n, vector<int> (m));
dp[0][0] = 1;
for (int i=0; i<n; i++) {
for (int j=0; j<m; j++) {
if (grid[i][j])
dp[i][j] = 0;
else {
if (i) dp[i][j] += dp[i-1][j];
if (j) dp[i][j] += dp[i][j-1];
}
}
}
return dp.back().back();
n = len(obstacleGrid)
m = len(obstacleGrid[0])
dp = [[0] * m for _ in range(n)]
dp[0][0] = 0 if obstacleGrid[0][0] else 1
for i, row in enumerate(obstacleGrid):
for j, cell in enumerate(row):
if cell:
continue
if i:
dp[i][j] += dp[i-1][j]
if j:
dp[i][j] += dp[i][j-1]
return dp[-1][-1]
C++
Python
dp = [1] + [0] * (len(obstacleGrid[0]) - 1)
for row in obstacleGrid:
for j, cell in enumerate(row):
if cell:
dp[j] = 0
elif j:
dp[j] += dp[j-1]
return dp[-1]
vector<int> dp(grid[0].size());
dp[0] = 1;
for (auto &row : grid)
for (int j=0; j<grid[0].size(); j++)
if (row[j])
dp[j] = 0;
else if (j)
dp[j] += dp[j-1];
return dp.back();
Bottom-up + 滾動
接下來讓我們看看第一題 - leetcode 64 吧!
給你一個二維地圖,從左上開始,
只能往右或往下地走到右下,
請問經過數字總和最小多少?
答案是: 7
好像跟剛剛類似?
給你一個二維地圖,從左上開始,
只能往右或往下地走到右下,
請問經過數字總和最小多少?
把「從 (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
+ grid[n][m]
f(n-1, m)
f(n, m-1)
+ grid[n][m]
C++
Python
dp = [0] + [math.inf] * len(grid[0])
for row in grid:
for j, cell in enumerate(row):
dp[j] = min(dp[j], dp[j-1]) + cell
return dp[-2]
Bottom-up + 滾動
vector<int> DP(grid[0].size());
for (int i=0; i<grid.size(); i++) {
for (int j=0; j<grid[0].size(); j++) {
if (j == 0)
DP[j] += grid[i][j];
else if (i == 0)
DP[j] = DP[j-1] + grid[i][j];
else
DP[j] = min(DP[j-1], DP[j]) + grid[i][j];
}
}
return DP.back();
像上題一樣,
稍微處理一下就可以寫出很乾淨的程式。
給定一個二維地圖,
1. 你從左上角出發,往右或往下走直到右下角。
2. 接著從右下角出發,往左或往上走直到起點。
你不能經過重複的格子,問路徑加總最大多少?
0 | 3 | 9 |
---|---|---|
2 | 8 | 5 |
5 | 7 | 0 |
舉例來說:
給定一個二維地圖,
1. 你從左上角出發,往右或往下走直到右下角。
2. 接著從右下角出發,往左或往上走直到起點。
你不能經過重複的格子,問路徑加總最大多少?
0 | 3 | 9 |
---|---|---|
2 | 8 | 5 |
5 | 7 | 0 |
好像有點難想狀態?
f(n, m) = 左上走到 (n, m) 的最大值
這樣你不知道
你是要過去還是要回來。
f(n, m, c) = 左上走到 (n, m) 的最大值,
c 代表是不是已經經過右下角。
這樣你會轉移了嗎?
給定一個二維地圖,
1. 你從左上角出發,往右或往下走直到右下角。
2. 接著從右下角出發,往左或往上走直到起點。
你不能經過重複的格子,問路徑加總最大多少?
0 | 3 | 9 |
---|---|---|
2 | 8 | 5 |
5 | 7 | 0 |
好像有點難想狀態?
過去沒有問題 (c=0 的狀況)
你不知道你曾經選了那些路,因此你也沒有足夠資訊轉移
f(n, m, c) = 左上走到 (n, m) 的最大值,
c 代表是不是已經經過右下角。
但回來呢 (c=1 的狀況)?
把走過哪些路記到狀態內嗎? (會TLE)
給定一個二維地圖,
1. 你從左上角出發,往右或往下走直到右下角。
2. 接著從右下角出發,往左或往上走直到起點。
你不能經過重複的格子,問路徑加總最大多少?
0 | 3 | 9 |
---|---|---|
2 | 8 | 5 |
5 | 7 | 0 |
好像有點難想狀態?
換個角度思考,其實我們可以變成
兩條路同時從左上往右下走。
因為兩條路同時走,
你就可以比較輕鬆判斷會不會互撞。
定義狀態:把兩個座標都寫進去。
0 | 3 | 9 |
---|---|---|
2 | 8 | 5 |
5 | 7 | 0 |
(7, 5)
(8, 9)
(8, 8)
(5, 9)
(5, 8)
(不能踩在同一格)
C++
Python
import math
n, m = map(int, input().split())
grid = [list(map(int, input().split())) for _ in range(n)]
dp = {}
def rec(xa, ya, xb, yb):
if xa == xb == ya == yb == 0:
return 0
if xa < 0 or xb < 0 or ya < 0 or yb < 0:
return -math.inf
if (xa, ya) == (xb, yb):
return -math.inf
if (xa, ya, xb, yb) not in dp:
dp[xa, ya, xb, yb] = max(
rec(xa-1, ya, xb-1, yb),
rec(xa-1, ya, xb, yb-1),
rec(xa, ya-1, xb-1, yb),
rec(xa, ya-1, xb, yb-1)
) + grid[xa][ya] + grid[xb][yb]
return dp[xa, ya, xb, yb]
print(rec(n-1, m-2, n-2, m-1))
#include <iostream>
#include <algorithm>
#define INF 100000000
using namespace std;
int n, m, grid[50][50], dp[50][50][50][50];
int rec(int xa, int ya, int xb, int yb) {
if (xa == 0 && ya == 0 && xb == 0 && yb == 0)
return 0;
if (xa < 0 || ya < 0 || xb < 0 || yb < 0)
return -INF;
if (xa == xb && ya == yb)
return -INF;
if (dp[xa][ya][xb][yb])
return dp[xa][ya][xb][yb];
return dp[xa][ya][xb][yb] =
grid[xa][ya] + grid[xb][yb] + max({
rec(xa-1, ya, xb-1, yb),
rec(xa-1, ya, xb, yb-1),
rec(xa, ya-1, xb-1, yb),
rec(xa, ya-1, xb, yb-1)
});
}
int main() {
scanf("%d%d", &n, &m);
for (int i=0; i<n; i++) {
for (int j=0; j<m; j++) {
scanf("%d",&grid[i][j]);
}
}
printf("%d\n", rec(n-1, m-2, n-2, m-1));
return 0;
}
Python 做狀態壓縮的 Top-down 解
import math
n, m = map(int, input().split())
grid = [list(map(int, input().split())) for _ in range(n)]
dp = {}
def rec(xa, ya, dis_ab):
xb, yb = xa - dis_ab, ya + dis_ab
if xa == xb == ya == yb == 0:
return 0
if xa < 0 or xb < 0 or ya < 0 or yb < 0:
return -math.inf
if (xa, ya) == (xb, yb):
return -math.inf
if (xa, ya, xb, yb) not in dp:
dp[xa, ya, xb, yb] = max(
rec(xa-1, ya, dis_ab),
rec(xa-1, ya, dis_ab-1),
rec(xa, ya-1, dis_ab),
rec(xa, ya-1, dis_ab+1)
) + grid[xa][ya] + grid[xb][yb]
return dp[xa, ya, xb, yb]
print(rec(n-1, m-2, 1))
什麼是子區間以及子序列?
以 azbec 來說:
我們先了解一下專有名詞:
題目大概:
Longest Common Subsequence (LCS)
給定兩個字串 S 和 T,請問 LCS(S, T) 的長度?
範例 1:
a1b2c3d4e
zz1yy2xx3ww4vv
最長共同子序列
範例 2:
abcdgh
aedbhr
這兩個字串的唯一
LCS 為 1234,
因此答案為4。
這兩個字串有兩個 LCS,
分別是 "adh","abh"。但答案都是3。
給定兩個字串 S 和 T,請問 LCS(S, T) 的長度?
最長共同子序列
範例 1:
a1b2c3d4e
zz1yy2xx3ww4vv
範例 2:
abcdgh
aedbhr
試試看題目裸定義?
不太行 ... 沒法拆解。
跟二維地圖只傳整個grid一樣,
等,當時怎麼定義才可以拆解的?
當時我們是用座標當成 DP 的狀態。
那這題怎樣定義才可以拆解呢?
給定兩個字串 S 和 T,請問 LCS(S, T) 的長度?
最長共同子序列
範例 1:
a1b2c3d4e
zz1yy2xx3ww4vv
範例 2:
abcdgh
aedbhr
那這題怎樣定義才可以拆解呢?
用一個變數 i 表示 S 的前 i + 1 個字元
S = "a1b2c3d4e"
但是我們有兩個字串?
那轉移式子呢?
也就是怎麼用小問題解決大問題呢?
有點難?回想一下之前的題目:
所以按照慣例我們考量兩個字串的最後一個字。
最長共同子序列
給定兩個字串 S 和 T,請問 LCS(S, T) 的長度?
轉移式子:
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[i] 不能跟 T[j] 匹配。
S[i] 跟 前面的 T 匹配
T[j] 沒人配,等同沒用。
同理,相反也是。
考量最後一個字
給定兩個字串 S 和 T,請問 LCS(S, T) 的長度?
最長共同子序列
if
if
最後一步:Base Case 呢?
一直遞迴下去,直到哪裡會沒有意義或有答案?
給定兩個字串 S 和 T,請問 LCS(S, T) 的長度?
箭頭表示答案是從哪裡得到的
最長共同子序列
如果用 Bottom-up 寫表格
就會是這樣。
註: 圖中字串編號是從 1 開始數,
所以它的base case 是 i = 0 或 j = 0
給定兩個字串 S 和 T,請問 LCS(S, T) 的長度?
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
給定兩個字串 S 和 T,問 S 和 T 的編輯距離為何?
edit distance
你可以花費 1 個 cost 做以下三種操作
編輯距離:最少要花多少 cost 才可以讓 S = T?
AGTCTGACGC
AGTAAGTAGGC
3次修改,1次刪除:編輯距離為 4
給定兩個字串 S 和 T,問 S 和 T 的編輯距離為何?
edit distance
同 LCS ,DP 定義就下成:
我們一樣來思考如何從最後一個字轉移吧!
你可以花費 1 個 cost 做以下三種操作
給定兩個字串 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]
給定兩個字串 S 和 T,問 S 和 T 的編輯距離為何?
edit distance
修改:
刪除:
那麼 Base Case 呢?
S為空,那麼編輯距離就是 |T|。
T為空,那麼編輯距離就是 |S|。
Constrained Subsequence Sum (leetcode 1425)
初探 Sliding Window / 單調對列優化
A = [10, -2, -10, -5, 20], k = 2
舉例來說:
[10, -2, -10, -5, 20]: 10
[10, -2, -10, -5, 20]: 20
[10, -2, -10, -5, 20]: 20
[10, -2, -10, -5, 20]: 23
最佳解:23。
給定一個數列,請選出一個子序列,使其總和最大。但選法有條件:你選的數字彼此必須距離 k 以下。
給定一個數列,請選出一個子序列,使其總和最大。但選法有條件:你選的數字彼此必須距離 k 以下。
我們先按照LCS的定義試試看?
好像做不出轉移式?
我們缺乏一個重要的資訊:
答案選擇的子序列到底斷在哪?
不知道的話就無法接續。但如果這樣...
你至少就有足夠的資訊可以轉移了!
給定一個數列,請選出一個子序列,使其總和最大。但選法有條件:你選的數字彼此必須距離 k 以下。
像之前題目一樣,A[n] 的上一步 (上一個數字)在哪?
10, -2, -10, -5
10
A[0, n-1]
A[n]
k ≤ 1
k ≤ 2
k ≤ 3
k ≤ 4
以 A[n] 為結尾最佳解
= 以A[i] 為結尾的最佳解 + An
給定一個數列,請選出一個子序列,使其總和最大。但選法有條件:你選的數字彼此必須距離 k 以下。
DP = []
for i in range(len(nums)):
DP.append(max([0] + DP[max(0, i-k):i]) + nums[i])
return max(DP)
vector<int> DP({0});
for (int i=0; i<nums.size(); i++) {
int v = *max_element(DP.end()-min(i+1, k), DP.end());
DP.push_back(max(0, v) + nums[i]);
}
return *max_element(DP.begin()+1, DP.end());
C++
Python
如果前面太差,你可以從 An 開始選:
給定一個數列,請選出一個子序列,使其總和最大。但選法有條件:你選的數字彼此必須距離 k 以下。
分析看看複雜度吧!
狀態數量 × 轉移複雜度 =
狀態應該是不能再壓了,那麼轉移可以更快嗎?
給定一個數列,請選出一個子序列,使其總和最大。但選法有條件:你選的數字彼此必須距離 k 以下。
對於 An,要找到前面 k 個中最大 DP 值。
觀察一下?
給定一個數列,請選出一個子序列,使其總和最大。但選法有條件:你選的數字彼此必須距離 k 以下。
對於 An,要找到前面 k 個中最大 DP 值。
只要想辦法維護單調性,應該就可以很快地轉移!
* 單調性 (monotone): 一個序列是遞增或遞減。
Container
?
Deque
註:因為你要檢查 a,所以你放入 deque 時至少要放兩個:DP值 和 索引值
A = [10, -2, -10, -5, 20], k = 2
Deque
過期
過期
刪掉
刪掉
Deque的頭
int constrainedSubsetSum(vector<int>& nums, int k) {
deque<pair<int, int>> Q;
int ans = nums[0];
for (int i=0; i<nums.size(); i++) {
// Step 1: DP_i = max(0, DP_a)+ A_n
int cur_v = max(0, Q.empty() ? 0 : Q.front().first);
cur_v += nums[i];
// Step 2: Remove tail for monotone
while (!Q.empty() && Q.back().first <= cur_v)
Q.pop_back();
Q.push_back({cur_v, i});
// Step 3: Check range for next transition
if (Q.front().second == i-k)
Q.pop_front();
ans = max(ans, cur_v);
}
return ans;
}
C++
def constrainedSubsetSum(self, nums, k):
ans = nums[0]
Q = deque()
for i in range(len(nums)):
# Step 1: DP_i = max(0, DP_a)+ A_n
cur_v = max(0, Q[0][0] if Q else 0)
cur_v += nums[i]
# Step 2: Remove tail for monotone
while Q and Q[-1][0] <= cur_v:
Q.pop()
# Step 3: Check range for next transition
Q.append((cur_v, i))
if Q[0][1] == i - k:
Q.popleft()
ans = max(ans, cur_v)
return ans
Python
給定一個數列,請選出一個子序列,使其總和最大。但選法有條件:你選的數字彼此必須距離 k 以下。
分析看看複雜度吧!
狀態數量 × 轉移複雜度 =
這種利用 Deque 維護單調性的技巧,叫做
Monotonic Queue
單調對列
而上述的題目是單調對列的其中一個特例,
所要求的 DP 範圍會一直往右移,這類題目叫做:
Sliding Window
滑動視窗
恭喜你學會了最簡單的DP優化,單調對列優化!
等認真上優化的時候,
會再統整一遍,所以別急。
等我真的寫到那
Longest Increasing Subsequence (LIS)
Part I - 從 1D1D 開始二分搜優化
給定一個陣列,問最長的嚴格遞增子序列長度為何?
嚴格遞增: 左邊的數 < 右邊的數
舉例來說:
如果數列為 [10,9,2,5,3,7,101,18]
則其中一個 LIS 為 2 3 7 18,因此答案為 4。
Longest Increasing Subsequence (LIS)
非嚴格遞增是 左邊的數 ≤ 右邊的數
我們先按照LCS的定義試試看?
好像做不出轉移式?
我們缺乏一個重要的資訊:
之前的 LIS 最後一個數字是多少?
不知道的話就無法接續。但如果這樣...
函式定義:
你至少就有足夠的資訊可以轉移了!
給定一個陣列,問最長的嚴格遞增子序列長度為何?
Longest Increasing Subsequence (LIS)
給定一個陣列,問最長的嚴格遞增子序列長度為何?
Longest Increasing Subsequence (LIS)
像之前題目一樣,A[n] 的上一步 (上一個數字)在哪?
上一步可以是前面的數字 i,只要這個數字 Ai < An
那麼以 A[n] 為結尾的 LIS 長度 ... = 以A[i] 為結尾的 LIS 長度 + 1
2, 6, 1, 7, 2
3
A[0, n-1]
A[n]
上一步可以是...
函式定義:
如何拆解:
int dp[2505] = {};
int f(vector<int> &nums, int n) {
if(dp[n])
return dp[n];
int now = 0;
for(int i=0; i<n; i++)
if(nums[i] < nums[n])
now = max(now, f(nums, i));
return dp[n] = now+1;
}
int lengthOfLIS(vector<int>& nums) {
int ans = 1;
for (int i=0; i<nums.size(); i++)
ans = max(ans, f(nums, i));
return ans;
}
給定一個陣列,問最長的嚴格遞增子序列長度為何?
Longest Increasing Subsequence (LIS)
函式定義:
如何拆解:
Base Case:
C++
dp = {}
def rec(n):
ans = 0
if n in dp:
return dp[n]
for i in range(n):
if nums[i] < nums[n]:
ans = max(ans, rec(i))
dp[n] = ans + 1
return ans + 1
return max(rec(i) for i in range(len(nums)))
Python
給定一個陣列,問最長的嚴格遞增子序列長度為何?
Longest Increasing Subsequence (LIS)
分析看看複雜度吧!
狀態數量 × 轉移複雜度 =
狀態應該是不能再壓了,那麼轉移可以更快嗎?
n 從 0 跑到 n-1。
對於 An,要找到比 An 還要小的數字們中,存的最大數字。
有什麼資料結構可以處理呢?
其實可以套線段樹但...對
函式定義:
如何拆解:
Base Case:
給定一個陣列,問最長的嚴格遞增子序列長度為何?
Longest Increasing Subsequence (LIS)
對於 An,要找到比 An 還要小的數字們中,存的最大數字。
Container
範例:A = [2, 6, 1, 7, 2, 3]
尋找 & 加入
符合條件
最大值
等等,哪裡怪怪的?
根本不用存所有紀錄在Container,
因為有些根本不可能是答案。
因為它們有絕對更優的選項。
給定一個陣列,問最長的嚴格遞增子序列長度為何?
Longest Increasing Subsequence (LIS)
對於 An,要找到比 An 還要小的數字們中,存的最大數字。
Container
重頭來一遍:
因為 1 比 2 小但 DP 卻一樣。
因為 2 比 6 小但 DP 卻一樣。
範例:A = [2, 6, 1, 7, 2, 3]
因為 3 比 7 小但 DP 卻一樣。
給定一個陣列,問最長的嚴格遞增子序列長度為何?
Longest Increasing Subsequence (LIS)
對於 An,要找到比 An 還要小的數字們中,存的最大數字。
想一下:
Container 內的數字:數值越大,DP值越大。
(具有單調性 / 單調嚴格遞增)
Container
Container
範例:A = [2, 6, 1, 7, 2, 3]
給定一個陣列,問最長的嚴格遞增子序列長度為何?
Longest Increasing Subsequence (LIS)
對於 An,要找到比 An 還要小的數字們中,存的最大數字。
再重新想一下:
Container
lower_bound 查找
範例:A = [2, 6, 1, 7, 2, 3]
int lengthOfLIS(vector<int>& nums) {
map<int, int> M;
M[INT_MIN] = 0;
int ans = 0;
for (auto num : nums) {
auto it = M.lower_bound(num);
int v = 1 + prev(it)->second;
ans = max(ans, v);
if (it != M.end())
M.erase(it);
M[num] = v;
}
return ans;
}
C++
from sortedcontainers import SortedDict
def lengthOfLIS(self, nums: List[int]) -> int:
d = SortedDict({-math.inf: 0})
for num in nums:
idx = d.bisect_left(num)
if idx != len(d):
del d.iloc[idx]
d[num] = d[d.iloc[idx-1]] + 1
return max(d.values())
Python
給定一個陣列,問最長的嚴格遞增子序列長度為何?
Longest Increasing Subsequence (LIS)
註: Sorted系列不是內建函式庫,
所以比賽/檢定 "應該是" 不支援。
怎麼辦?你可能要換寫法或自己建平衡樹 ;(
函式定義:
如何拆解:
Base Case:
給定一個陣列,問最長的嚴格遞增子序列長度為何?
Longest Increasing Subsequence (LIS)
分析看看複雜度吧!
狀態數量 × 轉移複雜度 =
好耶! 把一個 N 壓成 log!
這裡你就看到 Bottom-up 的優點:
可以進行 Top-down 做不到的轉移優化
函式定義:
如何拆解:
Base Case:
Longest Increasing Subsequence (LIS)
Part II - 從 2D0D 開始二分搜優化
給定一個陣列,問最長的嚴格遞增子序列長度為何?
Longest Increasing Subsequence (LIS)
對於 An,要找到比 An 還要小的數字們中,存的最大數字。
Container
範例:A = [2, 6, 1, 7, 2, 3]
有什麼必要一定要 map / SortedDict 存嗎?
這個 value 不就把它當成
是 vector / list 的 index
不就好了嗎?
那麼原本在 map / SortedDict 的 lower_bound,
現在就在 vector / list 上
做 lower_bound 就好了!
這樣就不用開複雜資料結構了!
int lengthOfLIS(vector<int>& nums) {
map<int, int> M;
M[INT_MIN] = 0;
int ans = 0;
for (auto num : nums) {
auto it = M.lower_bound(num);
int v = 1 + prev(it)->second;
ans = max(ans, v);
if (it != M.end())
M.erase(it);
M[num] = v;
}
return ans;
}
C++ / map + lb
def lengthOfLIS(self, nums):
d = SortedDict({-math.inf: 0})
for num in nums:
idx = d.bisect_left(num)
if idx != len(d):
del d.iloc[idx]
d[num] = d[d.iloc[idx-1]] + 1
return max(d.values())
Python / SortedDict + lb
int lengthOfLIS(vector<int>& nums) {
vector<int> DP;
DP.push_back(INT_MIN);
int ans = 0;
for (auto num : nums) {
auto it = lower_bound(DP.begin(), DP.end(), num);
int v = prev(it) - DP.begin() + 1;
ans = max(ans, v);
if (it == DP.end())
DP.push_back(num);
else
*it = num;
}
return ans;
}
C++ / vector + lb
def lengthOfLIS(self, nums):
DP = [-math.inf]
for num in nums:
idx = bisect_left(DP, num)
if idx == len(DP):
DP.append(num)
else:
DP[idx] = num
return len(DP) - 1
Python / list + lb
其實 it - DP.begin() 就好。
給定一個陣列,問最長的嚴格遞增子序列長度為何?
Longest Increasing Subsequence (LIS)
這推導鍊有點太長了...
不是說有好的狀態就有好的轉移嗎?
有沒有其他狀態呢?
我們試試看:
這種狀態定義很怪,
但其實很常出現。
只能夠熟記這類題型了。
函式定義:
如何拆解:
Base Case:
給定一個陣列,問最長的嚴格遞增子序列長度為何?
Longest Increasing Subsequence (LIS)
函式定義:
如何拆解:
舉例來說,如果 A = [2, 6, 1, 7, 2, 3]
轉移呢?考量取 An 或不取 An
給定一個陣列,問最長的嚴格遞增子序列長度為何?
Longest Increasing Subsequence (LIS)
函式定義:
如何拆解:
int lengthOfLIS(vector<int>& nums) {
vector<int> DP(nums.size()+1, INT_MAX);
DP[0] = INT_MIN;
int ans = 0;
for (auto num : nums) {
for (int i = nums.size(); i >= 1; i--) {
if (DP[i - 1] < num) {
DP[i] = min(DP[i], num);
ans = max(ans, i);
}
}
}
return ans;
}
def lengthOfLIS(self, nums):
DP = [-math.inf] + [math.inf] * len(nums)
ans = 0
for num in nums:
for i in range(len(nums), 0, -1):
if DP[i-1] < num:
DP[i] = min(DP[i], num)
ans = max(ans, i)
return ans
C++
Python
這裡我先用滾動壓成一維了。
給定一個陣列,問最長的嚴格遞增子序列長度為何?
Longest Increasing Subsequence (LIS)
函式定義:
如何拆解:
分析看看複雜度吧!
狀態數量 × 轉移複雜度 =
你在逗我?不是一樣是 N^2?
我們來觀察一下...
輸入:
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
DP 表格一定會是嚴格遞增序列。
你一定最多只會改變其中一個數字。 (因為如果你改兩個就一定不會是嚴格遞增。)
那一個數字在哪裡?用 lb 去找。
給定一個陣列,問最長的嚴格遞增子序列長度為何?
Longest Increasing Subsequence (LIS)
函式定義:
如何拆解:
我們來觀察一下...
int lengthOfLIS(vector<int>& nums) {
vector<int> DP(nums.size()+1, INT_MAX);
DP[0] = INT_MIN;
int ans = 0;
for (auto num : nums) {
for (int i = nums.size(); i >= 1; i--) {
if (DP[i - 1] < num) {
DP[i] = min(DP[i], num);
ans = max(ans, i);
}
}
}
return ans;
}
C++ / 2D0D + 滾動
def lengthOfLIS(self, nums):
DP = [-math.inf] + [math.inf] * len(nums)
ans = 0
for num in nums:
for i in range(len(nums), 0, -1):
if DP[i-1] < num:
DP[i] = min(DP[i], num)
ans = max(ans, i)
return ans
Python / 2D0D + 滾動
int lengthOfLIS(vector<int>& nums) {
vector<int> DP(nums.size()+1, INT_MAX);
DP[0] = INT_MIN;
int ans = 0;
for (auto num : nums) {
auto it = lower_bound(DP.begin(), DP.end(), num);
int v = prev(it) - DP.begin() + 1;
ans = max(ans, v);
*it = num;
}
return ans;
}
C++ / 2D0D + 滾動 + lb
def lengthOfLIS(self, nums):
DP = [-math.inf] + [math.inf] * len(nums)
ans = 0
for num in nums:
idx = bisect_left(DP, num)
DP[idx] = num
ans = max(ans, idx)
return ans
Python / 2D0D + 滾動 + lb
很有趣的是,你會得到跟原本的推法接近一樣Code
這類型的題目通常:
(interval / subarray)
因此這類題型的轉移通常都是 O(n) 找分割點
或者想辦法把分割點內化成狀態後 O(1) 轉移。
先一個小提醒:
所以你會發現序列有關的 DP,
常見的狀態定義就那幾種
那怎麼判斷用什麼會比較好寫?
直觀感受...
Kadane's Algorithm
給定一個數列 A,請輸出子區間和最大為多少。
這不是 Greedy 講過嗎?
舉例來說:
我們來從 DP 的角度來思考!
給定一個數列 A,請輸出子區間和最大為多少。
你應該能夠知道怎麼定義了!
✅
一定
要選
...
你有好多的選擇!最佳解怎麼算?
好像都跟 A_{n-1} 有關 ...?
給定一個數列 A,請輸出子區間和最大為多少。
int maxSubArray(vector<int>& nums) {
int cur_DP = 0, ans = nums[0];
for (auto num : nums) {
cur_DP = max(cur_DP, 0) + num;
ans = max(ans, cur_DP);
}
return ans;
}
def maxSubArray(self, nums):
cur_DP, ans = 0, nums[0]
for num in nums:
cur_DP = max(cur_DP, 0) + num
ans = max(ans, cur_DP)
return ans
C++
Python
是不是覺得太簡單了?我們來加點難度 :)
這個演算法就叫做 Kadane's Algorithm
舉例來說:
A = [1, 2, 1, 3, 1], k = 1
答案為 3
A = [1, 7, 1, 3, 1, 4, 4, 2, 7, 4], k = 3
答案為 8
給定一個序列以及整數 k。
請找出如何選出 k 個不重疊區間,且各個區間內無重複數字,使選到的數字最多。
只要完成 k = 1 就可以五級分了!
先想想看 k = 1 怎麼做吧!
給定一個序列以及整數 k = 1。
請找出如何選出 k 個不重疊區間,且各個區間內無重複數字,使選到的數字最多。
k=1 也可以雙指針
(爬行法) 解掉
好像有點麻煩 ...
如果選了第 n 天,那麼選的區間最遠只能到哪?
✅
一定選A_n
上次 A_n 這個數字出現的位置
給定一個序列以及整數 k = 1。
請找出如何選出 k 個不重疊區間,且各個區間內無重複數字,使選到的數字最多。
k=1 也可以雙指針
(爬行法) 解掉
這樣考慮不夠完全?
萬一出現 (4, 2, 2, 4) 的 Case ?
我們還要考慮 n-1,n-2 等等... 怎麼辦?
不怎麼辦,因為考慮這些的數字已經出現了。
給定一個序列以及整數 k = 1。
請找出如何選出 k 個不重疊區間,且各個區間內無重複數字,使選到的數字最多。
k=1 也可以雙指針
(爬行法) 解掉
int n, k, x;
scanf("%d%d", &n, &k);
int DP0 = 0, ans = 0;
for (int i=1; i<=n; i++) {
scanf("%d", &x);
DP0 = min(DP0+1, i-last[x]);
last[x] = i;
ans = max(ans, DP0);
}
printf("%d\n", ans);
from collections import defaultdict
n, k = map(int, input().split())
A = list(map(int, input().split()))
last = defaultdict(lambda:-1)
ans, DP0 = 0, 0
for i, x in enumerate(A):
DP0 = min(DP0+1, i-last[x])
last[x] = i
ans = max(ans, DP0)
print(ans)
C++
Python
嘗試看看裸定義?
你沒有「現在選了幾次區間」的資訊。
這導致轉移不了 (或很難轉移)。
給定一個序列以及整數 k。
請找出如何選出 k個不重疊區間,且各個區間內無重複數字,使選到的數字最多。
考量要不要選第 n 天,如果不考慮呢?
如果考慮呢?
給定一個序列以及整數 k。
請找出如何選出 k個不重疊區間,且各個區間內無重複數字,使選到的數字最多。
考慮 A_n
不考慮 A_n
✅
一定選A_n
最遠可以選 DP'n 個數字
最遠可以到哪裡?
int n, k, x, ans = 0;
scanf("%d%d", &n, &k);
int DP0 = 0;
for (int i=1; i<=n; i++) {
scanf("%d", &x);
DP0 = min(DP0+1, i-last[x]);
last[x] = i;
for (int cur_k=1; cur_k<=k; cur_k++) {
DP[i][cur_k] = max(
DP[i-1][cur_k],
DP[i-DP0][cur_k-1] + DP0);
ans = max(ans, DP[i][k]);
}
}
printf("%d\n", ans);
from collections import defaultdict
n, k = map(int, input().split())
A = list(map(int, input().split()))
last = defaultdict(lambda:-1)
ans, DP0 = 0, 0
DP = [[0] * (k+1) for _ in range(n)]
for cur_n, x in enumerate(A):
DP0 = min(DP0+1, cur_n-last[x])
last[x] = cur_n
for cur_k in range(1, k+1):
DP[cur_n][cur_k] = max(
DP[cur_n-1][cur_k],
DP[cur_n-DP0][cur_k-1] + DP0
)
ans = max(ans, DP[cur_n][cur_k])
print(ans)
C++
Python
主要多了這些
Python 會 TLE,但就別管了,複雜度是好的
給定一個序列以及整數 k。
請找出如何選出 k個不重疊區間,且各個區間內無重複數字,使選到的數字最多。
分析看看複雜度吧!
狀態數量 × 轉移複雜度 =
Best Time to Buy and Sell IV (leetcode 188)
AI-666 賺多少 (2017 學科全國賽 - P6, tioj 2039)
給定數列 P(第 i 天的股票價格)和整數 k,
求最多進行 k 筆買賣時的最大利潤。
此外,你只能同時持有一張股票。
舉例來說:
(4 - 1) = 3
Prices = [1,3,2,4]
k = 1
[1,3,2,4]
(3 - 1) + (4 - 2) = 4
k = 2
[1,3,2,4]
你會想怎麼定義呢?
給定數列 P(第 i 天的股票價格)和整數 k,
求最多進行 k 筆買賣時的最大利潤。
此外,你只能同時持有一張股票。
嘗試看看裸定義?
你沒有「現在買了多少次股票」的資訊。
這導致轉移不了 (或很難轉移)。
如何拆解? 想想看之前題目怎麼拆解的?
考慮要不要看第 n 天 (也就是考慮要不要在第 n 天賣)
* 如果你定義 DPn, k 在第n天一定要賣,轉移會很麻煩。
給定數列 P(第 i 天的股票價格)和整數 k,
求最多進行 k 筆買賣時的最大利潤。
此外,你只能同時持有一張股票。
考慮好像有點麻煩...
✅
一定在A_n賣
❓
不知道什麼時候買?
→枚舉
考慮要不要看第 n 天 (也就是考慮要不要在第 n 天賣)
給定數列 P(第 i 天的股票價格)和整數 k,
求最多進行 k 筆買賣時的最大利潤。
此外,你只能同時持有一張股票。
✅
一定在A_n賣
第 i 天買,第 n 天賣的利潤:
DP_n, k 表示已經做了 k 次交易,
所以第 i 天的時候最多只能進行 k-1 次交易
在第 i 天時,交易 k-1 次的最高利潤:
❓枚舉:
int maxProfit(int k, vector<int>& prices) {
int n = prices.size();
vector<vector<int>> DP(n, vector<int> (k+1));
for (int cur_k=1; cur_k<=k; cur_k++) {
for (int cur_n=1; cur_n<n; cur_n++) {
int cur_v = DP[cur_n-1][cur_k];
for (int i=0; i<cur_n; i++) {
cur_v = max(cur_v, DP[i][cur_k-1] + prices[cur_n] - prices[i]);
}
DP[cur_n][cur_k] = cur_v;
}
}
return DP.back().back();
}
def maxProfit(self, k, prices):
DP = [[0] * (k+1) for _ in range(len(prices))]
for cur_k in range(1, k+1):
for cur_n in range(1, len(prices)):
cur_v = DP[cur_n-1][cur_k]
for i in range(cur_n):
cur_v = max(cur_v, DP[i][cur_k-1] + prices[cur_n] - prices[i])
DP[cur_n][cur_k] = cur_v
return DP[-1][-1]
C++
Python
(Python 會 TLE)
給定數列 P(第 i 天的股票價格)和整數 k,
求最多進行 k 筆買賣時的最大利潤。
此外,你只能同時持有一張股票。
分析看看複雜度吧!
狀態數量 × 轉移複雜度 =
轉移有辦法在更快嗎?
我們先處理一下轉移式,你會發現 A_n 在這裡是常數。
...
做完了 ,很棒!那 呢?
...
只需要再多檢查一個值!
只需要再多檢查一個值!
int maxProfit(int k, vector<int>& prices) {
int n = prices.size();
vector<vector<int>> DP(n, vector<int> (k+1));
for (int cur_k=1; cur_k<=k; cur_k++) {
int cur_v = DP[0][cur_k-1] - prices[0];
for (int cur_n=1; cur_n<n; cur_n++) {
DP[cur_n][cur_k] = max(DP[cur_n-1][cur_k], prices[cur_n] + cur_v);
cur_v = max(cur_v, DP[cur_n][cur_k-1] - prices[cur_n]);
}
}
return DP.back().back();
}
def maxProfit(self, k: int, prices: List[int]) -> int:
DP = [[0] * (k+1) for _ in range(len(prices))]
for cur_k in range(1, k+1):
cur_v = DP[0][cur_k-1] - prices[0]
for cur_n, price in enumerate(prices):
DP[cur_n][cur_k] = max(DP[cur_n-1][cur_k], price + cur_v)
cur_v = max(cur_v, DP[cur_n][cur_k-1] - price)
return DP[-1][-1]
C++
Python
給定數列 P(第 i 天的股票價格)和整數 k,
求最多進行 k 筆買賣時的最大利潤。
此外,你只能同時持有一張股票。
分析看看複雜度吧!
狀態數量 × 轉移複雜度 =
好複雜啊... 如果狀態好好定,有沒有簡單的寫法?
只需要再多檢查一個值!
給定數列 P(第 i 天的股票價格)和整數 k,
求最多進行 k 筆買賣時的最大利潤。
此外,你只能同時持有一張股票。
考量第 n 天要不要 (買,當c=1) / (賣,當c=0)
🕒
昨天 (n-1) 的資產
今天 (n) 的資產
今天結束後沒股票
今天結束後有股票
❓
考量第 n 天要不要 (買,當c=1) / (賣,當c=0)
🕒
昨天 (n-1) 的資產
今天 (n) 的資產
今天結束後沒股票
今天結束後有股票
Pass 今天
昨天結束後沒股票
今天賣 + An
昨天結束後有股票
Pass 今天
昨天結束後沒股票
今天買 - An
(但昨天只交易了 k-1 次的狀況)
給定數列 P(第 i 天的股票價格)和整數 k,
求最多進行 k 筆買賣時的最大利潤。
此外,你只能同時持有一張股票。
考量第 n 天要不要 (買,當c=1) / (賣,當c=0)
int dp[1000][101][2] = {}, visit[1000][101][2] = {};
int rec(vector<int>& prices, int n, int k, bool c) {
if (n == -1 && k == 0 && c == 0)
return 0;
if (n == -1 || k == -1)
return INT_MIN + 1000;
if (visit[n][k][c])
return dp[n][k][c];
visit[n][k][c] = true;
if (c == 1)
return dp[n][k][c] = max(
rec(prices, n-1, k-1, 0)-prices[n],
rec(prices, n-1, k, 1));
else
return dp[n][k][c] = max(
rec(prices, n-1, k, 1)+prices[n],
rec(prices, n-1, k, 0));
}
int maxProfit(int k, vector<int>& prices) {
int ans = 0;
for (int i=0; i<=k; i++)
ans = max(ans, rec(prices, prices.size()-1, i, 0));
return ans;
}
C++
給定數列 P(第 i 天的股票價格)和整數 k,
求最多進行 k 筆買賣時的最大利潤。
此外,你只能同時持有一張股票。
Python
給定數列 P(第 i 天的股票價格)和整數 k,
求最多進行 k 筆買賣時的最大利潤。
此外,你只能同時持有一張股票。
def maxProfit(self, k: int, prices: List[int]) -> int:
dp = {}
def rec(prices, n, k, c):
if n == -1 and k == 0 and c == 0:
return 0
if n == -1 or k == -1:
return float('-inf')
if (n, k, c) not in dp:
if c == 1:
dp[n, k, c] = max(
rec(prices, n-1, k-1, 0) - prices[n],
rec(prices, n-1, k, 1)
)
else:
dp[n, k, c] = max(
rec(prices, n-1, k, 1) + prices[n],
rec(prices, n-1, k, 0)
)
return dp[n, k, c]
ans = 0
for i in range(0, k+1):
ans = max(ans, rec(prices, len(prices)-1, i, 0))
return ans
給定數列 P(第 i 天的股票價格)和整數 k,
求最多進行 k 筆買賣時的最大利潤。
此外,你只能同時持有一張股票。
分析看看複雜度吧!
狀態數量 × 轉移複雜度 =
結束了...
還沒!DP 還可以更快!
初探 Aliens 優化 / WQS 二分搜
(Weight Quick Select)
Aliens 優化 / WQS 二分搜
據說很久之前大陸發明這個技巧。
Aliens (TIOJ 1961)
但是紅起來的是因為 IOI 2016 的一題
然後近年台灣競程圈不知道為什麼突然流行了起來(?)
搞得很多不是 Aliens 的題大家都想用 Aliens 炸炸看
先來點簡單題吧!
給定數列 P(第 i 天的股票價格)和整數 k = ∞,
求最多進行 k 筆買賣時的最大利潤。
此外,你只能同時持有一張股票。
除了本題,這裡額外要求你算出此時的交易最少幾次
不過我們要怎麼得出最大利潤下,最少交易幾次呢?
原本存的「利潤」,改成 (利潤,-交易次數)
這樣第一條件就是利潤最大,如果一樣就會選次數最小!
int maxProfit(vector<int>& prices) {
pair<int, int> DP0(0, 0), DP1(INT_MIN, -1), ans(0, 0);
for (int price : prices) {
DP0 = max(DP0, {DP1.first + price, DP1.second});
DP1 = max(DP1, {DP0.first - price, DP0.second - 1});
ans = max(ans, DP0);
}
return ans.first;
}
int maxProfit(vector<int>& prices) {
int DP0 = 0, DP1 = INT_MIN, ans = 0;
for (int price : prices) {
DP0 = max(DP0, DP1 + price);
DP1 = max(DP1, DP0 - price);
ans = max(ans, DP0);
}
return ans;
}
C++ 原題
C++ 原題 + 最少次數
然後你在買股票的時候,順便多記錄一次交易就好
(也就是存成 pair)
多交易一次
不過我們要怎麼得出最大利潤下,最少交易幾次呢?
註:這裡的 DP(n,1) 其實是從 DP(n, 0) 轉移,而不是 DP (n-1, 0)。但答案不影響。
不過我們要怎麼最少交易紀錄幾次呢?
原本存的「利潤」,改成 (利潤,-交易次數)
這樣第一條件就是利潤最大,如果一樣就會選次數最小!
def maxProfit(self, prices):
DP0, DP1 = (0, 0), (-inf, -1)
ans = 0
for price in prices:
DP0, DP1 = (
max(DP0, (DP1[0] + price, DP1[1])),
max(DP1, (DP0[0] - price, DP0[1] - 1))
)
ans = max(ans, DP0)
return ans[0]
def maxProfit(self, prices):
DP0, DP1 = 0, -inf
ans = 0
for price in prices:
DP0, DP1 = (
max(DP0, DP1 + price),
max(DP1, DP0 - price)
)
ans = max(ans, DP0)
return ans
Python 原題
Python 原題 + 最少次數
(也就是存成 tuple)
多交易一次
然後你在買股票的時候,順便多記錄一次交易就好
給定數列 P(第 i 天的股票價格),
你可以進行無限次交易,但每次交易都會有手續費 fee。
請求出最大利潤為何?
此外,你只能同時持有一張股票,
除了本題,這裡額外要求你算出最大利潤下時的最少交易次數。
給定數列 P(第 i 天的股票價格),
你可以進行無限次交易,但每次交易都會有手續費 fee。
請求出最大利潤為何?
此外,你只能同時持有一張股票,
除了本題,這裡額外要求你算出最大利潤下時的最少交易次數。
def maxProfit(self, prices, fee):
DP0, DP1 = (0, 0), (-inf, -1)
ans = 0
for price in prices:
DP0, DP1 = (
max(DP0, (DP1[0] + price, DP1[1])),
max(DP1, (DP0[0] - price - fee, DP0[1] - 1))
)
ans = max(ans, DP0)
return ans[0]
Python 原題 + 最少次數
int maxProfit(vector<int>& prices, int fee) {
pair<int, int> DP0(0, 0), DP1(INT_MIN, -1);
pair<int, int> ans(0, 0);
for (int price : prices) {
DP0 = max(DP0,
{DP1.first + price, DP1.second});
DP1 = max(DP1,
{DP0.first - price - fee, DP0.second - 1});
ans = max(ans, DP0);
}
return ans.first;
}
C++ 原題 + 最少次數
接下來讓我們開始介紹 Aliens 優化吧!
給定數列 P(第 i 天的股票價格),
你可以進行無限次交易,但每次交易都會有手續費 fee。
請求出最大利潤為何?
此外,你只能同時持有一張股票,
除了本題,這裡額外要求你算出最大利潤下時的最少交易次數。
觀察一下題目:
隨著手續費升高,「最大利潤下的最少交易次數」會隨之遞減。
? 這有什麼用
隨著手續費升高,「最大利潤下的最少交易次數」會隨之遞減。
交易次數
交易手續費
手續費為 ∞,交易次數為0
怎麼知道交易 k 次的最大利潤?
k
手續費為 x,交易次數剛好為 k
如果非常的剛好的,有個手續費剛剛好讓最佳次數 = k ...
利潤 = 「交易 k 次,沒手續費的最佳利潤」 - kx
所以只要我們發現有個手續費 x 可以讓交易次數剛好是 k,
那麼就可以反推交易次數是 k 的最佳利潤。
為什麼答案不會變?明明跑的程式差了手續費?
交易次數
交易手續費
手續費為 ∞,交易次數為0
k
手續費為 x,交易次數剛好為 k
利潤 = 「交易 k 次,沒手續費的最佳利潤」 - kx
如果沒有手續費,假設 「沒手續費交易 k 次的最佳解的選法」 比 「有手續費,剛好最少交易次數是 k 次的選法」 還要好 (利潤更高)
不過我們要怎麼剛好搜到手續費 x,剛好使次數 = k 呢?
因為交易次數有單調性,所以使用二分搜尋法。
隨著手續費升高,「最大利潤下的最少交易次數」會隨之遞減。
因為交易次數有單調性,所以使用二分搜尋法。
不過這裡有個問題,交易次數是離散的。找不到剛好的 k 怎麼辦?
交易次數
交易手續費
手續費為 ∞,交易次數為0
k
k = 100
(L, Lk = 102)
(R, Rk = 99)
二分搜手續費: L, R
L 的交易次數為 Lk
R 的交易次數為 Rk
(如果不等於,那就表示二分搜根本沒結束)
隨著手續費升高,「最大利潤下的最少交易次數」會隨之遞減。
給定數列 P(第 i 天的股票價格)和整數 k,
求最多進行 k 筆買賣時的最大利潤。
此外,你只能同時持有一張股票。
讓我們來整理一下原題:
整體複雜度:
一般情況下比原本的 O(nk) 還要好!
pair<int, int> trial(vector<int>& prices, int fee) {
pair<int, int> DP0(0, 0), DP1(INT_MIN, -1);
pair<int, int> ans(0, 0);
for (int price : prices) {
DP0 = max(DP0,
{DP1.first + price, DP1.second});
DP1 = max(DP1,
{DP0.first - price - fee, DP0.second - 1});
ans = max(ans, DP0);
}
return ans;
}
int maxProfit(int k, vector<int>& prices) {
double L = 0, R =1000;
while (R - L > 1e-4) {
double M = (L + R) / 2;
if (-trial(prices, M).second > k)
L = M;
else
R = M;
}
return round(trial(prices, R).first + k*R);
}
def maxProfit(self, k, prices):
def trial(cost):
DP0, DP1 = (0, 0), (-inf, -1)
ans = DP0
for price in prices:
DP0, DP1 = (
max(DP0,
(DP1[0] + price, DP1[1])),
max(DP1,
(DP0[0] - price - cost, DP0[1] - 1))
)
ans = max(ans, DP0)
return ans
L, R = 0, max(prices)
while R-L > 1e-2:
M = (L+R) / 2
if -trial(M)[1] > k:
L = M
else:
R = M
val, _ = trial(R)
return round(val + k * R)
Python (Aliens 優化)
C++ (Aliens 優化)
給定數列 P(第 i 天的股票價格)和整數 k,
求最多進行 k 筆買賣時的最大利潤。
此外,你只能同時持有一張股票。
這類型的題目通常:
因此這類題型的轉移通常都是 O(n) 找分割點
Minimum Substring Partition (leetcode 3144)
給定一字串,問最少能將 S 分成幾段使其每段字串都是「平衡」的。
這裡定義「平衡」表示該字串內每個字的出現次數皆一樣。
狀態怎麼定呢?
舉例來說:
S = "fabccddg"
切割成 "fab", "ccdd", "g"
給定一字串,問最少能將 S 分成幾段使其每段字串都是「平衡」的。
好像有點麻煩 ...
如果最後一個片段選了第 n 個字元,那麼這個片段可以到哪?
最後片段,如果平衡
最後片段,如果平衡
最後片段,如果平衡
...
...
但 ... 這要怎麼知道?
給定一字串,回傳這個字串是不是「平衡」的。
沒事,這不是DP :p
你可能會這樣寫:搜尋每個字母然後檢查次數
int criteria = 0;
for (char check='a'; check<='z'; check++) {
int cnt = 0;
for (auto c : s)
if (c == check)
cnt ++;
if (cnt)
if (criteria == 0)
criteria = cnt;
else if (criteria != cnt)
return false;
}
return true;
L = []
for check in string.ascii_lowercase:
cnt = 0
for c in s:
if check == c:
cnt += 1
if cnt:
L.append(cnt)
return all(l==L[0] for l in L)
C++
Python
字母有幾種
時間複雜度:
那如果我們加大點難度呢?
給定一字串,回傳這個字串的所有後綴是不是「平衡」的。
所有後綴:
原本的做法也不是不行,對每個字元做區間和也是可以... 但很麻煩
舉例來說:
Counting Table
由後往前慢慢把字元加進 Table,
也許就可以做出來了?
也就是開一個陣列 A,
A[c] = c 出現幾次
那該怎麼做呢?
如果暴力做就會 ...
給定一字串,回傳這個字串的所有後綴是不是「平衡」的。
所有後綴:
原本的做法也不是不行,對每個字元做區間和也是可以... 但很麻煩
舉例來說:
2 2 2
1 2 2
1 1 2
1 1 1
1 1 0
1 0 0
a b c
不過怎麼知道每次添加都是不是平衡的呢?
字串長度 =
最大次數 * 出現的種類
3 種
3 種
3 種
3 種
2 種
1 種
出現的種類可以
從 0 變成 1 的瞬間維護。
1 1 1
1 1 0
1 0 0
給定一字串,問最少能將 S 分成幾段使其每段字串都是「平衡」的。
回歸原題,那這樣你會寫了嗎?
只要第二層迴圈 j 從 n 做到 0,順便跑剛剛的算法就可以知道 S[j, n] 是不是平衡的!
分析看看複雜度吧!
狀態數量 × 轉移複雜度 =
給定一字串,問最少能將 S 分成幾段使其每段字串都是「平衡」的。
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};
int max_v = 0, uniq = 0;
for (int j=i; j>=0; j--) {
max_v = max(max_v, ++table[s[j] - 'a']);
uniq += (table[s[j] - 'a'] == 1);
if (max_v*uniq == i-j+1)
dp[i] = min(dp[i], j==0 ? 1 : dp[j-1]+1);
}
}
return dp.back();
}
C++
def minimumSubstringsInPartition(self, s):
dp = [inf] * len(s) + [0]
for i in range(len(s)):
table = defaultdict(int)
uniq, max_v = 0, 0
for j in range(i, -1, -1):
table[s[j]] += 1
if table[s[j]] == 1:
uniq += 1
max_v = max(max_v, table[s[j]])
if uniq * max_v == i-j+1:
dp[i] = min(dp[i], dp[j-1]+1)
return dp[-2]
Python
Palindrome Partitioning II (leetcode 132)
舉例來說:
給你一個字串,請問最少可以切幾刀,使得所有子字串皆回文?
S = "bacdcaba"
"bacdcab" / "a"
最少必須切一刀,
才可以使所有片段都是回文。
狀態怎麼定呢?
給你一個字串,請問最少可以切幾刀,使得所有子字串皆回文?
但 ... 這要怎麼知道?
這不就跟上題一樣嗎?
如果每一個不同的 j 就重新判斷回文怎麼樣?
分析看看複雜度吧!
狀態數量 × 轉移複雜度 =
大噴爛,有沒有方法可以降低判斷回文的複雜度呢?
請預處理後, O(1) 判斷一個字串的
任何一個子字串是不是回文
如何問一個子區間?
我們會用兩個變數來指定一個區間。
memo = {}
def is_palin(l, r):
if l >= r:
return True
if (l, r) not in memo:
memo[l, r] = s[l] == s[r] and is_palin(l+1, r-1)
return memo[l, r]
Python
bool is_palin(vector<vector<int>> &memo,
string &s, int l, int r) {
if (l >= r)
return true;
if (memo[l][r] == -1)
memo[l][r] = is_palin(memo, s, l+1, r-1) && s[l]==s[r];
return memo[l][r];
}
C++
給你一個字串,請問最少可以切幾刀,使得所有子字串皆回文?
剩下不就跟上題一樣嗎?
def minCut(self, s: str) -> int:
def is_palin(l, r):
if l >= r:
return True
if (l, r) not in memo:
memo[l, r] = s[l] == s[r] and is_palin(l+1, r-1)
return memo[l, r]
memo = {}
dp = [inf] * len(s) + [-1]
for i in range(0, len(s)):
for j in range(i+1):
if is_palin(j, i):
dp[i] = min(dp[i], dp[j-1]+1)
return dp[-2]
Python
int minCut(string s) {
int n = s.size();
vector<vector<int>> memo(n, vector<int>(n, -1));
vector<int> dp(n, 2001);
dp[0] = 0;
for (int i=1; i<n; i++) {
for (int j=0; j<=i; j++) {
if (is_palin(memo, s, j, i))
dp[i] = min(dp[i], j==0 ? 0 : dp[j-1]+1);
}
}
return dp.back();
}
C++
給你一個字串,請問最少可以切幾刀,使得所有子字串皆回文?
那為什麼要選這題呢?
其實這題有比較漂亮的轉移
某個回文 S[i, j]
把整個轉移反過來想,我們就會變成這樣:
如果我們找到
更新一個答案
給你一個字串,請問最少可以切幾刀,使得所有子字串皆回文?
某個回文 S[i, j]
如果我們找到
更新
怎麼簡單地 (不用遞迴 / DP) 找到回文呢?
S = abcdcba
枚舉中心往外擴散就好了。
(不過回文長度是偶數,中心會在中間,要特別處理。)
S = abccba
給你一個字串,請問最少可以切幾刀,使得所有子字串皆回文?
def minCut(self, s: str) -> int:
dp = [inf] * len(s) + [-1]
for i in range(0, len(s)):
L, R = i, i
while L >= 0 and R < len(s) and s[L] == s[R]:
dp[R] = min(dp[R], dp[L-1] + 1)
L, R = L-1, R+1
L, R = i-1, i
while L >= 0 and R < len(s) and s[L] == s[R]:
dp[R] = min(dp[R], dp[L-1] + 1)
L, R = L-1, R+1
return dp[-2]
Python
不固定轉移
int n = s.size();
vector<int> dp(n, 2001);
for (int i=0; i<n; i++) {
for (int L=i, R=i; L>=0 && R<n && s[L] == s[R]; --L, ++R)
dp[R] = min(dp[R], L ? dp[L-1]+1: 0);
for (int L=i-1, R=i; L>=0 && R<n && s[L] == s[R]; --L, ++R)
dp[R] = min(dp[R], L ? dp[L-1]+1: 0);
}
return dp.back();
C++
不固定轉移
* 不固定轉移是我自己取的,
目前沒有這種講法。
附上論文連結
Split Array Largest Sum (leetcode 410)
答案:這四個可能中,最小的是 18。
舉例來說:
註:如果是 APCS,那麼應該會有小測資是 k = 2 。
這樣的 Case 可能就會給你 30 分。
將數列切成 kkk 個區間,
最小化這 kkk 個區間總和的最大值。
A = [7, 2, 5, 10, 8], k = 2
將數列切成 kkk 個區間,
最小化這 kkk 個區間總和的最大值。
試試看裸定義!
我們之前的題目,在拆解的時候都是考量最後一步的所有可能。
將數列切成 kkk 個區間,
最小化這 kkk 個區間總和的最大值。
你可能要寫一個區間和
處理這個會好寫一點。
我們之前的題目,在拆解的時候都是考量最後一步的所有可能。
int splitArray(vector<int>& nums, int k) {
// Prefix Sum
vector<int> PS({0});
for (auto num : nums)
PS.push_back(PS.back() + num);
auto Isum = [=](int l, int r) {return PS[r+1] - PS[l];};
vector<vector<int>> DP(nums.size(), vector<int> (k+1, INT_MAX));
// Base Case
for (int cur_n=0; cur_n < nums.size(); cur_n++)
DP[cur_n][1] = Isum(0, cur_n);
for (int cur_k=2; cur_k <= k; cur_k++) {
for (int cur_n=cur_k-1; cur_n < nums.size(); cur_n++) {
for (int i=cur_k-2; i < cur_n; i++) {
DP[cur_n][cur_k] = min(
DP[cur_n][cur_k],
max(DP[i][cur_k-1], Isum(i+1, cur_n)));
}
}
}
return DP.back().back();
}
C++
Base case:
k=1表示全選
想想看左右界是什麼:
i : [k-2, n-1]
* 這份程式 cur_n 跟 cur_k 可以對調,
但因為一些原因所以我們先跑 cur_k
def splitArray(self, nums: List[int], k: int) -> int:
# Prefix Sum
Psum = list(accumulate(nums)) + [0]
def Isum(l, r):
return Psum[r] - Psum[l-1]
DP = [[math.inf] * (k+1) for _ in range(len(nums))]
# Base Case
for i in range(len(nums)):
DP[i][1] = Isum(0, i)
for cur_k in range(2, k+1):
for cur_n in range(cur_k-1, len(nums)):
cur_v = math.inf
for i in range(cur_k-2, cur_n):
cur_v = min(cur_v, max(DP[i][cur_k-1], Isum(i+1, cur_n)))
DP[cur_n][cur_k] = cur_v
return DP[len(nums)-1][k]
Python
Base case:
k=1表示全選
想想看左右界是什麼:
i : [k-2, n-1]
* 這份程式 cur_n 跟 cur_k 可以對調,
但因為一些原因所以我們先跑 cur_k
將數列切成 kkk 個區間,
最小化這 kkk 個區間總和的最大值。
分析看看複雜度吧!
狀態數量 × 轉移複雜度 =
狀態應該是不能再壓了,那麼轉移可以更快嗎?
狀態應該是不能再壓了,那麼轉移可以更快嗎?
...
這些數值有什麼關係呢?
不是,這個有什麼用?
...
數值
i
DP 項
區間和項
你想要知道一個 i:
def splitArray(self, nums, k):
# Prefix Sum
Psum = list(accumulate(nums)) + [0]
def Isum(l, r):
return Psum[r] - Psum[l-1]
DP = [[math.inf] * (k+1) for _ in range(len(nums))]
# Base Case
for i in range(len(nums)):
DP[i][1] = Isum(0, i)
for cur_k in range(2, k+1):
for cur_n in range(cur_k-1, len(nums)):
L, R = cur_k-2, cur_n
while R-L > 1:
M = (L+R) // 2
if DP[M][cur_k-1] < Isum(M+1, cur_n):
L = M
else:
R = M
DP[cur_n][cur_k] = min(
max(DP[L][cur_k-1], Isum(L+1, cur_n)),
max(DP[R][cur_k-1], Isum(R+1, cur_n))
)
return DP[len(nums)-1][k]
Python
注意:二分搜到交叉項時,最大值有可能在 DP 項也可能在區間和項,
所以你必須要取左界 (L) 和右界 (R) 的最小值。
int splitArray(vector<int>& nums, int k) {
// Prefix Sum
vector<int> PS({0});
for (auto num : nums)
PS.push_back(PS.back() + num);
auto Isum = [=](int l, int r) {return PS[r+1] - PS[l];};
vector<vector<int>> DP(nums.size(), vector<int> (k+1, INT_MAX));
// Base Case
for (int cur_n=0; cur_n < nums.size(); cur_n++)
DP[cur_n][1] = Isum(0, cur_n);
for (int cur_k=2; cur_k <= k; cur_k++) {
for (int cur_n=cur_k-1; cur_n < nums.size(); cur_n++) {
int L = cur_k-2, R = cur_n;
while (R-L > 1) {
int M = (L+R)/2;
if (DP[M][cur_k-1] < Isum(M+1, cur_n))
L = M;
else
R = M;
}
DP[cur_n][cur_k] = min(
max(DP[L][cur_k-1], Isum(L+1, cur_n)),
max(DP[R][cur_k-1], Isum(R+1, cur_n))
);
}
}
return DP.back().back();
}
C++
二分搜
的地方
將數列切成 kkk 個區間,
最小化這 kkk 個區間總和的最大值。
分析看看複雜度吧!
狀態數量 × 轉移複雜度 =
這個複雜度滿意了嗎?
還沒!還有其他轉移
注意:二分搜到交叉項時,最大值有可能在 DP 項也可能在區間和項,
所以你必須要取左界 (L) 和右界 (R) 的最小值。
...
數值
i
DP 項
區間和項
你算完了 ,很棒!
下一次的區間和項
def splitArray(self, nums , k):
# Prefix Sum
Psum = list(accumulate(nums)) + [0]
# Interval Sum
def Isum(l, r):
return Psum[r] - Psum[l-1]
DP = [[math.inf] * (k+1) for _ in range(len(nums))]
# Base Case
for i in range(len(nums)):
DP[i][1] = Isum(0, i)
for cur_k in range(2, k+1):
L = cur_k-2
for cur_n in range(cur_k-1, len(nums)):
while L < cur_n and DP[L+1][cur_k-1] < Isum(L+2, cur_n):
L += 1
R = L + 1
DP[cur_n][cur_k] = min(
max(DP[L][cur_k-1], Isum(L+1, cur_n)),
max(DP[R][cur_k-1], Isum(R+1, cur_n))
)
return DP[len(nums)-1][k]
Python
注意:掃描到交叉項時,最大值有可能在 DP 項也可能在區間和項,
所以你必須要取左界 (L) 和右界 (R) 的最小值。
int splitArray(vector<int>& nums, int k) {
// Prefix Sum
vector<int> PS({0});
for (auto num : nums)
PS.push_back(PS.back() + num);
auto Isum = [=](int l, int r) {return PS[r+1] - PS[l];};
vector<vector<int>> DP(nums.size(), vector<int> (k+1, INT_MAX));
// Base Case
for (int cur_n=0; cur_n < nums.size(); cur_n++)
DP[cur_n][1] = Isum(0, cur_n);
for (int cur_k=2; cur_k <= k; cur_k++) {
int L = cur_k-2;
for (int cur_n=cur_k-1; cur_n < nums.size(); cur_n++) {
while (L < cur_n && DP[L+1][cur_k-1] < Isum(L+2, cur_n))
L++;
int R = L+1;
DP[cur_n][cur_k] = min(
max(DP[L][cur_k-1], Isum(L+1, cur_n)),
max(DP[R][cur_k-1], Isum(R+1, cur_n))
);
}
}
return DP.back().back();
}
C++
如果下一項還沒交叉,就往前
將數列切成 kkk 個區間,
最小化這 kkk 個區間總和的最大值。
分析看看複雜度吧!
狀態數量 × 轉移複雜度 =
注意:掃描到交叉項時,最大值有可能在 DP 項也可能在區間和項,
所以你必須要取左界 (L) 和右界 (R) 的最小值。
每輪最多移動 n 次,
均攤 O(1)
將數列切成 kkk 個區間,
最小化這 kkk 個區間總和的最大值。
恭喜你,被我恭喜到了。
如果你看的到這句話,那你真的很有毅力與勇氣在 DP 上。
在進入下個類型前先回憶一下吧!
主題 | 題目名稱 | 大概作法 |
---|---|---|
二維地圖類 | 二維圖路徑數 ☆ | DP[n][m] = DP[n-1][m] + DP[n][m-1]。 |
二維圖最小路 ☆ | 同上,但變成 min 。 | |
雙人路徑問題 |
增加狀態,把兩個人的座標紀錄變4D後又狀態壓縮成3D |
|
子序列選擇 |
最長共同子序列 (LCS) ☆ |
判斷結尾一不一樣。一樣就 DP[n][m] = DP[n-1][m-1]+1, |
編輯距離 ☆ | 跟 LCS 一樣。 | |
k-相鄰子序列 ☆ | 單調對列優化 / Sliding Window | |
最長遞增子序列 (LIS) ☆ | 可以做的跟 LCS 一樣,然後用二分搜優化成 O(nlogn) | |
區間選擇 | 最大子區間和 ☆ | 簡單題目。也可以用掃描線 + 區間和概念做 |
美食博覽會 (APCS) | 內層轉移 - k = 1:不重複區間可以用雙指針或 DP。 外層轉移 - 選k次:可以再做一次DP或貪心 (DP 套 DP) |
|
股票買賣 | 有簡單的狀態配上比較難看的轉移,或者比較難想的狀態 配上簡單的轉移(有沒有持股)。隨後是 Aliens 優化。 |
|
區間分割 | 平衡字串分割 | 寫出轉移很簡單,但優化轉移要利用 Counting Table。 |
最少回文分割 |
寫出轉移很簡單,但要漂亮的寫轉移要反過來想, 從順序的找到回文再想怎麼轉移過去。(不固定順序的轉移) |
|
最小化區間總和 |
轉移式很複雜,可以進一步用二分搜優化成轉移 O(logn) 又可以進一步發現轉移單調優化成轉移 O(1) |
|
所以動態規劃怎麼解呢?
所以動態規劃怎麼解呢?
所以動態規劃怎麼解呢?
所以動態規劃怎麼解呢?
所以動態規劃怎麼解呢?
知道了轉移式,該怎麼寫呢?
題目名稱 | 來源 | 備註 |
---|---|---|
Unique Paths | Leetcode 62 | 沒有障礙物,更簡單 |
Unique Paths III | Leetcode 980 | 不是DP,純遞迴 |
Paths Sum Is Divisible by K | Leetcode 2435 | 路總和為 K 倍的路有幾種 |
Minimum Path Cost in a Grid | Leetcode 2304 | 2D1D,很躁 |
Dungeon Game | Leetcode 174 | 反做純 DP 解,或 正做+對答案二分搜 |
Cherry Pickup | Leetcode 714 | 走過去然後走回來最大值 |
幸運表格 (Lucky) | 北市賽 2018, TIOJ 2182 | 不限定起點找最大路徑 |
得分高手 (Master) | 北市賽 2014, TIOJ 1268 | 不限定起點找最大路徑 |
勇者修煉 | APCS 2020 / 10 - 3, zj f314 | 可以走左,走右,走下 |
題目名稱 | 來源 | 備註 |
---|---|---|
Another LCS | 成高校內賽, zj a252 | 找三個字串的 LCS |
Compromise | zj e682 | 還原 LCS |
Max Dot of Two Subseqs | Leetcode 1458 | 最大子序列內積 |
Number of LIS | Leetcode 683 | 計算 LIS 的個數 |
Maximal Square | Leetcode 221 | 找最大全都是 1 的正方形 |
飛黃騰達 | APCS 2021 / 01 - 4, zj f608 | 二維的 LIS |
Maximize Win From Two Segments | leetcode 2555 | 不是 DP,但 Sliding Window |
題目名稱 | 來源 | 備註 |
---|---|---|
Best SightSeeing Pair | leetcode 1014 | 最大化 A[i]-A[j]+i-j |
內積 | APCS 2022 / 06 - 4, zj i402 | 很多個最大子區間和 |
Maximum Subarray Sum with One Deletion |
leetcode 1186 |
選一個最大和區間, |
投資遊戲 | APCS 2023 / 10 - 4, zj 373 |
同上,但可無視 k 個 |
Maximum of Spliced Array | leetcode 2321 | 給兩個陣列,選同樣位置的 兩個子區間互換,要求其一總和最大 |
Max sum of 3 subarrays | leetcode 689 | 可以寫 O(nk), k=3 的DP 或者掃描第二個區間,詳見貪心 |
股票買賣 III | leetcode 123 | 股票買賣 IV 但是 k=2。 除了裸套上課的算法外還可以對半拆。 |
美食博覽會 (困難版) | zj h926 | 美食博覽會,但 k 很大 |
leetcode 1696, 1425, 2944, 375, 3117
題目名稱 | 來源 | 備註 |
---|---|---|
Partition String into min substgrings | leetcode 2767 | 切出來的字串是5的倍數 且沒有前綴0 |
Partition Array for Maximum Sum | leetcode 1043 | |
Minimum Cost to Split an Array | leetcode 2547 | 轉移要 Counting Table |
https://leetcode.com/problems/divide-an-array-into-subarrays-with-minimum-cost-ii/description/
https://leetcode.com/problems/minimum-cost-to-cut-a-stick/description/
https://leetcode.com/problems/minimum-sum-of-values-by-dividing-array/description/
題目名稱 | 來源 | 備註 |
---|---|---|
Max Value of piles | leetcode 2218 | 要區間和的類背包問題 |
Max profit in job scheduling | leetcode 1235 | 有權重的排程問題 |
Max num of events II | leetcode 1751 | 類似上題 |
Domino and Tromino Tiling (leetcode 790)
現在你有兩種骨牌:
(Domino) (Tromino)
請問有幾種方法可以填滿 2 x N 的空間?
N = 5 的 Case:五種
現在你有兩種骨牌:Domino & Tromino
請問有幾種方法可以填滿 2 x N 的空間?
...
這種 Case 會很難算,嘗試多給一些狀態?
...
的可能數
...
的可能數
現在你有兩種骨牌:Domino & Tromino
請問有幾種方法可以填滿 2 x N 的空間?
考慮最後可以怎麼放!
Case 0:
...
...
...
...
現在你有兩種骨牌:Domino & Tromino
請問有幾種方法可以填滿 2 x N 的空間?
Case 1:
...
...
考慮最後可以怎麼放!
Case 0:
還沒寫
C++
dp = {}
def f(n, t):
M = 1_000_000_007
if n == 1:
return 1 if t==0 else 0
if n == 2:
return 2 if t==0 else 1
if (n, t) not in dp:
if t == 0:
dp[n, t] = (f(n-1, 0) + f(n-2, 0) + 2 * f(n-1, 1)) % M
else:
dp[n, t] = (f(n-1, 1) + f(n-2, 0)) % M
return dp[n, t]
return f(n, 0)
Python
Domino and Tromino Tiling (leetcode 790)
矩陣快速冪
所以 A 的 (col) 列數要等於 B 的 (row) 行數,否則不能乘法。
def mat_mul(A, B):
n, m, p = len(A), len(A[0]), len(B[0])
C = [[0] * p for _ in range(n)]
for i in range(n):
for j in range(p):
for k in range(m):
C[i][j] = (C[i][j] + A[i][k] * B[k][j])
return C
現在你有兩種骨牌:Domino & Tromino
請問有幾種方法可以填滿 2 x N 的空間?
現在你有兩種骨牌:Domino & Tromino
請問有幾種方法可以填滿 2 x N 的空間?
阿這有什麼用?
現在你有兩種骨牌:Domino & Tromino
請問有幾種方法可以填滿 2 x N 的空間?
這樣複雜度是多少呢?
那不是一樣嗎?我們先介紹一個技巧
現在給你一個浮點數 x 以及整數 n,
請求出
不過高中學指數的時候好像有這麼一回事:
所以如果 n 是偶數 (=2k) 的話,可以變成這樣:
指數的數字會剩一半!
直接用 for 迴圈:
那麼 n 是奇數 (=2k+1) 呢? n-1不就是偶數了嗎
現在給你一個浮點數 x 以及整數 n,
請求出
Base Case 呢?
n < 0 呢?
這個函數的複雜度是多少呢?
最多兩步就會砍一半:
這個方法叫做快速冪 (Fast Pow)
現在給你一個浮點數 x 以及整數 n,
請求出
double myPow(double x, int n) {
if (n == 0)
return 1;
if (n < 0)
return myPow(1/x, -(n+1))/x;
if (n % 2 == 0)
return myPow(x*x, n/2);
else
return x * myPow(x, n-1);
}
def myPow(self, x: float, n: int):
if n == 0:
return 1
if n < 0:
return self.myPow(1/x, -n)
if n % 2 == 0:
return self.myPow(x * x, n // 2)
else:
return x * self.myPow(x, n - 1)
Python
C++
註:當 n = INT_MIN 的時候,-n 會噴掉,所以需要把 n + 1 避免 overflow
現在你有兩種骨牌:Domino & Tromino
請問有幾種方法可以填滿 2 x N 的空間?
回來看這題,好像也可以用一樣的方法做?
這樣複雜度是多少呢?
現在你有兩種骨牌:Domino & Tromino
請問有幾種方法可以填滿 2 x N 的空間?
還沒寫
C++
因為題目要將答案 % M,
所以所有的運算記得要 % M。
但為什麼可以在中間就 % M,
請參考同餘定理。
現在你有兩種骨牌:Domino & Tromino
請問有幾種方法可以填滿 2 x N 的空間?
def numTilings(self, n, t=0) -> int:
M = 1_000_000_007
def mat_mul(A, B):
n, m, p = len(A), len(A[0]), len(B[0])
C = [[0] * p for _ in range(n)]
for i in range(n):
for j in range(p):
for k in range(m):
C[i][j] = (C[i][j] + A[i][k] * B[k][j]) % M
return C
def fast_pow(A, n):
if n == 1:
return A
if n % 2 == 0:
return fast_pow(mat_mul(A, A), n//2)
else:
return mat_mul(A, fast_pow(A, n-1))
A = [[1, 2, 1], [0, 1, 1], [1, 0, 0]]
if n == 1:
return 1
if n == 2:
return 2
return mat_mul(fast_pow(A, n-2), [[2], [1], [1]])[0][0]
Python
因為題目要將答案 % M,
所以所有的運算記得要 % M。
但為什麼可以在中間就 % M,
請參考同餘定理。
配對連線 (學科決賽 107 - 1, zj e876)
Catalan Number
現在有一個圓,圓上有 2n 個點。
將這些點分成兩個一組,並連線。
請問有幾種可能使得所有連線不相交?
不合法。
當 n = 2,答案為 2
舉例來說:
當 n = 3,答案為 5
現在有一個圓,圓上有 2n 個點。
將這些點分成兩個一組,並連線。
請問有幾種可能使得所有連線不相交?
考慮隨便一個點可以怎麼連線?
現在有一個圓,圓上有 2n 個點。
將這些點分成兩個一組,並連線。
請問有幾種可能使得所有連線不相交?
考慮隨便一個點可以怎麼連線?
...
...
2A 個點
2B 個點
現在有一個圓,圓上有 2n 個點。
將這些點分成兩個一組,並連線。
請問有幾種可能使得所有連線不相交?
dp = {0: 1}
def f(n):
if n not in dp:
cur = 0
for i in range(n):
cur += f(i) * f(n-i-1)
dp[n] = cur
return dp[n]
import sys
for row in sys.stdin:
n = int(row)
if n != 0:
print(f(n))
Python
C++ 這題很靠北
要實作大數
很麻煩捏 自己寫
現在有一個圓,圓上有 2n 個點。
將這些點分成兩個一組,並連線。
請問有幾種可能使得所有連線不相交?
這個 DP 的定義式,有個著名的稱呼:
(Catalan Number)
他有非常多的變形題目,
但最後的答案都指向卡特蘭數,
讓我們一起來看個幾題吧!
從 (0, 0) 走到 (n, n) 的最短路徑的數量,並且
不能跨越過對角線 (也就是整個過程 ) 有幾種
N = 4 的 Case: 14種
枚舉最後一次碰到
對角線的可能
假設最後一次碰到 (2, 2):
從 (0, 0) 走到 (n, n) 的最短路徑的數量,並且
不能跨越過對角線 (也就是整個過程 ) 有幾種
n 組括號的合法匹配可能數有幾種
((()))
()(())
()()()
(())()
(()())
n = 3 的 Case
判斷一個字串匹配是否合法:
遇到 ( +1,遇到 ) -1。
那麼只要最後是 0 且中間過程全程 > 0
這個組合就合法。
看起來跟上一題一樣?
因為變成往右 + 1,往上 - 1。
( | ( | ) | ( | ) | ) |
---|---|---|---|---|---|
1 | 2 | 1 | 2 | 1 | 0 |
有 n 個節點的二元樹有幾種?
n = 3 的 Case
這題就比較好理解,因為自己 root 少了一個節點,接著枚舉左右兩棵樹的節點樹就可以。
a 個節點的樹
n - a - 1 個節點的樹
更多等價問題可以參見 Wiki
球不同,箱子不同
球相同,箱子不同
球不同,箱子相同
球相同,箱子相同
給定球數 n 跟箱子數 m,盒子可為空,求可能數:
隔板法:n 球 + (m-1) 隔板的排列組合
每個球都可以選不同箱子,所以...
可以參考這個
只能暴搜 DP
只能暴搜 DP
給定球數 n 跟箱子數 m,盒子可為空,
求「球不同,箱子相同」可能數:
n = 2,m = 2
ABC | |
---|---|
AB | C |
AC | B |
A | BC |
AB | |
---|---|
A | B |
n = 3,m = 2
給定球數 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
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)
應該是偷帶搭到終點,趁機講離散化
https://leetcode.com/problems/combination-sum-iv/description/ 數字拆分
好的狀態,就會有簡單的轉移。
(APCS 2024/1 第四題)
給定一個序列,每次可選兩相鄰數 u,v 合併成 u+v,需要花費 |u-v|。
問合併成一個數字的最小花費?
(APCS 2024/1 第四題)
要DP好像有點困難... 不知道狀態怎麼設計...
不管是哪一種合併方法,
總會有最後一次怎麼合併吧?
就像背包問題總會有最後一個選的物品一樣
給定一個序列,每次可選兩相鄰數 u,v 合併成 u+v,需要花費 |u-v|。
問合併成一個數字的最小花費?
(APCS 2024/1 第四題)
最後一次怎麼合併長甚麼樣呢?
給定一個序列,每次可選兩相鄰數 u,v 合併成 u+v,需要花費 |u-v|。
問合併成一個數字的最小花費?
他們都源自於一個區間
因為只能相鄰才可以合併
我們就可以利用區間來定義狀態
DP[l, r] = 區間 [l, r) 的最小成本
(APCS 2024/1 第四題)
給定一個序列,每次可選兩相鄰數 u,v 合併成 u+v,需要花費 |u-v|。
問合併成一個數字的最小花費?
DP[l, r] = 區間 [l, r) 的最小成本
轉移式呢?
想想看最後一個答案 DP[0, n]
的所有轉移長什麼樣子?
(APCS 2024/1 第四題)
給定一個序列,每次可選兩相鄰數 u,v 合併成 u+v,需要花費 |u-v|。
問合併成一個數字的最小花費?
DP[l, r] = 區間 [l, r) 的最小成本
轉移式呢?
想想看最後一個答案 DP[0, n]
的所有轉移長什麼樣子?
用 for 迴圈決定切割點
如果切割點在 k,那麼...
左邊的數字加總 -
右邊的數字加總 = 這次合併的成本
合併左邊的成本
合併右邊的成本
(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 = 區間和)
https://leetcode.com/problems/minimum-cost-to-cut-a-stick/description/
也是區間DP
題目名稱 | 來源 | 備註 |
---|---|---|
House Robber III | Leetcode 337 | 樹 DP |
burst-balloons | Leetcode 312 | 合併成本類似題 |
應該要放掉的
在先前的題目,我們的DP狀態
都是怎麼定義的呢?
東東爬樓梯
DP[n] = 爬到第 n 階的可能數
C(n, m)
DP[n, m] = C(n, m),也就是n相異球取m球的方法數
0/1 背包問題 (祖靈)
DP[n, W] = 前 n 個背包中,限重 W 的情況可攜帶最高價值
這些狀態都定義的很 ... 直觀
但有時,你需要很精巧的設計狀態!
擲了n次硬幣,過程中出現連續3次正面的可能數為何?
嘗試想一下DP定義吧!
嘗試看看無腦定義狀態?
DP[n] = 連續三次正面的可能數
那麼 DP[n] 會包含著這些 case
你該怎麼寫?
n 個硬幣
x 個硬幣
➕
➕
➕
n-x-3 個硬幣
擲了n次硬幣,過程中出現連續3次正面的可能數為何?
DP[n] = 連續三次正面的可能數
n 個硬幣
x 個硬幣
➕
➕
➕
n-x-3 個硬幣
x = 0
x = 1
會被重複算!
不行了...在討論下去沒完沒了,可能還要排容原理
➕
➕
➕
➕
4個硬幣
這個定義行不通,我們需要想其他狀態定義!
擲了n次硬幣,過程中出現連續3次正面的可能數為何?
好像不能無腦定義狀態 :(
DP[n] = 沒有連續三次正面的可能數
這樣 DP[n] 可以分成哪些 Case 呢?
n 個硬幣
排組教過你:如果正攻不行,就反攻
➖
n-1次,沒有連續三次正面
➕
➖
n-2次,沒有連續三次正面
➕
➕
➖
n-3次,沒有連續三次正面
➕
➕
➖
好像不能無腦定義狀態 :(
DP[n] = 沒有連續三次正面的可能數
n 個硬幣
排組教過你:如果正攻不行,就反攻
➖
n-1次,沒有連續三次正面
➕
➖
n-2次,沒有連續三次正面
➕
➕
➖
n-3次,沒有連續三次正面
➕
➕
➖
擲了n次硬幣,過程中出現連續3次正面的可能數為何?
擲了n次硬幣,過程中出現連續3次正面的可能數為何?
DP[n] = 沒有連續三次正面的可能數
n 個硬幣
➖
n-1次,沒有連續三次正面
➕
➖
n-2次,沒有連續三次正面
➕
➕
➖
n-3次,沒有連續三次正面
➕
➕
➖
DP[n-1]
DP[n-2]
DP[n-3]
答案 = 全部的可能 - 沒有連續三次正面
擲了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;
}
擲了n次硬幣,過程中出現連續3次正面的可能數為何?
好難... 有沒有其他作法...
定義 DP[n][k] =
沒有連續3次,且最後連續 k 個 ➕
n 個硬幣
➖
n-1次,沒有連續三次正面
➕
➖
n-2次,沒有連續三次正面
➕
➕
➖
n-3次,沒有連續三次正面
➕
➕
➖
如果分成 3 個 case 做呢?
擲了n次硬幣,過程中出現連續3次正面的可能數為何?
定義 DP[n][k] = 沒有連續3次,且最後連續 k 個➕
不管是哪個Case,
加上一個➖都會在這裡。
➖
n-1次,沒有連續三次正面
➕
➖
n-2次,沒有連續三次正面
➕
➕
➖
n-3次,沒有連續三次正面
➕
➕
➖
最後是 ➖,再加上一個 ➕才會在這裡。
最後是➖➕,再加上一個 ➕ 才會在這裡。
看你覺得怎麼寫最順,
有非常多種方法都可以算出答案。
擲了n次硬幣,過程中出現連續3次正面的可能數為何?
其實正攻也可以!
DP[n][k][0] = n個硬幣,還沒有出現連續三次➕,最後有連續 k 個 ➕
DP[n][k][1] = n個硬幣,已經出現連續三次➕,最後有連續 k 個 ➕
你覺得這樣子的設計要怎麼轉移呢?
什麼時候使用DP?
可以爆搜題目的時候,並且狀態可能會重複。
通常都是最佳化答案 (背包問題),或者計算個數。
DP 的流派 ?
Top-down: 遞迴 + 記憶化 (Memoization)
Bottom-up: 用迴圈疊出答案
DP 的流程?
狀態設計,好的狀態會讓你DP好寫很多。
狀態轉移,思考你要怎麼將問題由大變小。
如果有,記得寫 Base case
考慮優化,有時候轉移複雜度太高,可能有魔法可以優化。
題目名稱 | 題目簡介 |
---|---|
東東爬樓梯 | 一次可以走 1、2 階,走到 n 階的可能數 |
Combination | n 個物品選 m 個的選法 |
0 / 1背包問題 | 每個物品都有價值跟重量,求限定重量下的最高價值選法 |
找硬幣問題 | 給定幣值的種類,用最少的硬幣數量找 n 元 |
最長共同子序列 (LCS) | 問兩個字串的最長共同子序列 |
編輯距離 (edit distance) | 問兩個字串要改或刪幾個字,才可以讓它們相等 |
最長遞增子序列 (LIS) | 問一個陣列的最長遞增子序列 |
Critical Mass | 擲 n 次硬幣,有連續三次正面的可能數 |
合併成本 | 兩相鄰數可以合併,問合併成一個數字的最小成本 |
最小字串切割 | 給一個字串,問最少可以切成幾個皆為平衡的子字串 |
美食博覽會 | 選 k 個不相交的數字不重複區間,使得涵蓋範圍最大 |
我們在這章節上的題目總覽
怎麼設計狀態?
大多時候,DP[???] = 題目問的答案。
這個 ??? 是什麼,就要靠你想像了。
常見的樣式:
看到第幾個數字
走樓梯問題的 n
題目給的限制
背包的限重 W
美食博覽會的區間個數 k
一些狀態,可以分成不同的 Case
例如 Critical Mass 的後面有幾個 +
有時也可以考慮反解:
例如 Critical Mass (連續三個+) 的題目
有些題目的狀態就真的很 ... 魔幻,這就真的要靠你想像了。
怎麼設計轉移?
考慮所有轉移時需要的狀態。
你可以劃出枚舉樹,可能會幫你思考。
背包問題:考量「限定重量的最大價值」或者「限定價值的最小重量」
Critical Mass:考量「最後一次連續出現了多少個 + 」。
有時候這個考量是需要用迴圈搜尋的,例如合併成本的切割點
如果找不到合適的,可以考慮 n -> n-1。
例如 LCS,LIS,裴波那契數列。
有些轉移式非常的困難 ( 例如 DP 的各種優化,這個就要吃你的想像力跟經驗了 ;( )
怎麼練習DP?
多多練習?
靠多看題目來培養狀態設計的感覺。
可以去 Leetcode 的 DP 找找看!
可以不用寫 code,想想看怎麼寫就好
可以從 easy,medium 開始練習
DP 的路很長很長 ...
建議你至少每周都寫個幾題 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的題目吧,
給定兩個字串,並且限制子序列的每個元素間距必須 <= k,那麼請問LCS的長度為何?
Hint: 你可能需要會做固定範圍的2維區間最大值
Convex Hull Optimization / 凸包優化
Quadrilateral Inequality Optimization
CDQ DC