資讀-進階DP

建國中學 賴昭勳

還記得DP在幹嘛ㄇ

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

 

\( n\leq 2*10^6\)

小小補充

DP 的順序

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

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

 

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

DP 的轉移方式

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

 

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

DP 的轉移方式:

pros and cons

Top-Down: 

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

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

 

Bottom-Up: 

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

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

經典問題

LIS:最長遞增子序列

 

給你一個序列 \(a\),求最長的遞增子序列長度。

 

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

 

\(n \leq 10^5, a_i \leq 10^9\)

令\(dp[i]\)代表以第\(i\)個元素結尾的LIS長度

 

轉移:\(dp[i] = max_{j < i, a_j < a_i} dp[j] + 1\)

答案:\( \max dp[i] \)

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

方法一:資料結構

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

對值域維護一個 BIT,儲存該數值大小目前最大的\(dp\)值

 

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

原本 dp 轉移可以看成一種前綴 max,在 \(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;
}

方法二:換個觀點

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

1

5

3

4

8

2

3

5

6

1

5

3

4

8

2

3

5

6

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

實作:用lower_bound

lower_bound: 回傳第一個\(\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, b\),在\(a, b\)中分別找出一個長度為\(x\)的子序列 \(a', b' \),使得兩個子序列相同。

請找出符合上述條件中最大的\(x\)

 

\(\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 分,求得分最大值

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

 

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

 

  • ​從\(dp[i][j - 1]\) 新增了 \(b[j]\)並在\(a\)填上一個空白
  • 從\(dp[i - 1][j]\) 新增了 \(a[i]\)並在\(b\)填上一個空白
  • 從\(dp[i - 1][j - 1]\) 把\(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: 編輯距離

給你起始字串\(a\)和目標字串\(b\),你每一秒可以做三種操作:

 

  • 選擇\(a\)的任意一個位置插入任意字元
  • 刪除\(a\)的一個字元
  • 將\(a\)的某一字元替換

 

求最少可以在幾秒內將\(a\)變成\(b\)

 

\(\vert a \vert, \vert b \vert \leq 3000\)

跟 LCS 是不是有點像?

 

用「配對」的觀點想想看

 

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

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

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

DP 方法相同!


               

 



 

   當前狀態

 
dp[i][j]

\(a\)的方向

\(b\)的方向

刪除

插入

a_i = 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 為例

回溯:紀錄目前的 \(dp\)位置從哪邊轉移過來

經典問題 Part 2

最大二維子矩陣

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

 

\(n, m \leq 300\)

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

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

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

\(i\)

\(j\)

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

練習題囉

開一堆怪東東

 

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

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

 

\(n, m \leq 100\)

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

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

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

 

\(n \leq 5*10^5\)

位元DP

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

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

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

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

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

來看例題吧:

 

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

\(n \leq 20\)

是不是感覺沒有好做法?

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

觀察一下

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

來DP 吧

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

 

轉移:

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

其中 \(i \& 2^j \neq 0\)

跟位元運算有關的語法

\(2^x\) -> 1<<x

\(11111_2\) -> (1<<5) - 1

 

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

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

\(n * 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看,只要找一個好的轉移順序,絕對是可以做到的。

Made with Slides.com