【复习笔记】Link-Cut Trees 小记

忘光啦 /dk /dk
本来 rotate 都会打错的我现在终于会打啦 于是 splay 打错了

基础要点

实链剖分

  • 重链剖分是我们很熟悉的一个结构,它的优秀之处在于可以将树上问题更加方便地转化为序列问题(虽然也不全是)。然而注意到重剖只适用于静态树,如果将树结构动态化那么重剖就不太行了。
  • 于是引入一个更加灵活的链剖结构:实链剖分。它不依据大小,也不按照深度,虚实边是人为规定的。
  • 不过我们仍然需要一个数据结构来维护实链。为了适应实链剖分的灵活,我们选择 Splay 作为辅助树。

辅助树

  • 由于下面辅助树可以表示出 唯一 的原树,所以我们一般只维护这些辅助树。
  • 对于一条实链,显然实链上的点深度连续,因此每棵 Splay 的 中序遍历 的顶点序列深度是递增且连续的。
  • 虚边在辅助树中 认父不认子。具体的,在每棵 Splay 的根结点,其父指针(fa)指向 当前所在链顶在原树上的父亲

基础操作

( ext{access})

  • access(x) 表示 打通 一条 (x) 到根的一条实链,实链以 (x) 为底,以根为顶。
  • 至关重要的操作之一,基本上是所有操作的基础。
  • 实现不复杂,一步步往上拉,具体分 4 步:splay(转到根);换儿子(换实边);pushup(换了儿子要及时更新);跳 fa
  • inline void access(int x) {
      for (int p = 0; x; x = fa[p = x])
        splay(x), ch[x][1] = p, pushup(x);
    }
    

( ext{makeRoot})

  • 若需要求出一条路径的信息,那么需要两点位于同一条实链上。然而这两个点 可能并没有祖孙关系,导致无法在同一个 Splay 中。
  • makeRoot(x) 表示将 (x) 换为原树的根。
  • 实现也很简单:首先 access(x),这时发现这条实链和预期相比恰好是反的,于是 splay 上来后就打翻转 Tag 就行了:
  • inline void makeRoot(int x) {
      access(x), splay(x), setRev(x);
    }
    

( ext{split})

  • split(x,y) 表示隔离出一条以 (x, y) 为端点的实链。
  • 实现依靠前两个操作:makeRoot(x), access(y), splay(y);。操作之后 (y) 就包含了链上的信息。

( ext{link})

  • link(x, y) 表示加入一条 (x, y) 之间的边。
  • 比较稳妥的写法:先 makeRoot(x), makeRoot(y),接下来如果 fa[x] 是 0 那么 (x, y) 不连通,之间 fa[x] = y 即可。

( ext{cut})

  • cut(x, y) 表示切断连接 (x, y) 的边。
  • 比较稳妥的写法:先 split(x,y),如果满足 ch[y][0] == x && !ch[x][1](说明 (x, y) 联通且 (x,y) 路径上不包含其他点)则表明 (x,y) 由一条边直接相连,那么 将这条实边直接断掉 即可:fa[x] = ch[y][0] = 0, pushup(y);
  • 如果维护了 Splay 上的子树大小也可以根据 split(x,y) 出来的 Splay 树大小判断,如果大小为 2 说明直接相连。

其他次要操作

  • 要找一个点所在原树的根,需要先 access(x), splay(x)根据深度递增的性质 易知原树的根必然就是以 (x) 为根的 Splay 的最左侧,找到后记得 splay 这个点保证复杂度。这个操作习惯称为 findRoot(x)
  • 判断两点连通性:可以 makeRoot(x) 之后用上面的方法判断 findRoot(y) 是否等于 (x) 即可。或者用 link 中的方法也行。不过对常数有要求时可以考虑并查集(如果可以)。
  • 修改时需要 先 splay 上来,保证不会漏更新上面的点。

