区间最值操作与区间历史最值详解

灵梦

2020-03-20 17:44:20

算法·理论

本文讲解了一种叫做 Segment Tree Beats 的使用线段树维护区间最值操作的方法,和维护区间历史最值的常用手段。主要参考了了吉老师 2016 年集训队论文(见文末参考资料)。

前置知识:懒标记线段树。

区间最值操作

首先假设我们有一个序列 A。区间最值操作是指,给定 l,r,k,对于所有 i\in[l,r] ,将 A_i 变成 \min(A_i,k)\max(A_i,k) 的一种操作。下面由一道例题引入。

HDU5306. Gorgeous Sequence

给出一个长度为 n(n\leq 10^6) 的序列 Am(m\leq 10^6) 次操作,每次操作为以下三种类型之一:

  1. 给出 l,r,k,对于所有 i\in[l,r],将 A_i 变成 \min(A_i,k)
  2. 给出 l,r,求序列 A 区间 [l,r] 的最大值;
  3. 给出 l,r,求 \sum_{i=l}^{r}A_i

在这道题中,第一种操作就是区间最值操作。我们发现,关于区间最大值的询问是可以使用带懒标记的线段树维护的,而区间和则不是很容易求出,因为这不能在下传标记时快速更新答案。

考虑到区间取 \min 的操作只会对最大值超过 k 的节点产生影响,我们可以在这方面产生思路。为了使复杂度变对,线段树的一个节点要维护三个信息:区间最大值 mx,区间严格次大值 se 和最大值的个数 cnt。那么,一次区间最值操作作用在这个节点上时,可以被分为以下三种情况:

举个例子,现在要以 k=2 对下面这棵线段树维护的区间取 \min。每个节点左侧表示区间最大值,右侧表示严格次大值。

按照上面描述的操作,我们应该沿着红色的边 DFS,最后在红色的节点上更新区间和并打上标记。

这样做的复杂度可以证明是 O(m\log n) 的。原论文的证明使用了势能分析,这里给出一种更好懂的 O((n+m)\log n) 下界的证明:

显然只有暴力 DFS 的过程会影响复杂度,我们考虑一次区间最值操作中哪些节点会被搜索到。发现到达的节点区间中不同的数的个数(下称「值域」)一定会减少(因为至少将最大值与次大值合并了)。线段树每层节点表示的区间的值域一共是 O(n) 的,一共 \log 层加起来就是 O(n\log n)。再加上每次操作的复杂度,可以得到复杂度的一个下界为 O((n+m)\log n)

为了方便理解,下面给出这道题区间修改打标记的代码:

void update(int o, int k) // 只打标记不下传
{
    if (k<tr[o].mx)
    // 这里需要判一下当前节点是否为最大值
    {
        tr[o].sum+=(1ll*k-tr[o].mx)*tr[o].cnt;
        tr[o].mx=tr[o].tag=k;
    }
}

void pushdown(int o) // 下传标记到左右子树
{
    if (~tr[o].tag)
    {
        update(lc, tr[o].tag);
        update(rc, tr[o].tag);
        tr[o].tag=-1; // 这个题里没有负数,把标记清为 -1 即可
    }
}

void modify(int o, int l, int r, int k)
{
    if (tr[o].l>r||tr[o].r<l||k>=tr[o].mx) return; // 情况 1
    if (l<=tr[o].l&&tr[o].r<=r&&k>tr[o].se)
        { update(o, k); return; } // 情况 2
    pushdown(o);
    modify(lc, l, r, k), modify(rc, l, r, k); // 继续在子树内搜索
    pushup(o);
}

支持区间加减

我们在支持上一道例题中所有操作的同时,加入区间加操作。

此时有两种考虑这个问题的方法:

其中后者看似麻烦一些,但思想很重要,可以扩展到更复杂的问题中(尤其是后面会提到的历史最值问题),因此最好也要理解。如果还不是很明白也没有关系,在下一道例题中会详细讲解。

之前的复杂度分析在这道题无法被沿用。论文中给出了一个基于对标记的分析的 O(m\log^2 n) 下界的证明,这个程序的实际运行效率是不错的。

同时支持区间最小值和最大值

BZOJ4695. 最假女选手

