資讀-進階DP

建國中學 賴昭勳

還記得DP在幹嘛ㄇ

給你一個由 P, E, C 組成的陣列,要將陣列分成三段連續的區間,且每段各派給 P, E, C三人。一個人拿到的分數是他的區間裡面跟他一樣的元素個數。求最大得分。

 

n2106 n\leq 2*10^6

小小補充

DP 的順序

在做DP 的時候,必須找到一種順序,使得

一個狀態的所有轉移來源都已經被算過

 

有時候,改變一下轉移順序會讓你對問題有新的觀點!

DP 的轉移方式

Top-Down: 從目前算好的狀態去更新後面的狀態

 

Bottom-Up: 遇到現在的狀態,看他是從哪裡轉移過來的

DP 的轉移方式:

pros and cons

Top-Down: 

優點:好做,有時候會比較快(BFS)

缺點:在某些狀況下不能用 (DP 優化)

 

Bottom-Up: 

優點:大部分時間都能使用,用數學觀點了解DP

缺點:有時候比較難寫(但其實我覺得還好)

經典問題

LIS:最長遞增子序列

 

給你一個序列 aa,求最長的遞增子序列長度。

 

(子序列:選一些序列內的元素形成新的序列,這些元素在新序列的順序不變)

 

n105,ai109n \leq 10^5, a_i \leq 10^9

dp[i]dp[i]代表以第ii個元素結尾的LIS長度

 

轉移:dp[i]=maxj<i,aj<aidp[j]+1dp[i] = max_{j < i, a_j < a_i} dp[j] + 1

答案:maxdp[i] \max dp[i]

這樣只能 O(n2)O(n^2)?

方法一:資料結構

先對數字做值域壓縮,使得1ain1 \leq a_i \leq n

對值域維護一個 BIT,儲存該數值大小目前最大的dpdp

 

在 modify 的時候,把平常用BIT的加法改成取 max 的運算

原本 dp 轉移可以看成一種前綴 max,在 O(logn)O(\log n)取得

#include <iostream>
#define maxn 100005
using namespace std;
int bit[maxn], a[maxn];
void modify(int ind, int val) {
	for (;ind < maxn;ind += ind & (-ind)) bit[ind] = max(bit[ind], val);
}
int query(int ind) {
	int ret = 0;
	for (;ind > 0;ind -= ind & (-ind)) ret = max(ret, bit[ind]);
	return ret;
}
int main() {
	int n;
	cin >> n;
	for (int i = 0;i < n;i++) cin >> a[i];
	//已經離散化了
	int ans = 0;
	for (int i = 0;i < n;i++) {
		int dp = query(a[i] - 1);
		ans = max(ans, dp);
		modify(a[i], dp);
	}
	cout << ans << endl;
}

方法二:換個觀點

假設我有一個陣列vv,使得viv_i代表長度為ii的遞增子序列的最後一個數字中最小可以是多少,那可以怎麼維護?

1

5

3

4

8

2

3

5

6

1

5

3

4

8

2

3

5

6

陣列保證必然存在 「當前陣列長度」的LIS!

實作:用lower_bound

lower_bound: 回傳第一個x\geq x 的數字的位置

#include <iostream>
#include <algorithm>
#include <vector>
using namespace std;
int main() {
    ios_base::sync_with_stdio(0);cin.tie(0);
    int n;
    cin >> n;
    int a[n];
    for (int i = 0;i < n;i++) cin >> a[i];
    vector<int> v;
    for (int i = 0;i < n;i++) {
        int ind = lower_bound(v.begin(), v.end(), a[i]) - v.begin();
        if (ind == v.size()) {
            v.push_back(a[i]);
        } else {
            v[ind] = a[i];
        }
    }
    cout << v.size() << endl;
}

LCS: 最長共同子序列

(Longest Common Subsequence)

 

給你兩個序列a,ba, b,在a,ba, b中分別找出一個長度為xx的子序列 a,ba', b' ,使得兩個子序列相同。

請找出符合上述條件中最大的xx

 

a,b3000\vert a \vert, \vert b \vert \leq 3000

可以這樣想這題

 

在兩個字串中間放入任意多個「空白」字元,使得字串之間盡量多個字元對應到。(最佳配對)

 

ex. abaabcc 和 badcbcac

 

a b a a - b c - c
- b a d c b c a c

假設位置相同、字元一樣得 1 分,求得分最大值

那這題的「重複子結構」是什麼?

 

考慮已經做完了aa的前ii個字元,做完bb的前jj個字元的最佳配對。令這個狀態的最大得分為dp[i][j]dp[i][j],則它可以從三種地方轉移:

 

  • ​從dp[i][j1]dp[i][j - 1] 新增了 b[j]b[j]並在aa填上一個空白
  • dp[i1][j]dp[i - 1][j] 新增了 a[i]a[i]並在bb填上一個空白
  • dp[i1][j1]dp[i - 1][j - 1]a[i],b[j]a[i], b[j]配對
dp[i][j]=max(dp[i][j1],dp[i1][j],dp[i1][j1]+(1  if  ai==bj))dp[i][j] = \max(dp[i][j - 1], dp[i - 1][j], \\ dp[i - 1][j - 1] + (1 \ \ if \ \ a_i == b_j))
dp[i][j] = \max(dp[i][j - 1], dp[i - 1][j], \\ dp[i - 1][j - 1] + (1 \ \ if \ \ a_i == b_j))
a b a a b c c
b 0 1 1 1
a 1
d
c
b
c
a
c

1

2

2

1

1

2

2

Edit Distance: 編輯距離

