Greedy / DC / DP

9/20 校隊培訓

becaido

講師介紹

  • 建國中學 陳柏凱
  • OJ handle:becaido

經歷:

  • 2023 IOI 銀牌
  • 2023 APIO 銅牌
  • 2022 全國賽 一等獎
  • 2022 北市賽 一等獎

因為想學習 greedy/dc/dp
所以來當 greedy/dc/dp 講師

GREEDY

貪心

有的時候直接貪心可能會出事

如果可以反悔的話...

有一個長度 \(n\leq 5\times 10^5\) 的字串,字元可能有 ()? 三種

? 有 \(m\) 個,第 \(i\) 個 ? 要變成 ( 或 )

變成 ( 的代價是 \(L_i\)

變成 ) 的代價是 \(R_i\)

問讓這個字串變成一個合法的括號字串最少的代價

看起來很難...

不如先把所有 ? 都先變成 )

等到出事了再說

目前的總代價是 \(\sum R_i\)

從左往右掃

記錄現在有多少個 ( 沒被配對到

如果數量 \(<0\)

出事了!

必須要讓一個 ) 變成 ( 才行

要從所有可以變成 ( 的 ) 找代價最小的...

用 priority_queue 存!

遇到原本是 ? 的位置就在 pq 裡 push 進 \(L_i-R_i\)

代表從 ) 變成 ( 的代價

貪心的地方在於每次選最小的一定是最好的

證明是當遇到下一個 ?,我們把之前的決策改變不會比較好

DC

分治

分治叫 Divide and Conquer

先把大問題拆成小問題做

然後把小的合併成大的

經典分治例子:Merge Sort

給一個長度為 \(n\leq 10^5\) 的陣列 \(a\),請將它排序好

先把大問題拆成小問題...

想排序 \([1,n]\),先排好 \([1,\frac{n}{2}],[\frac{n}{2}+1,n]\)

假設現在有兩個排序好的小陣列了

要把它們合併成一個排序好的大陣列

每次比較兩個小陣列最前面的數字

小的先拿

可以 \(O(n)\) 合併!

複雜度分析:

\(T(n)=2T(\frac{n}{2})+O(n)\)

\(T(n)=O(n\log n)\)

可以想像成每次把陣列切一半

共要切 \(\log\) 次

OJDL 7162

有個長度為 \(n\leq 10^5\) 的陣列 \(a(1\leq a_i\leq n)\)

問有多少區間 \([l,r](1\leq l\leq r\leq n)\)

滿足 \((\max(a_l,\dots,a_r))^2\geq \sum\limits_{i=l}^ra_i\)

對區間 \([1,n]\),把它分成左右各一半的小區間

答案有 \(3\) 種狀況:

\(l,r\) 都在左邊的區間

\(l,r\) 都在右邊的區間

\(l\) 在左,\(r\) 在右

前兩種可以遞迴處理

最後一種要想辦法計算跨越兩邊的答案

首先式子可以轉成

\((\max(a_l,\dots,a_r))^2 \geq \text{pre}_r-\text{pre}_{l-1}\)

先考慮最大值在左邊的情況

\(mx^2\geq \text{pre}_r-\text{pre}_{l-1}\)

移項變成

\(mx^2+\text{pre}_{l-1}\geq \text{pre}_r\)

對於一個 \(l\)

我們可以知道 \(mx^2+\text{pre}_{l-1}\) 是多少

\(\text{pre}_r\) 是遞增的

可以二分搜出 \(r\) 可以到哪裡

記得不要搜超過會讓最大值在右邊的 \(r\)

對於最大值在右邊的情況同理

\(T(n)=2T(\frac{n}{2})+O(n\log n)\)

\(T(n)=O(n\log^2(n))\)

一個問題能不能用分治做的關鍵是

它有沒有好的切法、有沒有一個好的合併方式

DP

動態規劃

DP 在幹嘛

簡單來說就是設計狀態與狀態轉移

兩個東西看起來只有四個字

真正做起來卻可以非常難

把陣列取名成 dp

剛開始介紹 DP 一定會講到費氏數列

費氏數列:

最前面兩項都是 \(1\),

之後每項都是前面兩項總和的數列

設計狀態

\(dp_i\) 為數列第 \(i\) 項的值

注意 DP 時邊界的狀態要設好

\(dp_1=1,dp_2=1\)

狀態轉移

根據題目的不同

轉移方法也會有所差異

大致上來說會有三種

Bottom-Up (Pull)

計算這個狀態的答案時直接去找所有轉移來源

\(dp_i=dp_{i-1}+dp_{i-2}\)

缺點:有的時候只能從轉移來源推會轉移到誰

無法從會被轉移到的點找出誰轉移過來

Bottom-Up (Push)

我直接看我會轉移到誰

然後去更新它

\(dp_{i+1}\ +=\ dp_i\)

\(dp_{i+2}\ +=\ dp_i\)

Top-Down

寫一個遞迴函式,把已經算過的結果存下來

int dp[30];

int fib(int n) {
    if (dp[n] != 0) return dp[n];
    if (n <= 2) {
        dp[n] = 1;
        return dp[n];
    }
    dp[n] = fib(n - 1) + fib(n - 2);
    return dp[n];
}

要怎麼練習 DP?

Atcoder DP contest 有很多 DP 經典題

CSES 的 DP section 的題目也很經典

接下來會從裡面的題目挑出幾個常見的基礎 DP

背包 dp

比 FFT 難的東西

有 \(N\leq 100\) 個物品,每個物品重 \(w_i\),價值 \(v_i\)

你有一個可以裝重量最多 \(W\leq 10^5\) 的背包

問在不超過重量限制下,裝的物品價值總和最高是多少

狀態設計

\(W\) 好像滿小的

那可以設 \(dp_{i,j}\) 代表

看完前 \(i\) 個物品以後,裝的重量不超過 \(j\) 的價值最大值

狀態轉移

遇到第 \(i\) 個物品,我可以選擇拿或不拿

\(dp_{i,j}=\max(dp_{i-1,j},dp_{i-1,j-w_i}+v_i)\)

時間複雜度 \(O(NW)\),空間複雜度 \(O(NW)\)

其實空間複雜度可以優化到 \(O(W)\)

我們每次都先做 \(dp_{i,j}=dp_{i-1,j}\)

其實就是把陣列複製一遍

現在不要複製,直接用同一個陣列

觀察到 \(dp_{i,j}=\max(dp_{i,j},dp_{i-1,j-w_i}+v_i)\)

如果 \(j\) 由大的掃到小的

那可以簡化成 \(dp_j=\max(dp_j,dp_{j-w_i}+v_i)\)

於是只要開一個一維陣列就好了!

以上的背包 DP 被稱為 0/1 背包問題

意思是可以取或不取這個物品

除了 0/1 背包以外

還有無限背包與多重背包

無限背包

一樣的問題,只是第 \(i\) 個物品從只能拿一個變成可以拿任意多個

狀態設計:與 0/1 背包一樣

狀態轉移:\(dp_{i,j}=\max(dp_{i-1,j},dp_{i,j-w_i}+v_i)\)

\(i,j\) 可以從 \(i,j-w_i\) 轉移過來

代表可以拿任意多個

一樣可以減少記憶體

\(dp_j=\max(dp_j,dp_{j-w_i}+v_i)\)

只是現在 \(j\) 要由小掃到大

LCS

最長共同子序列

給兩個長度 \(\leq 3000\) 的字串 \(s,t\)

請找一個長度最長的字串,且這個字串是 \(s,t\) 的子序列

子序列指的是原數列刪除若干個元素得到的東西

不一定要連續

設定狀態

\(dp_{i,j}\):\(s\) 看了前 \(i\) 個字元,\(t\) 看了前 \(j\) 個字元的最長共同子序列長度

邊界:\(dp_{i,0},dp_{0,j}\) 都設成 \(0\)

狀態轉移

\(dp_{i,j}\) 可以直接用 \(dp_{i-1,j}\) 或 \(dp_{i,j-1}\)

代表我直接不考慮 \(s\) 的第 \(i\) 個字元或 \(t\) 的第 \(j\) 個字元

如果 \(s_i=t_j\),那 \(dp_{i,j}\) 會是 \(dp_{i-1,j-1}+1\)

時間、空間複雜度 \(O(|s||t|)\)

可是題目要我們輸出一個答案耶

對於每個狀態,記錄它是從哪個狀態轉移過來的

最後從最佳解回溯

這題不需要另外開一個陣列記錄,因為只要 \(s_i=t_j\)

那我們就可以確定 \(i,j\) 是從 \(i-1,j-1\) 轉移過來的

否則直接走 \(dp_{i-1,j},dp_{i,j-1}\) 比較大的那一個

事實上如果只要求長度,那有空間 \(O(|t|)\) 的做法

最後會講到

其實要輸出答案也可以線性空間

TIOJ 2010

區間 dp

有 \(N\leq 400\) 個史萊姆,每個史萊姆初始的大小為 \(a_1\sim a_N\)

合併兩隻相鄰、大小為 \(x,y\) 的史萊姆

需要花費 \(x+y\),新的史萊姆大小為 \(x+y\)

問把所有史萊姆合併成一隻的最小花費

設定狀態

\(dp_{l,r}\):把第 \(l\sim r\) 隻史萊姆合併的最小花費

答案為 \(dp_{1,N}\)

邊界:\(dp_{i,i}=0\)

狀態轉移

找一個 \(l\leq k<r\)

代表要把 \([l,k]\) 和 \([k+1,r]\) 兩隻史萊姆合併

\(dp_{l,r}=\min\limits_{l\leq k<r}(dp_{l,k}+dp_{k+1,r}+\sum\limits_{i=l}^r a_i)\)

\(\sum\limits_{i=l}^r a_i\) 可以用前綴和 \(O(1)\) 算

時間複雜度:

有 \(O(N^2)\) 個狀態,每個狀態要花 \(O(N)\) 轉移

共 \(O(N^3)\)

事實上,這題可以優化到 \(O(N^2)\) 甚至 \(O(N\log N)\)

遇到區間問題時可以想想看能不能區間 DP

從長度小狀態一直 DP 到長度大的

位元 DP

當 \(n\) 很小的時候

可以把位元當作狀態

有 \(n\leq 21\) 個男生和女生

\(a_{i,j}=1\) 代表第 \(i\) 個男生和第 \(j\) 個女生可以配對

總共要配 \(n\) 對,且一個人只能屬於一對

問方法數 \(\text{mod }10^9+7\)

設定狀態

\(dp_{\text{mask}}\) 代表前 \(\text{popcnt(mask)}\) 個男生

跟 \(\text{mask}\) 代表的女生配對的方法數

如果把配對到的女生寫成 \(1\),沒配對到的寫成 \(0\)

那可以寫成一個二進位的數字 \(010010\dots\)

我們稱這個二進位的數字為 \(\text{mask}\)

\(\text{mask}\) 的範圍會是 \(0\sim 2^n-1\)

邊界:\(dp_0=1\)

答案:\(dp_{2^n-1}\)

狀態轉移

\(\text{mask}\) 如果第 \(i\) 位的數字是 \(1\)

那可以令 \(\text{last}=\text{mask}\oplus 2^i\)

拿 \(dp_{\text{last}}\) 更新 \(dp_{\text{mask}}\)

時間複雜度:

有 \(O(2^n)\) 個狀態

每個要花 \(O(n)\) 轉移

共 \(O(n2^n)\)

練習題:TIOJ 2282

數位 DP

跟計算「數字」有關的都統稱為數位 DP

給一個很大的數字 \(K(K\leq 10^{10000})\) 跟 \(D\leq 100\)

問 \(1\sim K\) 裡的數字,有幾個數字的每一位總和是 \(D\) 的倍數

答案 \(\text{mod }10^9+7\)

注意到 \(K\) 很大,所以要拿字串存

此題的關鍵就是

是 \(D\) 的倍數 \(\Leftrightarrow\text{ mod }D=0\)

設定狀態

\(dp_{i,s,x}\)

 

\(i\):到 \(K\) 從左往右的第幾位

\(s\):\(0\) 或 \(1\),目前的前 \(i\) 位是否等於 \(K\) 的前 \(i\) 位

\(x\):目前的 \(i\) 個數字總和 \(\text{mod }D\)  是多少

邊界:\(dp_{0,1,0}=1\)

答案:\(dp_{|K|,0,0}+dp_{|K|,1,0}\)

狀態轉移

枚舉下一位是 \(0\sim 9\) 的哪一個

然後判一些 Case

因為會算到 \(0\),所以最後答案要減 \(1\)

複雜度:有 \(O(|K|D)\) 個狀態

每個狀態要花 \(B=10\) 轉移

共 \(O(|K|DB)\)(如果你不把 \(B\) 視為常數)

練習題:

CSES 2220

ZJ g396

LIS

最長遞增子序列

給一個長度為 \(n\leq 2\times 10^5\) 的數列,輸出嚴格遞增子序列的最大長度

設定狀態

\(dp_i\):以 \(a_i\) 為結尾的最長長度

狀態轉移

\(dp_i=\max\limits_{1\leq j<i,a_j<a_i}(dp_j+1)\)

直接做要 \(O(n^2)\)

其中一個優化方法是離散化後開 BIT 取前綴 max

另外一個想法是維護一個陣列,第 \(i\) 個元素代表長度為 \(i\) 的遞增子序列最小的結尾數字

因為這個陣列是遞增的,所以每次加入 \(a_i\) 可以二分搜要更新到哪裡

最後的答案會是陣列的長度

複雜度 \(O(n\log n)\)

DP 小技巧

滾動 DP

滾動 DP 可以把 \(O(n^2)\) 的空間複雜度變成 \(O(n)\)

在求 LCS 的長度時,注意到 \(dp_{i,j}\) 只會從 \(dp_i,dp_{i-1}\) 轉移

之前的陣列都不會再被用到了

原本的 \(dp[i][j]\) 可以改成 \(dp[i \& 1][j]\)

\(dp[0]\) 代表偶數行的陣列,\(dp[1]\) 代表奇數行

#include <bits/stdc++.h>
using namespace std;

const int N = 3005;

int n, m;
int dp[N][N];
string s, t;

int main() {
    ios::sync_with_stdio(false), cin.tie(0);
    cin >> s >> t;
    n = s.size(), s = " " + s;
    m = t.size(), t = " " + t;
    for (int i = 1; i <= n; i++) {
        for (int j = 1; j <= m; j++) {
            if (s[i] == t[j]) dp[i & 1][j] = dp[!(i & 1)][j - 1] + 1;
            else dp[i & 1][j] = max(dp[!(i & 1)][j], dp[i & 1][j - 1]);
        }
    }
    cout << dp[n & 1][m] << '\n';
}

只有兩個長度為 \(n\) 的陣列

空間複雜度 \(O(n)\)!

結語:

DP 有非常多種,10 個題目有 11 種 DP 方法

要做的就是多練習題目,慢慢體會出要如何 DP

還想知道更多東西的,可以參考之前的培訓簡報

另外,也有許多可以降低狀態轉移複雜度的方法

稱為 DP 優化

謝謝大家!

Greedy / DC / DP

By becaido

Greedy / DC / DP

  • 461