算法 | 青训营

89 阅读3分钟

[蓝桥杯 2021 国 AB] 翻转括号序列

题目描述

给定一个长度为 nn 的括号序列,要求支持两种操作:

  1. [Li,Ri]\left[L_{i}, R_{i}\right] 区间内(序列中的第 LiL_{i} 个字符到第 RiR_{i} 个字符)的括号全部翻转(左括号变成右括号,右括号变成左括号)。

  2. 求出以 LiL_{i} 为左端点时,最长的合法括号序列对应的 RiR_{i} (即找出最大的 RiR_{i} 使 [Li,Ri]\left[L_{i}, R_{i}\right] 是一个合法括号序列)。

输入格式

输入的第一行包含两个整数 n,mn, m,分别表示括号序列长度和操作次数。

第二行包含给定的括号序列,括号序列中只包含左括号和右括号。

接下来 mm 行,每行描述一个操作。如果该行为 1 L R, 表示第一种操作,区间为 [L,R]\left[L, R\right];如果该行为 2 L 表示第二种操作,左端点为 LL

对于所有评测用例,1n106,1m2×1051 \leq n \leq 10^{6}, 1 \leq m \leq 2 \times 10^{5}

Solution

分析一下操作二

看到这两个操作不难想到这个问题大概可以用线段树解决,不难想到我们可以把左括号看成1,右括号看成-1。这个时候的合法序列就应该是

legal  sequence    sum(lr)=0  and  k{lr}sum(lk)0legal\;sequence\iff sum(l\cdots r) = 0 \;and\; \forall_{k\in \{l\cdots r\}}sum(l\cdots k) \ge 0

那如果用线段树直接去维护区间和,然后每次去查询ll右侧是否有这样一个点是很困难的,所以要把它转化成前缀和形式

legal  sequence    pre(r)=pre(l1)  and  k{lr}pre(k)pre(l1)legal\;sequence\iff pre(r) = pre(l - 1) \;and\; \forall_{k\in \{l\cdots r\}}pre(k) \ge pre(l - 1)

所以对于每一个ll我们要找的是l1l - 1右侧最后一个符合上述式子的位置即可。

对于操作一的实现

而对于翻转区间操作,如果翻转的区间的l=1l = 1,我们可以知道就是直接把lrl\cdots r全都取相反数而对于[r+1n][r + 1\cdots n]的区间应该全都减去某一个数

pre(r+1)=pre(r)+sum(r+1),  pre(r+2)=pre(r)+sum(r+1)+sum(r+2)pre(r + 1) = pre(r) + sum(r + 1),\; pre(r + 2) = pre(r) + sum(r + 1) + sum(r + 2) \cdots

所以当pre(r)pre(r)pre(r) \rightarrow -pre(r)时,[r+1n][r + 1\cdots n]的区间应该全都减去原来的2pre(r)2 * pre(r),由于是区间操作,我们需要一个懒惰标记,记为lazy_addlazy\_add

像这种区间翻转一个很套路的做法就是

reverse(lr)    reverse(1r)  +  reverse(1l1)reverse(l\cdots r) \iff reverse(1\cdots r)\; + \; reverse(1\cdots l-1)

在线段树上翻转区间我们只要维护区间最大值和区间最小值,每次翻转就是交换最大最小值并且取负数,由于是区间操作,我们需要一个懒惰标记,记为lazy_revlazy\_rev

//假设p是我们当前操作区间的节点id
tmp1 = mx[p], tmp2 = mn[p];
mx[p] = (~tmp2) + 1;//取反加1就是取相反数
mn[p] = (~tmp1) + 1;
lazy_rev[p] ^= 1;

对于操作二的实现

很显然右端点所在的位置是可以二分的,那我们先考虑直接进行二分,我们每次去二分一个位置pospos然后验证区间[lpos][l\cdots pos]的最小值是否小于pre(l1)pre(l - 1),小于我们就去左侧区间,否则就去右侧区间,但是我们要注意如果右侧区间最小值大于pre(l1)pre(l - 1)其实也是没有答案的,因为我们要找的右端点pre(r)=pre(l1)pre(r) = pre(l - 1)

这个做法时间复杂度是O(mlog2n)O(m\log^2n),像我写法不太好就可能被卡掉,要常数非常小才有可能通过。所以我们得考虑把这个二分的过程搬到线段树上去,线段树的很多操作本质上就是在做二分,我们可以利用这个二分的过程。

基于朴素二分的优化

先看一下我一开始错误的二分方式

其实有很显然的错误

在(2)处,虽然我们保证了当前的posmidpos \le mid但是ll(线段树的区间的ll)依旧可能在pospos的左侧,这就导致了mn[p << 1] < val这个语句会出错,我们要精确地找到这个pospos右侧的最小值通过这一个queryquery是不太可行的(至少我好像实现不了)。我们可以发现如果pospos的右侧有一个最小值小于valval那就一定是第一个小于valval的左侧点(因为prepre数组具有连续性且这个点是第一个小于val的点)。所以我们直接去二分pospos右侧第一个小于valval的点即可

int query_l(int p, int l, int r, int pos, int val) {
    if (l == r) return l;
    push_down(p);
    int mid = l + r >> 1;
    int ans = 0;
    if (mn[p << 1] < val && pos <= mid) ans =  query_l(p << 1, l, mid, pos, val);//pos在左侧区间并且最小值小于val
    if (ans) return ans;//如果已经有值直接返回就行,因为我们要找的是第一个
    if (mn[p << 1 | 1] < val) ans = query_l(p << 1 | 1, mid + 1, r, pos, val);//右侧要有<val的点才行
    return ans;
}

但是我们发现还有问题,比如这样一个序列((())),他的prepre数组就全都(pre(l1)=0)\ge (pre(l - 1) = 0)也就是不存在小于pre(l1)pre(l - 1)的点,此时我们就得再去二分一次,去找到ll右侧最后一个等于pre(l1)pre(l - 1)的点

int query_r(int p, int l, int r, int pos, int val) {
    if (l == r) return l;
    push_down(p);
    int mid = l + r >> 1;
    int ans = 0;
    if (mn[p << 1 | 1] <= val) ans = query_r(p << 1 | 1, mid + 1, r, pos, val);//由于前一次二分我们已经保证了pos右侧所有pre全都>=val所以有小于等于就是等于。还是贪心的先去右边
    if (ans) return ans;
    if (mn[p << 1] <= val && pos <= mid) ans =  query_r(p << 1, l, mid, pos, val);
    return ans;
}

然后还要在注意一下pospos处是))的情况。

LAZY标记!!!

最后,还有最关键的一点也是线段树区间修改最需要注意的地方也就是这种多个lazylazy标记的相互影响,打上lazy_addlazy\_add标记不会对lazy_revlazy\_rev产生影响, 但是打上lazy_revlazy\_rev会对lazy_addlazy\_add产生影响,由于lazy_revlazy\_rev是区间取反,所以要把lazy_addlazy\_add也取反。

CODE

O(nlogn)O(n\log n)