跳转至

括号序列专题 (Parentheses Sequences)

括号序列是算法竞赛和工程面试中极其高频的核心模型。它不仅是栈(Stack)结构的经典载体,更是深刻连接了前缀和折线理论(Dyck 路径)卡特兰数(Catalan Number)组合计数动态规划括号树与森林同构以及线段树区间不可逆信息合并的经典综合专题。


1. 核心数学原理与合法性充要条件

1.1 单类型括号序列:前缀和与 Dyck 路径

对于仅由 '('')' 构成的长度为 \(2n\) 的括号序列 \(s\): 为字符赋予权值:\(val('(') = +1\)\(val(')') = -1\)。 定义前缀和序列 \(S_i = \sum_{k=1}^i val(s[k])\)(约定 \(S_0 = 0\))。

充要条件定理: 序列 \(s\) 是合法括号序列(Valid Parentheses),当且仅当满足以下两个条件:

  1. 非负性前缀不变量:对任意前缀位置 \(1 \le i \le 2n\),均有 \(S_i \ge 0\)(在任意时刻,右括号的数量不能严格超过左括号);
  2. 零终态闭合性\(S_{2n} = 0\)(序列结束时,左括号和右括号的总数量严格相等)。

网格折线视角(Dyck Path): 将前缀和的变化轨迹绘制在二维直角坐标系中(从 \((0, 0)\) 出发,遇到 '(' 走向量 \((1, 1)\),遇到 ')' 走向量 \((1, -1)\))。合法括号序列等价于\((0, 0)\) 出发到达 \((2n, 0)\),且整条轨迹始终不穿过横轴下方(\(y \ge 0\))的格路

// 基础校验:单类型括号合法性检查
bool isValidSingle(const std::string &s) {
    int balance = 0;
    for (char c : s) {
        if (c == '(') {
            balance++;
        } else if (c == ')') {
            balance--;
            if (balance < 0) return false; // 任意时刻右括号超额即非法
        }
    }
    return balance == 0;
}

1.2 多类型括号序列:后进先出 (LIFO) 栈消消乐

当序列中混合出现多种括号(如 (), [], {})时,仅靠计数器前缀和无法解决交叉嵌套破坏结构的问题(例如 ([)] 虽满足各类括号数量守恒,但结构非法)。

消消乐归约定理: 一个多类型括号序列合法,当且仅当可以不断消除相邻的同类型匹配括号对(如消除 ()[]{}),最终化简为空串。 栈作为后进先出(LIFO)容器,每次遇到左括号压栈;遇到右括号时,栈顶必须是同类型的对应左括号,否则立刻判定非法。

#include <string>
#include <stack>
#include <unordered_map>

bool isValidMultiple(const std::string &s) {
    std::stack<char> st;
    std::unordered_map<char, char> matching = {
        {')', '('},
        {']', '['},
        {'}', '{'}
    };

    for (char c : s) {
        if (matching.count(c)) {
            // 遇到右括号:栈不能为空,且栈顶必须匹配
            if (st.empty() || st.top() != matching[c]) {
                return false;
            }
            st.pop();
        } else {
            // 遇到左括号:压栈
            st.push(c);
        }
    }
    return st.empty();
}

1.3 带通配符括号匹配:贪心可行区间维护

当序列中引入可替代为 '('')' 或空字符 "" 的通配符 '*' 时(例如 LeetCode 678),未匹配的左括号数量不再是一个固定数值,而是一个连续的整数区间 \([low, high]\)

连续可行区间引理: 遍历过程中,所有可能的未匹配左括号数必然构成一段连续的整数闭区间 \([low, high]\)

  • 遇到 '('\(low \leftarrow low + 1, \, high \leftarrow high + 1\)
  • 遇到 ')'\(low \leftarrow \max(0, low - 1), \, high \leftarrow high - 1\)
  • 遇到 '*'
    • 若视为 '('\(high \leftarrow high + 1\)
    • 若视为 ')'\(low \leftarrow \max(0, low - 1)\)
    • 若视为空串:维持不变。
    • 综合转移:\(low \leftarrow \max(0, low - 1), \, high \leftarrow high + 1\)

