淺入淺出 動態規劃

Intro to Dynamic Programming



Arvin Liu

前言

DP爆幹難

所以要好好專心看喔

什麼是動態規劃?

Dynamic Programming

什麼是動態規劃?

就是有技巧暴搜

技巧? 暴搜?

技巧暴搜

怎麼暴力搜尋?

 

暴搜比較好理解的其中一個方法就是遞迴。

技巧? 別急
,我們慢慢來。

為什麼叫做動態規劃?

Dynamic Programming

為什麼叫做動態規劃?

Dynamic Programming

這是一個關於發明者Bellman的有趣故事...

*你在圖論會學到以他為名的演算法

費波那契數列

Fibonacci Sequence

東東爬樓梯 (zj d212)

東東爬階梯可以一次走一或兩階。

假設階梯有三階,那他有三種走法

一:第一步走一階,第二步走二階。

二:第一步走二階,第二步走一階。

三:全程都走一階。
 

假設階梯有n階,那東東有幾種走法?

東東爬樓梯 (zj d212)

東東爬階梯可以一次走一或兩階。

假設階梯有n階,那東東有幾種走法?

直接求第n階答案 ...好像有點難

(但其實真的可以)

我們可以將第n階答案的走法分成兩類

  1. 最後一步是走一階。
  2. 最後一步是走兩階。

東東爬樓梯 (zj d212)

東東爬階梯可以一次走一或兩階。

假設階梯有n階,那東東有幾種走法?

我們可以將第n階答案的走法分成兩類

最後一步走一階的走法

最後一步走了兩階的走法

好像就是走到
第n-1階的答案...?

好像就是走到
第n-2階的答案...?

第n階答案 = 第n-1階答案 + 第n-2階答案

東東爬樓梯 (zj d212)

東東爬階梯可以一次走一或兩階。

假設階梯有n階,那東東有幾種走法?

第n階答案 = 第n-1階答案 + 第n-2階答案

我們定義 f(n) 表示第n階的答案

f(n) = f(n-1) + f(n-2),用code寫就是

int f(int n){
    return f(n-1) + f(n-2);
}

好像跑不完ㄟ?

沒寫終止條件的話...

東東爬樓梯 (zj d212)

東東爬階梯可以一次走一或兩階。

假設階梯有n階,那東東有幾種走法?

第n階答案 = 第n-1階答案 + 第n-2階答案

  • 終止條件 / Base case: f(0) = 1, f(1) = 1
int f(int n){
	if (n == 0 || n == 1)
    	return 1;
    return f(n-1) + f(n-2);
}

組合數

C^n_m

Combination

請問從 N 顆不同的球裡,
有幾種取出 M 顆球的方法?

求n取m的可能 ...好像有點難

n取m的可能分成兩類

  1. 取了第 n 號球當作 m 顆球之一。
  2. 不取第 n 號球當作 m 顆球之一。

(假設編號為1...n)

請問從 N 顆不同的球裡,
有幾種取出 M 顆球的方法?

求n取m的可能 ...好像有點難

(假設編號為1...n)

取了第n顆球

不取第n顆球

好像就是等於
從n-1顆球取m-1顆球...?

好像就是等於
從n-1顆球取m顆球...?

n取m = n-1取m-1 + n-1取m

請問從 N 顆不同的球裡,
有幾種取出 M 顆球的方法?

(假設編號為1...n)

n取m = n-1取m-1 + n-1取m

我們定義 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)

  1. m = 0 的時候,​表示不取,答案為1
  2. m = n 的時候,表示全取,答案為1

請問從 N 顆不同的球裡,
有幾種取出 M 顆球的方法?

(假設編號為1...n)

C(n,m) = C(n-1, m-1) + C(n-1, m)

  1. m = 0 的時候,​表示不取,答案為1
  2. m = n 的時候,表示全取,答案為1
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 背包問題

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個物品限重W ... 好難!

取了第n號物品

不取第n號物品

價值好像就是     +
n-1個物品限重         

價值好像就是
n-1個物品限重 W

v_n
W-w_n
f(n, W) = \max( f(n-1, W-w_n) + v_n , f(n-1, W))

給出 N 個物品和物品的重量和價值。

給出祖靈的背包的重量限制。

請問祖靈最多可以帶價值東西回家?

(假設編號為1...n)

f(n, W) = \max( f(n-1, W-w_n) + v_n , f(n-1, W))
  1.  n = 0 的時候表示所有東西都看完了,答案為0
  2.  W 不能是負數。
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);
}

思考看看

  1. 如果將有個函數 f(n, V) 的定義為n個物品裡面選到價值為V的最低重量

    ( 例如f(n,10)=3 表示選到價值為10的物品時,重量最低為3 )


    那麼你會寫出遞迴式嗎?
     

  2. 你要怎麼使用第一題的函數定義來找出限重為 W 的背包最高價值為何?
     
  3. 如果每個物品都可以拿無限次,請問你有辦法改寫遞迴式嗎?

記憶化

Memoization

記憶化 Memoization

剛剛的所有code

你知道嗎?

全都會TLE!

(其實是WA 因為我沒開long long)

DP是有技巧暴搜

怎麼暴力搜尋?

 

暴搜比較好理解的其中一個方法就是遞迴。

 

  • 技巧? 同樣的問題不算第二遍
  • 這稱為 記憶化 Memoization

記憶化 Memoization

重複算有差嗎?我們以爬樓梯的題目來看

fib(3)一樣
卻會重算!

fib(4)

fib(3)

fib(2)

fib(2)

fib(1)

fib(5)

fib(3)

fib(2)

fib(1)

記憶化 Memoization

重複算有差嗎?我們以爬樓梯的題目來看

重複計算 (原本的code)

重複計算 (記憶化)

f(n) = f(n-1) + f(n-2)

"大概"每多一個n,
就會多算一倍

O(2^n)

算完 f(n-1) 時,

f(n-2) 已經算過了,不用重算

O(n)

記憶化 Memoization

重複算有差嗎?我們以爬樓梯的題目來看

記憶化怎麼寫?

long long dp[1000];
long long f(int n){
	if (n == 0 || n == 1)
    	return 1;
    if (!dp[n])
    	dp[n] = f(n-1) + f(n-2);
    return dp[n];
}

如果陣列紀錄的是0,表示沒有計算過,那麼我們直接算一遍並記錄答案。

