P5494 【模板】线段树分裂

题意

Luogu P5494

维护几个可重集, 支持从一个可重集分裂出 \([l,r]\) 的元素, 合并两个可重集, 在一个可重集中加入若干元素, 查询某可重集对应值域元素数和第 \(k\) 小数.

值域 \(n\) 满足 \(1 \leq n \leq 2 * 10^5\)

操作数 \(m\) 满足 \(1 \leq m \leq 2 * 10^5\)

权值线段树

小值域暗示建立以值域为区间的权值线段树, 而每个可重集对应的权值线段树又能很好地满足两种查询的需要.

尝试根据题意, 建立 \(m\) 个权值线段树, 根据操作要求进行线段树修改. 但是如果仅仅是这样, 一次分裂和合并操作就要浪费 \(O(nlogn)\) 的复杂度 (以分裂为例, 先枚举值域 \(O(n)\), 然后在原树上进行单点修改 \(O(logn)\), 并且记录原树上的值, 在目标树上进行单点修改 \(O(logn)\). 而合并也就相当于以一棵树为原树, 另一棵为目标树, 将原树的整个值域分裂到目标树上, 所以两种操作在这种做法本质相同).

这种做法的空间复杂度来到了 \(O(mn)\)

动态开点

一般这种暴力需要多个线段树的题, 正解一般都会用到动态开点. 不过和可持久化不同的是, 线段树分裂的内核不是共享, 而是真正意义上的继承.

将空间复杂度优化到 \(((n+ m)logn)\)

子树继承

但是动态开点不能优化时间复杂度, 于是就引出了这种操作.

首先这个名字是我起的, 因为找不到名词做小标题这比较形象地描述了线段树合并和分裂的过程.

有些认真阅读的人可能要说 "欸, 你不是之前说可持久化是继承吗, 怎么现在又说线段树分裂才是继承?"

这只是一个相对的说法, 说可持久化继承是因为这种思想和面向对象中的类的继承有异曲同工之处, 多棵树共享一棵子树, 无需多倍存储. 而线段树分裂所说的继承相对来说更像是现实生活中的继承的概念. 继承意味着上一代人的财富向下一代转移, 继承了以后, 上一代就没有了, 不然银行总不能给你复制一份吧.

这里花了许多笔墨讨论线段树分裂和可持久化的区别, 其实是为了找出其中的相似之处, 即两棵树同步遍历, 直接将子树给别的点.

同步遍历

同步遍历也是计算机学家沃茨基朔德定义的一个名词, 因为没有小标题我没有找到合适的名词方便的表示. 遍历线段树时, 参数中两个代表线段树中节点的指针, 分别位于两棵定义域相同的线段树的同一位置, 表示同一区间, 为了方便称呼, 给这种节点取名叫 同位节点 (这篇文章里第三次了).

在递归过程中, 一个节点往左子树走, 两个节点就都往左子树走. 保持函数所在的两个节点在各自的线段树表示的区间位置一致, 对这两个节点的信息进行操作. 这就是同步遍历.

时间复杂度

\(_{看不懂没关系, 知道复杂度没锅能过就行}\)

普通线段树上的操作在本题中复杂度不变, 该 \(O(logn)\) 还是 \(O(logn)\).

对于分裂, 相当于特殊的查询, 只是将查询结果挖出, 存入了新的线段树, 所以还是 \(O(logn)\).

合并就有些麻烦, 如果是不重复的区间, 那 \(O(logn)\) 也是可以保证的, 但是如果区间有重复, 那么就要在两棵树上遍历和这段重复区间有关的所有节点, 最坏情况是 \(O(n)\). 但是要想得到两棵重复区间节点数达到 \(O(n)\) 级别的线段树, 就要\(O(n)\) 次单点修改 (仔细想一想, 如果在空树上进行两次大区间修改是不行的, 因为单个线段树是动态开点的). 将一次合并的 \(O(n)\) 均摊到 \(O(n)\)\(O(logn)\) 的操作上, 单次操作还是 \(O(logn)\).

实现

存储

struct Node {
  Node *L, *R;
  unsigned long long Val;
} N[4000005], *Cntn(N), *Vrsn[200005];//和可持久化类似, Vrsn[]存每个线段树的根

建树

和一般线段树类似的操作, 是权值线段树.

void Bld(Node *x, unsigned int l, const unsigned int &r) {
  if (l == r) {  //边界
    x->Val = a[l];
    return;
  }
  unsigned int m((l + r) >> 1);
  Bld(x->L = ++Cntn, l, m);
  Bld(x->R = ++Cntn, m + 1, r);  //递归
  x->Val = 0;                    //统计区间元素总个数
  if (x->L) {
    x->Val += x->L->Val;
  }
  if (x->R) {
    x->Val += x->R->Val;
  }
  return;
}

分裂

同步遍历, 随时开点.