若在任意时刻出现 \(high < 0\),说明即使把所有的 '*' 全变成左括号,右括号依然超标,直接判定非法;遍历结束时只要 \(low == 0\),说明可以通过合适选择让多余左括号全被抵消,判定合法。

bool checkValidString(const std::string &s) {
    int low = 0, high = 0;
    for (char c : s) {
        if (c == '(') {
            low++;
            high++;
        } else if (c == ')') {
            low = std::max(0, low - 1);
            high--;
            if (high < 0) return false; // 必有无法匹配的多余右括号
        } else { // c == '*'
            low = std::max(0, low - 1);
            high++;
        }
    }
    return low == 0;
}

2. 卡特兰数与合法序列生成

2.1 卡特兰数公式与几何折线法证明

\(n\)'('')' 构成的长度为 \(2n\) 的不同合法括号序列的总数,严格等于第 \(n\)卡特兰数(Catalan Number)\(C_n\)

\[ C_n = \frac{1}{n + 1} \binom{2n}{n} = \binom{2n}{n} - \binom{2n}{n + 1} \]

前若干项为:\(C_0 = 1, C_1 = 1, C_2 = 2, C_3 = 5, C_4 = 14, C_5 = 42, \dots\)

反射原理(Reflection Principle)几何证明

  1. \((0, 0)\) 到达 \((2n, 0)\) 的任意路径总数等于在 \(2n\) 步中任选 \(n\) 步向上,共有 \(\binom{2n}{n}\) 条;
  2. 凡是不合法的路径,必然在某个时刻首次触碰直线 \(y = -1\)
  3. 将路径在首次触碰 \(y = -1\) 之后的部分关于直线 \(y = -1\) 作对称翻转。对称后的终点必然从 \((2n, 0)\) 映射到 \((2n, -2)\)
  4. 终点在 \((2n, -2)\) 的路径由 \(n - 1\) 步向上和 \(n + 1\) 步向下组成,总数为 \(\binom{2n}{n + 1}\)
  5. 因此所有非法路径与到达 \((2n, -2)\) 的路径建立了一一双射。合法路径数即为:\(C_n = \binom{2n}{n} - \binom{2n}{n + 1} = \frac{1}{n + 1} \binom{2n}{n}\)

2.2 回溯生成合法括号组合

通过回溯搜索生成所有合法括号组合时(LeetCode 22),必须遵循前缀平衡剪枝铁律

  • 只要当前放入的左括号数 \(open < n\),就可以放入 '('
  • 只要当前放入的右括号数 \(close < open\),就可以放入 ')'
  • 绝不会搜索到任何非法状态,搜索树的叶子节点数恰好等于 \(C_n\)
#include <vector>
#include <string>

std::vector<std::string> generateParenthesis(int n) {
    std::vector<std::string> result;
    std::string current;

    auto backtrack = [&](auto &self, int open, int close) -> void {
        if ((int)current.size() == 2 * n) {
            result.push_back(current);
            return;
        }
        if (open < n) {
            current.push_back('(');
            self(self, open + 1, close);
            current.pop_back();
        }
        if (close < open) {
            current.push_back(')');
            self(self, open, close + 1);
            current.pop_back();
        }
    };

    backtrack(backtrack, 0, 0);
    return result;
}

3. 最长有效括号子串的三大解法

给定一个仅包含 '('')' 的字符串,求最长的连续合法括号子串的长度(LeetCode 32)。该题有三大经典解法:

3.1 解法一:下标基准栈(最常用)

  • 栈底维护一个“最后一个未匹配右括号的下标基准点”(初始压入虚拟基准 \(-1\));
  • 遍历下标 \(i\)
    • 遇到 '(':将其下标 \(i\) 压入栈;
    • 遇到 ')':先弹出栈顶;
      • 若栈为空:说明当前 ')' 没有匹配的左括号,该位置成为新的“未匹配右括号基准点”,将当前下标 \(i\) 压入栈底;
      • 若栈非空:当前有效子串的长度就是 \(i - \text{st.top()}\),更新全局最大值。
