首页 > 技术文章 > P6242 【模板】线段树 3

Wild-Donkey 2021-01-31 17:03 原文

题意

Luogu P6242

维护一个数组, 支持一下五种操作:

  • 区间加减
  • 区间抹平(使所有大于 v 的数变成 v)
  • 区间求和
  • 区间最值
  • 区间历史最值

区间加减

普通线段树的基础操作, 需要用 LazyTag 保证效率, 无需多提.

区间抹平

朴素方法是遍历所有和操作区间有交的节点, 一直递归到叶子, 对每一个元素进行判断和操作.

尝试优化, 记录节点最大值, 次大值, 最大值个数, 如果 v 在最大值和次大值之间, 就使其最大值发生改变, 其余元素不受影响, 再结合最大值个数修改总和即可.

需要注意是, 这样需要引入一个新的 LazyTag, 记录最大值的变化, 因为这项操作可以使最大值的变化和非最大值的变化不再同步.

区间求和

和一般线段树一样, 递归调用有交集的节点, 与此同时下传标记保证调用到节点的 Sum 值是最新的, 和普通线段树的区别是要下传两个标记而不是一个.

区间最值

非常简单, 由于维护了节点最大值 Mx, 所以操作和求和类似, 只是答案合并时是取最大值而不是求和.

区间历史最值

这是使本题直接提升一个难度等级的操作, 很多人 (就是我) 一看就给节点加了一个记录历史最值的变量 HisMx, 每次更新 Mx 就尝试更新一下 HisMx.

但是这样做是不对的, 在调试了十余小时候我终于明白了原因.

一个区间的 LazyTag, 不一定每次下传时就是最大值. 即下传一个标记时它可能已经被修改好几次了, 但是光看这个 Tag 值没人知道它最大时是多少.

这时要查询它的子区间历史最值了, 把它的最大值修改标记 Tagmax 标记一下传, 算出一个子区间的 Mx 一通修改后的最终值, 但是不一定是最大值. 导致了历史最值的错误.

HisMxTag

如果定义一个变量 HisMxTagmax, 记录这个节点从上一次下传到本次下传屡次修改的最大值, 每一次 Tagmax 被修改都尝试更新 HisMxTagmax, 将它用于历史最值的计算, 就能在下传时得到从上一次下传到现在几次修改中 Tagmax 最大的历史值了.

当然, 由于一个节点的子节点中不一定含有其父的最大值, 自然不能和父亲用同一个 Tagmax, 所以为了得到其历史最大的最大值变化标记, 其父需要一个记录历史最大的非最大值变化标记 HisMxTagpls, 用来传给最大值比自己最大值小的儿子.

复杂度

由于证明复杂度需要的知识过于高端, 这里只说结论, \(O((m + n)log^2n)\)

对于本体数据规模时间复杂度是正确的.

实现

以上就是本体的基本思路, 接下来分部分放出代码逐个进行解剖.

存储

为了节省空间, 将区间端点作为函数参数递归维护

int a[500005];  //初始值数组
unsigned int M, n;
long long Lst, A, B, C, D;  //全局变量存操作参数和答案防止调用函数时浪费时间
struct Node {
  Node *L, *R;  //左右儿子指针
  int Tagpls /*非最大值变化标记*/, Mx /*最大值*/, HisMx /*历史最值*/,
      SecMx /*次大值*/, Cntmax /*最大值计数*/, Tagmax /*最大值变化标记*/,
      HisMxTagpls /*非最大值变化标记的历史最值*/,
      HisMxTagmax /*最大值变化标记的历史最值*/;
  long long Sum;         //总和, 不开long long见祖宗
} N[2000005], *Cntn(N);  // 4倍内存池 + 指针

下改上 Udt

儿子的 LazyTag 永远不影响父亲, 所以只要修改 Sum, Mx, SecMx, HisMx 即可.

Mx, SecMx 分几种情况讨论, 代码已经注释得很清楚了.

