所以要好好專心看喔
Dynamic Programming
技巧? 暴搜?
暴搜比較好理解的其中一個方法就是遞迴。
技巧? 別急,我們慢慢來。
Dynamic Programming
Dynamic Programming
這是一個關於發明者Bellman的有趣故事...
*你在圖論會學到以他為名的演算法
Fibonacci Sequence
東東爬階梯可以一次走一或兩階。
假設階梯有三階,那他有三種走法
一:第一步走一階,第二步走二階。
二:第一步走二階,第二步走一階。
三:全程都走一階。
假設階梯有n階,那東東有幾種走法?
東東爬階梯可以一次走一或兩階。
假設階梯有n階,那東東有幾種走法?
(但其實真的可以)
東東爬階梯可以一次走一或兩階。
假設階梯有n階,那東東有幾種走法?
最後一步走一階的走法
最後一步走了兩階的走法
好像就是走到
第n-1階的答案...?
好像就是走到
第n-2階的答案...?
東東爬階梯可以一次走一或兩階。
假設階梯有n階,那東東有幾種走法?
我們定義 f(n) 表示第n階的答案
則 f(n) = f(n-1) + f(n-2),用code寫就是
int f(int n){
return f(n-1) + f(n-2);
}
好像跑不完ㄟ?
東東爬階梯可以一次走一或兩階。
假設階梯有n階,那東東有幾種走法?
int f(int n){
if (n == 0 || n == 1)
return 1;
return f(n-1) + f(n-2);
}
Combination
請問從 N 顆不同的球裡,
有幾種取出 M 顆球的方法?
(假設編號為1...n)
請問從 N 顆不同的球裡,
有幾種取出 M 顆球的方法?
(假設編號為1...n)
取了第n顆球
不取第n顆球
好像就是等於
從n-1顆球取m-1顆球...?
好像就是等於
從n-1顆球取m顆球...?
請問從 N 顆不同的球裡,
有幾種取出 M 顆球的方法?
(假設編號為1...n)
我們定義 C(n, m) 表示n球取m顆的方法數
則 C(n,m) = C(n-1, m-1) + C(n-1, m)
int C(int n, int m){
return C(n-1, m-1) + C(n-1, m);
}
終止條件呢?
請問從 N 顆不同的球裡,
有幾種取出 M 顆球的方法?
(假設編號為1...n)
C(n,m) = C(n-1, m-1) + C(n-1, m)
n 跟 m 減到哪裡該停?
想想看 C(3, 2) 的例子吧!
C(3, 2)
C(2, 1)
C(2, 2)
C(1, 0)
C(1, 1)
請問從 N 顆不同的球裡,
有幾種取出 M 顆球的方法?
(假設編號為1...n)
C(n,m) = C(n-1, m-1) + C(n-1, m)
int C(int n, int m){
if (m == 0 || m == n) return 1;
return C(n-1, m-1) + C(n-1, m);
}
0/1 Knapsack Problem
給出 N 個物品和物品的重量和價值。
給出祖靈的背包的重量限制。
請問祖靈最多可以帶價值東西回家?
(假設編號為1...n)
拿走什麼? | 總重 | 價值 |
---|---|---|
G+C+S | 15kg | $8 |
Y+C+S+B | 8kg | $15 |
... | ... | ... |
C
B
Y
G
S
給出 N 個物品和物品的重量和價值。
給出祖靈的背包的重量限制。
請問祖靈最多可以帶價值東西回家?
(假設編號為1...n)
取了第n號物品
不取第n號物品
價值好像就是 +
n-1個物品限重
價值好像就是
n-1個物品限重 W
給出 N 個物品和物品的重量和價值。
給出祖靈的背包的重量限制。
請問祖靈最多可以帶價值東西回家?
(假設編號為1...n)
int f(int n, int w) {
if (n == 0)
return 0;
if (w >= W[n])
return max(f(n-1, w), f(n-1, w - W[n]) + V[n]);
return f(n-1, w);
}
那麼你會寫出遞迴式嗎?
Memoization
(其實是WA 因為我沒開long long)
暴搜比較好理解的其中一個方法就是遞迴。
重複算有差嗎?我們以爬樓梯的題目來看
重複算有差嗎?我們以爬樓梯的題目來看
重複計算 (原本的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];
}
如果陣列紀錄的是0,表示沒有計算過,那麼我們直接算一遍並記錄答案。
記憶化就是把算過的答案的問題,
存到陣列裡面!這樣就不用再重算一次!
所以 dp[問題] = 這個問題的答案!
long long dp[1000];
long long f(int n){
if (n == 0 || n == 1)
return 1;
if (!dp[n])
dp[n] = f(n-1) + f(n-2);
return dp[n];
}
來用人腦跑一次看看吧!如果呼叫 f(4)...
n | 0 | 1 | 2 | 3 | 4 |
---|---|---|---|---|---|
f(n) | 1 | 1 | ? | ? | ? |
- | f(1) | f(2) | f(3) | ||
- | f(0) | f(1) | f(2) |
1
1
2
2
1
3
3
2
5
f(2) 不用再重新遞迴一次 f(1) 跟 f(0) 了!
#include <stdio.h>
long long dp[1000];
long long f(int n){
if (n == 0 || n == 1)
return 1;
if (!dp[n])
dp[n] = f(n-1) + f(n-2);
return dp[n];
}
int main() {
int n;
while(~scanf("%d", &n)) {
printf("%lld\n", f(n));
}
}
#include <stdio.h>
unsigned long long dp[101][101];
unsigned long long C(int n, int m){
if (m == 0 || m == n) return 1;
if (!dp[n][m])
dp[n][m] = C(n-1, m-1) + C(n-1, m);
return dp[n][m];
}
int main() {
int n, m;
while(~scanf("%d%d", &n, &m) && n != 0 && m != 0) {
printf("%d things taken %d at a time is %llu exactly.\n", n, m, C(n, m));
}
}
如果問題有兩個參數呢? 像是 C(n, m) ?
那記憶化表格就開兩維!
那麼時間複雜度呢?
最多有 O(nm) 的狀態,算出每個狀態需要 O(1)
(轉移複雜度)
總複雜度 : O(nm) * O(1) = O(nm)!
#include <stdio.h>
#include <memory.h>
#include <algorithm>
int dp[101][10001];
int W[101], V[101];
int f(int n, int w) {
if (n == 0)
return 0;
if (!dp[n][w]) {
if (w >= W[n])
dp[n][w] = std::max(f(n-1, w - W[n]) + V[n], f(n-1, w));
else
dp[n][w] = f(n-1, w);
}
return dp[n][w];
}
int main() {
int n, w;
while(~scanf("%d", &n)) {
// 將dp陣列全部清空
memset(dp, 0, sizeof(dp));
for (int i=1; i<=n; i++)
scanf("%d%d", &W[i], &V[i]);
scanf("%d", &w);
printf("%d\n", f(n, w));
}
}
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堆成小答案,再慢慢堆成大答案
通常比較直觀。
(遞迴寫出來就結束了)
有可能不太直觀,還需要考慮堆答案的順序。
優化困難。
比較可以優化。
比較慢。(呼叫函數比較慢)
比較快。
#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]);
}
}
請問從 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] = std::max(f(n-1, w - W[n]) + V[n], f(n-1, w));
else
dp[n][w] = f(n-1, w);
}
return dp[n][w];
}
for (int i=1; i<=n; i++) {
for (int j=0; j<=w; j++) {
if (j >= W[i])
dp[i][j] = std::max(dp[i-1][j-W[i]]+V[i], dp[i-1][j]);
else
dp[i][j] = dp[i-1][j];
}
}
給出 N 個物品和物品的重量和價值。
給出祖靈的背包的重量限制。
請問祖靈最多可以帶價值東西回家?
(假設編號為1...n)
* base case寫在memset裡面
給出 N 個物品和物品的重量和價值。
給出祖靈的背包的重量限制。
請問祖靈最多可以帶價值東西回家?
(假設編號為1...n)
接下來我們從bottom-up的0/1背包問題
開始進行優化吧!
Rolling Optimization
我們來觀察一下用 Bottom-up 解決背包問題的時候,DP表的狀態。
編號 | 1 | 2 | 3 | 4 |
---|---|---|---|---|
重量 | 2 | 1 | 3 | 2 |
價值 | 3 | 2 | 4 | 2 |
N \ W | 0 | 1 | 2 | 3 | 4 | 5 |
---|---|---|---|---|---|---|
0 | 0 | 0 | 0 | 0 | 0 | 0 |
1 | 0 | 0 | 3 | 3 | 3 | 3 |
2 | 0 | 2 | 3 | 5 | 5 | 5 |
3 | 0 | 2 | 3 | 5 | 6 | 7 |
4 | 0 | 2 | 3 | 5 | 6 | 7 |
DP[n][w] = max(DP[n-1][w], DP[n-1][w-wi]+vi)
我們來觀察一下用 Bottom-up 解決背包問題的時候,DP表的狀態。
N \ W | 0 | 1 | 2 | 3 | 4 | 5 |
---|---|---|---|---|---|---|
0 | 0 | 0 | 0 | 0 | 0 | 0 |
1 | 0 | 0 | 3 | 3 | 3 | 3 |
2 | 0 | 2 | 3 | 5 | 5 | 5 |
3 | 0 | 2 | 3 | 5 | 6 | 7 |
4 | 0 | 2 | 3 | 5 | 6 | 7 |
我們每次算第 n 列,只會需要第 n-1 列的答案。
n-2 以前都用不到了,不覺得很浪費嗎?
如果我們計算某一列,我們只需要他的前一列的答案。
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,你開心就好
其實背包問題可以不用滾動,只開一個一維陣列。你有辦法想出來嗎?
提示1: dp[n][w] 總會有一個答案是 dp[n-1][w]。
提示2: for迴圈的順序非常重要
在背包問題中,每個物品都可以無限拿,該怎麼做呢?
在原本的背包問題是,兩層 for 迴圈是可以交換的。
那麼如果使用滾動法,這兩層還可以交換嗎?
optimal substructure
overlapping subproblems
有這兩種性質的問題,就能動態規劃。
動態規劃條件: 最佳子結構 以及 重複子問題
給定一個正整數 n ,
請判斷 n 是否為質數
n 是不是質數無法由其他數是不是質數來判定。
沒有最佳子結構。
他不是個 DP 題。
*不過質數篩法好像勉強算個DP XD
動態規劃條件: 最佳子結構 以及 重複子問題
排序數列
排序大數列可以拆成排序兩個一半的數列,並且合併成一個排序的數列。
有最佳子結構。
但每個區間排序只會處理一次,
沒有重複子問題。
他不是個 DP 題,他是分治(D&C)題。
接下來列舉經典DP問題,
來想想看怎麼寫吧!
Change-making Problem
給你一個國家的面額種類,
請問最少可以用多少個硬幣湊出 amount?
舉例來說:
但考慮比較奇怪的 case:
目標 8 塊
5 + (目標 3 塊)
4 + (目標 4 塊)
5 + 1 + 1 + 1
(四個硬幣)
4 + 4
(兩個硬幣)
給你一個國家的面額種類,
請問最少可以用多少個硬幣湊出 amount?
總結來說,這題只能爆搜。
也就是你幾乎必須試遍所有可能。
那不是很慢嗎?
給你一個國家的面額種類,
請問最少可以用多少個硬幣湊出 amount?
某個外國有的面額為 [1, 4, 5],要找 8 塊
目標 8 塊
怎麼定義DP式子?
最簡單的方法:
DP(???) = 答案
DP(8)
min
1 + DP(5)
1 + DP(4)
1 + DP(7)
5 + (目標 3 塊)
4 + (目標 4 塊)
1 + (目標 7 塊)
那 base case?
給你一個國家的面額種類,
請問最少可以用多少個硬幣湊出 amount?
DP(x) = 湊出 x 的最少硬幣
class Solution {
public:
int dp[10001] = {};
int coinChange(vector<int>& coins, int amount) {
// base case
if (amount == 0)
return 0;
// memoization
if (dp[amount])
return dp[amount];
int ans = -1;
for (int coin : coins) {
if (coin <= amount) {
int tmp = coinChange(coins, amount - coin);
// update when
// 1. tmp must has solution (!= -1)
// 2. no answer currently or tmp is better than ans
if (tmp != -1 && (ans == -1 || 1 + tmp < ans))
ans = 1 + tmp;
}
}
return dp[amount] = ans;
}
};
TODO
Longest Common Subsequence (LCS)
什麼是子區間以及子序列?
以 azbec 來說:
題目求某字串的最大長度,並且同時是兩個字串的子序列。
給定兩個字串 S 和 T,請問 LCS(S, T) =?
最長共同子序列
給定兩個字串 S 和 T,請問 LCS(S, T) =?
範例 1:
a1b2c3d4e
zz1yy2xx3ww4vv
最長共同子序列
範例 2:
abcdgh
aedbhr
這兩個字串的唯一
LCS 為 1234,
因此答案為4。
這兩個字串有兩個 LCS,
分別是 "adh","abh"。但答案都是3。
怎麼定義DP式子?
遇到不知道怎麼定義狀態的時候,就先這樣寫:
給定兩個字串 S 和 T,請問 LCS(S, T) =?
最長共同子序列
DP [????] = 題目要求的答案
所以在這題上:
DP [????] = LCS(S, T)
怎麼定義DP式子?
給定兩個字串 S 和 T,請問 LCS(S, T) =?
最長共同子序列
那轉移式子呢?
也就是怎麼用小問題解決大問題呢?
好像很不好想也?我們回想一下之前的題目:
所以按照慣例我們考量兩個字串的最後一個字。
怎麼定義DP式子呢?
那轉移式子呢?
S[i] 跟 T[j] 匹配一定最好!
代表 LCS 尾巴一定是S[i]!
S = ......... X
T = .... X
S[i]
T[j]
T = .... Y
剩下的 LCS 會在哪呢?
在 S[0...i-1] 和 T[0...j-1] 之間
給定兩個字串 S 和 T,請問 LCS(S, T) =?
最長共同子序列
S[i] 不能跟 T[j] 匹配。
S[i] 跟 前面的 T 匹配
T[j] 沒人配,等同沒用。
同理,相反也是。
考量最後一個字
怎麼定義DP式子呢?
給定兩個字串 S 和 T,請問 LCS(S, T) =?
最長共同子序列
if
if
Base Case 呢?
考量一直遞迴會到哪個怪地方
箭頭表示答案是從哪裡得到的
給定兩個字串 S 和 T,請問 LCS(S, T) =?
最長共同子序列
如果用 Bottom-up 寫表格
就會是這樣。
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|。
Longest Increasing Subsequence (LIS)
給定一個陣列,問最長的嚴格遞增子序列長度為何?
嚴格遞增: 左邊的數 < 右邊的數
舉例來說:
如果數列為 [10,9,2,5,3,7,101,18]
則其中一個 LIS 為 2 3 7 18,因此答案為 4。
給定一個陣列,問最長的嚴格遞增子序列長度為何?
先來個最簡單的定義式吧
DP[n] = Ary[0...n] 的LIS長度
好像做不出轉移式?
因為左邊的數字 < 右邊的數字,而我們選了 Ary[n] 卻不知道左邊的數字的最佳解多少。
DP[n] = Ary[0...n] 選了第n個數字的LIS的長度
給定一個陣列,問最長的嚴格遞增子序列長度為何?
DP[n] = Ary[0...n] 選了第n個數字的LIS的長度
int rec(int n){
if(DP[n])
return DP[n];
int now = 0;
for(int i=0;i<n;i++){
if(ary[i] < ary[n])
now = max(rec(i),now);
}
return DP[i] = now + 1;
}
Base Case: 如果前面沒人比 Ary[n]還要小,則DP[n] = 1
給定一個陣列,問最長的嚴格遞增子序列長度為何?
嘗試分析看看複雜度吧!
狀態數量 * 轉移複雜度 =
有辦法變得更快嗎?
轉移其實有辦法可以做到 ,但好像有點困難....
給定一個陣列,問最長的嚴格遞增子序列長度為何?
試試看其他DP定義吧!
定義 DP[n][i] = 對於Ary[0...n]內,
長度為 i 的 LIS 的最後一個元素 (取最小)
int lengthOfLIS(vector<int>& nums) {
vector<int> DP(nums.size(), INT_MAX);
int ans = (nums.size() != 0);
for (int i=0; i<nums.size(); i++) {
for (int j=ans; j>=0; j--) {
if (j == 0 || DP[j-1] < nums[i]) {
DP[j] = min(DP[j], nums[i]);
ans = max(ans, j+1);
}
}
}
return ans;
}
最後像背包問題壓成一維就可以了。
最後像背包問題壓成一維就可以了。
我們來觀察一下DP表的狀態
輸入:
10
1 7 1 5 3 10 4 2 6 8
===========================
DP表格:
1
1 7
1 7
1 5
1 3
1 3 10
1 3 4
1 2 4
1 2 4 6
1 2 4 6 8
每次只會改到一個數字,
改在比 Ary[n] 還要小的數字的位置的後面。
想想看為甚麼?
int lengthOfLIS(vector<int>& nums) {
vector<int> DP(nums.size(), INT_MAX);
int ans = (nums.size() != 0);
for (int i=0; i<nums.size(); i++) {
int j = lower_bound(DP.begin(), DP.end(), nums[i]) - DP.begin();
DP[j] = nums[i];
ans = max(ans, j+1);
}
return ans;
}
每次只會改到一個數字,
改在比 Ary[n] 還要小的數字的位置的後面。
複雜度: O(nlogn)
=> 通過 lower_bound 查找
給定三個字串,請問LCS的長度為何?
最常共同子序列
#include <iostream>
#include <algorithm>
#include <cstring>
using namespace std;
string S[3];
int DP[101][101][101];
int rec(int i, int j, int k){
if(i==-1 || j==-1 || k == -1)
return 0;
if(DP[i][j][k] != -1)
return DP[i][j][k];
if(S[0][i] == S[1][j] && S[1][j] == S[2][k])
return DP[i][j][k] = rec(i-1,j-1,k-1) + 1;
else
return DP[i][j][k] = max({rec(i-1,j,k),rec(i,j-1,k),rec(i,j,k-1)});
}
int main(){
while(cin>>S[0]>>S[1]>>S[2]){
memset(DP, -1, sizeof(DP));
cout << rec(S[0].size()-1, S[1].size()-1, S[2].size()-1) << endl;
}
}
給輸出兩個字串的LCS字串為何。
(題目保證答案的 LCS 只有一個)
在DP時記錄這些箭頭,
算完之後由結尾往回走。
( 答案就是這些
斜線箭頭的組成 )
接下來看看
其他題目吧!
好的狀態,就會有簡單的轉移。
擲了n次硬幣,過程中出現連續3次正面的可能數為何?
嘗試想一下DP定義吧!
嘗試看看無腦定義狀態?
DP[n] = 連續三次正面的可能數
那麼 DP[n] 會包含著這些 case
你該怎麼寫?
n 個硬幣
x 個硬幣
➕
➕
➕
n-x-3 個硬幣
擲了n次硬幣,過程中出現連續3次正面的可能數為何?
DP[n] = 連續三次正面的可能數
n 個硬幣
x 個硬幣
➕
➕
➕
n-x-3 個硬幣
會被重複算!
不行了...在討論下去沒完沒了,可能還要排容原理
➕
➕
➕
➕
4個硬幣
擲了n次硬幣,過程中出現連續3次正面的可能數為何?
好像不能無腦定義狀態 :(
DP[n] = 沒有連續三次正面的可能數
n 個硬幣
排組教過你:如果正攻不行,就反攻
➖
n-1次,沒有連續三次正面
➕
➖
n-2次,沒有連續三次正面
➕
➕
➖
n-3次,沒有連續三次正面
➕
➕
➖
好像不能無腦定義狀態 :(
DP[n] = 沒有連續三次正面的可能數
n 個硬幣
排組教過你:如果正攻不行,就反攻
➖
n-1次,沒有連續三次正面
➕
➖
n-2次,沒有連續三次正面
➕
➕
➖
n-3次,沒有連續三次正面
➕
➕
➖
DP[n-1]
DP[n-2]
DP[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 個 ➕
你覺得這樣子的設計要怎麼轉移呢?
好的狀態,就會有簡單的轉移。
(APCS 2024/1 第四題)
給定一個序列,每次可選兩相鄰數 u,v 合併成 u+v,需要花費 |u-v|。
問合併成一個數字的最小花費?
(APCS 2024/1 第四題)
要DP好像有點困難... 不知道狀態怎麼設計...
不管是哪一種合併方法,
總會有最後一次怎麼合併吧?
就像背包問題總會有最後一個選的物品一樣
給定一個序列,每次可選兩相鄰數 u,v 合併成 u+v,需要花費 |u-v|。
問合併成一個數字的最小花費?
(APCS 2024/1 第四題)
最後一次怎麼合併長甚麼樣呢?
給定一個序列,每次可選兩相鄰數 u,v 合併成 u+v,需要花費 |u-v|。
問合併成一個數字的最小花費?
他們都源自於一個區間
因為只能相鄰才可以合併
我們就可以利用區間來定義狀態
DP[l, r] = 區間 [l, r) 的最小成本
(APCS 2024/1 第四題)
給定一個序列,每次可選兩相鄰數 u,v 合併成 u+v,需要花費 |u-v|。
問合併成一個數字的最小花費?
DP[l, r] = 區間 [l, r) 的最小成本
轉移式呢?
想想看最後一個答案 DP[0, n]
的所有轉移長什麼樣子?
(APCS 2024/1 第四題)
給定一個序列,每次可選兩相鄰數 u,v 合併成 u+v,需要花費 |u-v|。
問合併成一個數字的最小花費?
DP[l, r] = 區間 [l, r) 的最小成本
轉移式呢?
想想看最後一個答案 DP[0, n]
的所有轉移長什麼樣子?
用 for 迴圈決定切割點
如果切割點在 k,那麼...
左邊的數字加總 -
右邊的數字加總 = 這次合併的成本
合併左邊的成本
合併右邊的成本
(APCS 2024/1 第四題)
#include <bits/stdc++.h>
using namespace std;
int ary[100], pre_sum[100], n;
int dp[101][101];
pair<int, int> rec(int l, int r) {
if (l+1 == r) return {0, ary[l]};
int merged = ary[l] + rec(l+1, r).second;
if (dp[l][r]) return {dp[l][r], merged};
dp[l][r] = INT_MAX;
for (int k=l+1; k<r; k++){
auto L = rec(l, k), R = rec(k, r);
dp[l][r] = min(dp[l][r], L.first + R.first + abs(L.second - R.second));
}
return {dp[l][r], merged};
}
int main() {
scanf("%d", &n);
for (int i=0; i<n; i++) {
scanf("%d", &ary[i]);
}
printf("%d\n", rec(0, n).first);
return 0;
}
* 區間和你可以寫個前綴和算,
但我很懶所以直接用遞迴算。
(first = 最小cost, second = 區間和)
有的時候,DP 轉移也會需要其他技術!
給一字串 S,問最少能將將 S 分成幾段使得每段字串都是「平衡」的。
我們定義「平衡」表示該字串內每個字的出現次數皆一樣。
無腦下 DP 定義:
轉移呢?
想想看最後一切是在哪裡?
不知道?那就枚舉找!
不知道?那就枚舉找!
最後一切,如果平衡
最後一切,如果平衡
最後一切,如果平衡
...
...
最後一切,如果平衡
...
...
最後一切,如果 平衡
但是我們要怎麼知道一個子字串是不是平衡的呢?
我們先想想看簡單版的題目吧?
所以轉移式如果用數學寫出來的話就是:
給一字串 S,判斷 S 是否平衡
你可能會這樣寫:
Counting Table
對於每一種字母都跑過一遍,
算出出現的個數。
字母有幾種
有沒有更方便,更快的做法呢?
給一字串 S,判斷 S 是否平衡
Counting Table
A
B
D
C
C
A
D
B
字母 | A | B | C | D |
---|---|---|---|---|
出現次數 |
0
+1
+1
0
+1
+1
0
+1
+1
0
+1
+1
再檢查全部出現過的字母是不是都是同一個次數,就可以了!
有了S字串後,每次再後面多加一個字元,
並且每次都要判斷這個時候是不是平衡的。
每次加,只需要讓 Table 該元素 + 1 ...
那怎麼 O(1) 的檢查呢?
最大數字 * 出現的種類 = N
接著我們再更進階一點...
如果每次加,每次重新判斷 ...
給一字串 S,問最少能將將 S 分成幾段使得每段字串都是「平衡」的。
我們定義「平衡」表示該字串內每個字的出現次數皆一樣。
對於 n 來說,我們需要知道 ...
是不是平衡的
這跟之前講的有關係嗎?
知道了 DP 就結束了!
給一字串 S,問最少能將將 S 分成幾段使得每段字串都是「平衡」的。
是不是平衡的
每次再 S 後面多加一個字元,並且每次都要判斷這個時候是不是平衡的。
所以從 S[n] 往回做就可以了!
int minimumSubstringsInPartition(string s) {
vector<int> dp(s.length(), 1001);
dp[0] = 1;
for (int i=1; i<s.length(); i++) {
int table[26] = {0}, uniq = 0;
// 找分界點 (j,從後面往前找)
for (int j=i; j>=0; --j) {
int idx = s[j]-'a';
// 更新頻率表
table[idx]++;
// 判斷是不是多一個種類
uniq += (table[idx] == 1);
// 判斷是不是平衡
if (table[idx]*uniq == i-j+1)
// 因為沒有 dp[-1],所以 j=0 要特判
dp[i] = min(dp[i], j==0 ? 1 : dp[j-1]+1);
}
}
return dp.back();
}
給一字串 S,問最少能將將 S 分成幾段使得每段字串都是「平衡」的。
我們定義「平衡」表示該字串內每個字的出現次數皆一樣。
有的時候,轉移也很有技巧!
(APCS 2021/9 第四題)
給定一個序列以及整數k。
請找出如何選出k個不重疊區間,且各個區間內無重複數字,使得區間覆蓋最廣。
範例測資:
5 1
1 2 1 3 1
10 3
1 7 1 3 1 4 4 2 7 4
答案分別為 3, 8
(APCS 2021/9 第四題)
給定一個序列以及整數k。
請找出如何選出k個不重疊區間,且各個區間內無重複數字,使得區間覆蓋最廣。
好像有點困難?
只要完成 k = 1 就可以五級分了!
先想想看 k = 1 怎麼做吧!
也就是選一個最大區間,這個區間沒有重複數字。
(APCS 2021/9 第四題)
請找出1個最大的不重疊區間。
想想看 DP 可不可以解決吧!
先來個無腦 DP 定義:
DP[n] = 以 A[n] 結尾的最大區間 (的開頭)
DP[n] =
0
0
0
2
2
3
2
2
4
1
5
1
DP[n]的區間 = DP[n-1]的區間 + A[n],但是要扣掉有A[n]地方!
(APCS 2021/9 第四題)
給定一個序列以及整數k。
請找出如何選出k個不重疊區間,且各個區間內無重複數字,使得區間覆蓋最廣。
回來原本題目,我們還是來無腦決定狀態
= 前n個數字中,
選了k個區間覆蓋最多的值
恩... 轉移呢?
好像只要最大化我們最後一個選的區間就好?
那就是 k=1 的 DP!
為什麼?
(APCS 2021/9 第四題)
給定一個序列以及整數k。
請找出如何選出k個不重疊區間,且各個區間內無重複數字,使得區間覆蓋最廣。
那 DP[n][k] 呢?
k=1 的 case 我們換個名字 L:
= 前n個數字中,選了k個區間覆蓋最多的值
好像有點複雜...
(APCS 2021/9 第四題)
給定一個序列以及整數k。
請找出如何選出k個不重疊區間,且各個區間內無重複數字,使得區間覆蓋最廣。
取了第n個值當答案
不取第n個值當答案
最後一個區間:
好像就是等於
從選了 k-1 個區間推導:
= 前n個數字中,選了k個區間覆蓋最多的值
不吃第n個攤位的解
吃了第n個攤位的最佳解
(APCS 2021/9 第四題)
#include <stdio.h>
#include <algorithm>
using namespace std;
#define MAXN 1000005
int prv[MAXN], tmp[MAXN];
int dp[MAXN][21];
int main() {
int n, k, x, ans=0, L=0;
scanf("%d%d", &n, &k);
for (int i=1; i<=n; i++) {
scanf("%d", &x);
prv[x] = tmp[x];
tmp[x] = i;
L = max(L, prv[x]+1);
// 選 1 個區間的解 ~ 選 k 個區間的解
for (int j=1; j<=k; j++) {
dp[i][j] = max(dp[i-1][j], dp[L-1][j-1] + i - L + 1);
ans = max(ans, dp[i][j]);
}
}
printf("%d\n", ans);
return 0;
}
我們只會用到L[n-1],
所以其實不用開一個L陣列。
什麼時候使用DP?
DP 的流派 ?
DP 的流程?
題目名稱 | 題目簡介 |
---|---|
東東爬樓梯 | 一次可以走 1、2 階,走到 n 階的可能數 |
Combination | n 個物品選 m 個的選法 |
0 / 1背包問題 | 每個物品都有價值跟重量,求限定重量下的最高價值選法 |
找硬幣問題 | 給定幣值的種類,用最少的硬幣數量找 n 元 |
最長共同子序列 (LCS) | 問兩個字串的最長共同子序列 |
編輯距離 (edit distance) | 問兩個字串要改或刪幾個字,才可以讓它們相等 |
最長遞增子序列 (LIS) | 問一個陣列的最長遞增子序列 |
Critical Mass | 擲 n 次硬幣,有連續三次正面的可能數 |
合併成本 | 兩相鄰數可以合併,問合併成一個數字的最小成本 |
最小字串切割 | 給一個字串,問最少可以切成幾個皆為平衡的子字串 |
美食博覽會 | 選 k 個不相交的數字不重複區間,使得涵蓋範圍最大 |
我們在這章節上的題目總覽
怎麼設計狀態?
怎麼設計轉移?
怎麼練習DP?
題目名稱 | 來源 | 備註 |
---|---|---|
Min Cost Climbing Stairs | Leetcode 746 | 爬樓梯變形題 |
Triangle | Leetcode 120 | 巴斯卡三角變形題 |
Target Sum | Leetcode 494 | 類似背包的遞迴 |
禮物分配 Equal Subset |
Leetcode 416 Zj d890 |
99年北市賽 背包變形題 |
Unique Paths | Leetcode 62 | 排列組合經典題 |
Unique Paths II | Leetcode 63 | 排列組合經典題 |
burst-balloons | Leetcode 312 | 合併成本類似題 |
題目名稱 | 來源 | 備註 |
---|---|---|
Min Path Cost in a Grid | Leetcode 2304 | 2D/1D 裸題 |
Maximal Square | Leetcode 221 | 經典題,轉移很酷 |
Combination Sum IV | Leetcode 377 |
經典題,數字拆分變形 |
題目名稱 | 來源 | 備註 |
---|---|---|
House Robber | Leetcode 198 | 經典題 |
House Robber II | Leetcode 213 | 上一題的微變形 |
House Robber III | Leetcode 337 | 樹 DP |
Min Path Cost in a Grid | Leetcode 2304 | 2D/1D 裸題 |
burst-balloons | Leetcode 312 | 合併成本類似題 |
APCS 幾乎每兩次考一題!
沒有,我好懶得教後面的,數學太多了
要提到位元DP前,我們要先熟悉位元運算
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