int longestValidParentheses_Stack(const std::string &s) {
    int max_len = 0;
    std::stack<int> st;
    st.push(-1); // 虚拟基准点

    for (int i = 0; i < (int)s.size(); ++i) {
        if (s[i] == '(') {
            st.push(i);
        } else {
            st.pop();
            if (st.empty()) {
                st.push(i); // 新的非法基准点
            } else {
                max_len = std::max(max_len, i - st.top());
            }
        }
    }
    return max_len;
}

3.2 解法二:动态规划

\(dp[i]\) 表示以字符 \(s[i]\) 结尾的最长有效括号子串的长度。有效子串必然以 ')' 结尾,故当 \(s[i] == '('\) 时,\(dp[i] = 0\)

\(s[i] == ')'\) 时,考察前一个字符:

  1. 情况 1:\(s[i - 1] == '('\)(形如 ...()): $\(dp[i] = (i \ge 2 ? dp[i - 2] : 0) + 2\)$

  2. 情况 2:\(s[i - 1] == ')'\)(形如 ...))): 若 \(s[i - dp[i - 1] - 1] == '('\),说明越过 \(dp[i - 1]\) 跨度的前一个字符正好与当前 \(s[i]\) 配对: $\(dp[i] = dp[i - 1] + 2 + (i - dp[i - 1] - 2 \ge 0 ? dp[i - dp[i - 1] - 2] : 0)\)$

int longestValidParentheses_DP(const std::string &s) {
    int n = s.size(), max_len = 0;
    std::vector<int> dp(n, 0);

    for (int i = 1; i < n; ++i) {
        if (s[i] == ')') {
            if (s[i - 1] == '(') {
                dp[i] = (i >= 2 ? dp[i - 2] : 0) + 2;
            } else if (i - dp[i - 1] > 0 && s[i - dp[i - 1] - 1] == '(') {
                int prev = (i - dp[i - 1] >= 2) ? dp[i - dp[i - 1] - 2] : 0;
                dp[i] = dp[i - 1] + 2 + prev;
            }
            max_len = std::max(max_len, dp[i]);
        }
    }
    return max_len;
}

3.3 解法三:正反双向双指针(\(\mathcal{O}(1)\) 空间)