void Brkaw(Node *x /*To*/, Node *y /*From*/, unsigned int l, const unsigned int &r) {
  if (l == r) {
    return;
  }
  unsigned int m((l + r) >> 1);
  x->Val = 0;
  y->Val = 0;
  if (y->L) {
    if (m <= D && l >= C) {  //左边继承
      x->L = y->L;
      y->L = NULL;
    } else {
      if (!(m < C || l > D)) {  //左边递归
        Brkaw(x->L = ++Cntn, y->L, l, m);
      }
    }
  }
  if (y->R) {
    if (r <= D && m + 1 >= C) {  //右边继承
      x->R = y->R;
      y->R = NULL;
    } else {
      if (!(r < C || m + 1 > D)) {  //右边递归
        Brkaw(x->R = ++Cntn, y->R, m + 1, r);
      }
    }
  }
  if (y->L) {  //最后统计两个同位点的权值
    y->Val += y->L->Val;
  }
  if (x->L) {
    x->Val += x->L->Val;
  }
  if (y->R) {
    y->Val += y->R->Val;
  }
  if (x->R) {
    x->Val += x->R->Val;
  }
  return;
}

合并

合并的操作和分裂类似, 同步遍历并且在两棵树上进行区间加减. 虽然长得像是复杂度有锅, 但是一均摊就没了.

void Addto(Node *x /*To*/, Node *y /*From*/, unsigned int l, const unsigned int &r) {  // O(nlogn)
  x->Val += y->Val;
  if (l == r) {
    return;
  }
  unsigned int m = (l + r) >> 1;
  if (y->L) {
    if (x->L) {  //递归合并
      Addto(x->L, y->L, l, m);
    } else {  //直接继承
      x->L = y->L;
    }
  }
  if (y->R) {
    if (x->R) {  //递归合并
      Addto(x->R, y->R, m + 1, r);
    } else {  //直接继承
      x->R = y->R;
    }
  }
  return;
}

单点修改

和一般线段树类似, 就是单点修改.

void Chnge(Node *x, unsigned int l, const unsigned int &r) {
  x->Val += C;  //自己的子集增加 C 个元素, 先给自己加上 C
  if (l == r) {
    return;
  }
  unsigned int m((l + r) >> 1);
  if (D <= m && D >= l) {  //在左
    if (!(x->L)) {
      x->L = ++Cntn;  //开点
    }
    return Chnge(x->L, l, m);
  }
  if (D <= r && D >= m + 1) {  //在右
    if (!(x->R)) {
      x->R = ++Cntn;  //开点
    }
    return Chnge(x->R, m + 1, r);
  }
  return;
}

查询数字数

和普通线段树无异, 区间查询即可.

void Qrynm(Node *x, unsigned int l, const unsigned int &r) {
  if (l >= C && r <= D) {  //全包
    Lst += x->Val;
    return;
  }
  unsigned int m((l + r) >> 1);
  if (x->L) {
    if (!(m < C || l > D)) {  //波及左边
      Qrynm(x->L, l, m);
    }
  }
  if (x->R) {
    if (!(r < C || m + 1 > D)) {  //波及右边
      Qrynm(x->R, m + 1, r);
    }
  }
  return;
}

按排名索引

这个操作也比较常见, 在普通平衡树也有类似操作, 用数据结构实现的二分查找.

void Qryrk(Node *x, unsigned int l, const unsigned int &r) {
  if (l == r) {  //边界
    Lst = l;
    return;
  }
  unsigned int m((l + r) >> 1);
  if (x->L) {              //左边有元素
    if (x->L->Val >= C) {  //查询到在左边
      return Qryrk(x->L, l, m);
    }
    C -= x->L->Val;
  }
  if (x->R) {              //左边无
    if (x->R->Val >= C) {  //在右边
      return Qryrk(x->R, m + 1, r);
    }
    Lst = -1;  //没查到
    return;
  }
  Lst = -1;  //没右子树, 没查到
  return;
}

main()

头文件, 快读 RD() 等略.

int main() {
  n = RD();
  M = RD();
  for (register unsigned int i(1); i <= n; ++i) {
    a[i] = RD();
  }
  memset(N, 0, sizeof(N));
  Bld(Vrsn[1] = ++Cntn, 1, n);  //建树
  for (register int i(1); i <= M; ++i) {
    A = RD();
    B = RD();
    C = RD();  //用全局变量存修改参数, 防止多次被调用时时间空间浪费
    switch (A) {
      case 0: {  //分裂
        D = RD();
        Brkaw(Vrsn[++Cnta] = ++Cntn, Vrsn[B], 1, n);
        break;
      }
      case 1: {  //合并
        Addto(Vrsn[B], Vrsn[C], 1, n);
        break;
      }
      case 2: {  //修改
        D = RD();
        Chnge(Vrsn[B], 1, n);
        break;
      }
      case 3: {  //查询数字数
        D = RD();
        Lst = 0;
        Qrynm(Vrsn[B], 1, n);
        printf("%llu\n", Lst);
        break;
      }
      case 4: {  //按排名索引
        if (Vrsn[B]->Val < C) {
          printf("-1\n");
          break;
        }
        Lst = 0;
        Qryrk(Vrsn[B], 1, n);
        printf("%llu\n", Lst);
        break;
      }
      default: {  //不会出现的其他情况(除非数据锅)
        printf("FYSNB\n");
        break;
      }
    }
  }
  return 0;
}
posted @ 2021-01-30 08:40  Wild_Donkey  阅读(111)  评论(0编辑  收藏  举报