# 括號問題 ## 解法一覽 - 一、卡特蘭數 - 二、stack 解法 - 三、區間 dp(適用於有很多種括號類型時) - 四、前綴 dp(適用於只有一種括號類型) - 五、左括號想成 +1,右括號想成 -1,min prefix sum >= 0,sum = 0 - greedy 修改 - 資料結構優化 ## 左括號+1,右括號-1 ???+note "[2023 全國賽模擬賽 pF. 關卡設計 (level)](https://codeforces.com/gym/104830/attachments/download/23302/zh_TW.pdf#page=17)" 給一個括號序列,一次操作可將一個括號改變方向,問是否能恰好做 k 次操作使括號序列合法,若可以的話輸出一組解 $n, k \le 2 \times 10^6$ ??? note "思路" **【一、轉換問題】** 如果將左括號想成 +1,右括號想成 -1, 那麼合法的條件是 1. 每個 prefix sum 都大於等於 0 2. 總和為 0 **【二、修改的 greedy 策略】** 也就是我們需要將一些 -1 要變成 +1,一些 +1 要變成 -1。因為要盡量讓每個 prefix sum 都大於等於 0,我們得到了一個 greedy 的策略: - -1 改成 +1 的,優先改愈左邊的愈好 - +1 改成 -1 的,優先改愈右邊的愈好 **【三、求得修改個別的數量】** 我們假設一開始有 x 個 +1, y 個 -1,-1 改成 +1 的有 u 個,+1 改成 -1 的有 v 個。那麼我們是否可以直接透過 x, y, k 得到 u, v ? 因為總共就只能改 k 個,所以 u + v = k,一開始的 sum = x - y,之後的每個 u 會讓 sum += 2,每個 v 會讓 sum -= 2。而最後的 sum 要是 0,所以 sum = (x-y) + 2u - 2v = 0,我們就可以解一元二次方程式得到 u, v,再套用步驟二的 greedy 策略即可。無解的話可以在解 u, v 或是做 greedy 完後得到。 --- > 另一種想法: 為了簡化題目,我們將原本的括號序列每一項都替換成左括號。接著,我們令原本的括號序列為 a,依照上一句話替換後的序列為 b,則對於修改的貢獻,我們可以這麼想: 假設有一個陣列 v,若 a[i] != b[i],則 v[i] = -1,代表反悔操作,反則若 a[i] = b[i],則 v[i] = 1,代表花費一單位的貢獻改變第 i 項。我們的目標就是要從 b 選出 n / 2 項,讓他們重新變回右括號,而且要滿足: 括號序列須合法、最後的序列只能是更動 k 項,那其實這就等價於在 v 內選 n/2 項,讓他們的總和是 k'(k' 就是 a 改成 b 後還能修改的次數)。我們將式子列出來,令要選的 1 的總和為 x,-1 的總合為 y,則可以列出
x - y = n / 2
x + y = k'
舉個實際的例子,例如說 n = 10,k = 6,`a = )))))(((((`,則 `b = (((((((((( `,所以 `v = [-1, -1, -1, -1, -1, 1, 1, 1, 1, 1]`。此時 k' = 6 - 5 = 1。我們來解二元一次方程式
x - y = 5
x + y = 1
得到 x = 3, y = -2。 x, y 若會不合法,此時可輸出無解,否則在得到 x, y 之後,我們就可以 greedy 的由後往前,將 b 變成我們最終的答案,具體來說,我們從後往前考慮,假如此時看到第 i 項,我們就看 v[i] 是對應到 x 或 y,若對應到的(假如是 x)還有剩,則就將 b[i] 改動,否則,我們就繼續往前面一項考慮。注意,最後改完的時候,也要記得檢查是否為不合法的括號序列。例如說 n = 10, k = 2, `a = )))))(((((`,則 `b = (((((((((( `,所以 `v = [-1, -1, -1, -1, -1, 1, 1, 1, 1, 1]`。此時 k' = 2 - 5 = -3。我們解二元一次方程式可得到 x = 1, y = -4,但這樣改完後續列為 `())))(((()`,不合法。 ???+note "最少修改次數" 給一個長度為 $n$ 的括號序列,一次操作可將一個括號改變方向,問至少幾次操作才可以使括號序列合法 $n\le 2\times 10^6$ ??? note "思路" 一樣使用上面分析的觀點,假設需要 u 個 -1 變成 +1,v 個 +1 變成 -1,我們的目標是最小化 u + v。我們分成兩個步驟: 1. 使 sum 變成 0: 依照 (x-y) + 2u - 2v = 0,我們可求得 u - v,我們先將 u, v 其中一個設成 0,使 u + v 最小 2. 使 min prefix sum >= 0: 若此時 min prefix sum < 0,我們就必須將一些-1 變成 +1,也就是 u 要增加到讓 min prefix sum = 0,而 u 增加 v 也要跟著增加,因此我們就可以確定 u + v 要是多少了 舉例來說,假設此時序列為 `- + - - + + + - + +`,算出 x = 6, y = 4,所以可以由 (x-y) + 2u - 2v = 0 得到 u - v = -1,我們將 u 設為 0,這時 u = 0, v = 1,目前的 prefix sum: ``` - + - - + + + - + - -1 0 -1 -2 -1 0 1 0 1 0 ``` 可以看到 min prefix sum 為 -2,則我們需要 u = 1 來使 min prefix sum >= 0,用 greedy 的策略從前面修改 u,從後面修改 v,得: ``` + + - - + + + - - - 1 2 1 0 1 2 3 2 1 0 ``` 所以最後是 u = 1, v = 2 --- 另外一種方法是先使 min prefix sum >= 0,再調整讓 sum = 0。我們從第一項開始往後依序考慮,若當前右括號 - 左括號的數量 < 0 了,則將目前的右括號改成左括號,否則就往下一項看,這樣做到最後可以保證 min prefix sum >= 0,而且可能會多放了幾個左括號,也就是我們需要去增加 v 的數量,所以我們從後面往前將看到的右括號改成左括號直到 sum = 0 即可 ???+note "合法判斷/最大匹配深度 [CF 1263 E. Editor](https://codeforces.com/contest/1263/problem/E)" 現在有一個打字機,有以下操作 : - `L` : 將 pointer 往左移 1 格 - `R` : 將 pointer 往右移 1 格 - 一個小寫字母或者 `(`, `)` : 將 pointer 上的字元替換為給定字元 在每次操作後,判斷這一行是否是合法括號序列。如果是的話,輸出最大匹配深度 ??? note "思路" 對於一個區間而言,括號能否成功匹配有兩個判斷標準: 1. 左右括號數量要相同 2. 任意前綴中,右括號的數目不能大於左括號的數目. 如果我們把左括號看為 +1,右括號看為 -1,則上述標準等價於: 1. 區間和為 0 2. 區間最小前綴和也應等於 0 此時最大匹配深度應是區間最大連續子段和。假設區間可以正確匹配,則前綴和不會出現負數情況,因此最大連續子段和等價於最大前綴和,我們只需要維護最大前綴和即可。 因此對於這類問題,我們只需要維護 : - 最大前綴和(最大深度) - 最小前綴和(判斷合法) - 區間和(判斷合法) > 參考 : ## 合法長度/方法數 ???+note "[Leetcode 32. Longest Valid Parentheses](https://leetcode.com/problems/longest-valid-parentheses/)" 給一個長度為 n 的括號序列,問最長合法子字串(連續)長度 $0\le n\le 3\times 10^4$ ??? note "code" ```cpp linenums="1" class Solution { public: int longestValidParentheses(string s) { int res = 0, start = 0, n = s.size(); stack st; for (int i = 0; i < n; ++i) { if (s[i] == '(') { st.push(i); } else if (s[i] == ')') { if (st.empty()) { start = i + 1; } else { st.pop(); res = st.empty() ? max(res, i - start + 1) : max(res, i - st.top()); } } } return res; } }; ``` ???+note "合法子字串方法數 [51Nod 1791 合法括号子段](https://www.51nod.com/Challenge/Problem.html#problemId=1791)" 給一個長度為 n 的括號序列,求合法子字串個數 $n\le 1.1\times 10^6$ ??? note "思路" 考慮 dp 求解,令 dp(i) 表示以 i 結尾的合法括號子字串個數,答案就會是 $\sum dp(i)$ 使用 stack 對於每一個左括號開始記錄它的位置,當遇到一個右括號,它可以由和自己匹配的位置減去 1 的位置轉移而來,即 $$ dp(i)=dp(j-1)+1 $$ 要 + 1 是因為可以選擇要不要繼續往前接,或是用跟自己匹配的就好。以這個例子來說 ( ) ( ( ) ( ) ),跟最後一個右括號匹配的左括號以藍色標註: ( ) ( ( ) ( ) ),由於子字串一定要連續,所以只能從匹配的左括號之前繼續接 > 參考自: ??? note "code" ```cpp linenums="1" #include #define int long long using namespace std; int countValidSubstrings(string s) { int n = s.length(); s = "$" + s; vector dp(n + 1, 0); stack stk; int ans = 0; for (int i = 1; i <= n; ++i) { if (s[i] == '(') { stk.push(i); } else if (!stk.empty()) { dp[i] = dp[stk.top() - 1] + 1; stk.pop(); ans += dp[i]; } } return ans; } signed main() { int t; cin >> t; while (t--) { string s; cin >> s; cout << countValidSubstrings(s) << '\n'; } } ``` ???+note "[AcWing 4207. 最长合法括号子序列](https://www.acwing.com/problem/content/4210/)" 給一個長度為 $n$ 的括號序列,問最長合法子序列(不一定連續)長度 $1\le n\le 10^6$ ??? note "思路" 我們從第一項開始往後依序考慮,若當前右括號 - 左括號的數量 < 0 了,就不取當前這一項的右括號,否則就將 ans++ ??? note "code" ```cpp linenums="1" #include #include using namespace std; const int N = 1e6 + 10; char str[N]; int main() { scanf("%s", str); int cnt = 0, ans = 0; for (int i = 0; str[i]; i++) { if (str[i] == '(') { cnt++; ans++; } else if (cnt > 0) { cnt--; ans++; } } printf("%d", ans); return 0; } ``` ???+note "最長合法括號序列 [CF 380 C. Sereja and Brackets](https://www.luogu.com.cn/problem/CF380C)" 給定一個長度 $n$ 的括號序列 $s$,有 $q$ 個詢問 : - $\text{query}(l, r):$ 輸出 $s_l,\ldots ,s_r$ 的最長合法括號序列長度 $1\le n\le 10^6, 1\le m\le 10^5$ ??? note "思路" 考慮一個括號序列,我們把能匹配的括號全都刪掉,剩下的括號一定形如 `))))))(((((((((` 我們考慮用線段樹去維護。考慮記錄每個區間 - 未匹配的左括號數量 - 未匹配的右括號數量 - 當前區間已經產生的貢獻和 在合併兩個區間時: 新區間的貢獻 = 左區間原先的貢獻 + 右區間原先的貢獻 + 合併後新產生的貢獻。其中,合併後新產生的貢獻 = 2 * min(左區間未匹配的左括號數, 右區間未匹配的右括號數)。 ```cpp linenums="1" struct Node { Node *lc = nullptr; Node *rc = nullptr; int l, r; int sum, vl, vr; void pull() { int macthes = min(l->vl, r->vr); sum = lc->sum + rc->sum + 2 * macthes; vl = l->vl + r->vl - matches; vr = l->vr + r->vr - matches; } }; ``` > 參考 : ???+note "合法括號子序列方法數" 給一個長度為 n 的序列,求括號序列的合法「子字串」個數 $n\le 5000$ ??? note "思路" dp(i, k) 表示考慮 1~i,左括號比右括號多 k 個的子序列數量 轉移如下 ``` dp(i, k) += dp(i - 1, k) if s[i] == '(': dp(i, k) += dp(i - 1, k - 1) else s[i] == ')': dp(i, k) += dp(i - 1, k + 1) ``` ## 其他 ???+note "最少交換次數" 給一個長度為 $n$ 的括號序列,每次可以 swap 相鄰的兩項,問至少幾次才能變合法 $n\le 2\times 10^6$ ??? note "思路" 利用 stack 處理括號的方式,令 cnt 為當前左括號 - 右括號的數量,若當前 cnt < 0,則要將當前最近的左括號給 swap 過來,具體來說,假設當前的 index 是 i,在右側離 i 最近的左括號在 j,則需要花 j - i 的 cost 將這個左括號給搬過來,然後我們再將 i, j swap 即可。實作上,可以用一個 queue 存所有左括號個位置,然後用一個指針在裡面單調往右移動即可,詳見代碼。 > 參考自: ??? note "code" ```cpp linenums="1" #include #include using namespace std; long swapCount(string s) { vector pos; for (int i = 0; i < s.length(); ++i) { if (s[i] == '(') { pos.push_back(i); } } int counter = 0; int p = 0; long sum = 0; for (int i = 0; i < s.length(); ++i) { if (s[i] == '(') { ++counter; ++p; } else if (s[i] == ')') { --counter; } if (counter < 0) { sum += pos[p] - i; swap(s[i], s[pos[p]]); ++p; counter = 1; } } return sum; } int main() { string s = "())()("; cout << swapCount(s) << endl; s = "(()())"; cout << swapCount(s) << endl; return 0; } ``` ???+note "[AcWing 3420. 括号序列](https://www.acwing.com/problem/content/3423/)" 給一個長度為 n 的括號序列,盡可能少地添加若干括號使序列合法,問有幾種添加的方法數 $n\le 5000$ ??? note "思路" **【分析,簡化問題】** 左括號與右括號所新增的位置方案是相互獨立的,不會相互影響,即使左、右括號添加在同一個間隙,因為不能存在 "()" 的形式,此處只能為類似 "))((" 的一種形式,故總的方案數等於左括號的方案數 * 右括號的方案數。 單獨考慮添加左括號,若以右括號為分割點,將整個序列進行分割,因為分割後的子字串中均為左括號,添加任意數目的左括號方案數均為一種,那麼此時,我們僅需考慮添加不同數量的左括號的方案數即可。右括號同理。 **【前綴 dp】** dp(i, j) 表示只考慮前 i 部分,左括號比右括號多 j 個的所有方案數 轉移如下: - 若 s[i] == '(',轉移式為 dp(i, j) = dp(i - 1, j - 1) - 若 s[i] == ')',轉移式為 dp(i, j) = dp(i - 1, j + 1) + dp(i - 1, j) + ... + dp(i - 1, 0)。可以透過前綴優化得到 dp(i, j) = dp(i - 1, j + 1) + dp(i, j - 1)。為了防止越界,dp(i, 0) 需要特判。 > 參考自: ??? note "code" ```cpp linenums="1" #include using namespace std; typedef long long ll; const int N = 5010, MOD = 1e9 + 7; int n; char str[N]; ll dp[N][N]; ll add(ll x, ll y) { return (x + y) % MOD; } ll brackets() { memset(dp, 0, sizeof dp); dp[0][0] = 1; for (int i = 1; i <= n; i++) { if (str[i] == '(') { for (int j = 1; j <= n; j++) { dp[i][j] = dp[i - 1][j - 1]; } } else { dp[i][0] = add(dp[i - 1][0], dp[i - 1][1]); for (int j = 1; j <= n; j++) { dp[i][j] = add(dp[i - 1][j + 1], dp[i][j - 1]); } } } for (int i = 0; i <= n; i++) { if (dp[n][i]) { return dp[n][i]; } } return -1; } int main() { scanf("%s", str + 1); n = strlen(str + 1); ll ans_l = brackets(); reverse(str + 1, str + n + 1); for (int i = 1; i <= n; i++) { if (str[i] == '(') { str[i] = ')'; } else { str[i] = '('; } } ll ans_r = brackets(); printf("%lld\n", ans_l * ans_r % MOD); return 0; } ``` ???+note "[2022 全國賽 pD. 文字編輯器 (editor)](https://nhspc2022.twpca.org/release/problems/problems.pdf#page=11)" 有一個由 $\texttt{+}, \texttt{[}, \texttt{]}, \texttt{x}$ 組成合法序列,此時將其中一個 $\texttt{+}$ 改成 $\texttt{|}$,並將所有 $\texttt{[}, \texttt{]}$ 換成 $\texttt{|}$。給你這個改完的序列 $s$,輸出任意一個原來的合法序列。 $|s| \le 10^6$ ??? note "思路" 兩個 $\texttt{x}$ 中一定要有 $\texttt{+}$,看哪兩個 $\texttt{x}$ 之間沒有 $\texttt{+}$,Greedy 的放即可 ???+note "2023 IOIC 305 . 括號國" 給一個長度為 $n$ 的括號字串,問所有 substring 的「最長合法括號子序列長度」總和為何? $1\le n\le 2\times 10^5$ ??? note "思路" 我們使用一般用 stack 括號序列的方式,若我們現在遇到了一個 closing,那前一個 opening 與目前這個 closing 的貢獻就是 opening 往前的長度 $\times$ closing 往後的長度,這些 l, r 在這些範圍內的都會算到我們 ??? note "code" ```cpp linenums="1" #include #define int long long using namespace std; const int N = 2e5 + 5; int st[N]; signed main() { int n; string s; cin >> n >> s; s = "$" + s; int ans = 0, r = 0; for (int i = 1; i <= n; i++) { if (s[i] == '(') st[++r] = i; else if (s[i] == ')' && r > 0) ans += 1ll * st[r--] * (n - i + 1); } cout << 2 * ans << '\n'; } ``` ???+note "[UVA 1626. Brackets Sequence](https://vjudge.net/problem/UVA-1626)" 給一個長度 n 的括號序列 s,可任意增加括號,問最少增加幾次才能使 s 成為合法括號序列 ? $n\le 100$ ??? note "思路" dp(l, r) = s[l..r] 最少要加入幾個括號可以變成合法的 轉移的話我們分成看看能不能拆成前後兩段,不能的話代表一定是前後配對 dp(l, r) = min{ - dp(l, k) + dp(k + 1, r) // 可以分成前後兩段 - dp(l + 1, r - 1) if ok(s[l], s[r]) // 前後兩個剛好可以配對 - dp(l, r - 1) + 1 // 例如說 [ ( [ ( ] - dp(l + 1, r) + 1 // 例如說 ( [ ( ] ) ???+note "[TOI 2021 二模 pC. 配對問題(Pairing)](https://drive.google.com/file/d/1aKGdK6ZjvXVAiXB5iH2eOiLdtC4w31j5/view)" 給定一個序列 $a_1, a_2, ..., a_n$,一個點只能被匹配最多一次,當兩個點 $i$ 與 $j$ 配對時,就會獲得 $a_i + a_{i+1} + \cdots + a_j$ 的分數。任何配對都不能出現部份相交的情形。匹配結束後,所有沒有被匹配到的點 $i$ ,如果 $a_i > 0$,可以獲得 $a_i$ 分。問分數最大是多少 $1 \leq n \leq 10^5, -10^9 \leq a_i \leq 10^9$ ??? note "思路" **【區間 dp: O(n^3)】** dp(l, r) : l ~ r 的最多 cost - dp(l+1, r) + a[l] - dp(l, r-1) + a[r] - 切兩半 dp(l, k) + dp(k+1, r) - l 和 r 配對 dp(l+1, r-1) + (a[l]+...+a[r]) --- **【前綴 dp: O(n^2)】** dp(i, k) : 1~i 的 (#左括號 - #右括號) = k ans = dp(n, 0) 轉移 - i 是 "(" : dp(i-1, k-1) - pre[i-1] - i 是 ")" : dp(i-1, k+1) + pre[i] - i 是 "X" : dp(i-1, k) + pre[i] - pre[i-1] --- **【Greedy 觀察性質, 後悔法: O(n log n)】** 如果有辦法匹配(與 pq.top 的貢獻是正的),就將目前這項選為右括弧,並 push 到 heap 中。若沒辦法匹配,也 push 到 heap 中。 那麼要如何處理「不選」的貢獻呢,我們其實可以將有選的貢獻中加入 -a[i],這樣就可以用扣得算了 這邊提供一組範例 ``` i 1 2 3 4 5 6 7 8 a[i]: 3 -1 -5 3 8 4 -3 4 pre[i]: 3 2 -3 0 8 12 9 13 ``` > 參考自 : [TOI 2021 Solutions - p3 pairing](https://omeletwithoutegg.github.io/2021/09/22/toi-2021-sols/) ???+note "[USACO 2017 OPEN Modern Art 2 G](https://www.luogu.com.cn/problem/P3668)" 有一個長度為 n 的序列,一開始全部都是為未塗色,一次操作中可將若干個區間覆蓋上指定的顏色,且一種顏色只能塗一次。給一個目標序列 a,問最少做幾次操作才能原始序列變成 a $n\le 10^5$ ??? note "思路" 對於一種顏色 i,因為他只能塗一次,所以我們可以找出這種顏色第一次出現的位置 l[i],與最後一次出現的位置 r[i],[l[i], r[i]] 就是他當初所覆蓋的區間。那如何區分塗色的先後關係?我們發現如果要合法的話,求出來的每個值的 l[i] 和 r[i] 只會是互不重疊,或是完全重疊,不會有部分重疊的情況,這就有點像我們括號問題的感覺了,同時,題目要問最少操作次數,對應到括號就是問最大匹配深度。具體來說,我們先預處理好每個顏色的 l[i], r[i],然後從左到右掃過去,每遇到一個 l[i] 就加上一個嵌套層數,每遇到一個 r[i] 就減去一個嵌套層數,那麼答案就是當前位置嵌套層數的最大值。
![Image title](./images/9.png){ width="350" }
不合法的情況就是有部分重疊的時候,例如 1 2 1 2,其實就相當於在掃描的過程中,若加入的點不是左端點,也不與當前在塗的顏色(stack 的頂端)相同。 至於無色的情況怎麼處理? 假設我們的序列是 1-base,我們可以把無色想成是一個從 [0, n + 1] 的塗色。 ??? note "code" ```cpp linenums="1" #include #define int long long using namespace std; const int MAXN = 1e5 + 5; int n, ans, a[MAXN], l[MAXN], r[MAXN]; signed main() { cin >> n; for (int i = 1; i <= n; i++) { cin >> a[i]; if (l[a[i]] == 0) { l[a[i]] = i; } r[a[i]] = i; } l[0] = 0, r[0] = n + 1, a[n + 1] = 0; stack stk; for (int i = 0; i <= n + 1; i++) { int x = a[i]; if (i == l[x]) { stk.push(x); ans = max(ans, 1ll * (int)stk.size()); } if (x != stk.top()) { cout << "-1" << '\n'; return 0; } if (i == r[x]) { stk.pop(); } } cout << ans - 1 << '\n'; } ``` ---- ## 參考資料 - 進階延伸 - -