利用前缀和计数器,无需额外数组或栈空间:

  1. 从左到右扫描:统计 leftright
  2. right == left:更新有效长度 \(2 \times right\)
  3. right > left:右括号超标,重置 left = right = 0
  4. 从右到左扫描:避免漏掉类似 (() 这种左括号一直多于右括号的情况。
  5. left == right:更新有效长度 \(2 \times left\)
  6. left > right:左括号超标,重置 left = right = 0
int longestValidParentheses_TwoPass(const std::string &s) {
    int left = 0, right = 0, max_len = 0, n = s.size();

    // 1. 正向扫描
    for (int i = 0; i < n; ++i) {
        if (s[i] == '(') left++;
        else right++;
        if (left == right) max_len = std::max(max_len, 2 * right);
        else if (right > left) left = right = 0;
    }

    // 2. 逆向扫描
    left = right = 0;
    for (int i = n - 1; i >= 0; --i) {
        if (s[i] == '(') left++;
        else right++;
        if (left == right) max_len = std::max(max_len, 2 * left);
        else if (left > right) left = right = 0;
    }

    return max_len;
}

4. 括号树的分层、权重与嵌套结构

4.1 括号序列与有根森林的同构

每一个合法的括号序列,天然对应一棵有根树(或森林)

  • 外层的配对 () 对应树的一个节点;
  • 其内部包含的所有配对子序列,对应其直接子节点列表;
  • 并列出现的并列括号对(如 ()())对应森林中同级的兄弟节点。
括号序列: ( ( ) ( ( ) ) ) ( )
树形结构:
       Root (虚拟总根)
      /      \
    Node A   Node B
    /    \
  Leaf1  Node C
           |
         Leaf2

4.2 括号的分数与位权累加 (LC 856)

规则:() 得 1 分;AB 得分 \(A + B\)(A) 得分 \(2 \times A\)

核心数学观察: 每一个最底层的独立括号对 (),其外部包裹了多少层括号(设当前嵌套深度为 \(d\)),它对全局总分的贡献就是:

\[ 1 \times 2^d = 2^d \]

全局总分恰好等于所有最内层叶子 ()\(2^{\text{depth}}\) 之和!无需任何树或栈的递归合并,单遍扫描维护当前深度即可:

int scoreOfParentheses(const std::string &s) {
    int score = 0, depth = 0;
    for (int i = 0; i < (int)s.size(); ++i) {
        if (s[i] == '(') {
            depth++;
        } else {
            depth--;
            // 只有当紧邻前一个是 '(' 时,当前 ')' 才是最底层的叶子节点
            if (s[i - 1] == '(') {
                score += (1 << depth);
            }
        }
    }
    return score;
}

5. 竞赛高阶:线段树维护动态括号序列

在高级数据结构题目中(如 Codeforces 380C - Sereja and Brackets),题目可能要求:支持动态修改括号字符,并高效回答任意区间 \([L, R]\) 内的最长有效括号匹配长度

5.1 区间节点信息设计

在普通括号匹配中,括号运算具有不可逆性(无法直接做区间减法)。在线段树中,每个节点维护三个核心属性:

  1. match:当前区间内部已经成功配对的括号对数;
  2. unmatched_open:当前区间在内部相互抵消后,剩余未匹配的左括号 '(' 数量
  3. unmatched_close:当前区间在内部相互抵消后,剩余未匹配的右括号 ')' 数量

5.2 区间结合律合并推导 (Push Up)

当合并左子区间 \(L\) 与右子区间 \(R\) 时: \(L\) 右侧多出来的未匹配左括号,可以与 \(R\) 左侧多出来的未匹配右括号进行跨区间配对:

\[ \text{new\_match} = \min(L.unmatched\_open, \, R.unmatched\_close) \]

合并后的三个属性更新为:

\[ \begin{aligned} match &= L.match + R.match + \text{new\_match} \\ unmatched\_open &= L.unmatched\_open + R.unmatched\_open - \text{new\_match} \\ unmatched\_close &= L.unmatched\_close + R.unmatched\_close - \text{new\_match} \end{aligned} \]

该运算严格满足结合律,单次合并 \(\mathcal{O}(1)\),可在 \(\mathcal{O}(\log n)\) 时间内完成动态单点修改和区间最长有效长度查询(查询结果为 \(2 \times query(L, R).match\))。

#include <string>
#include <vector>
#include <algorithm>

struct BracketNode {
    int match = 0;
    int open = 0;   // 未匹配左括号
    int close = 0;  // 未匹配右括号

    static BracketNode merge(const BracketNode &L, const BracketNode &R) {
        BracketNode res;
        int new_match = std::min(L.open, R.close);
        res.match = L.match + R.match + new_match;
        res.open = L.open + R.open - new_match;
        res.close = L.close + R.close - new_match;
        return res;
    }
};

class BracketSegmentTree {
    int n;
    std::vector<BracketNode> tree;

    void build(int u, int l, int r, const std::string &s) {
        if (l == r) {
            if (s[l - 1] == '(') tree[u].open = 1;
            else tree[u].close = 1;
            return;
        }
        int mid = (l + r) >> 1;
        build(u << 1, l, mid, s);
        build(u << 1 | 1, mid + 1, r, s);
        tree[u] = BracketNode::merge(tree[u << 1], tree[u << 1 | 1]);
    }

public:
    BracketSegmentTree(const std::string &s) : n(s.size()), tree(4 * s.size()) {
        build(1, 1, n, s);
    }

    BracketNode query(int u, int l, int r, int ql, int qr) {
        if (ql <= l && r <= qr) return tree[u];
        int mid = (l + r) >> 1;
        if (qr <= mid) return query(u << 1, l, mid, ql, qr);
        if (ql > mid)  return query(u << 1 | 1, mid + 1, r, ql, qr);
        return BracketNode::merge(query(u << 1, l, mid, ql, qr),
                                  query(u << 1 | 1, mid + 1, r, ql, qr));
    }

    int queryMaxMatch(int ql, int qr) {
        return 2 * query(1, 1, n, ql, qr).match;
    }
};

LeetCode 经典真题精选与题解提示

  • LeetCode 20 - 有效的括号 (Valid Parentheses) 简单

    提示

    多类型括号栈匹配基石

    • 遍历字符串,遇左括号入栈。
    • 遇右括号时,栈必须非空且栈顶为对应的左括号,弹出匹配;若不匹配或栈空则非法。遍历结束栈须为空。
  • LeetCode 22 - 括号生成 (Generate Parentheses) 中等

    提示

    回溯剪枝与卡特兰数生成

    • 维护当前已放左括号数 \(open\) 和右括号数 \(close\)
    • 只要 \(open < n\) 就可以放 '(';只要 \(close < open\) 就可以放 ')'。叶子节点总数严格等于第 \(n\) 个卡特兰数 \(C_n\)
  • LeetCode 32 - 最长有效括号 (Longest Valid Parentheses) 困难

    提示

    栈基准法 / 动态规划 / 双向扫描

    • 栈底常驻“上一个未匹配右括号基准下标”(初值 \(-1\))。遇到 ')' 出栈后,若非空,有效长度为 \(i - \text{st.top()}\);若空则将当前 \(i\) 压入作为新基准。
    • 亦可正反双向扫描维护 leftright 计数器,实现 \(\mathcal{O}(1)\) 空间。
  • LeetCode 678 - 有效的括号字符串 (Valid Parenthesis String) 中等

    提示

    带通配符可行连续区间贪心维护

    • 维护未匹配左括号的最小可能数 \(low\) 和最大可能数 \(high\)
    • '(' 使二者均 \(+1\)')' 使 \(low = \max(0, low - 1), high - 1\)'*' 使 \(low = \max(0, low - 1), high + 1\)
    • 若途中 \(high < 0\) 立即判假;最终 \(low == 0\) 则为真。
  • LeetCode 856 - 括号的分数 (Score of Parentheses) 中等

    提示

    括号嵌套深度与位权累加

    • 识别最内层的独立叶子对 ():每个内层 () 在深度 \(d\) 处对全局分数的贡献就是 \(1 \ll d\)
    • 单遍扫描:遇到 '(' 深度 \(+1\);遇到 ')' 且前一位是 '(' 时累加 \(1 \ll \text{depth}\),然后深度 \(-1\)
  • LeetCode 921 - 使括号有效的最少添加 (Minimum Add to Make Parentheses Valid) 中等

    提示

    贪心统计非法单向偏差

    • 维护未匹配左括号数 \(open\) 和需补全的左括号数 \(ans\)
    • 遇到 '('\(open++\);遇到 ')'\(open > 0\) 则抵消 \(open--\),否则说明出现了孤立右括号,必须添加左括号 \(ans++\)
    • 最终需添加总数即为 \(ans + open\)
  • LeetCode 1111 - 有效括号字符串的最大嵌套深度 (Maximum Nesting Depth of Two Valid Parentheses Strings) 中等

    提示

    奇偶深度分流平摊

    • 目标是把原括号序列拆分为两个不相交子序列 A 和 B,使得各自的最大嵌套深度最小化。
    • 最优划分策略:根据当前嵌套深度 \(d\) 的奇偶性分配,奇数层分给 A,偶数层分给 B(即令分配数组元素为 \(d \bmod 2\)),两者的嵌套深度均被平摊为 \(\lceil \max(d) / 2 \rceil\)
  • LeetCode 1249 - 移除无效的括号 (Minimum Remove to Make Valid Parentheses) 中等

    提示

    标记多余括号位置

    • 第一次遍历:遇到多余的右括号(栈为空时)直接在原串标记删除;遇到左括号将其下标入栈。
    • 遍历结束后,栈中残余的左括号下标即为多余的左括号,全部标记删除。
    • 第二次遍历收集所有未被标记的字符拼接返回。
  • LeetCode 301 - 删除无效的括号 (Remove Invalid Parentheses) 困难

    提示

    最少删除量统计 + 回溯去重

    • 第一步:单遍扫描求出全串必须删除的左括号数 \(l_{rem}\) 与右括号数 \(r_{rem}\)
    • 第二步:回溯尝试删除括号,仅当 \(l_{rem} > 0\) 时可尝试删 '(',当 \(r_{rem} > 0\) 时可尝试删 ')'
    • 去重剪枝:遇到连续相同的括号时只尝试删除第一个,避免生成重复答案串。