给出一个长度为 n(n\leq 5\times 10^5) 的序列 Am(m\leq 5\times 10^5) 次操作,每次操作为以下六种类型之一:

  1. 给出 l,r,k,对于所有 i\in[l,r],将 A_i 加上 k
  2. 给出 l,r,k,对于所有 i\in[l,r],将 A_i 变成 \max(A_i,k)
  3. 给出 l,r,k,对于所有 i\in[l,r],将 A_i 变成 \min(A_i,k)
  4. 给出 l,r,求 \sum_{i=l}^{r}A_i
  5. 给出 l,r,求序列 A 区间 [l,r] 的最大值;
  6. 给出 l,r,求序列 A 区间 [l,r] 的最小值。

在这道题中,区间取最大值和最小值的操作同时出现了。虽然我们同样可以套用第一道例题的方法,维护区间加、区间取 \max,区间取 \min 三个标记,但实现起来会很复杂(可以参考 OI-wiki 上的题解)。这里我们可以仍考虑通过划分数域将区间最值操作转化为区间加减

应这道题的需要,我们将一个区间的元素划分为最大值、最小值和其他值三种。首先在每个节点上肯定要维护区间和 sum、区间最大值 mx1 和最小值 mn1 的信息,同时我们也要维护次大值 mx2、次小值 mn2 和最大值最小值的个数 cmx,cmn。我们分别讨论题目中的三种修改操作。

那么,我们需要分别维护这三个数域上的加减标记。但是有一些细节要注意:

以上两点都会在下面的代码段中体现:

// add1, add2, add3 分别表示最小值、最大值和其他值的加减标记。
// k1, k2, k3 也是按这个顺序。
void update(int o, int k1, int k2, int k3)
// 作用标记并合并标记,一定要注意顺序
{
    if (tr[o].mn1==tr[o].mx1)// 只有一种值时,最大值等于最小值
    {
        if (k1==k3) k1=k2; else k2=k1; // 不应被其他值的标记作用
        tr[o].sum+=1ll*k1*tr[o].cmn;
    }
    else tr[o].sum+=1ll*k1*tr[o].cmn+1ll*k2*tr[o].cmx
        +1ll*k3*(tr[o].r-tr[o].l+1-tr[o].cmn-tr[o].cmx);

    if (tr[o].mn2==tr[o].mx1) tr[o].mn2+=k2;
    // 次小值等于最大值,应该被最大值标记作用
    else if (tr[o].mn2!=INF) tr[o].mn2+=k3;
    // 否则应该被其他值标记作用

    if (tr[o].mx2==tr[o].mn1) tr[o].mx2+=k1;
    else if (tr[o].mx2!=-INF) tr[o].mx2+=k3;
    // 对次大值同理

    tr[o].mn1+=k1, tr[o].mx1+=k2;
    tr[o].add1+=k1, tr[o].add2+=k2, tr[o].add3+=k3;
}

void pushdown(int o) // 下传标记
{
    int mn=min(tr[lc].mn1, tr[rc].mn1);
    int mx=max(tr[lc].mx1, tr[rc].mx1);
    // 找出最大值和最小值

    update(lc, tr[lc].mn1==mn?tr[o].add1:tr[o].add3,
           tr[lc].mx1==mx?tr[o].add2:tr[o].add3, tr[o].add3);
    // 下传标记到左子树,若左子树中有最大值则下传最大值加减标记,否则下传其他值加减标记。对最小值同理
    update(rc, tr[rc].mn1==mn?tr[o].add1:tr[o].add3,
           tr[rc].mx1==mx?tr[o].add2:tr[o].add3, tr[o].add3);
    // 右子树同理

    tr[o].add1=tr[o].add2=tr[o].add3=0;
}

一个应用的例子

有些奇怪的问题可以转化为区间最值操作。下面这道题本来可以在第一道例题后面直接讲,但我还是选择了将更重要的内容放到前面。

UOJ#515. 【UR #19】前进四

给出一个长度为 n(n\leq 10^6) 的序列 Am(q\leq 10^6) 次操作,每次操作为以下两种类型之一:

  1. 给出 x,v,将 A_x 变成 v
  2. 给出 x,求 A_x,A_{x+1},\cdots,A_n 的不同后缀最小值个数。

首先这道题与 楼房重建 是同一个问题,所以也有一个 O(q\log^2 n) 的在线做法,但这不是本文讲述的重点。由于数据范围很大,且时限只有 1 秒,因此我们采用一个效率更高的离线做法。