給你起始字串aa和目標字串bb,你每一秒可以做三種操作:

 

  • 選擇aa的任意一個位置插入任意字元
  • 刪除aa的一個字元
  • aa的某一字元替換

 

求最少可以在幾秒內將aa變成bb

 

a,b3000\vert a \vert, \vert b \vert \leq 3000

跟 LCS 是不是有點像?

 

用「配對」的觀點想想看

 

每多一個空白就是多一秒,同位置不相同的兩個字元也要花一秒修改。

空白 -> 插入、刪除操作 (Insert, Delete)

同位置不相同的兩個字元 -> 失配 (mismatch)

DP 方法相同!


               

 



 

   當前狀態

 
dp[i][j]dp[i][j]
dp[i][j]

aa的方向

bb的方向

刪除

插入

ai=bja_i = b_j
a_i = b_j
aibja_i \neq b_j
a_i \neq b_j

配對

失配

Where your code

#include <iostream>
#include <algorithm>
#define ll long long
using namespace std;
int main() {
	string a, b;
	getline(cin, a);
	getline(cin, b);
	int dp[2][1005 + 1];
	for (int i = 0; i < b.size() + 1;i++) {
		dp[0][i] = i;
		dp[1][i] = i;
	}
	for (int i = 1; i <= a.size();i++) {
		dp[1][0] = dp[0][0] + 1;
		for (int j =1; j <= b.size();j++) {
			if (a[i - 1] == b[j - 1]) {
				dp[1][j] = dp[0][j - 1];
			} else {
				dp[1][j] = min(min(dp[0][j], dp[1][j - 1]), dp[0][j - 1]) + 1;
			}
		}
		for (int j = 0; j < b.size() + 1; j++) dp[0][j] = dp[1][j];
	}
	cout << dp[1][b.size()] << endl;
}

二維DP 的空間壓縮:滾動!

你有聽過滾動DP嗎?

假設這一排的狀態只有用到

這一排跟上一排的話...

Code Time!

如果我們要輸出一組解怎麼辦?

以lower_bound 為例

回溯:紀錄目前的 dpdp位置從哪邊轉移過來

經典問題 Part 2

最大二維子矩陣

給你一個二維整數陣列a[n][m]a[n][m],選擇一個長方形子矩陣,使其總和最大

 

n,m300n, m \leq 300

先想 O(n4)O(n^4)怎麼做?

還記得最大連續和怎麼做嗎?

結合枚舉和最大連續和的想法

ii

jj

把這個方向當成一個最大連續和看!O(n3)O(n^3)

練習題囉

開一堆怪東東

 

噢對然後不一定跟前面講的東西有關www

給你nmn * m的地圖,上面有障礙物和寶藏位置。有兩個人各從左上角開始走到右下角要拿寶藏,他們只能往右和往下走,且一個人拿過的另一個人不會再拿到。求最多可以拿到幾個寶藏。

 

n,m100n, m \leq 100

Bessie the cow 要吃數線上 nn 株草,每一株的位置是aia_i,而她一開始在位置LL,每秒可以向左或向右移動一單位。若她在第tit_i秒吃完第ii株草,請找到吃完所有草的最小時間總和,也就是min(i=0n1ti)min(\sum_{i = 0}^{n -1} t_i)

Hint:時間比較小就代表總時間少嗎?

給你一個正整數序列aa,一開始每個位置都有「可以動」跟「不能動」兩種。你每次操作可以把一個可以動的數字修改為任意數。問最少需要經過幾次操作,才能使aa呈嚴格遞增?(不可能的話輸出 -1)

 

n5105n \leq 5*10^5

位元DP

用二進位表示某種「有」或「沒有」的狀態

通常用在複雜度要指數的問題(n25n \leq 25)

(或是你不知道正解是什麼的喇分)

轉移:把一些 0 轉成 1 或 1 轉成 0。

答案:通常是全部選 dp[1111112]dp[111111_2] 或全部不選dp[0]dp[0] 

來看例題吧:

 

給你一個n×nn \times n 的矩陣,你必須選出nn個數字,使得這些數字都在不同的行跟列上,且乘積最大。

n20n \leq 20

是不是感覺沒有好做法?

那就來枚舉吧!但是直接枚舉好像太沒效率...

觀察一下

  • ​假設我已經幫前 ii列選好了,那麼之後再也不會用到他們。
  • 每一列都一定要選一個
  • 在考慮能不能選a[i][j]a[i][j]的時候,只要看他前面有沒有和他同一行的東西就好了,不用考慮他們的順序

來DP 吧

假設dp[bitmask]dp[bitmask]kk個 1,那麼他代表的是前kk排選的位置分別為bitmaskbitmask裡面 1 的 bit。

 

轉移:

dp[i]=max(dp[i2j]a[j])dp[i] = \max (dp[i - 2^j] * a[j])

其中 i&2j0i \& 2^j \neq 0

跟位元運算有關的語法

2x2^x -> 1<<x

11111211111_2 -> (1<<5) - 1

 

bitwise and (&), or (|), xor (^)

給你一個由0, 1組成的矩陣,每次操作可以改變一個元素的值,問最少要多少操作,使得所有邊長為偶數的正方形方陣都有奇數個1

nm106n * m \leq 10^6

這題講師還沒寫過

https://tioj.ck.tp.edu.tw/problems/1028

這題很讚(但很難)

https://tioj.ck.tp.edu.tw/problems/2070

圖論 x DP

DAG 有向無環圖

什麼?這也可以DP?

 

其實一般的DP轉移就可以把它當作一個DAG看,只要找一個好的轉移順序,絕對是可以做到的。

進階DP (資讀)

By justinlai2003

進階DP (資讀)

  • 1,620