記憶化就是把算過的答案的問題

存到陣列裡面!這樣就不用再重算一次!

所以 dp[問題] = 這個問題的答案!

記憶化 Memoization

long long dp[1000];
long long f(int n){
	if (n == 0 || n == 1)
    	return 1;
    if (!dp[n])
    	dp[n] = f(n-1) + f(n-2);
    return dp[n];
}

來用人腦跑一次看看吧!如果呼叫 f(4)...

n 0 1 2 3 4
f(n) 1 1 ? ? ?
- f(1) f(2) f(3)
- f(0) f(1) f(2)

1

1

2

2

1

3

3

2

5

f(2) 不用再重新遞迴一次 f(1) 跟 f(0) 了!

東東爬樓梯 (zj d212) AC code

#include <stdio.h>
long long dp[1000];
long long f(int n){
	if (n == 0 || n == 1)
    	return 1;
    if (!dp[n])
    	dp[n] = f(n-1) + f(n-2);
    return dp[n];
}
int main() {
    int n;
    while(~scanf("%d", &n)) {
        printf("%lld\n", f(n));
    }
}

00369 - Combinations (zj d134) AC code

#include <stdio.h>
unsigned long long dp[101][101];
unsigned long long C(int n, int m){
	if (m == 0 || m == n) return 1;
    if (!dp[n][m])
        dp[n][m] = C(n-1, m-1) + C(n-1, m);
    return dp[n][m];
}

int main() {
    int n, m;
    while(~scanf("%d%d", &n, &m) && n != 0 && m != 0) {
        printf("%d things taken %d at a time is %llu exactly.\n", n, m, C(n, m));
    }
}

如果問題有兩個參數呢? 像是 C(n, m) ?

那記憶化表格就開兩維!

那麼時間複雜度呢?

最多有 O(nm) 的狀態,算出每個狀態需要  O(1)

(轉移複雜度)

總複雜度 : O(nm) * O(1) = O(nm)!

祖靈好孝順 ˋˇˊ (zj a587) AC Code

#include <stdio.h>
#include <memory.h>
#include <algorithm>
int dp[101][10001];
int W[101], V[101];
int f(int n, int w) {
    if (n == 0)
        return 0;
    if (!dp[n][w]) {
        if (w >= W[n])
            dp[n][w] = std::max(f(n-1, w - W[n]) + V[n], f(n-1, w));
        else
            dp[n][w] = f(n-1, w);
    }
    return dp[n][w];
}
int main() {
    int n, w;
    while(~scanf("%d", &n)) {
        // 將dp陣列全部清空
        memset(dp, 0, sizeof(dp));
        for (int i=1; i<=n; i++)
            scanf("%d%d", &W[i], &V[i]);
        scanf("%d", &w);
        printf("%d\n", f(n, w));
    }
}

記憶化 Memoization

思考看看

  1. 請問背包問題 (祖靈好孝順 ˋˇˊ) 經過記憶化後的時間複雜度是多少?

     

  2. 我們確信問題的答案都大於零,所以才可以使用 !dp[n] 來表示沒有算過。

    那麼如果答案可能是 0 怎麼辦?
     
  3. 如果參數的範圍很大,大到無法開陣列,但問題的種類數量很小 (DP為稀疏陣列),那怎麼使用記憶化?

    (例如 dp[n] 的 n 有可能超大,但 dp[n] 不為0的值其實沒有那麼多,那要怎麼節省空間?)

DP兩流派

Top-down vs. Bottom-up 

DP是有技巧暴搜

怎麼暴力搜尋?

 

暴搜比較好理解的其中一個方法就是遞迴。

我遞迴苦手 :(
有沒有不是遞迴的方法?

東東爬樓梯 (zj d212)

東東爬階梯可以一次走一或兩階。

假設階梯有n階,那東東有幾種走法?

第n階答案 = 第n-1階答案 + 第n-2階答案

  • 終止條件 / Base case: f(0) = 1, f(1) = 1

所以 ...

  • f(2) = f(1) + f(0) = 2
  • f(3) = f(2) + f(1) = 3
  • f(4) = f(3) + f(2) = 5

好像可以用迴圈寫ㄟ?

東東爬樓梯 (zj d212)

東東爬階梯可以一次走一或兩階。

假設階梯有n階,那東東有幾種走法?

第n階答案 = 第n-1階答案 + 第n-2階答案

  • 終止條件 / Base case: f(0) = 1, f(1) = 1

所以 ...

  • f(2) = f(1) + f(0) = 2
  • f(3) = f(2) + f(1) = 3
  • f(4) = f(3) + f(2) = 5

好像可以用迴圈寫ㄟ?

#include <stdio.h>
int main() {
    int n;
    long long dp[100] = {0, 1};
    for (int i=2; i<100; i++) {
        dp[i] = dp[i-1] + dp[i-2];
    }
    while(~scanf("%d", &n)) {
        printf("%lld\n", dp[n]);
    }
}

Top-down & Buttom-up

DP主要兩大實做方法

Top-down

將大問題切成小問題,再將小問題切到 Base case。 

Bottom-up

將 Base case堆成小答案,再慢慢堆成大答案

通常比較直觀。
(遞迴寫出來就結束了)

有可能不太直觀,還需要考慮堆答案的順序。

優化困難。

比較可以優化。

比較慢。(呼叫函數比較慢)

比較快。

Top-down & Buttom-up

Buttom-up 怎麼寫?

#include <stdio.h>
int main() {
    int n;
    long long dp[100] = {0, 1};
    for (int i=2; i<100; i++) {
        dp[i] = dp[i-1] + dp[i-2];
    }
    while(~scanf("%d", &n)) {
        printf("%lld\n", dp[n]);
    }
}

以東東爬階梯為例子

  1. 在用迴圈寫DP前,先寫好 base case。(或也可以寫在定義式上)
     
  2. 決定迴圈的順序,以及開頭結尾等等...
     
  3. 在內部寫上DP的定義式(或我們稱DP轉移)

請問從 N 顆不同的球裡,
有幾種取出 M 顆球的方法?

(假設編號為1...n)

unsigned long long dp[101][101];
unsigned long long C(int n, int m){
	if (m == 0 || m == n) return 1;
    if (!dp[n][m])
        dp[n][m] = C(n-1, m-1) + C(n-1, m);
    return dp[n][m];
}
unsigned long long dp[101][101];
for (int n=1; n<101; n++) {
	for (int m=0; m<=n; m++) {
		if (m == 0 || n == m)
			dp[n][m] = 1;
		else
			dp[n][m] = dp[n-1][m-1] + dp[n-1][m];
    }
}

Top-down

Bottom-up

請問從 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)