观察到本题询问的是一个后缀而不是区间,可以考虑按下标从后往前扫描线,使用线段树维护关于时间的后缀最小值。假设现在扫描到了位置 i,考虑这个位置的某个修改操作,它会影响到从该修改的时刻开始,到位置 i 的下一次修改的时刻为止的一个时间段,具体地说,是将这个时间段的后缀 \min 都对 v 取最小值。而对于询问,则是求扫描到 x 时该询问发生的时刻被取了多少次最小值。这个次数是可以在下传标记的过程中顺带维护的,具体可以看文末链接中我的代码。

对比这种做法和两个 \log 的经典做法,其优势显然在于复杂度低;而劣势则是不能在线且不好处理区间询问(套分治会多一个 \log)。这道题需要注意一下常数。

小结

这里再次强调,上面的这种处理区间最值的方法的核心是数域的划分。也就是说,我们以一个 \log 的代价,将区间最值操作转化成了最值数域上的区间加减操作。本文的后半部分将会继续应用这种思想。

区间历史最值

对于序列 A 的某个位置 i,它的历史最值是指 A_i 从初始化到当前达到的最大值 / 最小值,或者每一次修改操作后的值之和。我们把这三种分别称作历史最大值、历史最小值和历史版本和。

而区间历史最值则是指求序列 A 的历史最值的最大值 / 最小值或者代数和等的区间问题。其中最大值和最小值的维护方式与单点问题相同,比较容易处理;而求和则需要一步转化。

沿用懒标记

对于一些修改操作不复杂的问题(区间加,区间覆盖等),我们可以沿用懒标记的做法。这是最基本也是最重要的一种做法。

BZOJ3064. Tyvj 1518 CPU监控

给出一个长度为 n(n\leq 10^5) 的序列 A,定义辅助数组 B,初始时与 A 完全相同。给出 m(m\leq 10^5) 次操作,每次操作为以下四种类型之一:

  1. 给出 l,r,k,对于所有 i\in[l,r],将 A_i 加上 k
  2. 给出 l,r,k,对于所有 i\in[l,r],将 A_i 变成 k
  3. 给出 l,r,求序列 A 区间 [l,r] 的最大值;
  4. 给出 l,r,求序列 B 区间 [l,r] 的最大值。

每次操作后,对于所有 i,将 B_i 变成 \max(B_i,A_i)

发现序列 B 就是序列 A 的历史最大值,那么第四种操作询问的就是 [l,r] 中历史最大值的最大值。

先考虑区间加操作,我们在线段树的每个节点上维护两个值:区间当前最大值 mx 和历史最大值 mx',以及两个标记:区间加标记 add 和历史最大加减标记 add'。这里唯一不容易理解的就是「历史最大加减标记」,它表示的是从上一次下传 add 到现在,区间加标记达到的最大值

考虑历史最值标记的合并。当节点 x 下传标记到它的儿子 ch 时,儿子历史最大值应与当前值取 \max,写成公式就是下面的样子:

\begin{gathered} add'_{ch}\gets\max(add'_{ch},add_{ch}+add'_x)\\ mx'_{ch}\gets \max(mx'_{ch},mx_{ch}+add'_x) \end{gathered}

现在加入区间覆盖操作,我们把标记换成二元组 (add,cov),表示将当前区间先加上 add 再全部变成 cov。这个标记是可以合并的:如果一个区间已经被覆盖成了一个值,那么区间加操作可以转化为区间覆盖操作,直接把它加到 cov 上即可。同理,我们还需要维护 (add',cov'),表示上一次下传这个节点的标记到现在,第一阶段区间加的最大值为 add',第二阶段区间覆盖的最大值为 cov'

这里给出打标记和下传标记部分的代码:

// 以下代码段中,所有带下划线的变量都是历史最值
void plus(int o, int k, int k_) // 区间加标记
{
    tr[o].mx_=max(tr[o].mx_, tr[o].mx+k_), tr[o].mx+=k;
    // 更新历史最大值和当前最大值,这里要注意顺序
    if (!tr[o].tag)
        tr[o].add_=max(tr[o].add_, tr[o].add+k_), tr[o].add+=k;
    // 如果没有区间覆盖标记,正常更新 add 标记
    else tr[o].cov_=max(tr[o].cov_, tr[o].cov+k_), tr[o].cov+=k;
    // 否则直接加到 cov 上
}

