数据结构专题-专项训练:线段树2(GSS1-5)
一些 update
update 2020/12/29:感谢机房 hxh 大佬指出问题,GSS5 的分类讨论 1 有点问题,现在已经更正,对各位读者造成的影响深表歉意。
回顾
在 数据结构专题-专项训练:线段树1 中我们见识了线段树的各种神奇应用,同时了解了线段树题目的五部曲:
- 我们需要维护什么?
- 线段树的每个叶子节点是什么?
- 需要 lazy_tag 吗?lazy_tag 又要维护什么呢?
- 要不要重载运算符?
- 最后又要怎么修改?怎么查询?
那么,我们来看看如何将这五部曲运用到 GSS1-5 上(之所以没有 GSS6-8 是因为他们不是线段树)。
这里对不知道 GSS 系列的题目的人做一个说明:GSS 系列题目都是数据结构题,而且都是基于树结构之上(比如线段树,平衡树),而这套题目当中出现次数最多的是求区间最大子段和问题。
题单:
GSS1
题目要求区间最大子段和,是 GSS 当中经典的一道题。
- 我们需要维护什么?
首先最显然的肯定要维护最大子段和 \(maxn\)(记作 \(m(p)\))。
那么还要维护什么呢?
考虑如何合并最大子段和:
我们要合并上面两个黑色区间,有三种情况(如图):
- 最大子段和是左儿子的最大子段和,即 \(m(p << 1)\)。
- 最大子段和是右儿子的最大子段和,即 \(m(p << 1 | 1)\)。
- 最大子段和跨界了,左右都有,那么我们怎么取呢?
仔细考虑一下就会发现:我们本质上是需要求 左儿子的最大后缀和 和 右儿子的最大前缀和。这样我们就可以保证最后的总和最大。
于是一个问题解决了,此时我们又需要维护两个东西:最大前缀和 \(pre\) (记作 \(p(p)\))和最大后缀和 \(aft\) (记作 \(a(p)\))。但是这样以来,我们怎样维护最大前缀和和最大后缀和呢?
以最大前缀和为例,有两种情况:
- 就是左儿子的最大前缀和,为 \(a(p << 1)\)。
- 左儿子的总和与右儿子的最大前缀和,为 \(sum(p<<1)+a(p<<1|1)\)。
于是我们就惊喜的发现我们只需要再维护一个 \(sum\) 就可以完美的解决问题了!
于是乎,我们最后定下来:维护 \(sum,maxn,pre,aft\),然后维护即可。
- 线段树的每个叶子节点是什么?
每个值的初始节点。
- 需要 lazy_tag 吗?lazy_tag 又要维护什么呢?
没有修改操作,不需要 lazy_tag。
- 要不要重载运算符?
这道题最好使用重载运算符,方便修改(虽然这道题没有,但是建树要用)与查询。
- 最后又要怎么修改?怎么查询?
首先考虑建树。
根据第 1 问所述,代码如下:
s(p) = s(p << 1) + s(p << 1 | 1);
p(p) = Max(p(p << 1), s(p << 1) + p(p << 1 | 1));
a(p) = Max(a(p << 1 | 1), s(p << 1 | 1) + a(p << 1));
m(p) = Max(m(p << 1), Max(m(p << 1 | 1), a(p << 1) + p(p << 1 | 1)));
然后修改呢?
如果修改区间 \([l,r]\) 再当前节点 \(l(p),r(p)\) 的左右儿子内都有区间,那么相应的求出每个区间的结果,使用 结构体 存储(而且是建树的结构体,我们需要知道每一个返回结果的相应变量),然后做一次加法(这就是为什么要用重载运算符);否则,单边寻找答案,返回结果结构体即可。
不理解?可以看看代码。
代码:
#include <bits/stdc++.h>
#define Max(a, b) ((a > b) ? a : b)
using namespace std;
const int MAXN = 5e4 + 10;
int n, m, a[MAXN];
typedef long long LL;
struct node
{
int l, r;
LL pre, aft, sum, maxn;//最大前缀和,最大后缀和,总和,最大子段和
#define l(p) tree[p].l
#define r(p) tree[p].r
#define p(p) tree[p].pre
#define a(p) tree[p].aft
#define s(p) tree[p].sum
#define m(p) tree[p].maxn
node operator + (const node &b)const
{
node c;
c.l = l; c.r = b.r;
c.pre = Max(pre, sum + b.pre);
c.aft = Max(b.sum + aft, b.aft);
c.sum = sum + b.sum;
c.maxn = Max(maxn, Max(b.maxn, aft + b.pre));
return c;
}
}tree[MAXN << 2];
int read()
{
int sum = 0, fh = 1; char ch = getchar();
while (ch < '0' || ch > '9') {if (ch == '-') fh = -1; ch = getchar();}
while (ch >= '0' && ch <= '9') {sum = (sum << 3) + (sum << 1) + (ch ^ 48); ch = getchar();}
return sum * fh;
}
void build(int p, int l, int r)
{
l(p) = l, r(p) = r;
if (l == r) {p(p) = a(p) = s(p) = m(p) = a[l]; return ;}
int mid = (l + r) >> 1;
build(p << 1, l, mid); build(p << 1 | 1, mid + 1, r);
tree[p] = tree[p << 1] + tree[p << 1 | 1];
}
node ask(int p, int l, int r)
{
if (l(p) >= l && r(p) <= r) return tree[p];
int mid = (l(p) + r(p)) >> 1;
if (l <= mid && r > mid) return ask(p << 1, l, r) + ask(p << 1 | 1, l, r);
if (l <= mid) return ask(p << 1, l, r);
if (r >= mid) return ask(p << 1 | 1, l, r);
}
int main()
{
n = read();
for (int i = 1; i <= n; ++i) a[i] = read();
build(1, 1, n);
m = read();
for (int i = 1; i <= m; ++i)
{
int l = read(), r = read();
printf ("%lld\n", ask(1, l, r).maxn);
}
return 0;
}
GSS3
GSS3 只是在 GSS1 的基础上加了单点修改而已。
相信有了之前的基础,各位不难想到直接单线修改,返回时维护即可。别的与 GSS1 没有任何区别。
代码:
#include <bits/stdc++.h>
#define Max(a, b) ((a > b) ? a : b)
using namespace std;
typedef long long LL;
const int MAXN = 5e4 + 10;
int n, m, a[MAXN];
struct node
{
int l, r;
LL pre, aft, sum, maxn;
#define l(p) tree[p].l
#define r(p) tree[p].r
#define p(p) tree[p].pre
#define a(p) tree[p].aft
#define s(p) tree[p].sum
#define m(p) tree[p].maxn
node operator + (const node &b)
{
node c;
c.l = l; c.r = b.r;
c.sum = sum + b.sum;
c.pre = Max(pre, sum + b.pre);
c.aft = Max(b.aft, b.sum + aft);
c.maxn = Max(maxn, Max(b.maxn, aft + b.pre));
return c;
}
}tree[MAXN << 2];
int read()
{
int sum = 0, fh = 1; char ch = getchar();
while (ch < '0' || ch > '9') {if (ch == '-') fh = -1; ch = getchar();}
while (ch >= '0' && ch <= '9') {sum = (sum << 3) + (sum << 1) + (ch ^ 48); ch = getchar();}
return sum * fh;
}
void build(int p, int l, int r)
{
l(p) = l, r(p) = r;
if (l == r) {s(p) = a(p) = p(p) = m(p) = a[l]; return ;}
int mid = (l + r) >> 1;
build(p << 1, l, mid); build(p << 1 | 1, mid + 1, r);
tree[p] = tree[p << 1] + tree[p << 1 | 1];
}
void change(int p, int loc, int val)
{
if (l(p) == r(p)) {s(p) = a(p) = p(p) = m(p) = val; return ;}
int mid = (l(p) + r(p)) >> 1;
if (loc <= mid) change(p << 1, loc, val);
else change(p << 1 | 1, loc, val);
tree[p] = tree[p << 1] + tree[p << 1 | 1];
}
node ask(int p, int l, int r)
{
if (l(p) >= l && r(p) <= r) return tree[p];
int mid = (l(p) + r(p)) >> 1;
if (l <= mid && r > mid)
{
return ask(p << 1, l, r) + ask(p << 1 | 1, l, r);
}
else if (l <= mid) return ask(p << 1, l, r);
else return ask(p << 1 | 1, l, r);
}
int main()
{
n = read();
for (int i = 1; i <= n; ++i) a[i] = read();
build(1, 1, n);
m = read();
for (int i = 1; i <= m; ++i)
{
int opt = read();
if (opt == 0)
{
int x = read(), y = read();
change(1, x, y);
}
else
{
int l = read(), r = read();
printf("%lld\n", ask(1, l, r).maxn);
}
}
return 0;
}
GSS5
GSS5 是 GSS1 的进一步的升级。
这一道题控制了左右端点的范围,因此我们需要分类讨论。
第一种情况:\(r1 \leq l2\),如图:
那么从图上我们可以很清晰的看到:我们实质是要求 \([r1,l2]\) 的和加上 \([l1,r1]\) 的最大后缀加上 \([l2,r2]\) 的最大前缀然后减去 \(a_{r1},a_{l2}\)。这样做是为了防止 \(l1==r1\) 这种坑爹的情况干扰我们的判断(如果直接求 \([l1,r1-1]\) 的前缀就直接炸了)。
第二种情况:\(l2 < r1\),如图:
那么此时我们需要进行分类讨论。设我们选取的最大子段和区间为 \([x,y]\)。
- 当 \(x\) 在 \([l1,l2)\) 中,\(y\) 在 \([l2,r1]\) 中时,我们要求的是 \([l1,l2]\) 的最大后缀加上 \([l2,r1]\) 的前缀减去 \(a_{l2}\)。
- 当 \(x\) 在 \([l1,l2)\) 中,\(y\) 在 \((r1,l2]\) 中时,此时的询问就变成了前面 \(r1 \leq l2\) 的询问,此处不再讲解。
- 当 \(x\) 在 \([l2,r1]\) 中,\(y\) 在 \([l2,r1]\) 中时,我们要求的是 \([l2,r1]\) 的最大子段和,模仿 GSS1 即可。
- 当 \(x\) 在 \([l2,r1]\) 中,\(y\) 在 \((r1,l2]\) 中时,我们要求的是 \([l2,r1]\) 的最大后缀加上 \([r1,l2]\) 的最大前缀减去 \(a_{r1}\)。
所以我们只需要按照上面的讨论解题即可。
代码:
#include <bits/stdc++.h>
using namespace std;
typedef long long LL;
int Max(LL fir, LL sec) {return (fir > sec) ? fir : sec;}
int Min(LL fir, LL sec) {return (fir < sec) ? fir : sec;}
const int MAXN = 1e4 + 10;
int t, n, m, a[MAXN];
struct node
{
int l, r;
LL sum, pre, aft, maxn;
#define l(p) tree[p].l
#define r(p) tree[p].r
#define s(p) tree[p].sum
#define p(p) tree[p].pre
#define a(p) tree[p].aft
#define m(p) tree[p].maxn
node operator + (const node &b)
{
node c;
c.l = l, c.r = b.r;
c.sum = sum + b.sum;
c.pre = Max(pre, sum + b.pre);
c.aft = Max(b.aft, b.sum + aft);
c.maxn = Max(maxn, Max(b.maxn, aft + b.pre));
return c;
}
}tree[MAXN << 2];
int read()
{
int sum = 0, fh = 1; char ch = getchar();
while (ch < '0' || ch > '9') {if (ch == '-') fh = -1; ch = getchar();}
while (ch >= '0' && ch <= '9') {sum = (sum << 3) + (sum << 1) + (ch ^48); ch = getchar();}
return sum * fh;
}
void build(int p, int l, int r)
{
l(p) = l, r(p) = r;
if (l == r) {s(p) = a(p) = p(p) = m(p) = a[l]; return ;}
int mid = (l(p) + r(p)) >> 1;
build(p << 1, l, mid); build(p << 1 | 1, mid + 1, r);
tree[p] = tree[p << 1] + tree[p << 1 | 1];
}
node Ask(int p, int l, int r)
{
if (l(p) >= l && r(p) <= r) return tree[p];
int mid = (l(p) + r(p)) >> 1;
if (l <= mid && r > mid) return Ask(p << 1, l, r) + Ask(p << 1 | 1, l, r);
if (l <= mid) return Ask(p << 1, l, r);
if (r > mid) return Ask(p << 1 | 1, l, r);
}
int main()
{
t = read();
while (t--)
{
memset(a, 0, sizeof(a)); memset(tree, 0, sizeof(tree));
n = read();
for (int i = 1; i <= n; ++i) a[i] = read();
build(1, 1, n); m = read();
for (int i = 1; i <= m; ++i)
{
int l1 = read(), r1 = read(), l2 = read(), r2 = read(); LL ans;
if (r1 <= l2) ans = Ask(1, r1, l2).sum + Ask(1, l1, r1).aft + Ask(1, l2, r2).pre - a[r1] - a[l2];
else
{
ans = Ask(1, l1, l2).aft + Ask(1, l2, r1).pre - a[l2];
ans = Max(ans, Ask(1, l1, l2).aft + Ask(1, l2, r1).sum + Ask(1, r1, r2).pre - a[l2] - a[r1]);
ans = Max(ans, Ask(1, l2, r1).maxn);
ans = Max(ans, Ask(1, l2, r1).aft + Ask(1, r1, r2).pre - a[r1]);
}
printf("%lld\n", ans);
}
}
return 0;
}
GSS4
是 这道题 的双倍经验,在 数据结构专题-专项训练:线段树1 中已经讲过,此处不作讲解。唯一需要注意的是值域变大了。
GSS2
这道题放在最后是因为它的难度是最大的。
求最大子段和是件容易事。去重之后再求就不是件容易事了。
- 我们需要维护什么?
显然的, GSS1 中前后缀维护已经变得不可行,所以我们需要另行他法。
而这类问题离线,又要去重的题目有一个固定的套路:离线询问,逐个击破。
啥意思?针对这道题,我们首先在 \(n\) 上建立一棵空树(除了 \(l(p),r(p)\) 啥都没有)。然后考虑去重。
为了去重,我们总需要知道在 \(a_i\) 前面且与它相等的数在哪个位置吧?于是我们需要预先处理出 \(pre_i\) 表示上一个与 \(a_i\) 相同的数的位置。
然后我们将所有询问离线,以右端点为关键字升序排序(左端点无所谓)。
这样做有什么好处吗?我们在离线处理询问的时候,如果以 \(i\) 为右端点的询问已经全部处理完了,那么我们后面就可以放心的去重了。
那么又如何处理答案呢?
首先,对于第 \(i\) 个位置 \(a_i\) ,我们针对 \([pre_i+1,a_i]\) 区间做一次区间加法。
比如现在有这样一个序列:1 2 3 4 5 6 5 7
那么前 6 个数加完之后线段树的区间变成了:21 20 18 15 11 6 0 0
此时 \(pre_7 = 5\)。
然后我们对 \([5 + 1, 7]\) 做一次区间加法之后有 21 20 18 15 11 11 5 0
此时你会惊奇的发现 我们实际上是对序列进行了自动去重。
然后我们又要维护什么呢?
四个值:\(sum,maxn,lazy\_sum,lazy\_maxn\)。(简写为 \(s(p),m(p),ls(p),lm(p)\))
\(s(p)\) 为这个序列的最大子段和。
\(m(p)\) 为 \(s(p)\) 出现过的最大和(历史最大和,注意这跟吉老师线段树没关系)。
\(ls(p)\) 是 \(s(p)\) 的 lazy_tag。
\(lm(p)\) 是 \(m(p)\) 的lazy_tag。
所以我们维护完了~
- 线段树的每个叶子节点是什么?
就是上文所述的区间
- 需要 lazy_tag 吗?lazy_tag 又要维护什么呢?
需要一个加法 lazy_tag,一个最大子段和 lazy_tag。
- 要不要重载运算符?
不需要。
- 最后又要怎么修改?怎么查询?
重点!
先看 \(s(p)\) 。
如果是加法,那么直接加即可。
如果是合并左右两个区间,我们需要做的是求最大值而不是合并。
为什么不需要跟 GSS1 一样弄 \(a(p),p(p)\) ?原因很简单,因为我们左右儿子的区间是连续的。
\(m(p)\) 好维护,同样求最大值。
\(ls(p),lm(p)\) 在区间修改的时候维护,但是合并左右两个区间的时候不要动。
我们再看看怎么查询。
对于区间 \([l,r]\) 的询问,我们直接求 \([l,r]\) 的最大子段和即可。为什么不需要做处理?还是因为左右儿子的区间是连续的。
代码:
#include <bits/stdc++.h>
#define int long long
using namespace std;
const int MAXN = 1e5 + 10;
int n, m, a[MAXN], ans[MAXN], pre[MAXN], las[MAXN << 2];
struct node
{
int l, r, sum, maxn, lazy_sum, lazy_maxn;
#define l(p) tree[p].l
#define r(p) tree[p].r
#define s(p) tree[p].sum
#define m(p) tree[p].maxn
#define ls(p) tree[p].lazy_sum
#define lm(p) tree[p].lazy_maxn
}tree[MAXN << 2];
struct query
{
int l, r, id;
}q[MAXN];
int Max(int fir, int sec) {return (fir > sec) ? fir : sec;}
int read()
{
int sum = 0, fh = 1; char ch = getchar();
while (ch < '0' || ch > '9') {if (ch == '-') fh = -1; ch = getchar();}
while (ch >= '0' && ch <= '9') {sum = (sum << 3) + (sum << 1) + (ch ^ 48); ch = getchar();}
return sum * fh;
}
bool cmp(const query &fir, const query &sec)
{
if (fir.r ^ sec.r) return fir.r < sec.r;
return fir.l < sec.l;
}
void build(int p, int l, int r)
{
l(p) = l, r(p) = r;
if (l == r) return ;
int mid = (l + r) >> 1;
build(p << 1, l, mid); build(p << 1 | 1, mid + 1, r);
return ;
}
void spread(int p)
{
m(p << 1) = Max(m(p << 1), s(p << 1) + lm(p));
s(p << 1) += ls(p);
lm(p << 1) = Max(lm(p << 1), ls(p << 1) + lm(p));
ls(p << 1) += ls(p);
m(p << 1 | 1) = Max(m(p << 1 | 1), s(p << 1 | 1) + lm(p));
s(p << 1 | 1) += ls(p);
lm(p << 1 | 1) = Max(lm(p << 1 | 1), ls(p << 1 | 1) + lm(p));
ls(p << 1 | 1) += ls(p);
lm(p) = ls(p) = 0;
}
void change(int p, int l, int r, int k)
{
if (l(p) >= l && r(p) <= r)
{
s(p) += k; m(p) = Max(m(p), s(p));
ls(p) += k; lm(p) = Max(lm(p), ls(p));
return ;
}
spread(p);
int mid = (l(p) + r(p)) >> 1;
if (l <= mid) change(p << 1, l, r, k);
if (r > mid) change(p << 1 | 1, l, r, k);
s(p) = Max(s(p << 1), s(p << 1 | 1));
m(p) = Max(m(p << 1), m(p << 1 | 1));
}
int ask(int p, int l, int r)
{
if (l(p) >= l && r(p) <= r) return m(p);
spread(p);
int mid = (l(p) + r(p)) >> 1; int val = -0x7f7f7f7f;
if (l <= mid) val = Max(val, ask(p << 1, l, r));
if (r > mid) val = Max(val, ask(p << 1 | 1, l, r));
return val;
}
signed main()
{
n = read();
for (int i = 1; i <= n; ++i) a[i] = read();
for (int i = 1; i <= n; ++i)
{
pre[i] = las[a[i] + 100000];
las[a[i] + 100000] = i;
}
build(1, 1, n);
m = read();
for (int i = 1; i <= m; ++i) {q[i].l = read(); q[i].r = read(); q[i].id = i;}
sort(q + 1, q + m + 1, cmp);
for (int i = 1, j = 1; i <= n; ++i)
{
change(1, pre[i] + 1, i, a[i]);
for (; j <= m && q[j].r == i; ++j)
ans[q[j].id] = ask(1, q[j].l, q[j].r);
}
for (int i = 1; i <= m; ++i) printf("%lld\n", ans[i]);
return 0;
}
总结:
GSS 的题目还是有一定的思维难度,更多的是看我们怎么想题目,思维性较强。
接下来,我们将介绍由线段树扩展而来的算法:可持久化线段树。
详情请见 数据结构专题-学习笔记:可持久化线段树。