注意点

  • 为了 保证复杂度,理论上每次在 Splay 中向下遍历的行为都要 splay 上来找到的点。一般而言,并不推荐在 access 之外的地方使用循环遍历 Splay 树结构的行为。当然有为了优化复杂度等目的使用高明方法的例外(动态加边重心),具体问题具体分析。

  • 小心操作的副作用!如上述的 findRoot 需要为了保证复杂度但却 改动了 Splay 的结构,这是一个很危险的信号。比如 link 如果直接使用 findRoot 判连通性的话,此时 (y) 不能保证还是 Splay 的根,如果不干其他补救措施(加一个 splay(y) 之类的)坐以待毙的话,在需要维护虚子树的信息时就会 遗漏更新

  • rotatesplay 的写法略与朴素 Splay 有别,这里直接贴:

    #define ident(x) (x == ch[fa[x]][1])
    #define isRoot(x) (x != ch[fa[x]][0] && x != ch[fa[x]][1])
    inline void rotate(int x) {
      int y = fa[x], z = fa[y], k = ident(x);
      if (!isRoot(y)) ch[z][y == ch[z][1]] = x;
      ch[y][k] = ch[x][k ^ 1], fa[ch[x][k ^ 1]] = y;
      ch[x][k ^ 1] = y, fa[fa[y] = x] = z;
      pushup(y), pushup(x);
    }
    void flush(int x) {
      if (!isRoot(x)) flush(fa[x]);
      pushdown(x);
    }
    inline void splay(int x) {
      flush(x);
      for (int y = fa[x]; y = fa[x], !isRoot(x); rotate(x))
        if (!isRoot(y)) rotate(ident(y) == ident(x) ? y : x);
    }
    
  • 如果题目有强烈法方向性要求(比如 LCA 特判、有根树等等),那么可以考虑 有根树写法的 LCT(上面是无根树写法)。具体的,没有换根 & 翻转标记;link 直接连虚边;cut 直接断重边。少去了很多花里胡哨的操作。但是注意,写法简洁、常数减小的同时也更需要斟酌其正确性。

常用技巧

边权转点权

  • 如果不问点权而是边权,怎么维护?
  • 很简单,把边 成一个点和两条边然后照做就行。注意防止 点对应的结点影响信息,于是可以初始化为无用值或 pushup 特判等手段规避。
  • 注意现在 LCT 中点数为 (n+m)
  • 例题:SPOJ QTREE

动态加边 MST

  • 需要基于上面的技巧,维护边权最大值及其 位置(假设求最小生成树)。
  • 现在来了一条边 ((x,y)) 权值为 (w)。先 split 提取出 (x,y) 的路径,然后求出路径上所有边的最大权值 (w^prime)。如果 (w<w^prime) 说明这条 新边加上会更优,于是果断将原来那个给 cut 了,link 上新边。最后得到的树就是答案。
  • 例题:Luogu P4234 最小差值生成树

动态加边维护桥

  • 给边赋权:(1) 表示桥,(0) 表示不是桥。那么需要维护边权和。
  • 如果一条边连接两个原本不连通的点,那么 link 上一条权值为 1 的边。
  • 反之则会出现一个环,而环上 不应存在任何一个桥。那么不用 link 原本连接 (x,y) 的路径上的所有边都赋值为 0。
  • 查询两点间桥的个数,直接就是路径求和。
  • 当然也有 FlashHu 那样的 显式缩点,常数小但容易写错。
  • 例题:Luogu P2542 [AHOI2005] 航线规划

维护子树信息

  • 遗憾的是 LCT 对于子树就是短板了……但补救手段也不是没有。

  • 常见的处理方法额外维护 虚子树 信息,然后在原来的写法上略加改动:

    inline void pushup(int x) {
      siz[x] = siz[ch[x][0]] + siz[ch[x][1]] + isiz[x] + 1;//此时的 siz 包含的虚子树的大小
    }
    inline void access(int x) {
      for (int p = 0; x; x = fa[p = x]) {
        splay(x);
        isiz[x] += siz[ch[x][1]];
        isiz[x] -= siz[ch[x][1] = p]; // 及时切换贡献
        pushup(x);
      }
    }
    
  • 注意到上面写法维护的信息需要满足可减性。如果不满足(比如子树最值)那么可以使用些数据结构辅助维护。

  • 例题:Luogu P4219 [BJOI2014] 大融合

动态加边维护重心

Solution 1

  • 考虑启发式合并的思路:每次合并两棵的树,将小树拆成单点一个个连到大树上。由于一个点之后被合并 (O(log n)) 次,所以复杂度为 (O(nlog^2 n))
  • 那么怎么动态维护重心?考虑到一个性质:一棵树加一个点,重心 最大移动一个距离。于是每次加点判断一下暴力挪动即可。显然挪动次数不超过加点次数,复杂度是对的。