void cover(int o, int k, int k_) // 区间覆盖标记
{
    tr[o].mx_=max(tr[o].mx_, k_), tr[o].mx=k;
    tr[o].cov_=max(tr[o].cov_, k_), tr[o].cov=k, tr[o].tag=1;
}

void pushdown(int o) // 下传标记
{
    if (tr[o].add)
    {
        plus(lc, tr[o].add, tr[o].add_);
        plus(rc, tr[o].add, tr[o].add_);
        tr[o].add=tr[o].add_=0;
    }
    if (tr[o].tag) // 这里的 tag 表示当前节点有没有区间覆盖标记
    {
        cover(lc, tr[o].cov, tr[o].cov_);
        cover(rc, tr[o].cov, tr[o].cov_);
        tr[o].tag=0, tr[o].cov_=INT_MIN;
    }
}

所有操作的复杂度都和普通线段树一致,总共是 O(m\log n) 的。

此外,有些数据结构问题可以离线后转化为这种历史最值问题,下面是一道例题。

SP1557. GSS2 - Can you answer these queries II

给出长度为 n(n\leq 10^5) 的序列 Aq(q\leq 10^5) 次询问,每次询问给出 [l,r],求区间 [l,r] 相同数只算一次 的最大子段和。序列 A 的值域为 [-10^5,10^5]

这道题用常规方法不好快速解决,我们考虑离线。

将询问按 r 从小到大排序。对于普通的最大子段和问题,我们可以逐个加入下标不大于 r 的元素,对于每个位置 i 维护以 i 为左端点的区间元素和的最大值。观察加数的过程,可以看出这个值就是区间 [i,n] 的元素和的历史最大值。用线段树维护,发现加入下标为 k 的元素就相当于把区间 [1,k] 加上 A_i,那么问题就转化为区间加、区间求历史最大值的最大值。而有了相同数只算一次的限制,我们不妨只考虑某个数在 [i,n] 中第一次出现的位置,那么加入第 k 个数就只需要将区间 [pre_{A_i}+1,k] 加上 A_i 就可以了,其中 pre_{A_i} 表示 A_i 上一次出现的位置。同样处理即可,复杂度 O(m\log n)

与区间最值操作结合

在有些题目中,区间历史最值的询问会与本文第一部分讲解的区间最值操作同时出现。我们考虑这种问题的解决方法,通常可以分为下面的两类。

区间最值操作与历史最值询问同向

UOJ#164. 【清华集训2015】V

给出一个长度为 n(n\leq 5\times 10^5) 的序列 A,定义辅助数组 B,初始时与 A 完全相同。给出 m(m\leq 5\times 10^5) 次操作,每次操作为以下五种类型之一:

  1. 给出 l,r,k,对于所有 i\in[l,r],将 A_i 加上 k
  2. 给出 l,r,k,对于所有 i\in[l,r],将 A_i 变成 \max(A_i-k,0)
  3. 给出 l,r,k,对于所有 i\in[l,r],将 A_i 变成 k
  4. 给出 p,询问 A_p
  5. 给出 p,询问 B_p

每次操作后,对于所有 i,将 B_i 变成 \max(B_i,A_i)

定义标记 (add,v) 表示将一个区间先加上 add 再与 v 取最大值,我们发现题目中的三种修改都可以用这种标记表示,分别是 (k,0),(-k,0),(-\infty,k)。此时我们说区间最值操作与历史最值询问同向,因为一个是取最大值,另一个是求历史最大值。这种标记的合并方式在前文已经提到过了,在此不再赘述。而维护历史最大值时,可以把标记看成自变量为当前值的分段函数,它的前半段是水平的直线,后半段是斜率为 1 的直线。更新历史最大标记时就要对这两个函数取 \max,如下图:

其中红色段表示两个函数的最大值。容易发现这个最大值也是一个类似的两段函数,我们直接把 addv 分别取 \max 就可以更新历史标记了。复杂度仍是和普通线段树一样的 O(m\log n)

区间最值操作与历史最值询问反向

UOJ#169. 【UR #11】元旦老人与数列