Top-
down

Bottom-
up

* base case寫在memset裡面

給出 N 個物品和物品的重量和價值。

給出祖靈的背包的重量限制。

請問祖靈最多可以帶價值東西回家?

(假設編號為1...n)

接下來我們從bottom-up的0/1背包問題

開始進行優化吧!

滾動法

Rolling Optimization

滾動法 Rolling

我們來觀察一下用 Bottom-up 解決背包問題的時候,DP表的狀態。

編號 1 2 3 4
重量 2 1 3 2
價值 3 2 4 2
N \ W 0 1 2 3 4 5
0 0 0 0 0 0 0
1 0 0 3 3 3 3
2 0 2 3 5 5 5
3 0 2 3 5 6 7
4 0 2 3 5 6 7

DP[n][w] = max(DP[n-1][w], DP[n-1][w-wi]+vi)

滾動法 Rolling

我們來觀察一下用 Bottom-up 解決背包問題的時候,DP表的狀態。

N \ W 0 1 2 3 4 5
0 0 0 0 0 0 0
1 0 0 3 3 3 3
2 0 2 3 5 5 5
3 0 2 3 5 6 7
4 0 2 3 5 6 7

我們每次算第 n 列,只會需要第 n-1 列的答案。
n-2 以前都用不到了,不覺得很浪費嗎?

滾動法 Rolling

如果我們計算某一列,我們只需要他的前一列的答案。

N \ W 0 1 2 3 4 5
0 0 0 0 0 0 0
1 0 0 3 3 3 3
2 0 2 3 5 5 5
3 0 2 3 5 6 7
4 0 2 3 5 6 7

這個"某一列",可以利用前兩列的空間來存。

0

0

1

我們可以使用 n % 2 來判斷現在是哪一列。

滾動法 Rolling

int pst=0, cur=1;
for (int i=1; i<=n; i++) {
	for (int j=0; j<=w; j++) {
		if (j >= W[i])
			dp[cur][j] = std::max(dp[pst][j-W[i]]+V[i], dp[pst][j]);
		else
			dp[cur][j] = dp[pst][j];
		}
	cur = 1-cur;
	pst = 1-pst;
}

或者用兩個變數輪替交換。這樣只需要開兩排空間!

x = 1 - x 這個式子:
如果 x == 0,那麼 x = 1。

如果 x == 1,那麼 x = 0。

這樣每一排做完的時候,0/1就會交換。

*也可以用 x ^= 1,你開心就好

思考看看

  1. 如果第n排的答案需要參照第n-1排以及第n-2排,這使得你必須滾動三排,那麼你該怎麼滾?
     
  2. 其實背包問題可以不用滾動,只開一個一維陣列。你有辦法想出來嗎?

    • 提示1: dp[n][w] 總會有一個答案是 dp[n-1][w]。

    • 提示2: for迴圈的順序非常重要

  3. 在背包問題中,每個物品都可以無限拿,該怎麼做呢?

  4. 在原本的背包問題是,兩層 for 迴圈是可以交換的。
    那麼如果使用滾動法,這兩層還可以交換嗎?

動態規劃使用時機

動態規劃使用時機

  1. 問題具有最佳子結構

    講人話就是這個大問題可以透過小問題解決。

     
  2. 問題具有重複子問題

    講人話就是解決大問題的時候,
    小問題會被問不只一次。

optimal substructure

 overlapping subproblems

有這兩種性質的問題,就能動態規劃。

動態規劃使用時機

動態規劃條件: 最佳子結構 以及 重複子問題

給定一個正整數 n ,
請判斷 n 是否為質數

n 是不是質數無法由其他數是不是質數來判定。

沒有最佳子結構

 

他不是個 DP 題。

*不過質數篩法好像勉強算個DP XD

動態規劃使用時機

動態規劃條件: 最佳子結構 以及 重複子問題

排序數列

排序大數列可以拆成排序兩個一半的數列,並且合併成一個排序的數列。
最佳子結構


但每個區間排序只會處理一次,

沒有重複子問題
 

他不是個 DP 題,他是分治(D&C)題。

DP經典問題

DP經典問題

接下來列舉經典DP問題,
來想想看怎麼寫吧!

找硬幣問題

Change-making Problem

Coin Change (leetcode 322)

給你一個國家的面額種類,

請問最少可以用多少個硬幣湊出 amount?

舉例來說:

  • 台灣有 [1, 5, 10, 50, 100, 500, 1000]
    • 如果你要找給別人 261 塊你會怎麼做?
    • 1+1+1+1+1+1+1+1+1+1+1+1+1
    • 100 + 100 + 50 + 10 + 1
    • 也就是優先使用大面額

Coin Change (leetcode 322)

但考慮比較奇怪的 case:

  • 某個外國有的面額為 [1, 4, 5]
    • 如果你要找給別人 8 塊你會怎麼做?

目標 8 塊

5 + (目標 3 塊)

4 + (目標 4 塊)

5 + 1 + 1 + 1

(四個硬幣)

4 + 4

(兩個硬幣)

給你一個國家的面額種類,

請問最少可以用多少個硬幣湊出 amount?

Coin Change (leetcode 322)

總結來說,這題只能爆搜。

也就是你幾乎必須試遍所有可能。

那不是很慢嗎?

給你一個國家的面額種類,

請問最少可以用多少個硬幣湊出 amount?

Coin Change (leetcode 322)

某個外國有的面額為 [1, 4, 5],要找 8 塊

目標 8 塊

怎麼定義DP式子?

最簡單的方法:
DP(???) = 答案

DP(8)

min

1 + DP(5)

1 + DP(4)

1 + DP(7)

5 + (目標 3 塊)

4 + (目標 4 塊)

1 + (目標 7 塊)

那 base case?

給你一個國家的面額種類,

請問最少可以用多少個硬幣湊出 amount?

DP(x) = 湊出 x 的最少硬幣

Coin Change (leetcode 322)