void Udt(Node *x, const unsigned int &l, const unsigned int &r,
         const unsigned int &m) {  //儿子更新父亲
  x->Sum = x->L->Sum + x->R->Sum;  //总和为两儿子的和
  x->HisMx =
      max(x->L->HisMx, x->R->HisMx);  //历史最大值为两儿子历史最大值的较大的一个
  if (x->L->Mx == x->R->Mx) {  //更新最值和最值数量, 两个儿子最大值相等
    x->Cntmax = x->L->Cntmax + x->R->Cntmax;  //最大值计数为两儿子之和
    x->Mx = x->L->Mx;  //最大值就是两儿子最大值
    x->SecMx =
        max(x->L->SecMx, x->R->SecMx);  //次大值是两儿子次大值中的较大的一个
    return;
  }
  if (x->L->Mx > x->R->Mx) {   //最大值在左儿子上
    x->Mx = x->L->Mx;          //最大值是左儿最大值
    x->Cntmax = x->L->Cntmax;  //最大值计数是左儿最大值计数
    x->SecMx =
        max(x->R->Mx, x->L->SecMx);  //次大值为右儿最大值和左儿次大值较大的那个
    return;
  }
  x->Mx = x->R->Mx;          //最大值在右儿上
  x->Cntmax = x->R->Cntmax;  //最大值计数是右儿最大值计数
  x->SecMx =
      max(x->L->Mx, x->R->SecMx);  //次大值为左儿最大值和右儿次大值中较大的那个
  return;
}

建树 Bld

利用刚刚写的 Udt, 建树除了初始化的变量多了, 结构上和一般线段树没什么两样.

void Bld(Node *x, unsigned int l, const unsigned int &r) {
  x->Tagpls = 0;
  x->Tagmax = 0;
  x->HisMxTagpls = 0;
  x->HisMxTagmax = 0;                //初始化标记
  if (l == r) {                      //叶子
    x->Cntmax = 1;                   //最大值有1个
    x->SecMx = -0x3f3f3f3f3f3f3f3f;  //无次大值
    x->Sum = a[l];
    x->Mx = a[l];
    x->HisMx = a[l];  //总和, 历史最大值, 最大值都是当前单点的初始值
    return;
  }
  unsigned int m((l + r) >> 1);  //非叶节点两个儿子的分界点
  Bld(x->L = ++Cntn, l, m);      //左儿
  Bld(x->R = ++Cntn, m + 1, r);  //右儿
  return Udt(x, l, r, m);               //两儿更新x
}

打标记 PsCm

大部分的线段树, 由于 LazyTag 操作简单, 一般直接将打标记写在单点修改和标记下传中, 但是由于本题 LazyTag 十分复杂, 适合封装进一个小函数.

提示, 一个节点的标记对自己无效, 当前节点标记被更新都是为了传给儿子, 自己的各种值在父亲的标记下传时已经被更新了.

void PsCm(Node *x, const int &tgp /*非最大值标记*/,
          const int &tgm /*最大值标记*/,
          const int &hmtp /*从上一次下传以来历史最大的非最大值标记*/
          ,
          const int &hmtm /*从上一次下传以来历史最大的最大值标记*/,
          const unsigned int &l,
          const unsigned int &r) {  //传来的标记对当前节点的影响
  x->HisMxTagpls =
      max(x->HisMxTagpls, x->Tagpls + hmtp);  //尝试更新历史最大的非最大值的标记
  x->HisMxTagmax =
      max(x->HisMxTagmax, x->Tagmax + hmtm);  //尝试更新历史最大的最大值标记
  x->HisMx =
      max(x->HisMx, x->Mx /*上一次更新时的最大值*/ +
                        hmtm /*从上一次更新到现在出现过的最大的最大值标记*/);
  x->Tagpls += tgp;  //非最大值标记更新
  x->Tagmax += tgm;  //更新最大值标记
  x->Mx += tgm;      //更新最大值
  x->Sum += (long long)tgm * x->Cntmax /*最大值对总和的贡献*/ +
            (long long)tgp * (r - l + 1 - x->Cntmax) /*非最大值对总和的贡献*/;
  x->SecMx += tgp;  //更新次大值(非最大值)
  return;
}

标记下传 PsDw

在有了打标记的函数 PsCm 后, 标记下传变得简洁许多, 但是由于分类讨论的情况存在, 这个函数也没有比一般线段树的短.

因为对于没有当前节点最大值子节点, 子节点的最大值受到的修改也只是当前节点的次大值标记, 所以要分类讨论保证每个子节点接受的标记是正确的.