给出一个长度为 n(n\leq 5\times 10^5) 的序列 A,定义辅助数组 B,初始时与 A 完全相同。给出 m(m\leq 5\times 10^5) 次操作,每次操作为以下四种类型之一:

  1. 给出 l,r,k,对于所有 i\in[l,r],将 A_i 加上 k
  2. 给出 l,r,k,对于所有 i\in[l,r],将 A_i 变成 \max(A_i,k)
  3. 给出 l,r,求序列 A 区间 [l,r] 的最小值;
  4. 给出 l,r,求序列 B 区间 [l,r] 的最小值。

每次操作后,对于所有 i,将 B_i 变成 \max(B_i,A_i)

这道题与上一道例题的最大区别在于区间最值操作与历史最值询问是反向的,因为一个是取最大值,而另一个是求历史最小值。如果用上一题的方法维护历史标记的话,两个函数取最小值就会出现下图的情况:

其中紫色段表示两个函数的最小值。可以看出这样合并会导致函数段数无限增长,就不好维护了。我们需要其他的方法。

本文上一部分提到了一种通过划分数域把区间最值操作转化为区间加减的方法。在这里我们需要把一个区间的元素划分成最小值和非最小值两部分。那么区间最值操作可以转化为对最小值的加减操作,而题目中的区间加减则同时作用于这两类值。于是我们以一个 \log 的代价把问题就变成了两个数域上的区间加减操作和历史最值询问,也就是 CPU 监控 那道题了。具体地说,我们需要维护四种标记:

前两个标记是只修改最小值的,所以下传时要判断当前节点是否包含区间最小值,这也在前文例题中提到了。复杂度为 O(m\log^2 n),需要注意一下常数。

维护历史最值和

这一部分的内容作为补充。因为网上资料较少,笔者几乎没遇到过这种题目,所以这里写得比较简略。

这里我们先考虑区间加减、区间求历史最值和的问题。比如下面的问题:

给出一个长度为 n(n\leq 10^5) 的序列 A,定义辅助数组 B,初始时与 A 完全相同。给出 m(m\leq 10^5) 次操作,每次操作为以下两种类型之一:

  1. 给出 l,r,k,对于所有 i\in[l,r],将 A_i 加上 k
  2. 给出 l,r,求 \sum_{i=l}^{r}B_i

每次操作后,对于所有 i,将 B_i 变成 \min(B_i,A_i)

发现用上面的方法求历史最值 \max\min 的方法是无法沿用到这个问题上的,我们考虑把它转化为区间最值操作。定义辅助数组 C,满足对于所有 iC_i=A_i-B_i。我们在区间加操作中将一个位置加上 k,有两种情况:

综上,有 \forall i\in[l,r],C_i\gets \max(C_i+k,0)。这是我们熟悉的问题,可以在均摊 O(\log^2n) 的时间内完成对数组 C 的修改。询问时,就是求 A 的区间和加上 C 的区间和。总复杂度为 O(m\log^2 n)

现在我们在上面这题中,加入区间取最大值操作,这道题就变成了论文的最后一道例题 赛格蒙特彼茨(segment-beats)。这是一个比较复杂的问题,我们仍考虑维护一个差分数组 CA 数组的区间和是可以用前文的常规方法维护的;而作用在 C 数组上也是区间加、区间取最大值操作……吗?

并没有这么简单。因为对于一个区间内 A 是或不是最小值的位置,作用在 C 上的标记是不同的。实际上,我们应该做两次数域的划分(见上图)。第一次把一个区间的所有元素按 A 分成了最小值和非最小值两类,第二次又把这两类里的值分别再次按 C 分为了最小值和非最小值。对于这四类值,我们要对它们分别维护标记。数一数发现线段树上的每个节点中包括区间和一共要维护 16 个信息,维护的过程中还有各种冗杂的讨论。我调了很久也没有和暴力拍对……

这种方式的复杂度被证明有一个 O(m\log^3n) 的上界,具体可以参考论文。实际上这类问题还能继续扩展,本题可以看成是 AB 两个数组的嵌套问题(两次数域划分),而 k 个数组的嵌套也会有类似的时间复杂度为 O(2^km\log^{k+1}n) 的做法。

本文内容到这里就结束了。所有有名字的例题的笔者的完整 AC 代码可以在这里看到。

为了配套这篇日报,我特意造了一道模板题,看完上文后做这题就不难了。

参考资料