class Solution {
public:
    int dp[10001] = {};

    int coinChange(vector<int>& coins, int amount) {
        // base case
        if (amount == 0)
            return 0;
        // memoization
        if (dp[amount])
            return dp[amount];
        int ans = -1;
        for (int coin : coins) {
            if (coin <= amount) {
                int tmp = coinChange(coins, amount - coin);
                // update when
                //   1. tmp must has solution (!= -1)
                //   2. no answer currently or tmp is better than ans
                if (tmp != -1 && (ans == -1 || 1 + tmp < ans))
                    ans = 1 + tmp;
            }
        }
        return dp[amount] = ans;
    }
};

走格子問題

TODO

最長共同子序列

Longest Common Subsequence (LCS)

10405 - LCS (zj c001)

什麼是子區間以及子序列?

  • 子區間/子陣列 (subarray) :
    有序且連續的部分。
     
  • 子序列 (subsequence) :
    有序但不一定連續的部分。

以 azbec 來說:

  • zbe
  • a
  • ec
  • abc
  • ze
  • 子區間都是子序列

題目求某字串的最大長度,並且同時是兩個字串的子序列。

給定兩個字串 S 和 T,請問 LCS(S, T) =?

共同子序列

10405 - LCS (zj c001)

給定兩個字串 S 和 T,請問 LCS(S, T) =?

範例 1:

a1b2c3d4e
zz1yy2xx3ww4vv

共同子序列

範例 2:

abcdgh
aedbhr

這兩個字串的唯一
LCS 為 1234,
因此答案為4。

這兩個字串有兩個 LCS,

分別是 "adh","abh"。但答案都是3。

  • 為了方便,這題的LCS(S, T)都指LCS的長度,不是LCS本身

10405 - LCS (zj c001)

怎麼定義DP式子?

遇到不知道怎麼定義狀態的時候,就先這樣寫:

給定兩個字串 S 和 T,請問 LCS(S, T) =?

共同子序列

DP [????] = 題目要求的答案

所以在這題上:

DP [????] = LCS(S, T)

  1. 問號一定要跟 S 和 T 有關
  2. 希望有順序性,會比較好遞迴
DP_{i, j} = LCS(S_{[0, i]}, T_{[0, j]}) = LCS(S_{前i+1個字元}, T_{j+1個字元})

10405 - LCS (zj c001)

怎麼定義DP式子?

給定兩個字串 S 和 T,請問 LCS(S, T) =?

共同子序列

DP_{i, j} = LCS(S_{[0, i]}, T_{[0, j]}) = LCS(S_{前i+1個字元}, T_{j+1個字元})

那轉移式子呢?

也就是怎麼用小問題解決大問題呢?

好像很不好想也?我們回想一下之前的題目:

  1. 爬樓梯考量最後一步走 1 還是 2 階
  2. C(n, m) 考量要不要取最後一顆球 (第 n 顆球)
  3. 背包問題考量要不要取最後的一個物品
  4. 找硬幣考量最後一個硬幣是拿哪種硬幣

所以按照慣例我們考量兩個字串的最後一個字。

10405 - LCS (zj c001)

怎麼定義DP式子呢?

那轉移式子呢?

S[i] 跟 T[j] 匹配一定最好!
代表 LCS 尾巴一定是S[i]!

S =  .........   X

T =  ....   X

S[i]

T[j]

T =  ....   Y

剩下的 LCS 會在哪呢?

在 S[0...i-1] 和 T[0...j-1] 之間

給定兩個字串 S 和 T,請問 LCS(S, T) =?

共同子序列

DP_{i, j} = LCS(S_{[0, i-1]}, T_{[0, j-1]}) + 1 \\ = DP_{i-1, j-1} + 1

S[i] 不能跟 T[j] 匹配。

S[i] 跟 前面的 T 匹配
 T[j] 沒人配,等同沒用。

LCS(S_{[0, i]}, T_{[0, j-1]}) = DP_{i, j-1}

同理,相反也是。

LCS(S_{[0, i-1]}, T_{[0, j]}) = DP_{i-1, j}
S_i = T_j
S_i \ne T_j
DP_{i, j} = LCS(S_{[0, i]}, T_{[0, j]})

考量最後一個字

10405 - LCS (zj c001)

怎麼定義DP式子呢?

DP_{i, j} = LCS(S_{[0, i]}, T_{[0, j]})

給定兩個字串 S 和 T,請問 LCS(S, T) =?

共同子序列

