所以要記得多複習喔~
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!
直接把它轉成程式?
跑跑看!
int factorial(int N) {
return N * factorial(N-1);
}
def factorial(N):
return N * factorial(N-1)
C++
Python
給你一個數字 N,請輸出 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
這樣就可以了!
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
給你一個數字 N,請輸出 N!
Recursion - Review
以 作為舉例
呼叫
呼叫
呼叫
回傳
回傳
回傳
回傳
實際例子可以參考另一個投影片
Dynamic Programming
技巧? 暴搜?
暴搜比較好理解的其中一個方法就是遞迴。
技巧? 別急,我們慢慢來。
Dynamic Programming
Dynamic Programming
這是一個關於發明者Bellman的有趣故事...
*你在圖論會學到以他為名的演算法
Fibonacci Sequence
🐦
🐦
3 階有 3 種走法
4 階有 5 種走法
1+1+1
2+1
1+2
有點難?想想看遞迴的三個步驟!
1+1+1+1
1+2+1
2+1+1
1+1+2
2+2
舉例來說:
東東爬階梯每次可以走一或兩階。
假設階梯有 n 階,那有幾種走法?
有點難?想想看遞迴的三個步驟!
在簡單的 DP 中,函式定義高機率是裸定。
裸定?那就是 f(題目給的) = 題目要的
假設階梯有 n 階,那有幾種走法?
所以這題的裸定就是:f(n) = 走 n 階的可能數
東東爬階梯每次可以走一或兩階。
假設階梯有 n 階,那有幾種走法?
f(n) = 走 n 階的可能數
東東爬階梯每次可以走一或兩階。
假設階梯有 n 階,那有幾種走法?
如何拆解 DP?
第一步:想一個規則可以把 f(n) 的所有可能拆碎
什麼意思呢?
回憶一下你小時候玩的大風吹...
回憶一下你小時候玩
的大風吹...
拆解 DP 第一步:
那就是想一個規則可以把 f(n) 的所有可能拆碎
大風吹後面會帶一個規則,
可以將所有人分成兩半
(要起來跟沒起來的)
DP 同理,你只要想得出任何一個規則能把所有可能拆碎,那麼它就是一個可行的拆解。
用例子想想看吧!
(不一定要分成兩半,可以分成很多半)
1+1+1+1
1+2+1
2+1+1
1+1+2
2+2
n = 4 的所有可能
拆解 DP:
規則:
最後一步走1
這樣就可以拆碎所有可能了!
最後一步走幾步?
走到 n 階,但最後一定走 1 的可能數 = ?
最後一步走2
= 走 n-1 階的可能數 = f(n-1)
走到 n 階,但最後一定走 2 的可能數 = ?
= 走到 n-2 階的可能數 = f(n-2)
(第一步也可以)
f(n) = 走 n 階的可能數
⭕
❌
❌
東東爬階梯每次可以走一或兩階。
假設階梯有 n 階,那有幾種走法?
比較簡單的 Base Case 想法:
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
東東爬階梯每次可以走一或兩階。
假設階梯有 n 階,那有幾種走法?
f(n) = 走 n 階的可能數
簡單的程式碼:
這就是鼎鼎大名的費波那契數列
fibonacci
以 作為舉例
2
1
3
2
1. 狀態定義
2. 如何拆解
3. Base Case
1. 狀態定義
2. 如何拆解
3. Base Case
1. 狀態定義
2. 如何拆解
3. Base Case
1. 狀態定義
2. 如何拆解
3. Base Case
也有一種解釋方法是 f(0) = 什麼都不做也是一種可能,看你要怎麼解讀。
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 的可能數
n-m 跟 m 用 GCD 處理後直接除就可以保證 數值不會 Overflow 了
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 |
---|---|---|---|---|---|
dp[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 一下
認真說起來的話,其實 Bottom-up 也有
區分成 pull / push。
我們基本上都使用 Pull Method。
for (int i=2; i<=n; i++) {
dp[i] = dp[i-1] + dp[i-2];
}
Fibonacci (Pull)
for (int i=2; i<=n; i++) {
dp[i] = dp[i-1] + dp[i-2];
}
for (int i=2; i<=n; i++) {
dp[i] = dp[i-1] + dp[i-2];
}
Fibonacci (Push)
for (int i=0; i<=n-1; i++) {
dp[i+1] += dp[i];
dp[i+2] += dp[i];
}
不過你用 Push 寫出來也可以。
而我也推薦你
認真說起來的話,其實 Top-down 也有
區分成兩種想法: DP-style / dfs-style
for (int i=2; i<=n; i++) {
dp[i] = dp[i-1] + dp[i-2];
}
Fibonacci (DP style)
int f(int n) {
if (n == 0 || n == 1)
return 1;
if (dp[n])
return dp[n]
return dp[n] = f(n-1) + f(n-2);
}
for (int i=2; i<=n; i++) {
dp[i] = dp[i-1] + dp[i-2];
}
Fibonacci (dfs style)
int f(int cur, int n) {
if (cur == n)
return 1;
if (cur > n)
return 0;
if (dp[cur])
return dp[cur]
return dp[cur] = f(cur+1, n) + f(cur+2, n);
}
將所有可能拆成兩個 Case:最後一步是 1 還是 2
從 0 開始走,枚舉下一步要走 1 還是 2,直到 n 或超過 n。
我們基本上接下來的投影片都是 DP style,
但也有一些題目要用 dfs style 會比較簡單。
你也可以想成 DP 是考慮最後一步,dfs 是考慮第一步。
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 的流程?
要怎麼考量好的轉移呢?
只要有辦法把所有可能分類,並且可能數會更小,
那麼這個拆法就是一個可行的轉移
舉例來說,爬樓梯:
🐦
4 階有 5 種走法
1+1+1+1
1+1+2
1+2+1
2+1+1
2+2
條件:
1+1+1+1
1+2+1
2+1+1
1+1+2
2+2
最後一步
是 1
最後一步
是 2
有沒有其他條件也可以拆呢?當然有
要怎麼考量好的轉移呢?
只要有辦法把所有可能分類,並且可能數會更小,
那麼這個拆法就是一個可行的轉移
舉例來說,爬樓梯:
🐦
4 階有 5 種走法
1+1+1+1
1+1+2
1+2+1
2+1+1
2+2
條件:
1+1+1+1
1+2+1
1+1+2
2 +1+1
2+2
第一步
是 1
第一步
是 2
有沒有其他條件也可以拆呢?當然有
要怎麼考量好的轉移呢?
只要有辦法把所有可能分類,並且可能數會更小,
那麼這個拆法就是一個可行的轉移
想出一個分類法通常不會太難
(當然也有難到很難分的,這個時候大多數要增加狀態)
但並不是所有分類法的實作難度都一樣
所以看到問題的時候我們通常在想:
怎麼設計狀態 / 怎麼分類轉移會比較好實作?
題目名稱 | 來源 | 備註 |
---|---|---|
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 |
非DP,遞迴+對半拆 |
置物櫃分配 | APCS 2018 / 10 - 4 | zj 範圍怪怪的, 直接做 0/1 背包就可以了 |
Partition to K Equal Sum | Leetcode 698 | 可暴搜可背包 |
我們說動態規劃是...
可是可以暴搜的題目 ... 這不是很多嗎?
你答對了,所以 ...
動態規劃的題目五花八門,非常多種
動態規劃的題目五花八門,非常多種
接下來我們會嘗試列舉各種題型,
並且選出其中的經典題。
因為動態規劃很重要,題型也很多,
接下來就感受 DP 的神奇魔力吧!
我們會先把前面類型的DP帶過一遍後,
偷偷的介紹一些優化讓你好有感覺,
最後再正式介紹這些優化跟這些優化的條件!
凹性 / 凸性就是函數是否滿足凹函數 / 凸函數性質。
凸性:
講人話就是任兩點的連線都在函數上方。
凹性:
講人話就是任兩點的連線都在函數下方。
凹性 / 凸性就是函數是否滿足凹函數 / 凸函數性質。
在 DP 優化中,要找
時,這個 DP(i) 如果具有凸性或凹性,
那麼就可以用比較聰明的方式找到最佳點。
其實高一的排列組合就有上過!
不信嗎?我們來看看這題
教學影片: 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 以下。
不過為什麼這樣定義可以得出全局最佳解呢?
...
假設最佳解發生在
給定一個數列,請選出一個子序列,使其總和最大。但選法有條件:你選的數字彼此必須距離 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 num in nums:
DP.append(max([0] + DP[-k:]) + num)
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 dp[n]
return max(rec(i) for i in range(len(nums)))
Python
給定一個陣列,問最長的嚴格遞增子序列長度為何?
Longest Increasing Subsequence (LIS)
分析看看複雜度吧!
狀態數量 × 轉移複雜度 =
狀態應該是不能再壓了,那麼轉移可以更快嗎?
i 從 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({-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
DP[v] = 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 i = it - DP.begin();
DP[i] = num;
ans = max(ans, i);
}
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,請輸出子區間和最大為多少。
給定一個數列 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 |
有限間距最長共同子序列 / 股票趨勢 (每個字元不同限制) |
zj b478, zj a374 學科決賽 100 - 5 |
限制 LCS 間距不能太大, |
Regular Expression Matching | Leetcode 10 | .* wildcard 匹配 |
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 很大 |
分組開會 | APCS 2025 / 01 - 4, zj q184 | Sliding Window 處理 + 選 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 |
Allocate Mail Boxes | leetcode 1478 | APCS 分組開會困難版 |
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 = 3 的 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
還在寫
Python
現在你有兩種骨牌: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,盒子可為空,
求以下幾種 Case 的可能數:
這裡並不是排列組合課
我們一個一個討論吧!
給定球數 n 跟箱子數 m,盒子可為空,
求以下 Case 的可能數:
A📦
⚽a
B📦
C📦
⚽b
給定球數 n 跟箱子數 m,盒子可為空,
求以下 Case 的可能數:
如何拆解?考量最後一個箱子
如果最後一個箱子有 i 顆球:
A📦
B📦
C📦
m📦
...
⚽⚽⚽
⚽⚽
給定球數 n 跟箱子數 m,盒子可為空,
求以下 Case 的可能數:
你稍微觀察會發現: 的前綴和
所以對著 Base Case (n=0可能數為1) 做 n 次前綴和就好了。
from itertools import accumulate
M = int(1e9+7)
dp = [1] * max_m
for _ in range(n):
dp = [x % M for x in accumulate(dp)]
print(*dp)
Python
C++
給定球數 n 跟箱子數 m,盒子可為空,
求以下 Case 的可能數:
事實上,這在高中被刪除
的課綱叫做重複組合
我們可以用排列組合的形式來看這題:
假設現在有五顆球,三個箱子,可以看成是
五顆球 + 兩個隔板的排列組合
⚽|⚽⚽⚽|⚽
舉例來說:
箱子分別有: 1/3/1
⚽⚽⚽⚽⚽||
箱子分別有: 5/0/0
接下來就可以套C的公式。
給定球數 n 跟箱子數 m,盒子可為空,
求以下 Case 的可能數:
n = 2
m = 2
ABC | |
---|---|
AB | C |
AC | B |
A | BC |
AB | |
---|---|
A | B |
n = 3
m = 2
我們先從 n = 2,3, m = 2
的 Case,找尋靈感。
觀察下狀態是怎麼轉移的。
每多一顆球,會根據原本
有幾個非空的箱子而轉移。
不考慮 C 自己一個箱子,
C 放在 |AB| 中只有一種可能
C 放在 |A|B| 中有兩種可能
給定球數 n 跟箱子數 m,盒子可為空,
求以下 Case 的可能數:
ABC | |
---|---|
AB | C |
AC | B |
A | BC |
AB | |
---|---|
A | B |
考量新的一顆球要放哪?
給定球數 n 跟箱子數 m,盒子可為空,
求以下 Case 的可能數:
M = int(1e9+7)
dp = [[0] * (max_m+1) for _ in range(n+1)]
for i in range(1, n+1):
for j in range(1, i+1):
if j == 1 or i == j:
dp[i][j] = 1
else:
dp[i][j] = (dp[i-1][j-1] + (j) * dp[i-1][j]) % M
print(*map(lambda x: x%M, accumulate(dp[n][1:max_m+1])))
Python
C++
給定球數 n 跟箱子數 m,盒子可為空,
求以下 Case 的可能數:
既然球沒有分,那應該用數字
表示箱子有幾顆球?
順帶一提,第二個 Case
(球相同,箱子不同) 也可以寫成
這樣的形式,這也是
重複組合的基本寫法。
接著思考一樣看怎麼轉
4
n = 3 的
所有 Case
n = 4 的
所有 Case
3 + 1
2 + 2
3
2 + 1
2 + 1 + 1
1 + 1 + 1
1 + 1 + 1 + 1
註:這又叫做整數拆分
給定球數 n 跟箱子數 m,盒子可為空,
求以下 Case 的可能數:
6 + 1 + 1
5 + 2 + 1
4 + 3 + 1
4 + 2 + 2
3 + 3 + 2
怎麼拆呢?考慮 Case 有沒有 1
說實話有點難...
4 + 2 + 2
3 + 3 + 2
怎麼拆呢?考慮 Case 沒有 1
考慮從 轉移?
5 + 1 + 1
4 + 2 + 1
3 + 3 + 1
3 + 2 + 2
會有以下問題:
這是因為+1有不對稱的問題。
有沒有方法可以公平的對待所有數字呢?
從 轉移
3 + 1 + 1
2 + 2 + 1
→ 4 + 2 + 2
→ 3 + 3 + 2
這樣可以同時解決兩個問題!
也就是可能會重複
給定球數 n 跟箱子數 m,盒子可為空,
求以下 Case 的可能數:
考慮 Case 有沒有 1。有 1 ( ) + 沒有 1 ( )
M = int(1e9+7)
dp = [[0] * (max_m+1) for _ in range(n+1)]
for i in range(1, n+1):
for j in range(1, i+1):
if j == 1 or i == j:
dp[i][j] = 1
else:
dp[i][j] = (dp[i-1][j-1] + (j) * dp[i-j][j]) % M
print(*map(lambda x: x%M, accumulate(dp[n][1:max_m+1])))
Python
C++
如果你很有興趣,那麼關於
如果箱子不可以為空的 Case,
可以參考這個網站。
Case 4 好難想...
但我覺得這題是整個投影片裡面
最通靈的地方了 (?)
給一個矩陣,問有多少個全部都是 1 個正方形?
1 | 0 | 1 |
---|---|---|
0 | 1 | 1 |
1 | 1 | 1 |
0 | 1 | 1 | 1 |
---|---|---|---|
1 | 1 | 1 | 1 |
0 | 1 | 1 | 1 |
舉例來說:
讓我們回歸比較正常的題目吧!
(雖說那些數學題比賽也會出)
給一個矩陣,問有多少個全部都是 1 個正方形?
函式定義這題比較特別,
想想看怎麼定義會比較有機會轉移吧!
0 | 1 | 1 | 1 |
---|---|---|---|
1 | 1 | 1 | 1 |
0 | 1 | 1 | 1 |
以這格為右下角,最大矩形為 3x3
怎麼從其他 DP 推出 3 呢?
如果這格答案要是 3,
那麼這兩個 2x2 一定要全是 1。
並且左上角,右下角一定要是 1。
或者,判斷左上角其實也可以用這個 2x2 來判斷。
給一個矩陣,問有多少個全部都是 1 個正方形?
DP_{n,m} 怎麼推到答案?
給一個矩陣,問有多少個全部都是 1 個正方形?
def countSquares(self, matrix):
n, m = len(matrix), len(matrix[1])
DP = [[0] * m for _ in range(n)]
ans = 0
for i, row in enumerate(matrix):
for j, cell in enumerate(row):
if cell:
DP[i][j] = 1 + min(
DP[i-1][j],
DP[i][j-1],
DP[i-1][j-1],
)
ans += DP[i][j]
return ans
Python
C++
給一個矩陣,問有多少個全部都是 1 個正方形?
如果是矩形呢?
一般的DP,但+數值離散化 / 區間和
現在有一條一維數線,並在上面有 n 個公車路線。
每個公車路線會有兩個變數 start, end,這表示
你可以從 [start, end-1] 搭車搭到 end 下車。
請問從 0 搭車搭到 m 有幾種搭法?(方法數需要 % p)
舉例來說:
4 7 11
0 4 0 3
4 6 6 7
n m p
s0 s1 ... sn
e0 e1 ... en
0
4
6
7
一號車
二號車
三號車
四號車
現在有一條一維數線,並在上面有 n 個公車路線。
每個公車路線會有兩個變數 start, end,這表示
你可以從 [start, end-1] 搭車搭到 end 下車。
請問從 0 搭車搭到 m 有幾種搭法?(方法數需要 % p)
好像有點熟悉?
如果一樣用 Bottom up
的角度來思考的話:
0
4
6
7
三號車
四號車
二號車
一號車
1
1
1
- 3
4
現在有一條一維數線,並在上面有 n 個公車路線。
每個公車路線會有兩個變數 start, end,這表示
你可以從 [start, end-1] 搭車搭到 end 下車。
請問從 0 搭車搭到 m 有幾種搭法?(方法數需要 % p)
如果一樣用 Bottom up
的角度來思考的話:
0
4
6
7
三號車
四號車
二號車
一號車
1
1
1
- 3
4
考慮 Bottom up 的順序,
我們應該是先填完
前面的位置,再填後面的位置。
實作上來說,
對每班公車的結尾排序,
由前往後做。
以左邊的例子:處理順序為
一號 / 二號 / 三號 / 四號
# Input
n, m, p = map(int, input().split())
S = list(map(int, input().split()))
E = list(map(int, input().split()))
bus = sorted(list(zip(E, S)))
# Start DP
DP = [1 % p] + [0] * m
for e, s in bus:
if e <= m:
DP[e] = (DP[e] + sum(DP[s:e])) % p
print(DP[-1])
Python
int n, m, p;
cin >> n >> m >> p;
vector<pair<int, int>> bus(n);
vector<long long> DP(m + 1);
DP[0] = 1 % p;
for (int i=0; i<n; i++)
scanf("%d", &bus[i].second);
for (int i=0; i<n; i++)
scanf("%d", &bus[i].first);
sort(bus.begin(), bus.end());
for (auto &[end, start] : bus) {
for (int i=start; i<end; i++)
DP[end] = (DP[end] + DP[i]) % p;
}
printf("%lld\n", DP[m] % p);
C++
分析看看複雜度吧!
超胖... 有沒有辦法可以加速呢?
紅色區域看起來好像是區間和?
動態計算前綴和,就可以把一個 O(m) 壓掉!
(因為DP陣列是動態產生的)
n, m, p = map(int, input().split())
S = list(map(int, input().split()))
E = list(map(int, input().split()))
bus = sorted(list(zip(E, S)))
DP = [1] + [0] * m
PS = [1] + [0] * m
PS_top = 1
IS = lambda l, r: PS[r] - (PS[l - 1] if l > 0 else 0)
for e, s in bus:
while PS_top < e:
PS[S_top] = (PS[PS_top - 1] + DP[PS_top]) % p
PS_top += 1
if e <= m:
DP[e] = (DP[e] + IS(s, e - 1)) % p
print(DP[-1])
Python
int n, m, p;
cin >> n >> m >> p;
vector<pair<int, int>> bus(n);
vector<long long> DP(m + 1);
vector<long long> PS{1 % p};
DP[0] = 1 % p;
for (int i=0; i<n; i++) cin >> bus[i].second;
for (int i=0; i<n; i++) cin >> bus[i].first;
sort(bus.begin(), bus.end());
for (auto &[end, start] : bus) {
while (PS.size() < end)
PS.push_back((PS.back() + DP[PS.size()]) % p);
DP[end] = (DP[end] + PS[end - 1] - (start ? PS[start - 1] : 0) + p) % p;
}
printf("%lld\n", DP[m] % p);
C++
分析看看複雜度吧!
有沒有辦法向雙人走路一樣,做到空間壓縮呢?
觀察一下,DP「有值」的地方只有 個,
但要開 個空間很浪費。
註: 這個值有可能是一個 -p+1 ~ 0 的負數,所以可以多加一個 p 讓它變成正數。
有沒有辦法向雙人走路一樣,做到空間壓縮呢?
0
4
6
7
三號車
四號車
二號車
一號車
0
三號車
四號車
二號車
一號車
原本的座標們
0 4 0 3
4 6 6 7
0 1 2 3
被壓縮的座標們
1
2
3
神奇方法?
觀察一下,DP「有值」的地方只有 個,
但要開 個空間很浪費。
有沒有辦法向雙人走路一樣,做到空間壓縮呢?
0 1 2 3 4
原本的座標們
被壓縮的座標們
用 set / map 把
重複座標刪掉
0 4 0 3
4 6 6 7
原本座標的可能
0 4 3 6 7
0 3 4 6 7
排序的座標可能
用 map / dict 把
原本座標對應到
他們的 index
觀察一下,DP「有值」的地方只有 個,
但要開 個空間很浪費。
n, m, p = map(int, input().split())
S = list(map(int, input().split()))
E = list(map(int, input().split()))
bus = sorted(list(zip(E, S)))
mapping = set([0, m] + E + S)
mapping = {x:i for i, x in enumerate(sorted(mapping))}
bus = [(mapping[e], mapping[s]) for e, s in bus]
m = mapping[m]
DP = [1] + [0] * m
PS = [1] + [0] * m
PS_top = 1
IS = lambda l, r: PS[r] - (PS[l - 1] if l > 0 else 0)
for e, s in bus:
while PS_top < e:
PS[PS_top] = (PS[PS_top - 1] + DP[PS_top]) % p
PS_top += 1
if e <= m:
DP[e] = (DP[e] + IS(s, e - 1)) % p
print(DP[-1])
Python
int n, m, p;
cin >> n >> m >> p;
vector<pair<int, int>> bus(n);
map<int, int> mapping({{0, 0}, {m, 0}});
for (int i=0; i<n; i++) {
scanf("%d", &bus[i].second);
mapping[bus[i].second] = 0;
}
for (int i=0; i<n; i++) {
scanf("%d", &bus[i].first);
mapping[bus[i].first] = 0;
}
int idx = 0;
for (auto &[k, v] : mapping)
v = idx++;
for (auto &[end, start] : bus)
end = mapping[end], start = mapping[start];
m = mapping[m];
// 下略,跟原本 DP 一樣
C++
分析看看複雜度吧!
Digit DP
舉例來說:
num1 = "5", num2 = "12",
min_sum = 2, max_sum = 4
定義 S(x) = x 的所有 digit 加總。問在 num1 到 num2 的數字中,
有多少數字的 S 在 min_sum 到 max_sum 之間?(答案要 % 1e9+7)
所以現在我們只需考慮:
定義 S(x) = x 的所有 digit 加總。問 0 ~ num 的數字中,
有多少數字的 S ≤ max_sum 之間?(答案要 % 1e9+7)
定義 S(x) = x 的所有 digit 加總。問在 num1 到 num2 的數字中,
有多少數字的 S 在 min_sum 到 max_sum 之間?(答案要 % 1e9+7)
定義 S(x) = x 的所有 digit 加總。問 0 ~ num 的數字中,
有多少數字的 S ≤ max_sum 之間?(答案要 % 1e9+7)
首先我們觀察一下題目:
可以得到一個比較簡單的 DP 定義:
理論上是 [0-7],但這樣寫,
之後再處理會比較好寫。
定義 S(x) = x 的所有 digit 加總。問 0 ~ num 的數字中,
有多少數字的 S ≤ max_sum 之間?(答案要 % 1e9+7)
M = int(1e9+7)
DP = {}
def rec(num_len, high_sum):
if high_sum < 0: return 0
if num_len == 0: return 1
if (num_len, high_sum) not in DP:
ans = 0
for i in range(10):
ans += rec(num_len-1, high_sum-i)
DP[num_len, high_sum] = ans % M
return DP[num_len, high_sum]
Python
const long long M = 1000000007;
map<pair<int, int>, long long> DP;
long long rec(int num_len, int max_sum) {
if (max_sum < 0) return 0;
if (num_len == 0) return 1;
if (DP.find({num_len, max_sum}) == DP.end()) {
long long ans = 0;
for (int i=0; i<10; i++)
ans = (ans + rec(num_len-1, max_sum-i)) % M;
DP[{num_len, max_sum}] = ans;
}
return DP[{num_len, max_sum}];
}
C++
枚舉第一個數是什麼
def find(num, max_sum):
ans = 0
for idx, start in enumerate(map(int, num)):
for cur_start in range(start):
ans = (ans + rec(len(num)-idx-1, max_sum-cur_start)) % M
max_sum -= start
return ans
Python
long long find(string num, int max_sum) {
long long ans = 0;
for (int idx=0; idx < num.size(); idx++) {
for (int cur_start=0; cur_start < num[idx]-'0'; cur_start++)
ans = (ans + rec(num.size()-idx-1, max_sum-cur_start)) % M;
max_sum -= num[idx]-'0';
}
return ans;
}
C++
根據上面的邏輯
寫出這樣的函數:
find(num, max_sum) =
「在 0 到 num-1 的數字中,有幾個數字加總 ≤ max_sum」
class Solution {
public:
const long long M = 1000000007;
map<pair<int, int>, long long> DP;
long long rec(int num_len, int max_sum) {
if (max_sum < 0)
return 0;
if (num_len == 0)
return 1;
if (DP.find({num_len, max_sum}) == DP.end()) {
long long ans = 0;
for (int i = 0; i < 10; i++)
ans = (ans + rec(num_len - 1, max_sum - i)) % M;
DP[{num_len, max_sum}] = ans;
}
return DP[{num_len, max_sum}];
}
long long find(string num, int max_sum) {
long long ans = 0;
for (int idx = 0; idx < num.size(); idx++) {
for (int cur_start = 0; cur_start < num[idx] - '0'; cur_start++)
ans =
(ans + rec(num.size() - idx - 1, max_sum - cur_start)) % M;
max_sum -= num[idx] - '0';
}
return ans;
}
int count(string num1, string num2, int min_sum, int max_sum) {
long long A = find(num2, max_sum) - find(num2, min_sum - 1);
long long B = find(num1, max_sum) - find(num1, min_sum - 1);
int sum = 0;
for (char c : num2)
sum += c - '0';
bool num2_valid = min_sum <= sum && sum <= max_sum;
return ((A - B + (num2_valid)) % M + M) % M;
}
};
C++
最後把題目要的答案更出來就可以了。
find(num2+1, max_sum) -
find(num2+1, min_sum-1) -
find(num1, max_sum) +
find(num1, min_sum-1)
num2+1 在 C++ 轉 long long 會噴範圍 (overflow),所以只能要 find(num2)
然後特判 num2 本身。
定義 S(x) = x 的所有 digit 加總。問在 num1 到 num2 的數字中,
有多少數字的 S 在 min_sum 到 max_sum 之間?(答案要 % 1e9+7)
class Solution:
def count(self, num1: str, num2: str, min_sum: int, max_sum: int):
M = int(1e9 + 7)
DP = {}
def rec(num_len, high_sum):
if high_sum < 0:
return 0
if num_len == 0:
return 1
if (num_len, high_sum) not in DP:
ans = 0
for i in range(10):
ans += rec(num_len - 1, high_sum - i)
DP[num_len, high_sum] = ans % M
return DP[num_len, high_sum]
def find(num, _max_sum):
ans = 0
max_sum = _max_sum
for idx, start in enumerate(map(int, num)):
for cur_start in range(start):
ans = (ans + rec(len(num) - idx - 1, max_sum - cur_start)) % M
max_sum -= start
return ans
num2 = str(int(num2) + 1)
A = find(num2, max_sum) - find(num2, min_sum - 1)
B = find(num1, max_sum) - find(num1, min_sum - 1)
return (A - B) % M
Python
最後把題目要的答案更出來就可以了。
find(num2+1, max_sum) -
find(num2+1, min_sum-1) -
find(num1, max_sum) +
find(num1, min_sum-1)
定義 S(x) = x 的所有 digit 加總。問在 num1 到 num2 的數字中,
有多少數字的 S 在 min_sum 到 max_sum 之間?(答案要 % 1e9+7)
主題 | 題目名稱 | 大概作法 |
---|---|---|
記數類DP | 骨牌問題 ☆ | 列舉最後一排可能,枚舉最後所有疊法。 |
矩陣快速覓:寫成轉移矩陣的形式後 + 快速冪技巧算出來 | ||
配對連線 ☆ | 枚舉第一跟線怎麼畫,最後得出卡特蘭數的公式。 | |
國小排列組合 ☆ | 球相同,箱不同:分箱法 - 重複組合 / 推完轉移變成多次前綴和 |
|
球不同,箱相同:考慮新的球要不要獨立放。 | ||
球相同,箱相同:(拆分數) 把所有可能分成有沒有 1 這兩個 Case。 | ||
計算正方形個數 ☆ | 定義DP = 最大正方形,觀察轉移的關係。 | |
搭到終點 | 給定拓樸順序的 DAG 上路徑,用離散化壓縮空間,用區間和快速轉移。 | |
數位 DP | Count of Ints ☆ | 將數字拆成各個前綴後,歸納出 DP 狀態。 |
這個章節比較多排列組合的感覺,
大概可以算是 DP 題目裡面比較偏門的題目。
找個尋找最大矩形
質方圖舉行不知道是不是和放
把條件寫到狀態內
題目名稱 | 來源 | 備註 |
---|---|---|
円円數磁磚 | 資芽算法班作業 | Domino 放 3xN 的可能數 |
Painting Grid | leetcode 1932 | 不相鄰塗3色,問塗 (1~5)xN 的可能數 |
白色世界 (困難版) | zj g566 | 簡單的轉移但要矩陣快速冪 |
Critical Mass | zj a388 | 骰n次硬幣有連續三+的次數 |
三元樹 | TOI 2009 1! |
求三元樹的可能數 (需要優化到 O(N^2)) |
Combination Sum IV | leetcode 377 | 裸數字拆分 |
Count Pyramids | leetcode 2088 | 數等腰三角形有幾個 |
Find All Stable Arrays | leetcode 3130 | 要求長度為k的子陣列都要包含 0/1 的可能數 |
題目名稱 | 來源 | 備註 |
---|---|---|
blank |
https://leetcode.com/problems/falling-squares/
純離散化可以過 但可以線段樹+懶人標
Range DP
給定一個序列,每次可選兩相鄰數 u,v 合併成一個數字 u+v,
並寫需要花費 |u-v|。問合併到只剩一個數字的最小花費?
舉例來說:[-5, 3, 0, -4, 3, -2] 的最佳解操作:
答案:8 + 7 + 1 + 1 + 1 = 18
給定一個序列,每次可選兩相鄰數 u,v 合併成一個數字 u+v,
並寫需要花費 |u-v|。問合併到只剩一個數字的最小花費?
我們先來試試看最原始的定義吧!
想想看這個數字往前可以怎麼合併吧!
-5 | 3 | 0 | -4 | 3 | -2 |
---|
好像怪怪的?
你不知道怎麼跟上個數字合併會最好。
因為上個數字可能會先跟別人合併再跟 An 併
給定一個序列,每次可選兩相鄰數 u,v 合併成一個數字 u+v,
並寫需要花費 |u-v|。問合併到只剩一個數字的最小花費?
-5 | 3 | 0 | -4 | 3 | -2 |
---|
你不知道怎麼跟上個數字合併會最好。
因為上個數字可能會先跟別人合併再跟 An 併
換句話說,我們必須枚舉上個數字是怎麼合併的。
怎樣的定義才有足夠的資訊知道
前一個數字會併到哪裡去呢?
給定一個序列,每次可選兩相鄰數 u,v 合併成一個數字 u+v,
並寫需要花費 |u-v|。問合併到只剩一個數字的最小花費?
-5 | 3 | 0 | -4 | 3 | -2 |
---|
解答的最後一次合併
也可以這樣想:
考量解答的最後一個合併
我們其實可以用
一個區間表達合併的前一步。
給定一個序列,每次可選兩相鄰數 u,v 合併成一個數字 u+v,
並寫需要花費 |u-v|。問合併到只剩一個數字的最小花費?
-5 | 3 | 0 | -4 | 3 | -2 |
---|
考量最後一步的什麼?
考量最後一次的合併
一個區間合併完的數字
= 這個區間的總和
拆解 = 枚舉每一種合併方法,或者說最後一次的切割點。
給定一個序列,每次可選兩相鄰數 u,v 合併成一個數字 u+v,
並寫需要花費 |u-v|。問合併到只剩一個數字的最小花費?
分析看看複雜度吧!
狀態數量 × 轉移複雜度 =
int IS(int l, int r) {
return PS[r] - (l == 0 ? 0 : PS[l-1]);
}
int rec(int l, int r) {
if (l == r) return 0;
if (dp[l][r]) return dp[l][r];
dp[l][r] = INT_MAX;
for (int m=l; m<r; m++){
dp[l][r] = min(
dp[l][r],
rec(l, m) + rec(m+1, r) + \
abs(IS(l, m) - IS(m+1, r))
);
}
return dp[l][r];
}
int main() {
scanf("%d", &n);
for (int i=0; i<n; i++) {
scanf("%d", &ary[i]);
PS[i] = ary[i] + (i == 0 ? 0 : PS[i-1]);
}
printf("%d\n", rec(0, n-1));
return 0;
}
from math import inf
from itertools import accumulate
n = int(input())
A = list(map(int, input().split()))
PS = list(accumulate(A))
IS = lambda l, r: PS[r] - (PS[l-1] if l > 0 else 0)
dp = {}
def rec(l, r):
if l == r: return 0
if (l, r) not in dp:
ans = inf
for k in range(l, r):
cost = rec(l, k) + rec(k+1, r)
cost += abs(IS(l, k) - IS(k+1, r))
ans = min(ans, cost)
dp[l, r] = ans
return dp[l, r]
print(rec(0, n-1))
C++
Python
這題出自於這樣子的實用題:
現在你有很多個矩陣要相乘,
請問怎麼乘最有效率?
矩陣乘法長這樣子:
舉例來說,現在有三個矩陣要乘
先做
AB:
先做
BC:
舉例來說,現在有三個矩陣要乘
雖然設定有點複雜,但 DP 式仔細看是一樣的!
在一個長度為 P 的線性牢籠中,釋放第 x 個人時,你必須支付錢給他左右連續的鄰居,每人 1 塊錢,直到遇到邊界或空位為止。
請問釋放指定的 Q 個人時,最少需要花費多少?
😈
1
😈
2
😭
3
😈
4
😈
5
😭
6
😈
7
...
😈
13
😭
14
😈
15
...
😈
20
舉例來說,P = 20, Q = {3, 6, 14}
🔓 Cost $19
🔓 Cost $4
🔓 Cost $12
順序:14,6,3,這樣成本總共 19 + 12 + 4 = 35
在一個長度為 P 的線性牢籠中,釋放第 x 個人時,你必須支付錢給他左右連續的鄰居,每人 1 塊錢,直到遇到邊界或空位為止。
請問釋放指定的 Q 個人時,最少需要花費多少?
😈
1
😈
2
😭
3
😈
4
😈
5
😭
6
😈
7
...
😈
13
😭
14
😈
15
...
😈
20
我們先來試試看向上一題一樣的定義吧!
像往常一樣,考量最後一次釋放會在哪裡?
🔓
假設最後解放第六個人會怎樣?
在一個長度為 P 的線性牢籠中,釋放第 x 個人時,你必須支付錢給他左右連續的鄰居,每人 1 塊錢,直到遇到邊界或空位為止。
請問釋放指定的 Q 個人時,最少需要花費多少?
😈
1
😈
2
😭
3
😈
4
😈
5
😭
6
😈
7
...
😈
13
😭
14
😈
15
...
😈
20
像往常一樣,考量最後一次釋放會在哪裡?
🔓
假設最後解放第六個人會怎樣?
如果最後一次釋放第六個人,
那麼左右邊的囚犯也要互相考慮,沒有比較好做...
在一個長度為 P 的線性牢籠中,釋放第 x 個人時,你必須支付錢給他左右連續的鄰居,每人 1 塊錢,直到遇到邊界或空位為止。
請問釋放指定的 Q 個人時,最少需要花費多少?
😈
1
😈
2
😭
3
😈
4
😈
5
😭
6
😈
7
...
😈
13
😭
14
😈
15
...
😈
20
按照正常遞迴的寫法就會變成:
考量第一次釋放會在哪裡?
🔓
假設先解放第六個人會怎樣?
怎麼辦呢?想想看如果你在寫遞迴 (dfs-style) 暴搜會怎麼樣?
在一個長度為 P 的線性牢籠中,釋放第 x 個人時,你必須支付錢給他左右連續的鄰居,每人 1 塊錢,直到遇到邊界或空位為止。
請問釋放指定的 Q 個人時,最少需要花費多少?
😈
1
😈
2
😭
3
😈
4
😈
5
😭
6
😈
7
...
😈
13
😭
14
😈
15
...
😈
20
考量第一次釋放會在哪裡?
🔓
假設先解放第六個人會怎樣?
1~5 的牢房和 7~20的牢房從此隔開!
可以直接往下遞迴算 (1~5) 的成本 + (7~20) 的成本
這樣你好像就知道遞迴怎麼取了!枚舉第一次釋放的位置就可以!
在一個長度為 P 的線性牢籠中,釋放第 x 個人時,你必須支付錢給他左右連續的鄰居,每人 1 塊錢,直到遇到邊界或空位為止。
請問釋放指定的 Q 個人時,最少需要花費多少?
...
🔓
😈
😭
😈
😈
😭
...
😭
😈
中間總共有 個人,
這個成本再 -1 因為釋放的人不用給錢。
這樣你好像就知道遞迴怎麼取了!枚舉第一次釋放的位置就可以!
...
💨
😈
...
😈
💨
已經烙幹了
已經烙幹了
在一個長度為 P 的線性牢籠中,釋放第 x 個人時,你必須支付錢給他左右連續的鄰居,每人 1 塊錢,直到遇到邊界或空位為止。
請問釋放指定的 Q 個人時,最少需要花費多少?
分析看看複雜度吧!
狀態數量 × 轉移複雜度 =
雖然標程到這裡已經 OK 了,但其實還可以更快!
在優化前,你想得出怎麼寫成 Bottom-up 嗎?
實作上,我們假想有 0 號囚犯 跟 (P+1) 號囚犯已經被釋放,這樣就不用判斷邊界了。
vector<vector<int>> dp;
int rec(const vector<int> &A, int l, int r){
if (l > r) return 0;
if (l == r) return A[r + 1] - A[l - 1] - 2;
if (dp[l][r] != -1) return dp[l][r];
int ans = INT_MAX;
for (int m = l; m <= r; ++m)
ans = min(ans,
rec(A, l, m - 1) + rec(A, m + 1, r));
dp[l][r] = ans + A[r + 1] - A[l - 1] - 2;
return dp[l][r];
}
// 省略一些些
vector<int> A(Q+2, 0);
A[Q+1] = P+1;
for (int i = 1; i <= Q; ++i)
cin >> A[i];
dp.assign(Q + 2, vector<int>(Q + 2, -1));
printf("Case #%d: %d\n", t, rec(A, 1, Q));
from math import inf
T = int(input())
def rec(A, l, r):
if l > r:
return 0
if l == r:
return A[r+1] - A[l-1] - 2
if (l, r) not in dp:
ans = inf
for m in range(l, r+1):
ans = min(ans, rec(A, l, m-1) + rec(A, m+1, r))
dp[l, r] = ans + A[r+1] - A[l-1] - 2
return dp[l, r]
for t in range(1, T+1):
P, Q = map(int, input().split())
dp = {}
A = [0] + list(map(int, input().split())) + [P+1]
print(f"Case #{t}: {rec(A, 1, Q)}")
C++ (Top-down)
Python (Top-down)
int main() {
int T, P, Q;
scanf("%d", &T);
for (int t = 1; t <= T; ++t){
cin >> P >> Q;
vector<int> A(Q+2, 0);
A[Q+1] = P+1;
for (int i = 1; i <= Q; ++i)
cin >> A[i];
vector<vector<int>> dp(Q + 2,
vector<int>(Q + 2, 0));
for (int w=0; w < Q; w++) {
for (int l=1, r=l+w; r<=Q; l++, r++) {
if (w == 0) {
dp[l][r] = A[r+1] - A[l-1] - 2;
continue;
}
int ans = INT_MAX;
for (int m = l; m <= r; ++m)
ans = min(ans, dp[l][m-1] + dp[m+1][r]);
dp[l][r] = ans + A[r+1] - A[l-1] - 2;
}
}
printf("Case #%d: %d\n", t, dp[1][Q]);
}
}
for t in range(1, T+1):
P, Q = map(int, input().split())
dp = [[0] * (Q+2) for _ in range(Q+2)]
A = [0] + list(map(int, input().split())) + [P+1]
for w in range(Q):
for l in range(1, Q-w+1):
r = l + w
if w == 0:
dp[l][r] = A[r+1] - A[l-1] - 2
continue
ans = inf
for m in range(l, r+1):
ans = min(ans, dp[l][m-1] + dp[m+1][r])
dp[l][r] = ans + A[r+1] - A[l-1] - 2
print(f"Case #{t}: {dp[1][Q]}")
C++ (Bottom-up)
Python (Bottom-up)
先從長度 = 0 開始 (Base Case)
接著在做長度 = 1的Case
直到做完 (1, Q)
先從長度 = 0 開始 (Base Case)
接著在做長度 = 1的 Case,直到做完 (1, Q)
走訪順序大概是長這樣
初探 Knuth 優化 (Knuth's Optimization)
不過仔細觀察一下你會發現:轉移點會單調移動
...
😈
😭
...
🔓
😈
😭
😈
😈
😭
...
😭
😈
此時的最佳切割點不會往左邊跑,因為你會希望你切的時候是可以「平衡」的切的。
(也就是第一次要釋放誰)
不過仔細觀察一下你會發現:轉移點會單調移動
...
😈
😭
😈
😭
😈
🔓
...
😭
😈
...
此時的最佳切割點不會往左邊跑,因為你會希望你切的時候是可以「平衡」的切的。
😭
😈
在一個長度為 P 的線性牢籠中,釋放第 x 個人時,你必須支付錢給他左右連續的鄰居,每人 1 塊錢,直到遇到邊界或空位為止。
請問釋放指定的 Q 個人時,最少需要花費多少?
// 省略前面
vector<vector<int>> dp(Q + 2, vector<int>(Q + 2));
vector<vector<int>> opt(Q + 2, vector<int>(Q + 2));
for (int w=0; w < Q; w++) {
for (int l=1, r=l+w; r<=Q; l++, r++) {
if (w == 0) {
dp[l][r] = A[r+1] - A[l-1] - 2;
opt[l][r] = l;
continue;
} else {
dp[l][r] = INT_MAX;
int lb = opt[l][r-1];
int ub = opt[l+1][r];
for (int m = lb; m <= ub; m++) {
int val = dp[l][m-1] + dp[m+1][r];
if (val < dp[l][r]) {
dp[l][r] = val;
opt[l][r] = m;
}
}
dp[l][r] += A[r+1] - A[l-1] - 2;
}
}
}
printf("Case #%d: %d\n", t, dp[1][Q]);
from math import inf
T = int(input())
for t in range(1, T+1):
P, Q = map(int, input().split())
dp = [[0] * (Q+2) for _ in range(Q+2)]
opt = [[0] * (Q+2) for _ in range(Q+2)]
A = [0] + list(map(int, input().split())) + [P+1]
for w in range(Q):
for l in range(1, Q-w+1):
r = l + w
if w == 0:
dp[l][r] = A[r+1] - A[l-1] - 2
opt[l][r] = l
else:
dp[l][r] = inf
for m in range(opt[l][r-1], opt[l+1][r]+1):
if dp[l][m-1] + dp[m+1][r] < dp[l][r]:
dp[l][r] = dp[l][m-1] + dp[m+1][r]
opt[l][r] = m
dp[l][r] += A[r+1] - A[l-1] - 2
print(f"Case #{t}: {dp[1][Q]}")
C++ (Bottom-up + Knuth opt)
Python (Bottom-up + Knuth opt)
修改跑的範圍
修改跑的範圍
在一個長度為 P 的線性牢籠中,釋放第 x 個人時,你必須支付錢給他左右連續的鄰居,每人 1 塊錢,直到遇到邊界或空位為止。
請問釋放指定的 Q 個人時,最少需要花費多少?
分析看看複雜度吧!
狀態數量 × 轉移複雜度 =
看起來好像沒有比較快啊?
轉移雖然最多 O(n) ,但我們稍微分析一下...
轉移雖然最多 O(n) ,但我們稍微分析一下...
第一層迴圈決定 w,第二層迴圈決定 l,而 r = l + w
第二層迴圈中的搜尋總共會跑幾次?
總共 O(n),尋找 n 次,所以尋找均攤 O(1)
在一個長度為 P 的線性牢籠中,釋放第 x 個人時,你必須支付錢給他左右連續的鄰居,每人 1 塊錢,直到遇到邊界或空位為止。
請問釋放指定的 Q 個人時,最少需要花費多少?
分析看看複雜度吧!
狀態數量 × 轉移複雜度 =
是不是覺得這種做法很玄乎呢?
這種優化就叫做 Knuth's Optimization
在寫題目的時候,如果你發現 DP 轉移是這種形式:
這裡的 w 函數符合四邊形不等式 (Monge 性質)
那麼轉移就滿足單調性,也就是
也就可以套 Knuth's Optimization (Knuth 優化)
思考看看這題有沒有滿足 Knuth 優化條件吧!
只要上面這兩項都成立,那麼轉移符合單調性。
也就可以套上我們原本寫的算法,好耶!
至於為什麼 ... 等到認真講優化再證明給你看吧!
為什麼叫做四邊形不等式呢?
速記:「交叉≤包含」
如果對一個四邊形做交叉編號,
那麼滿足以下不等式:
跟上面寫的四邊形不等式很像!
A
B
C
D
現在有一排方塊,並且給你每個方塊的顏色。
你可以刪除任一個同顏色連續方塊,並獲得長度²的分數,
並且刪除完後左右會合併。請問刪完後最高分數是多少?
舉例來說,[1, 2, 2, 2, 2, 3, 3, 3, 1] 最高答案是 29
註:你可以先計算連續方塊的個數,但複雜度一般來說不變。
想想看之前的例題?
想想看最後一次刪除可以怎麼分 Case?
現在有一排方塊,並且給你每個方塊的顏色。
你可以刪除任一個同顏色連續方塊,並獲得長度²的分數,
並且刪除完後左右會合併。請問刪完後最高分數是多少?
刪左邊
...
...
枚舉刪的切割點
刪右邊
這是錯的!為什麼?
現在有一排方塊,並且給你每個方塊的顏色。
你可以刪除任一個同顏色連續方塊,並獲得長度²的分數,
並且刪除完後左右會合併。請問刪完後最高分數是多少?
這是錯的!為什麼?
沒有考量到合併後的刪除。
上面的 Case 的最佳解不能把它拆成兩個區塊處理。
這也代表這個切法沒有考慮到所有 Case。
那換另外的想法!
想想看最後一個方塊有哪些刪法?
現在有一排方塊,並且給你每個方塊的顏色。
你可以刪除任一個同顏色連續方塊,並獲得長度²的分數,
並且刪除完後左右會合併。請問刪完後最高分數是多少?
自己刪
刪一個區間的雜色,合併再刪
刪兩個區間的雜色,合併再刪
這也錯!為什麼?
...
...
現在有一排方塊,並且給你每個方塊的顏色。
你可以刪除任一個同顏色連續方塊,並獲得長度²的分數,
並且刪除完後左右會合併。請問刪完後最高分數是多少?
這也錯!為什麼?
這樣的切法相當於要求橘色被刪除時,一定是連續的。
這代表想合併右邊+左邊的橘色,你也會要求中間橘色也要一起合併。
但上面的 Case 的最佳解是先把中間刪掉: 1² + 4² + 2² = 21。
而不是先把藍色削掉,把橘色合併:2² + 2² + 3² = 17。
這也代表這個切法沒有考慮到所有 Case。
我們可以借鏡「卡特蘭數 - 對角線」那題的拆法:
這樣拆解好像就可以了!
那題是枚舉最後一次碰到對角線的點,這題呢?
到底怎麼拆才可以考量所有Case呢?
...
...
...
考量下一個要合併的橘色在哪?
最右邊的刪除
...
最右邊的刪除
...
最右邊的刪除
...
考量下一個要合併的橘色在哪?
不是?好像哪裡怪怪的?
最右邊的刪除
需要再多一個狀態:
尾巴的橘色有幾個併在一起?
最右邊的刪除
...
把右邊的刪除後
vector<int> boxes;
int dp[101][101][101] = {0};
int rec(int l, int r, int nR) {
if (l > r) return 0;
if (dp[l][r][nR])
return dp[l][r][nR];
int ans = rec(l, r-1, 0) + (nR+1)*(nR+1);
for (int m=l; m<r; m++) {
if (boxes[m] == boxes[r])
ans = max(ans, rec(l, m, nR+1) +
rec(m+1, r-1, 0));
}
return dp[l][r][nR] = ans;
}
int removeBoxes(vector<int>& boxes) {
this->boxes = boxes;
return rec(0, boxes.size()-1, 0);
}
def removeBoxes(self, boxes):
dp = {}
def rec(l, r, nR):
if l > r: return 0
if (l, r, nR) in dp:
return dp[l, r, nR]
ans = rec(l, r-1, 0) + (nR + 1) ** 2
for m in range(l, r):
if boxes[m] == boxes[r]:
ans = max(ans, rec(l, m, nR+1) + \
rec(m+1, r-1, 0))
dp[l, r, nR] = ans
return ans
return rec(0, len(boxes)-1, 0)
C++ (Top-down)
Python (Top-down)
沒有合併了:
沒有合併了:
分析看看複雜度吧!
狀態數量 × 轉移複雜度 =
結束了嗎?也許還可以更快!
Yeh, Here we go again
初探分治優化 (Divide and Conquer / D&C DP)
我們來稍微想一下有沒有單調性:
最右邊的刪除
...
如果 nR 變多了,那麼你會有更大的可能去合併更多
所以你會希望最右邊的刪除會往右移 (保留更多合併的可能)
這可以做什麼呢?
可以一直縮短範圍,減少搜尋的次數!
可以一直縮短範圍,減少搜尋的次數!
...
不過如果前期找到的 opt 都在前面,
那麼還是會變整體 ...
我們可以採用類似二分搜的方法,一開始從中間找,也許比較好!
我們可以採用類似二分搜的方法,一開始從中間找,也許比較好!
這樣一直分下去,就叫做分治優化。
def DC(nR_L, nR_R, opt_lb, opt_rb):
if nR_L > nR_R: return
nR_M = (nR_L + nR_R) // 2
ans_m, opt_m = solve(nR_M, opt_lb, opt_rb)
DC(nR_L, nR_M-1, opt_lb, opt_m)
DC(nR_M+1, nR_R, opt_m, opt_rb)
void DC(int nR_L, int nR_R, int lb, int rb) {
if (nR_L > nR_R) return;
int nR_M = (nR_L + nR_R) / 2;
auto [ans_m, opt_m] = solve(nR_M, lb, rb);
DC(nR_L, nR_M - 1, lb, opt_m);
DC(nR_M + 1, nR_R, opt_m, rb);
};
C++ (分治優化)
Python (分治優化)
我們可以採用類似二分搜的方法,一開始從中間找,也許比較好!
def DC(nR_L, nR_R, opt_lb, opt_rb):
if nR_L > nR_R: return
nR_M = (nR_L + nR_R) // 2
ans_m, opt_m = solve(nR_M, opt_lb, opt_rb)
DC(nR_L, nR_M-1, opt_lb, opt_m)
DC(nR_M+1, nR_R, opt_m, opt_rb)
void DC(int nR_L, int nR_R, int lb, int rb) {
if (nR_L > nR_R) return;
int nR_M = (nR_L + nR_R) / 2;
auto [ans_m, opt_m] = solve(nR_M, lb, rb);
DC(nR_L, nR_M - 1, lb, opt_m);
DC(nR_M + 1, nR_R, opt_m, rb);
};
C++ (分治優化)
Python (分治優化)
def DC(nR_L, nR_R, opt_lb, opt_rb):
if nR_L > nR_R: return
nR_M = (nR_L + nR_R) // 2
ans_m, opt_m = solve(nR_M, opt_lb, opt_rb)
DC(nR_L, nR_M-1, opt_lb, opt_m)
DC(nR_M+1, nR_R, opt_m, opt_rb)
void DC(int nR_L, int nR_R, int lb, int rb) {
if (nR_L > nR_R) return;
int nR_M = (nR_L + nR_R) / 2;
auto [ans_m, opt_m] = solve(nR_M, lb, rb);
DC(nR_L, nR_M - 1, lb, opt_m);
DC(nR_M + 1, nR_R, opt_m, rb);
};
C++ (分治優化)
Python (分治優化)
用分析的不好寫,我們改用樹法,假設一開始搜尋
你會發現每一層的搜尋範圍加總 =
總共最多 所以總共為
沒有合併了:
分析看看複雜度吧!
對於每個 (l, r),用分治算完所有 (l, r, nR)
好耶!
雖然 leetcode / UVA 都可以 AC,但這其實是錯的!
nR 沒有單調性! (但如果有單調性,分治會是好的。)
#include <bits/stdc++.h>
using namespace std;
class Solution {
public:
int removeBoxes(vector<int>& boxes) {
// color_table[color] = The indices that the boxes with color
// color_table_idx[i] = The i-th color in the boxes
unordered_map<int, vector<int>> color_table;
vector<int> color_table_idx(boxes.size());
for (int i = 0; i < boxes.size(); i++) {
int c = boxes[i];
color_table_idx[i] = color_table[c].size();
color_table[c].push_back(i);
}
int n = boxes.size();
vector<vector<vector<int>>> dp(n, vector<vector<int>>(n));
function<int(int,int,int)> rec = [&](int l, int r, int nR) -> int {
// Base Case
if (l > r) return 0;
// Memoization
if (dp[l][r].size()) return dp[l][r][nR];
// We only need to consider the transition point
// with the boxes[r] color
int color = boxes[r];
// ----------------------------------------------------
// solve(cur_nR, opt_lb, opt_rb):
// Search DP[l, r, nR] with the bounds [opt_lb, opt_rb]
// and return [optimal value, optimal transition point]
// ----------------------------------------------------
function< pair<int,int>(int,int,int) > solve =
[&](int cur_nR, int opt_lb, int opt_rb) -> pair<int,int> {
int ans = rec(l, r - 1, 0) + (cur_nR + 1) * (cur_nR + 1);
// We let the opt be opt_lb when the base case is the ans.
int ans_opt = opt_lb;
for (int m_idx = opt_lb; m_idx <= opt_rb; m_idx++) {
int m = color_table[color][m_idx];
if (m == r)
break;
int tmp = rec(l, m, cur_nR + 1) + rec(m + 1, r - 1, 0);
if (tmp > ans) {
ans = tmp;
ans_opt = m_idx;
}
}
// Record answer ans return it's best trans point
dp[l][r][cur_nR] = ans;
return {ans, ans_opt};
};
// D&C solve all the nR in [nR_L, nR_R] with bounds
// [opt_lb, opt_rb]
function<void(int,int,int,int)> DC =
[&](int nR_L, int nR_R, int lb, int rb) {
if (nR_L > nR_R) {
return;
}
int nR_M = (nR_L + nR_R) / 2;
auto [ans_m, opt_m] = solve(nR_M, lb, rb);
DC(nR_L, nR_M - 1, lb, opt_m);
DC(nR_M + 1, nR_R, opt_m, rb);
};
int opt_rb = color_table_idx[r];
int max_nR = color_table[color].size() - opt_rb - 1;
dp[l][r].resize(max_nR+1);
int opt_lb = opt_rb;
for (int i = l; i <= r; i++) {
if (boxes[i] == color) {
opt_lb = color_table_idx[i];
break;
}
}
DC(0, max_nR, opt_lb, opt_rb);
return dp[l][r][nR];
};
return rec(0, (int)boxes.size() - 1, 0);
}
};
class Solution:
def removeBoxes(self, boxes):
# color_table[color] = The indices that the boxes with color
# color_table_idx[i] = The i-th color in the boxes
color_table = defaultdict(list)
color_table_idx = []
for idx, box in enumerate(boxes):
color_table_idx.append(len(color_table[box]))
color_table[box].append(idx)
dp = {}
def rec(l, r, nR):
if l > r:
return 0
if (l, r, nR) in dp:
return dp[l, r, nR]
# We only need to consider the transition point
# with the boxes[r] color
color = boxes[r]
# Search DP[l, r, nR] with the bounds [opt_lb, opt_rb]
# and return [optimal value, optimal transition point]
def solve(nR, opt_lb, opt_rb):
ans = rec(l, r - 1, 0) + (nR + 1) ** 2
ans_opt = opt_lb
for m_idx in range(opt_lb, opt_rb+1):
m = color_table[color][m_idx]
if m == r:
break
tmp = rec(l, m, nR + 1) + rec(m + 1, r - 1, 0)
if tmp > ans:
ans = tmp
ans_opt = m_idx
dp[l, r, nR] = ans
return ans, ans_opt
# D&C solve all the nR in [nR_L, nR_R] with bounds
# [opt_lb, opt_rb]
def DC(nR_L, nR_R, opt_lb, opt_rb):
if nR_L > nR_R:
return
nR_M = (nR_L + nR_R) // 2
ans_m, opt_m = solve(nR_M, opt_lb, opt_rb)
DC(nR_L, nR_M-1, opt_lb, opt_m)
DC(nR_M+1, nR_R, opt_m, opt_rb)
opt_rb = color_table_idx[r]
max_nR = len(color_table[color]) - opt_rb - 1
# Find the first block with color boxes[r]
# after l.
opt_lb = opt_rb
for i in range(l, r+1):
if boxes[i] == boxes[r]:
opt_lb = color_table_idx[i]
break
DC(0, max_nR, opt_lb, opt_rb)
return dp[l, r, nR]
return rec(0, len(boxes) - 1, 0)
C++ (分治優化 / 假解)
Python (分治優化 / 假解)
提供一下最後的 AC Code,可以參考一下
講古時間 - 分享個有趣的事件
我 (筆者) 當時看到這題,是在 IOI Camp 2015 的講義上
然後它在後面寫上這句話:
我 (筆者) 笨笨,想不出解答,所以有事就問脆
然後我自己「感覺」到了 n^3logn 的 nR 單調性
但我也只是「感覺」,沒有證明,所以疊了甲
然後我自己「感覺」到了 n^3logn 的 nR 單調性
然後被學弟打臉說這是假解 Orz
註: 這裡指的127 (不包括大神) 是我
接著我發到 NTUCPC (台大解題社) 尋求協助...
然後 baluteshih 得知的 IOI Camp 這句話的原處:
這個 PDF 是台大競賽程式課程的第一個作業的解答的補充
這個 PDF 是台大競賽程式課程的第一個作業的解答的補充
裡面寫到 nR 單調性
然後用魔法把 O(n^3logn) 優化成 O(n^3)
這裡有個問題:1. 單調是假解。2. 這個麻煩實作是什麼我也不知道
不過下面有寫證明!難道只是程式寫錯嗎?
證明 nR 單調性
不過下面有寫證明!難道只是程式寫錯嗎?
歐不... 證明留給大家想
dreamoon 提供了這個「證明」的出處:
oToToT 提供了這本書的節錄
附個證明很難嗎 Orz
最後進展:
所以結論就是路上台灣的大神們都不會。
如果你找到 O(n^3) 歡迎偷偷跟我說喔!
其實前面的優化相關扣掉LIS也不會考...
題目名稱 | 來源 | 備註 |
---|---|---|
Burst Balloons | Leetcode 312 | 矩陣鍊成積類似題 |
Cut a Stick | Leetcode 1547 | 抓囚犯類似題 |
刪除邊界 | APCS 2019 / 10 - 4 | n 很小,4D/1D 都會過 |
彩色紙條 | 104 學科決賽 - D | 簡單的 Remove Boxes |
先讓我們回想起「單人遊戲暴搜」的解法
4
1
2
5
-
3
4
1
2
5
3
-
4
1
2
-
5
3
4
-
2
5
1
3
4
1
-
5
3
2
4
1
2
5
-
3
4
2
-
5
1
3
-
4
2
5
1
3
4
1
2
5
-
3
4
1
2
5
-
3
-
1
2
4
5
3
(順序: 右上左下)
看怎樣搜尋到解答。
「雙人遊戲」的必勝解就會是長這樣
⭕
先手回合
後手回合
⭕
⭕
⭕
...
先手回合
⭕
❌
⭕
⭕
❌
⭕
⭕
❌
⭕
...
...
每回合,每個人都會選對自己有利的的局面。
「雙人遊戲」的必勝解就會是長這樣
每回合,每個人都會選對自己有利的的局面。
感覺有點模糊?
讓我們直接進題目吧!
如果只以一個角度來看的話:
所以博弈型 DP 基本上都是採 dfs + memo 的寫法。
現在有兩個人要對決,前方有 n 個石頭,每個人輪流拿石頭,
一次可以拿 1~3 顆石頭,拿到最後一顆石頭的人獲勝。
請問先手有必勝解嗎?
4
舉例來說: n = 4
1
2
3
拿 3 顆
拿 2 顆
拿 1 顆
先手回合
後手回合
後手贏
後手贏
後手贏
所以 n = 4 後手贏, n = 1~3 先手贏
https://leetcode.com/problems/guess-number-higher-or-lower-ii/description/
https://leetcode.com/problems/predict-the-winner/description/
三角尼姆https://oj.ntucpc.org/problems/525
題目名稱 | 來源 | 備註 |
---|---|---|
House Robber III | Leetcode 337 | 樹 DP |
病毒演化 | APCS 2020 / 07 - 4 | 樹 DP |
https://leetcode.com/problems/minimize-the-total-price-of-the-trips/description/?envType=problem-list-v2&envId=dynamic-programming
先做LCA在做DP,複雜度 O(NlogN)
把 bellman ford 跟 floyd-warshall 放這裡
DAG 最長路?
現在有一條一維數線,並在上面有 n 個公車路線。
每個公車路線會有兩個變數 start, end,這表示
你可以從 [start, end-1] 搭車搭到 end 下車。
請問從 0 搭車搭到 m 有幾種搭法?(方法數需要 % p)
如果一樣用 Bottom up
的角度來思考的話:
0
4
6
7
三號車
四號車
二號車
一號車
1
1
1
- 3
4
0
4
6
7
1
1
3
4
這題其實就是在
問 DAG 的路徑可能!
DAG: 有向無環圖
要提到位元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
https://oi-wiki.org/dp/opt/quadrangle/
在先前的題目,我們的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 個 ➕
你覺得這樣子的設計要怎麼轉移呢?