Solution 2

  • 充分利用重心的性质:两棵树合并,新重心必然在 原来两个重心在新树上的路径上

  • 那么在 link 之后先将这个连接重心的路径 split 出来,然后做 平衡树上二分

  • 具体的,需要维护好 子树大小,然后维护两个字段:lsum 表示当前搜索区间外左侧的大小,同理定义 rsum

  • 对于当前点 (x),如果说 lsum 加上左子树 siz 不超过一半,右边也是,那么当前点必然可以作为一个重心。

  • 然而我们似乎遗漏了当前点的 isiz,这部分会不会很大导致错误?回顾上面的性质,由于 isiz 必然不在这条重链 上,于是不必考虑。

  • 找到之后记得及时 splay 保证复杂度,总复杂度 (O(nlog n))

    // uset : 维护连通块重心的并查集
    inline void update(int u, int v) {
      split(u = find(u), v = find(v));
      int x(v), lsum(0), rsum(0), tot(siz[x]), ret(N);
      while (x) {
        pushdown(x);
        int lcur(lsum + siz[ch[x][0]]), rcur(rsum + siz[ch[x][1]]);
        if (lcur * 2 <= tot && rcur * 2 <= tot) {
          if (tot & 1) { ret = x; break; }
          else { ret = std::min(ret, x); }
        }
        if (lcur < rcur) lsum = lcur + isiz[x] + 1, x = ch[x][1];
        else rsum = rcur + isiz[x] + 1, x = ch[x][0];
      }
      splay(ret);
      uset[u] = uset[v] = uset[ret] = ret;
    }
    
  • 例题:Luogu P4299 首都

动态 dp

  • 大致思路和重链剖分相似,适用维护子树信息的技巧维护 (f,g) 两个矩阵即可。
  • 但要注意(广义)矩阵乘法 没有交换律,注意 pushup 时的顺序。
  • access 时需要撤销原来虚子树的影响,直接根据矩阵的 实际意义 做即可。注意判空。
  • 一般来说复杂度为 (O(nlog n)) 乘上矩乘的复杂度,略优于树剖。
  • 例题:Luogu P5024 保卫王国

维护同色连通块

  • 有些题目要求维护点的颜色(一般种类不会很多)及其相关信息(比如同色连通块大小)。
  • 一个简单的想法是,对于每条边,只有在两端同色时被连上。然而被 菊花图 卡爆。
  • 考虑对原树定一个根,只连父边,然后 开颜色种类个 LCT。对于每个 LCT 中的一个点,仅在其当前颜色的 LCT 中连父边。
  • 然后查询时,要先砍掉当前连通块的根。因为上面只连父边导致会有一个 虚假的点(并不是这个颜色)出现在连通块中,而这个点可能 联立了其他连通块,于是不能仅仅将大小减一。实际上也不是真正要执行 cut 操作,注意到 access(x) 之后当前点已经在实链上了,于是我们先 findRoot(x) 找到这个根,然后这个根已经在 findRootsplay 上来了,于是直接找这个根的右儿子就是所求。
  • 例题:SPOJ QTREE6

杂题选做

【HNOI2010】弹飞绵羊

(n) 个弹簧排成一排,每个弹簧有一个弹力系数 (K_i),表示从 (i) 可以弹到 (i+K_i) 的位置。(Q) 次操作,每次修改一个 (K_i),或询问从 (i) 开始弹几次被弹飞(弹到 (>n) 的位置)。

  • 首先应该得认出这个 森林 的模型:对于每个点 (i),父节点为 (i+K_i),如果这个点之后被弹飞那么就是根。
  • 发现这题有比较强的 方向性,考虑有根树 LCT。
  • 修改就是换父亲,而查询就是这个点在原树中的 深度——也就是 access 之后得到的 Splay 的 siz
  • 当然也可以用一个虚点 (n+1) 作为 超级根 联立森林,这样就可以套有根 LCT 模板了。复杂度都是 (O(nlog n))

【CodeChef GERALD07】Chef and Graph Queries

给定一个 (n) 个点,(m) 条边的无向图,有 (Q) 次询问,每次给定区间 ([l, r]),求只保留编号在 ([l,r]) 内的边,形成的图的连通块个数。

  • 一道神仙题(应该是 wtrl
  • 首先考虑按编号将边一条条加入,无非分两种情况:这条边联通了两个不同的连通块,那么答案减一;否则这条边与其他边构成了环,那么对答案没有贡献。
  • 那么如果知道了这个区间的边一个个加入,那些边构成了环就行。
  • 着重思考成环边:虽然构成一个环,不过如果这个环上有些 出现较早的边因为区间性质不复存在,那么环上出现缺口,这条边仍然产生贡献。实际上一个环一旦存在一个缺口就会断开两个连通块,于是不妨只考虑 出现最早的那条边。每次转化为求一条 (当前边尚未加入)上 编号最小的边
  • 那么考虑预处理一个 ( ext{pre}_i) 表示第 (i) 条边在前面的一条边 ( ext{pre}_i) 不存在是它会有贡献。用 LCT 维护 最大生成树
  • 最后询问可以看做是求区间 ([l,r]) 内满足 ( ext{pre}_i<l)(i) 的个数。二维数点,随便搞。(O(nlog n))

后记

原文地址:https://www.cnblogs.com/-Wallace-/p/14191391.html