LCS(S_{[0, i-1]}, T_{[0, j-1]}) + 1 = DP_{i-1, j-1} + 1
LCS(S_{[0, i]}, T_{[0, j-1]}) = DP_{i, j-1}
LCS(S_{[0, i-1]}, T_{[0, j]}) = DP_{i-1, j}
\max\{

if

if

Base Case 呢?

考量一直遞迴會到哪個怪地方

DP_{-1, ?} = DP_{?, -1} = 沒字匹配 = 0
S_i = T_j
S_i \ne T_j

10405 - LCS (zj c001)

DP_{i, j} = \begin{cases} 0\text{ ,if } i=-1 \text{ or } j = -1 \\ DP_{i-1, j-1} + 1 \text{ ,if } S_{i} = T_{j} \\ \max(DP_{i-1, j}, DP_{i, j-1}) \text{, otherwise} \end{cases}

箭頭表示答案是從哪裡得到的

給定兩個字串 S 和 T,請問 LCS(S, T) =?

共同子序列

如果用 Bottom-up 表格
就會是這樣。

  • 圖中字串編號是從 1 開始數,
    所以它的base case 是 i = 0 或 j = 0

10405 - LCS (zj c001)

string S1, S2;
int DP[1001][1001];
int rec(int i,int j){
    if(i==-1 || j==-1)
        return 0;
    if(DP[i][j] != -1)
        return DP[i][j];
    if(S1[i] == S2[j])
        return DP[i][j] = rec(i-1,j-1) + 1;
    else
        return DP[i][j] = max(rec(i-1,j),rec(i,j-1));
}

int main(){
    while(cin>>S1>>S2){
        memset(DP, -1, sizeof(DP));
        cout << rec(S1.size()-1, S2.size()-1) << endl;
    }
}
DP_{i, j} = \begin{cases} 0\text{ ,if } i=-1 \text{ or } j = -1 \\ DP_{i-1, j-1} + 1 \text{ ,if } S_{i} = T_{j} \\ \max(DP_{i-1, j}, DP_{i, j-1}) \text{, otherwise} \end{cases}

給定兩個字串 S 和 T,請問 LCS(S, T) =?

共同子序列

編輯距離

Edit Distance

1207 - AGTC (zj f507)

給定兩個字串 S 和 T,問 S 和 T 的編輯距離為何?

edit distance

你可以花費 1 個 cost 做以下三種操作

  1. 刪除任何一個字元
  2. 修改任何一個字元
  3. 增加任何一個字元

編輯距離:最少要花多少 cost 才可以讓 S = T?

AGTCTGACGC
AGTAAGTAGGC

3次修改,1次刪除:編輯距離為 4 

1207 - AGTC (zj f507)

給定兩個字串 S 和 T,問 S 和 T 的編輯距離為何?

edit distance

同 LCS ,DP 定義就下成:

DP_{i, j} = \text{編輯距離}(S_{[0, i]}, T_{[0, j]}) = \text{編輯距離}(S_{前i+1個字元}, T_{j+1個字元})

我們一樣來思考如何從最後一個字轉移吧!

你可以花費 1 個 cost 做以下三種操作

  1. 刪除任何一個字元
  2. 修改任何一個字元

1207 - AGTC (zj f507)

給定兩個字串 S 和 T,問 S 和 T 的編輯距離為何?

edit distance

  1. 刪除任何一個字元
  2. 修改任何一個字元

S[i] 跟 T[j] 匹配一定最好!

剩下的編輯距離?

在 S[0...i-1] 和 T[0...j-1] 之間

DP_{i, j} = \text{編輯距離}(S_{[0, i-1]}, T_{[0, j-1]})

S[i] 不能跟 T[j] 匹配。

考量兩種操作:

修改:把 S[i] 修改成 T[j]

DP_{i-1, j-1} + 1
S_i = T_j
S_i \ne T_j
DP_{i, j} = \text{編輯距離}(S_{[0, i]}, T_{[0, j]})

S =  .........   X

T =  ....   X

S[i]

T[j]

T =  ....   Y

刪除:刪掉 S[i] 或者 刪掉 T[j]

\min (DP_{i, j-1}, DP{i-1, j})

1207 - AGTC (zj f507)

給定兩個字串 S 和 T,問 S 和 T 的編輯距離為何?

edit distance

DP_{i, j} = \text{編輯距離}(S_{[0, i-1]}, T_{[0, j-1]})

修改:

DP_{i-1, j-1} + 1
S_i = T_j
S_i \ne T_j
DP_{i, j} = \text{編輯距離}(S_{[0, i]}, T_{[0, j]})

刪除:

\min (DP_{i, j-1}, DP{i-1, j})
\min\{

那麼 Base Case 呢?

S為空,那編輯距離就是 |T|。

T為空,那麼編輯距離就是 |S|。

最長遞增子序列

Longest Increasing Subsequence (LIS)

給定一個陣列,問最長的嚴格遞增子序列長度為何?

嚴格遞增: 左邊的數 < 右邊的數

\text{數學來說 } a_i < a_j, \forall i < j

舉例來說:
如果數列為 [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的長度

DP_{n} = max_{0 \le i < n} \{DP_{i} + 1 | A_{i} < A_{n}\}
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

給定一個陣列,問最長的嚴格遞增子序列長度為何?

嘗試分析看看複雜度吧!

O(N)

狀態數量                  *    轉移複雜度                = 

O(N)
O(N^2)

有辦法變得更快嗎?

轉移其實有辦法可以做到                        ,但好像有點困難....

O(\log N)
DP_{n} = max_{0 \le i < n} \{DP_{i} + 1 | A_{i} < A_{n}\}

給定一個陣列,問最長的嚴格遞增子序列長度為何?

試試看其他DP定義吧!

定義 DP[n][i] = 對於Ary[0...n]內,
長度為 i 的 LIS 的最後一個元素 (取最小)

DP_{n, i} = \min \begin{cases} DP_{n-1, i} \\ A_{n} \text{ if } DP_{n-1, i-1} < A_{n} \end{cases}
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_{n, i} = \min \begin{cases} DP_{n-1, i} \\ A_{n} \text{ if } DP_{n-1, i-1} < A_{n} \end{cases}

最後像背包問題壓成一維就可以了。

我們來觀察一下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] 還要小的數字的位置的後面。

 

想想看為甚麼?

DP_{n, i} = \min \begin{cases} DP_{n-1, i} \\ A_{n} \text{ if } DP_{n-1, i-1} < A_{n} \end{cases}
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 查找

DP_{n, i} = \min \begin{cases} DP_{n-1, i} \\ A_{n} \text{ if } DP_{n-1, i-1} < A_{n} \end{cases}

思考看看

  1. 求出其中一個 LIS 該怎麼寫?
     
  2. 求出LIS的個數。(leetcode 683)
     
  3. LCS 其實可以使用 LIS 來實作,想想看怎麼寫。

 

  • ​考慮如果LCS的兩個字串的每個字元都不重複的話
  • 那重複的該怎麼做?

一些練習題

Another LCS (zj a252)

給定個字串,請問LCS的長度為何?

最常共同子序列

#include <iostream>
#include <algorithm>
#include <cstring>
using namespace std;
string S[3];
int DP[101][101][101];
int rec(int i, int j, int k){
    if(i==-1 || j==-1 || k == -1)
        return 0;
    if(DP[i][j][k] != -1)
        return DP[i][j][k];
    if(S[0][i] == S[1][j] && S[1][j] == S[2][k])
        return DP[i][j][k] = rec(i-1,j-1,k-1) + 1;
    else
        return DP[i][j][k] = max({rec(i-1,j,k),rec(i,j-1,k),rec(i,j,k-1)});
}
int main(){
    while(cin>>S[0]>>S[1]>>S[2]){
        memset(DP, -1, sizeof(DP));
        cout << rec(S[0].size()-1, S[1].size()-1, S[2].size()-1) << endl;
    }
}

00531 - Compromise (zj e682)

給輸出兩個字串的LCS字串為何。
(題目保證答案的 LCS 只有一個)

在DP時記錄這些箭頭,
算完之後由結尾往回走。

( 答案就是這些
斜線箭頭的組成 )

練習時間

練習時間

接下來看看
其他題目吧!

不同DP的定義?

好的狀態,就會有簡單的轉移。

Critical Mass (zj a388)

Critical Mass (zj a388)

擲了n次硬幣,過程中出現連續3次正面的可能數為何?

嘗試想一下DP定義吧!

嘗試看看無腦定義狀態?

DP[n] = 連續三次正面的可能數

那麼 DP[n] 會包含著這些 case

你該怎麼寫?

n 個硬幣

x 個硬幣

n-x-3 個硬幣

Critical Mass (zj a388)

擲了n次硬幣,過程中出現連續3次正面的可能數為何?

DP[n] = 連續三次正面的可能數

n 個硬幣

x 個硬幣

n-x-3 個硬幣

\sum 2^x \times 2^{n-x-3}?
  • x = 0
  • x = 1

會被重複算!

不行了...在討論下去沒完沒了,可能還要排容原理

4個硬幣

Critical Mass (zj a388)

擲了n次硬幣,過程中出現連續3次正面的可能數為何?

好像不能無腦定義狀態 :(

DP[n] = 沒有連續三次正面的可能數

n 個硬幣

排組教過你:如果正攻不行,就反攻

n-1次,沒有連續三次正面

n-2次,沒有連續三次正面

n-3次,沒有連續三次正面

Critical Mass (zj a388)

好像不能無腦定義狀態 :(

DP[n] = 沒有連續三次正面的可能數

n 個硬幣

排組教過你:如果正攻不行,就反攻

n-1次,沒有連續三次正面

n-2次,沒有連續三次正面

n-3次,沒有連續三次正面

DP[n-1]

DP[n-2]

DP[n-3]

DP_{n} = DP_{n-1} + DP_{n-2} + DP_{n-3}

擲了n次硬幣,過程中出現連續3次正面的可能數為何?

Critical Mass (zj a388)

擲了n次硬幣,過程中出現連續3次正面的可能數為何?

DP[n] = 沒有連續三次正面的可能數

n 個硬幣

n-1次,沒有連續三次正面

n-2次,沒有連續三次正面

n-3次,沒有連續三次正面

DP[n-1]

DP[n-2]

DP[n-3]

DP_{n} = DP_{n-1} + DP_{n-2} + DP_{n-3}

答案 = 全部的可能 - 沒有連續三次正面

2^n
-DP_n

Critical Mass (zj a388)

擲了n次硬幣,過程中出現連續3次正面的可能數為何?

DP[n] = 沒有連續三次正面的可能數

答案 = 全部的可能 - 沒有連續三次正面

2^n
-DP_n
Ans=
#include <stdio.h>
unsigned long long DP[1000] = {1, 2, 4, 7};
unsigned long long rec(int n) {
    if (DP[n])
        return DP[n];
    return DP[n] = rec(n-1) + rec(n-2) + rec(n-3);
}
int main() {
    int n;
    while(~scanf("%d", &n) && n != 0) {
        printf("%llu\n", (1 << (n)) - rec(n));
    }
    return 0;
}

Critical Mass (zj a388)

擲了n次硬幣,過程中出現連續3次正面的可能數為何?

好難... 有沒有其他作法...

定義 DP[n][k] =
沒有連續3次,且最後連續 k 個

n 個硬幣

n-1次,沒有連續三次正面

n-2次,沒有連續三次正面

n-3次,沒有連續三次正面

如果分成 3 個 case 做呢?

Critical Mass (zj a388)

擲了n次硬幣,過程中出現連續3次正面的可能數為何?

定義 DP[n][k] = 沒有連續3次,且最後連續 k 個

不管是哪個Case,
加上一個➖都會在這裡。

n-1次,沒有連續三次正面

n-2次,沒有連續三次正面

n-3次,沒有連續三次正面

DP_{n, 0} = DP_{n-1, 0} + DP_{n-1, 1} + DP_{n-1, 2}

最後是 ➖,再加上一個 ➕才會在這裡。

最後是➕,再加上一個 ➕ 才會在這裡。

DP_{n, 1} = DP_{n-1, 0}
DP_{n, 2} = DP_{n-1, 1}

DP的狀態定義
決定了你的轉移!

看你覺得怎麼寫最順,
有非常多種方法都可以算出答案。

Critical Mass (zj a388)

擲了n次硬幣,過程中出現連續3次正面的可能數為何?

其實正攻也可以!

DP[n][k][0] = n個硬幣,還沒有出現連續三次,最後有連續 k 個

DP[n][k][1] = n個硬幣,已經出現連續三次,最後有連續 k 個

你覺得這樣子的設計要怎麼轉移呢?

精緻的DP狀態!

好的狀態,就會有簡單的轉移。

(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,那麼...

DP_{l, r} = \text{區間[l, r) 的最小成本}
\underset{\min\, k}{=} DP_{l, k}
+ DP_{k, r}
+ |\sum_{i=l}^{k-1} A[i] - \sum_{i=k}^{r-1} A[i]|

左邊的數字加總 -
右邊的數字加總 = 這次合併的成本

合併左邊的成本

合併右邊的成本

(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!

有的時候,DP 轉移也會需要其他技術!

給一字串 S,問最少能將將 S 分成幾段使得每段字串都是「平衡」的。

我們定義「平衡」表示該字串內每個字的出現次數皆一樣。

無腦下 DP 定義:

DP_{n} = 字串[0,n]的最小切法

轉移呢?

想想看最後一切是在哪裡?

不知道?那就枚舉找!

DP_{n} = 字串[0,n]的最小切法
  • 想想看最後一切是在哪裡?

不知道?那就枚舉找!

S_0
S_1
S_2
...
S_n
S_{n-1}
S_3
S_4

最後一切,如果平衡

= 1

最後一切,如果平衡

DP_{0}
= DP_{0} + 1

最後一切,如果平衡

DP_{1}
= DP_{1} + 1

...

...

最後一切,如果平衡

DP_{4} = \text{字串[0,4]的最小切法}
= DP_{4} + 1

...

...

DP_{n} = 字串[0,n]的最小切法
  • 想想看最後一切是在哪裡?

最後一切,如果               平衡

DP_{k-1} = \text{字串[0,k-1]的最小切法}
= DP_{k-1} + 1
S[k,n]

但是我們要怎麼知道一個子字串是不是平衡的呢?

我們先想想看簡單版的題目吧?

DP_{n} = \min_{0 \le k < n}\{DP_{k-1} + 1 | S[k,n] \text{是平衡的}\}

所以轉移式如果用數學寫出來的話就是:

給一字串 S,判斷 S 是否平衡

你可能會這樣寫:

Counting Table

對於每一種字母都跑過一遍,
算出出現的個數。

O( |\sum| \times N)

字母有幾種

有沒有更方便,更快的做法呢?

給一字串 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

再檢查全部出現過的字母是不是都是同一個次數,就可以了!

O( |\sum| + N)

有了S字串後,每次再後面多加一個字元,
並且每次都要判斷這個時候是不是平衡的。

每次加,只需要讓 Table 該元素 + 1 ...

那怎麼 O(1) 的檢查呢?

  • Hint: 你可以多記兩個變數
    1. 目前最多出現幾次?
    2. 目前出現幾種?

最大數字 * 出現的種類 = N

接著我們再更進階一點...

如果每次加,每次重新判斷 ...

O(N^2)
O(1)

給一字串 S,問最少能將將 S 分成幾段使得每段字串都是「平衡」的。

我們定義「平衡」表示該字串內每個字的出現次數皆一樣。

DP_{n} = \min_{0 \le k < n}\{DP_{k-1} + 1 | S[k,n] \text{是平衡的}\}
DP_{n} = 字串[0,n]的最小切法

對於 n 來說,我們需要知道 ...

S[0, n]
S[1, n]
S[n-1, n]
...
S[n, n]

是不是平衡的

這跟之前講的有關係嗎?

知道了 DP 就結束了!

給一字串 S,問最少能將將 S 分成幾段使得每段字串都是「平衡」的。

S[0, n]
S[1, n]
S[n-1, n]
...
S[n, n]

是不是平衡的

每次再 S 後面多加一個字元,並且每次都要判斷這個時候是不是平衡的。

S[n]
S[n]
+ S[n-1]
S[n]
S[n]
+ S[n-1] + .... + S[1] + S[0]
+ S[n-1] + .... + S[1]
...

所以從 S[n] 往回做就可以了!

    int minimumSubstringsInPartition(string s) {
        vector<int> dp(s.length(), 1001);
        dp[0] = 1;
        for (int i=1; i<s.length(); i++) {
            int table[26] = {0}, uniq = 0;
            // 找分界點 (j,從後面往前找)
            for (int j=i; j>=0; --j) {
                int idx = s[j]-'a';
                // 更新頻率表
                table[idx]++;
                // 判斷是不是多一個種類
                uniq += (table[idx] == 1);
                // 判斷是不是平衡
                if (table[idx]*uniq == i-j+1)
                	// 因為沒有 dp[-1],所以 j=0 要特判
                    dp[i] = min(dp[i], j==0 ? 1 : dp[j-1]+1);
            }
        }
        return dp.back();
	}

給一字串 S,問最少能將將 S 分成幾段使得每段字串都是「平衡」的。

我們定義「平衡」表示該字串內每個字的出現次數皆一樣。

精緻的DP轉移!

有的時候,轉移也很有技巧!

(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

DP_{n} = \max (DP_{n-1}, 1 + \text{上一次出現} DP_{n} \text{這個數字的時候})

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!

為什麼?

DP_{n, k}

(APCS 2021/9 第四題)

給定一個序列以及整數k。
請找出如何選出k個不重疊區間,且各個區間內無重複數字,使得區間覆蓋最廣。

L_{n} = \max (L_{n-1}, 1 + \text{上一次出現} L_{n} \text{這個數字的時候})

那 DP[n][k] 呢?

k=1 的 case 我們換個名字 L:

           = 前n個數字中,選了k個區間覆蓋最多的值

DP_{n, k}

好像有點複雜...

(APCS 2021/9 第四題)

給定一個序列以及整數k。
請找出如何選出k個不重疊區間,且各個區間內無重複數字,使得區間覆蓋最廣。

取了第n個值當答案

不取第n個值當答案

最後一個區間:              

好像就是等於

[L_{n}, n]

從選了 k-1 個區間推導:

DP_{(L_{n}-1), k-1}
DP_{n-1, k}

           = 前n個數字中,選了k個區間覆蓋最多的值

DP_{n, k}
DP_{n, k} = \max ({DP_{n-1, k}, DP_{L_{n-1},k-1} + n - L_{n} + 1})

不吃第n個攤位的解

吃了第n個攤位的最佳

+ n - L_{n} + 1
L_{n} = \max (L_{n-1}, 1 + \text{上一次出現} L_{n} \text{這個數字的時候})

(APCS 2021/9 第四題)

#include <stdio.h>
#include <algorithm>
using namespace std;
#define MAXN 1000005
int prv[MAXN], tmp[MAXN];
int dp[MAXN][21];
int main() {
    int n, k, x, ans=0, L=0;
    scanf("%d%d", &n, &k);
    for (int i=1; i<=n; i++) {
        scanf("%d", &x);
        prv[x] = tmp[x];
        tmp[x] = i;
        L = max(L, prv[x]+1);
        // 選 1 個區間的解 ~ 選 k 個區間的解
        for (int j=1; j<=k; j++) {
            dp[i][j] = max(dp[i-1][j], dp[L-1][j-1] + i - L + 1);
            ans = max(ans, dp[i][j]);
        }
    }
    printf("%d\n", ans);
    return 0;
}

我們只會用到L[n-1],
所以其實不用開一個L陣列。

DP小結

DP 小結

什麼時候使用DP?

  • 可以爆搜題目的時候,並且狀態可能會重複。
  • 通常都是最佳化答案 (背包問題),或者計算個數。

DP 的流派 ?

  • Top-down: 遞迴 + 記憶化 (Memoization)
  • Bottom-up: 用迴圈疊出答案

DP 的流程?

  1. 狀態設計,好的狀態會讓你DP好寫很多。
  2. 狀態轉移,思考你要怎麼將問題由大變小。
  3. 如果有,記得寫 Base case
  4. 考慮優化,有時候轉移複雜度太高,可能有魔法可以優化。

DP 小結

題目名稱 題目簡介
東東爬樓梯 一次可以走 1、2 階,走到 n 階的可能數
Combination n 個物品選 m 個的選法
0 / 1背包問題 每個物品都有價值跟重量,求限定重量下的最高價值選法
找硬幣問題 給定幣值的種類,用最少的硬幣數量找 n 元
最長共同子序列 (LCS) 問兩個字串的最長共同子序列
編輯距離 (edit distance) 問兩個字串要改或刪幾個字,才可以讓它們相等
最長遞增子序列 (LIS) 問一個陣列的最長遞增子序列
Critical Mass 擲 n 次硬幣,有連續三次正面的可能數
合併成本 兩相鄰數可以合併,問合併成一個數字的最小成本
最小字串切割 給一個字串,問最少可以切成幾個皆為平衡的子字串
美食博覽會 選 k 個不相交的數字不重複區間,使得涵蓋範圍最大

我們在這章節上的題目總覽

DP 小結

怎麼設計狀態

  • 大多時候,DP[???] = 題目問的答案。
    • 這個 ??? 是什麼,就要靠你想像了。
    • 常見的樣式:
      • 看到第幾個數字
        • 走樓梯問題的 n
      • 題目給的限制
        • 背包的限重 W
        • 美食博覽會的區間個數 k
      • 一些狀態,可以分成不同的 Case
        • 例如 Critical Mass 的後面有幾個 +
  • 有時也可以考慮反解:
    • 例如 Critical Mass (連續三個+) 的題目
  • 有些題目的狀態就真的很 ... 魔幻,這就真的要靠你想像了。

DP 小結

怎麼設計轉移

  • 考慮所有轉移時需要的狀態。
    • 你可以劃出枚舉樹,可能會幫你思考。
    • 背包問題:考量「限定重量的最大價值」或者「限定價值的最小重量」
    • Critical Mass:考量「最後一次連續出現了多少個 + 」。
    • 有時候這個考量是需要用迴圈搜尋的,例如合併成本的切割點
  • 如果找不到合適的,可以考慮 n -> n-1。
    • 例如 LCS,LIS,裴波那契數列。
  • 有些轉移式非常的困難 ( 例如 DP 的各種優化,這個就要吃你的想像力跟經驗了 ;( )

DP 小結

怎麼練習DP?

  • 多多練習?
  • 多看題目來培養狀態設計的感覺。
    • 可以去 Leetcode 的 DP 找找看!
      可以不用寫 code,想想看怎麼寫就好
      • 可以從 easy,medium 開始練習
  • DP 的路很長很長 ...
    • 建議你至少每周都寫個幾題 DP

DP 練習題 - 1

題目名稱 來源 備註
Min Cost Climbing Stairs Leetcode 746 爬樓梯變形題
Triangle Leetcode 120 巴斯卡三角變形題
Target Sum Leetcode 494 類似背包的遞迴
禮物分配
Equal Subset
Leetcode 416
Zj d890
99年北市賽
背包變形題
Unique Paths Leetcode 62 排列組合經典題
Unique Paths II Leetcode 63 排列組合經典題
burst-balloons Leetcode 312 合併成本類似題

DP 練習題 - 2

題目名稱 來源 備註
Min Path Cost in a Grid Leetcode 2304 2D/1D 裸題
Maximal Square Leetcode 221 經典題,轉移很酷
Combination Sum IV Leetcode 377

經典題,數字拆分變形

DP 練習題 - 3

題目名稱 來源 備註
House Robber Leetcode 198 經典題
House Robber II Leetcode 213 上一題的微變形
House Robber III Leetcode 337 樹 DP
Min Path Cost in a Grid Leetcode 2304 2D/1D 裸題
burst-balloons Leetcode 312 合併成本類似題

APCS DP 考古題

題目名稱 來源 備註
置物櫃分配 APCS 2018 / 10 - 4 類背包問題
刪除邊界 APCS 2019 / 10 - 4 zj 連結消失了
投資遊戲 APCS 2023 / 10 - 4  
內積 APCS 2022 / 06 - 4  
飛黃騰達 APCS 2021 / 01 - 4 LIS 二分搜版本
勇者修練 APCS 2020 / 10 - 3  
病毒演化 APCS 2020 / 07 - 4 樹DP

APCS 幾乎每兩次考一題!

DP 小結

接下來我們來開始更難的DP吧!

沒有,我好懶得教後面的,數學太多了

位元DP

位元運算

要提到位元DP前,我們要先熟悉位元運算

bit operation

你知道 && 和 & 的差別嗎?

5 & 6 = ?

\,\,\,\,\,5_{10} = 101 _{2} \\ \& 6_{10} = 110_{2} \\ --------\\ \,\,\,\,\,4_{10} = 100_{2} \\

同理可以應用在 ^, | 上。

另外, ! 的位元運算是 ~

位元運算

bit operation

給定一個數字n,請枚舉所有選法 (2^n)

來個簡單題吧!

這一看不就是遞迴題嗎?


0
1
01
2
02
12
012
3
03
13
013
23
023
123
0123
4
04
14
014
24
024
124
0124
34
034
134
0134
234
0234
1234
01234
#include <stdio.h>
int main() {
    int n = 5;
    for (int i=0; i<(1<<n); i++) {
        for (int j=0; j<n; j++) {
            if (i & (1 << j))
                printf("%d", j);
        }
        printf("\n");
    }
}

* 1<<n = 2^n

位元運算

bit operation

給定一個數字n,請枚舉所有選法 (2^n)

來個簡單題吧!

這一看不就是遞迴題嗎?


0
1
01
2
02
12
012
3
03
13
013
23
023
123
0123
4
04
14
014
24
024
124
0124
34
034
134
0134
234
0234
1234
01234
00000
10000
10000
11000
10000
10100
11000
11100
10000
10010
10100
10110
11000
11010
11100
11110
10000
10001
10010
10011
10100
10101
10110
10111
11000
11001
11010
11011
11100
11101
11110
11111

位元DP

接著我們來做做看位元DP的題目吧,

單調對列優化

有限間距 LCS (zj b478)

給定兩個字串,並且限制子序列的每個元素間距必須 <= k,那麼請問LCS的長度為何?

Hint: 你可能需要會做固定範圍的2維區間最大值

斜率優化

Convex Hull Optimization / 凸包優化

四邊形優化

Quadrilateral Inequality Optimization

Aliens 優化

Made with Slides.com