void PsDw(Node *x, const unsigned int &l, const unsigned int &r,
          const unsigned int &m) {
  unsigned int m1(m + 1);      //右子节点的左端点
  if (x->L->Mx == x->R->Mx) {  //两个儿子都包含当前节点的最大值
    PsCm(x->L, x->Tagpls, x->Tagmax, x->HisMxTagpls, x->HisMxTagmax, l,
         m);  //左儿最大值的两种标记传当前节点最大值对应的标记
    PsCm(x->R, x->Tagpls, x->Tagmax, x->HisMxTagpls, x->HisMxTagmax, m1,
         r);  //右儿最大值的两种标记传当前节点最大值对应的标记
    x->Tagpls = x->Tagmax = x->HisMxTagpls = x->HisMxTagmax =
        0;  //清空标记(标记对自己的影响在父亲传下来的时候已经作用完了)
    return;
  }
  if (x->L->Mx > x->R->Mx) {  //最大值在左儿
    PsCm(x->L, x->Tagpls, x->Tagmax, x->HisMxTagpls, x->HisMxTagmax, l,
         m);  //左儿最大值的两种标记传当前节点最大值对应的标记
    PsCm(x->R, x->Tagpls, x->Tagpls, x->HisMxTagpls, x->HisMxTagpls, m1,
         r);  //右儿最大值的两种标记传当前节点非最大值对应的标记
    x->Tagpls = x->Tagmax = x->HisMxTagpls = x->HisMxTagmax =
        0;  //清空标记(标记对自己的影响在父亲传下来的时候已经作用完了)
    return;
  }  //最大值在右儿
  PsCm(x->L, x->Tagpls, x->Tagpls, x->HisMxTagpls, x->HisMxTagpls, l,
       m);  //左儿最大值的标记传当前节点非最大值对应的标记
  PsCm(x->R, x->Tagpls, x->Tagmax, x->HisMxTagpls, x->HisMxTagmax, m1,
       r);  //右儿最大值的两种标记传当前节点最大值对应的标记
  x->Tagpls = x->Tagmax = x->HisMxTagpls = x->HisMxTagmax =
      0;  //清空标记(标记对自己的影响在父亲传下来的时候已经作用完了)
  return;
}

区间加减 Addnm

通过 PsCm 打标记, 使其操作和一般和一般线段树无异.

由于区间加减的操作对每个单点雨露均沾 (有点 "各向同性" 的味道), 所以四个标记都打一样的值就可以.

void Addnm(Node *x, unsigned int l, const unsigned int &r) {
  if (l >= B && r <= C) {  //全包
    PsCm(x, D, D, D, D, l,
         r);  //给当前节点打上修改标记(区间加减修改对最大值和非最大值各向同性)
    return;
  }
  unsigned int m((l + r) >> 1);
  PsDw(x, l, r, m);  //下传标记以递归左右儿
  if (m >= B) {      //与左有交
    Addnm(x->L, l, m);
  }
  if (m < C) {  //与右有交
    Addnm(x->R, m + 1, r);
  }
  Udt(x, l, r, m);  //最后将修改结更新当前
  return;
}

区间抹平 Tomin

这是唯一一个递归规则不同, 把复杂度提高了一个 \(log\) 的函数, 因为就算节点区间被操作区间完全包含, 当次大值受波及时, 也要递归其两个子树.

void Tomin(Node *x, unsigned int l, const unsigned int &r) {
  if (l >= B && r <= C) {  //全包
    if (x->Mx <= D) {      //都不受影响
      return;
    }
    if (x->SecMx < D) {  //节点完全被包含并且仅最大值受影响
      PsCm(x, 0, D - x->Mx, 0, D - x->Mx, l, r);
      return;
    }
  }                              //除此以外都要递归两个子节点
  unsigned int m((l + r) >> 1);  //递归子节点
  PsDw(x, l, r, m);
  if (m >= B) {
    Tomin(x->L, l, m);
  }
  if (m < C) {
    Tomin(x->R, m + 1, r);
  }
  Udt(x, l, r, m);
  return;
}

区间求和 QrSum

在一层层调用中, 当前节点的各种值已经被传下来的标记更新至最新, 无需再修改, 所以只要被操作区间包含就直接统计, 并且递归完后不再调用 Udt, 因为查询操作都不会改变已经成为最新状态的节点的值, 所以其他查询操作都不需要 Udt.

