ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

线段树维护括号匹配:从翻转序列问题看区间信息合并的艺术

线段树维护括号匹配:从翻转序列问题看区间信息合并的艺术 1. 项目概述从一道国赛题看线段树的实战艺术去年备赛蓝桥杯国赛刷到这道“翻转括号序列”时我第一反应是“这题有点意思但估计暴力模拟能过一部分”。真正上手后才发现它完美地诠释了算法竞赛中“思维难度”与“数据结构威力”的结合。题目本身描述很简洁给你一个由(和)组成的初始序列然后进行两种操作——一是翻转某个区间内的所有括号(变))变(二是查询以某个位置为左端点的最长合法括号子序列的长度。暴力法在O(n*m)的复杂度下面对10^5量级的数据规模瞬间就会超时。这道题之所以被圈内人称为“线段树好题”正是因为它逼迫你跳出对线段树“区间求和、最值”的刻板印象去思考如何用这种灵活的结构来维护括号匹配这种复杂的“状态”信息。今天我就结合自己的解题和教学经验彻底拆解这道题不仅告诉你“怎么做”更重点剖析“为什么这么做”以及线段树在此类问题中建模的通用思路。2. 核心需求解析与暴力法的局限2.1 问题形式化定义我们首先把问题翻译成更清晰的描述。假设有一个长度为N的字符串s仅包含字符(和)。需要支持以下两种操作操作1翻转给定区间[L, R]将s[L...R]中的每一个括号取反即(变成))变成(。操作2查询给定一个下标L要求找到最大的RL R N使得子串s[L...R]是一个合法的括号序列。如果不存在这样的R例如s[L]本身就是)则输出0。这里“合法括号序列”的定义是经典的栈匹配定义一个空串是合法的如果A和B是合法的那么(A)和AB也是合法的。2.2 暴力模拟为何行不通最直观的想法是对于每个查询我们从左端点L开始用一个栈模拟括号匹配过程依次扫描字符直到栈为空且无法继续匹配成合法序列为止记录下最远的R。对于翻转操作则直接遍历区间修改字符。这种方法的复杂度是每次查询O(n)每次修改O(n)。当操作次数m也达到10^5级别时总复杂度O(n*m)高达10^10必然超时。问题的核心在于每次查询都几乎要重头扫描没有利用历史信息每次修改也是直接作用于原始数组没有高效维护序列的“整体性质”。因此我们需要一种数据结构能够在动态修改翻转的情况下快速回答关于区间“括号匹配状态”的查询。线段树正是处理这种“动态区间属性维护”问题的利器。3. 线段树建模如何用数字描述括号序列线段树不能直接存储字符串。我们必须设计一套“指标”用几个数字就能刻画出一段区间作为括号序列的“健康状态”并且这些指标要能通过子区间的指标快速合并即线段树的push_up操作。这是解决本题最核心、最巧妙的一步。3.1 关键指标的定义经过分析也是此类问题的经典套路定义两个核心属性对于每个线段树节点代表一个区间sum区间整体括号值的代数和。我们定义(的值为1)的值为-1。那么一个区间所有字符值的和就是sum。对于一个合法括号序列其总和必须为0左右括号数量相等但总和为0不一定合法如“)(”。mx区间前缀和的最大值。这里“前缀和”是指从该区间左端点开始依次累加每个字符的值(为1)为-1在这个过程中出现的最大值。为什么是mx前缀和最大值这是判断合法性的关键。对于一个从区间开头开始的子串其合法的充要条件是1) 整个子串的sum为02) 在累加过程中前缀和始终非负。因为一旦出现负数就意味着)的数量超过了(后续无论怎么补都无法再匹配成合法序列。而“始终非负”等价于“前缀和的最小值0”。但我们常用最大值mx是因为在合并区间时用mx推导查询条件更方便。实际上我们更关心“后缀”信息这点后面会看到。实际上为了高效处理区间合并和查询我们通常需要维护更丰富的信息。一个更健壮、更通用的模型是维护三个值a: 区间内未匹配的右括号)数量即多余的)。可以理解为给这个区间从左到右进行匹配后栈里剩下的)的数量。b: 区间内未匹配的左括号(数量即多余的(。即匹配后栈里剩下的(的数量。c: 区间内可以形成的合法括号子序列的数量或者更常用于推导的是区间整体的sum。对于本题的查询我们主要依赖a和b。它们的物理意义非常清晰a代表这个区间“欠”多少左括号需要左边补(来匹配b代表这个区间“多出”多少左括号可以供给右边去匹配。3.2 区间合并的推导这是线段树的核心。假设我们有左儿子区间left和右儿子区间right如何得到父区间node的(a, b)左儿子的b_left代表它多出的(这些(可以尝试去匹配右儿子的a_right即右儿子欠的)。匹配掉一部分后左儿子剩余的(为b_left - min(b_left, a_right)右儿子剩余的)为a_right - min(b_left, a_right)。因此合并后node.a left.a (a_right - min(b_left, a_right))解释父区间未匹配的) 左儿子本来就未匹配的) 右儿子匹配掉一部分后仍剩余的)。node.b right.b (b_left - min(b_left, a_right))解释父区间未匹配的( 右儿子本来就未匹配的( 左儿子匹配掉一部分后仍剩余的(。我们可以用一个结构体Node来存储(a, b)。合并函数push_up可以这样写struct Node { int a; // 未匹配的 ) int b; // 未匹配的 ( // 有时也加一个 sum本题用a,b足以推导。 }; Node merge(Node l, Node r) { Node res; int match min(l.b, r.a); // 左右之间可以互相匹配的数量 res.a l.a (r.a - match); res.b r.b (l.b - match); return res; }这个合并操作是满足结合律的因此线段树可以维护。3.3 懒标记处理翻转操作翻转操作(-)对于我们的(a, b)模型有什么影响非常有趣且对称一个(值是1对(a,b)的贡献是(0, 1)不欠)多一个(。一个)值是-1对(a,b)的贡献是(1, 0)欠一个)不多(。当它翻转后(变)贡献从(0,1)变为(1,0))变(贡献从(1,0)变为(0,1)。发现了吗翻转操作等价于交换a和b对于一个区间翻转操作就是将这个区间节点的a值和b值互换。这是一个非常简洁的性质。因此我们的懒标记tag可以设计为一个布尔值表示当前区间是否需要翻转。apply函数给节点打上翻转标记或执行翻转的逻辑就是交换node.a和node.b。在下传标记push_down时只需要将标记异或给左右儿子即可。4. 查询操作的实现二分搜索与线段树结合现在我们有了一棵能维护区间(a,b)信息并支持区间翻转交换a,b的线段树。如何回答查询“以L为起点的最长合法子串的右端点R”4.1 查询的转化根据合法括号序列的定义和我们的(a,b)模型子串s[L...R]合法的条件是区间[L, R]的sum为0即a b不完全是我们的a,b是未匹配数对于整个区间sum b - a。合法要求sum0即b a。更关键的是在从L到R的匹配过程中任何前缀都不能出现“未匹配的)”多于“未匹配的(”的情况。在我们的模型中这意味着对于任何前缀区间[L, k]L k R其a值必须为0。因为a代表这个前缀区间净欠的)如果大于0说明)多了已经不合法。然而在线段树上直接检查所有前缀是不现实的。我们需要一个等效的全局条件。一个经典的结论是区间[L, R]是合法的当且仅当[L, R]的a值为0且[L, R]的b值也为0即node.a 0 node.b 0不对这要求太严格了这只是说明这个区间自身完全匹配。我们允许区间作为整体其b可以供给更右边但a必须为0。实际上条件等价于区间[L, R]的a值为0。因为a0意味着从L到R没有出现无法被区间内(匹配的)这是合法性的核心。b的值可以大于0表示多出的(这没关系。所以查询转化为寻找最大的R使得线段树查询区间[L, R]返回的节点的a值为0。4.2 在线段树上进行“二分搜索”我们不能枚举所有R。由于区间[L, R]的a值随着R增大具有单调性并非严格单调但我们可以利用线段树的结构我们可以在线段树上进行类似二分的查找。具体查询函数query(L)的逻辑如下首先检查s[L]本身是否是)可以通过查询单点或根据a,b判断如果L单点的a0就是)。如果是直接返回L-1即长度为0。否则我们从根节点开始搜索目标是找到那个使a0的最远R。我们需要一个函数它知道当前已经累积的“未匹配状态”是什么。设计一个函数find(node, l, r, L, cum)其中cum是一个临时变量记录在进入当前节点node所代表的区间之前我们已经累积了多少未匹配的(记为left_b。这个left_b可以用来匹配当前节点区间自带的a_node。在访问节点时计算匹配量match min(left_b, a_node)。匹配后更新left_b left_b - match并得到当前节点区间仍净剩的未匹配的)为remain_a a_node - match。如果remain_a 0说明即使加上左边累积的(这个区间仍然有多余的)无法匹配那么从这个区间开始就已经不合法了直接返回失败。如果remain_a 0说明这个区间在左边(的帮助下可以完全匹配掉自身的)。那么我们需要更新left_b为left_b b_node因为当前区间匹配完后多出的(可以继续供给右边。现在我们利用这个逻辑在线段树上二分优先进入左儿子区间因为我们要找以L开头的。如果左儿子区间在当前的left_b下能完全匹配即remain_a 0我们就更新left_b然后尝试进入右儿子看看能否扩展得更远。如果左儿子区间已经无法匹配remain_a 0那么最长合法端点就在左儿子区间内部我们递归进入左儿子继续查找。当递归到叶子节点时如果它能被匹配就返回这个位置。这个find函数写起来需要仔细处理递归边界和状态传递是本题查询实现中最精妙的部分。它本质上是在模拟从L开始进行括号匹配的过程但利用了线段树预计算的(a,b)信息将匹配过程从O(n)加速到了O(log n)。5. 代码实现框架与关键细节5.1 数据结构定义#include bits/stdc.h using namespace std; const int MAXN 1e6 5; // 根据题目规模调整 struct Node { int a; // 未匹配的 ) int b; // 未匹配的 ( int tag; // 懒标记0表示无翻转1表示需要翻转 } tr[MAXN 2]; char s[MAXN]; // 初始括号序列下标从1开始5.2 建树与信息上传建树时对于叶子节点单个字符如果是(则a0, b1。如果是)则a1, b0。void push_up(int u) { int l u 1, r u 1 | 1; int match min(tr[l].b, tr[r].a); tr[u].a tr[l].a (tr[r].a - match); tr[u].b tr[r].b (tr[l].b - match); } void build(int u, int l, int r) { tr[u].tag 0; if (l r) { tr[u].a (s[l] )); tr[u].b (s[l] (); return; } int mid (l r) 1; build(u 1, l, mid); build(u 1 | 1, mid 1, r); push_up(u); }5.3 懒标记下传与应用// 对节点u执行翻转操作 void apply(int u) { swap(tr[u].a, tr[u].b); tr[u].tag ^ 1; // 标记取反 } void push_down(int u) { if (tr[u].tag) { apply(u 1); apply(u 1 | 1); tr[u].tag 0; } } // 区间翻转更新 void update(int u, int l, int r, int ql, int qr) { if (ql l r qr) { apply(u); return; } push_down(u); int mid (l r) 1; if (ql mid) update(u 1, l, mid, ql, qr); if (qr mid) update(u 1 | 1, mid 1, r, ql, qr); push_up(u); }5.4 查询实现这是最复杂的部分需要实现上述的“在线段树上二分”逻辑。// 返回从L开始在节点u所辖区间[l,r]内能匹配到的最远位置。 // left_b是进入该区间前左边已积累的未匹配(的数量。 int find(int u, int l, int r, int L, int left_b) { if (l r || l L) return -1; // 无关区间或起点不对 if (l r) { // 叶子节点 // 计算这个字符在当前left_b下的状态 int match min(left_b, tr[u].a); int remain_a tr[u].a - match; if (remain_a 0) { // 这个字符无法匹配返回前一个位置 return l - 1; } else { // 可以匹配更新left_b并返回这个位置 left_b left_b - match tr[u].b; return l; } } push_down(u); int mid (l r) 1; int res -1; if (L mid) { // 先查左儿子 res find(u 1, l, mid, L, left_b); if (res mid) { // 左儿子完全匹配成功可以继续查右儿子 int temp_res find(u 1 | 1, mid 1, r, L, left_b); if (temp_res ! -1) res temp_res; } // 如果res不是mid说明在左儿子内部就失败了直接返回结果 } else { // 起点在右儿子直接查右儿子 res find(u 1 | 1, mid 1, r, L, left_b); } // 这里不需要push_up因为查询不修改 return res; } // 封装查询函数 int query(int L, int n) { // 快速判断起点是否为) Node single query_single(1, 1, n, L); // 需要一个查单点的函数或直接看s[L] if (single.a 0) { // 等价于 s[L] ) return L - 1; } int left_b 0; // 初始时左边没有积累的( int R find(1, 1, n, L, left_b); // 注意find返回的是最后一个成功匹配的位置。题目要求的是长度即 R - L 1如果RL则长度为0。 if (R L) return 0; else return R - L 1; }query_single函数需要实现或者更简单地在find函数开始前先检查s[L]如果初始数组未被修改覆盖的话。由于有翻转操作必须通过线段树查询来获取当前真实值所以实现query_single是必要的。6. 常见问题与调试技巧实录6.1 为什么我的查询结果总是比答案小这是实现find函数时最容易出错的地方。核心在于对left_b状态的理解和传递。left_b的初始值从L开始查询时left_b应该初始化为多少是0吗是的。因为L之前没有字符所以没有积累任何(。left_b的含义它代表“在考虑当前区间之前已经确定可以用于匹配当前及之后区间内)的(数量”。注意是“已经确定”而不是“可能”。在递归进入左儿子时我们用的是当前的left_b。从左儿子出来后我们获得了一个新的left_b它包含了左儿子区间匹配后净剩的(这个left_b才能用于右儿子。递归返回条件在find函数中当发现当前节点区间无法被left_b完全匹配即计算出的remain_a 0时说明最长合法端点就在这个区间内部必须深入这个区间去找确切的失败点而不是直接返回l-1。上面代码框架中叶子节点的处理是一种方式对于非叶子节点需要在递归中正确处理。一个更清晰且不易错的find实现方式是返回一个结构体包含当前区间匹配后对外表现的(a, b)然后在主查询函数里控制二分过程。但上述递归二分方式在思维上更直接。6.2 懒标记处理翻转导致的信息错误翻转操作交换a和b。务必验证apply函数是否正确影响了所有必要信息本题中只需交换a和b。如果维护了sumb-a那么sum会变为-sum。push_down的时机在update和query如果query需要进入子节点中在访问子节点前必须push_down当前节点的标记。这是线段树懒标记的标准操作但很容易忘记。标记的叠加翻转两次等于不翻转。所以懒标记可以用异或操作。tr[u].tag ^ 1。6.3 边界条件与初始化序列下标通常从1开始方便线段树操作。查询区间当L等于N时查询区间是[N, N]需要单独处理。初始化建树时确保所有节点的tag初始化为0。单点查询实现一个query_single函数用于获取某个位置的当前字符状态通过下传标记直到叶子。6.4 对拍与调试策略这类复杂数据结构题光靠肉眼检查很难。暴力对拍写一个naive的程序用数组直接存储字符串翻转就遍历修改查询就线性扫描。生成小规模随机数据n, m 1000随机执行操作比较线段树程序和大暴力程序的结果是否一致。输出中间状态在调试时可以写一个print_tree函数按层打印线段树每个节点的(a,b,tag)特别是在执行几次翻转和查询后检查信息是否正确。单步跟踪针对一个出错的测试用例手动模拟线段树的操作重点关注出错的查询一步步跟踪find函数中的left_b变化和递归路径。测试极端数据全(序列反复翻转和查询。全)序列。交替序列()()()。深度嵌套序列(((...)))。大量操作集中在序列开头或结尾。7. 线段树模型扩展与同类问题归纳这道题的精髓在于用(a,b)这对值来刻画括号序列的“匹配状态”。这个模型非常强大可以解决一系列括号序列的动态问题。7.1 模型扩展维护区间最长合法子串长度这是另一个经典问题。我们需要在每个节点额外维护四个值pre前缀和最小值不对于最长子串需要维护的是mx区间内最长合法子串长度lmx从左端点开始的最长合法前缀长度rmx以右端点结束的最长合法后缀长度以及sum区间和。合并逻辑更复杂但核心思想仍是利用sum和前缀/后缀信息。支持区间赋值统一修改为某种括号此时懒标记需要能处理赋值操作。赋值操作会直接重置节点的(a,b)信息并且会覆盖掉翻转标记。查询区间是否是合法括号序列这比本题查询简单直接查区间[L,R]的a和b如果a0 b0即完全匹配则是合法序列。7.2 同类问题举一反三Codeforces 380C Sereja and Brackets静态查询区间内最长合法括号子序列的长度。可以用线段树维护(a,b,c)其中c是区间内已经匹配的括号对数。查询时合并即可。SPOJ BRKTS只有一种操作将某个括号翻转判断整个序列是否合法。可以简化为单点更新全局查询。带有多种括号的序列如()[]{}。此时状态会变得更复杂可能需要用栈信息来维护或者使用哈希等方法但线段树维护合并信息的核心思想不变。解决这类问题的通用步骤是定义状态思考用什么数据一个值、一对值、一个结构体能足够描述一个区间关于该问题的“全部信息”。设计合并如何由左右子区间的状态推导出父区间的状态。这是最关键的一步决定了线段树能否维护。设计更新操作翻转、赋值等如何影响定义的状态。常常是交换、取反、重置等。设计查询根据问题可能需要直接获取状态也可能需要像本题一样在线段树上进行二分查找。这道“翻转括号序列”几乎涵盖了线段树处理复杂区间问题的所有要点状态定义、区间合并、懒标记设计、树上二分查询。吃透它你对线段树的理解会上一个大台阶。在调试那个find函数时我花了整整一个下午画图、模拟但当它终于跑通所有测试数据的那一刻那种对算法结构豁然开朗的感觉比单纯AC一道题要珍贵得多。
返回列表