void QrSum(Node *x, unsigned int l, const unsigned int &r) {
  if (l >= B && r <= C) {  //节点被包含
    Lst += x->Sum;
    return;
  }
  unsigned int m((l + r) >> 1);
  PsDw(x, l, r, m);  //下传标记以递归调用其子树
  if (m >= B) {      //左边
    QrSum(x->L, l, m);
  }
  if (m < C) {  //右边
    QrSum(x->R, m + 1, r);
  }
  return;
}

区间最值 QMaxA

上一个函数改几下就出来了, 答案合并从求和变成取最值, 其余结构一样.

void QMaxA(Node *x, unsigned int l, const unsigned int &r) {
  if (l >= B && r <= C) {  //全包
    Lst = max((long long)x->Mx, Lst);
    return;
  }
  unsigned int m((l + r) >> 1);
  PsDw(x, l, r, m);
  if (m >= B) {
    QMaxA(x->L, l, m);
  }
  if (m < C) {
    QMaxA(x->R, m + 1, r);
  }
  return;
}

区间历史最值 QMaxB

由于这个函数和上一个函数改变就更小了, 基本上就改了几个变量名, 就不加注释了.

void QMaxB(Node *x, unsigned int l, const unsigned int &r) {
  if (l >= B && r <= C) {
    Lst = max(Lst, (long long)x->HisMx);
    return;
  }
  unsigned int m((l + r) >> 1);
  PsDw(x, l, r, m);
  if (m >= B) {
    QMaxB(x->L, l, m);
  }
  if (m < C) {
    QMaxB(x->R, m + 1, r);
  }
  return;
}

主函数 main

别人都说我码风奇特, 但是这个主函数还是比较中规中矩的吧.

int main() {
  n = RD();
  M = RD();
  for (register unsigned int i(1); i <= n; ++i) {
    a[i] = RD();
  }
  memset(N, 0, sizeof(N));  //各种输入 + 初始化
  Bld(N, 1, n);             //建树, 以N[0]为根, 表示区间 [1,n]
  for (register int i(1); i <= M; ++i) {  //操作
    A = RD();
    B = RD();
    C = RD();
    switch (A) {
      case 1: {
        D = RD();
        Addnm(N, 1, n);  //区间加减
        break;
      }
      case 2: {
        D = RD();
        Tomin(N, 1, n);  //区间抹平
        break;
      }
      case 3: {
        Lst = 0;
        QrSum(N, 1, n);  //区间求和
        printf("%lld\n", Lst);
        break;
      }
      case 4: {
        Lst = -0x3f3f3f3f3f3f3f3f;
        QMaxA(N, 1, n);  //区间最值
        printf("%lld\n", Lst);
        break;
      }
      case 5: {
        Lst = -0x3f3f3f3f3f3f3f3f;
        QMaxB(N, 1, n);  //区间历史最值
        printf("%lld\n", Lst);
        break;
      }
      default: {
        printf("FYSNB\n");
        break;
      }
    }
  }
  return 0;
}

总结

这份代码从期末之前就开始写, (2021.01.15 AC Luogu P5494), 放假后已经可以运行, 昨天从上午做到晚上11点, 到今天上午11点半AC已经过去了半个月.

由于一开始没头绪, 看了题解后只理解了前两个标记, 便写了一个两个标记的 \(10\) 分的版本, 并且发帖讨论.

然后理解了剩余两个 Tag 的意义, 自己证明了自己是错的. 开始重构, 然后经历了一晚上加一上午, 终于AC.

毫不夸张地说, 这道题是一道线段树劝退题. 当初学者写完线段树二, 开始做线段树三时, 他们看到 "此题前置知识: AC线段树一" 后欣喜若狂. 然后呢? 看了一会发现没思路, 看题解, 发现自己看题解后也一知半解, 有些意志不坚定的同学便放弃了, 还有些人比着葫芦画瓢, 把题解用自己的码风抄了一遍.

像我这种只认自己的死理, 不愿意放弃自己尚未被证伪的想法的人, 恐怕非常少. 这也是我 CSP2020 爆零的原因. 不愿放弃自己的 julian.cpp, 然后最后由于心态问题 julian3.in 遗憾离场. 这还是我在好多平台的昵称为 Wild_Donkey 的原因, 像一头倔驴, 朝着自己的方向走.

我不知道这种性格会对我造成什么或好或坏的后果, 但是如果说 OI 这三年半能给我留下最大的财富什么, 那一定是这种锲而不舍的精神和耐心.

推荐阅读