随笔 - 23  文章 - 0  评论 - 3  阅读 - 3855

伸展树(上)

  1. 参考:

    1. 大佬1的理解:https://zhuanlan.zhihu.com/p/376621778

    2. 算法训练营5.2(没有将值相同的节点合并,但是代码易懂)

  2. 简介:

    1. 名称:伸展树(分裂树)

    2. 本质:一种二叉搜索树,可以在O(logn)的时间内完成插入、查找和删除操作,是基于数据的时间局部性和空间局部性原理产生的。

    3. 一些abstract:

      1. 时间局部性和空间局部性的原理:

        时间局部性:刚刚被访问的元素,极有可能在不久后被再次访问

        空间局部性:刚刚被访问的元素,它的相邻节点也很有可能被访问

        因为局部性原理,伸展树在每次操作后都将刚被访问的节点旋转至树根,用来加速后续的操作。

      2. 和别的树的区别:平衡二叉树(AVL树)通过动态调整平衡,使树的高度保持在O(logn),所以单词搜索的时间复杂度为O(logn),但是为了严格保持平衡,在调整时会做过多旋转,影响了插入和删除的性能。

        而伸展树的实现更为简捷,无须时刻保持全树平衡,任意节点的左右子树高无限制。伸展树的单次搜索也可能需要n次操作,但是均摊之后复杂度也为O(logn)的,也就是说,连续m次操作的复杂度可以保持在O(mlogn)。其优势在于不用记录平衡因子、树高、子树size等额外信息,效率更高(尤其是对于连续的搜索操作),适用范围更广。

      3. 核心是不断将节点旋转到根节点,使得树不会退化成链表。每个节点至少保存以下信息:

        1. parent:父节点的指针

        1. child[0/1]:左右儿子指针

        1. value:这个节点存的值

        1. count:出现了多少个值相同的点

        1. size:子树中有多少个节点,算自己

  3. 节点类:

     struct Node {
       int fa;  // 父亲的id,就是数组的下标值
       int child[2];  // 两个儿子的id
       int val, count, size;
     }node[maxn];
     int cnt;
     
     // 当没有node的val为val时,创建一个节点
     // 不更新size的原因是:后面插入的时候已经将路径中点的size都加了1,所以root的size已经加过了
     void createNode(int root, int val) {
       node[++cnt].val val;
       node[cnt].fa root;
       node[root].child[node[root].val val] cnt;
       node[cnt].child[0] node[cnt].child[1] 0;
       node[cnt].count node[cnt].size 1;
     }
  4. 操作:

    1. 旋转:包括右旋和左旋

      Splay(x, goal)是在保持伸展树有序性的前提下,通过一系列旋转将伸展树的元素x调整到goal的子节点,若goal=0,则是调整到根部。

      具体旋转策略和Treap一样,这里略过,可以看Treap篇。

      板子:这里右旋左旋写在了一起,比较巧妙,因为左旋还是右旋取决于x到底是父亲的哪个儿子,所以可以根据这个父子关系知道是该左旋还是右旋,因为旋转的对称性,所以肯定可以写成一个函数。

       void Rotate(int x) {  // x旋转上去
         int node[x].fa, node[y].fa;
         int = (node[y].child[0]==x);  // 判断是不是左儿子,c=1左儿子,c=0是右儿子
         node[y].child[!c] node[x].child[c];
         node[node[x].child[c]].fa y;
         node[x].fa z;
         if (z) {  // y不是原来的树根,就需要改变z的儿子,变为x
           node[z].child[node[z].child[1]==y]=x;
        }
         node[x].son[c] y;
         node[y].fa x;
       }
    2. 伸展:旋转是其基本操作。伸展操作分为逐层伸展和双层伸展。

      1. 逐层伸展:

        将x旋转到目标goal之下,若x的父节点不是goal,就一直往上旋转。如果goal为0,则旋转到根的位置。

        板子:

         void Splay(int x, int goal) {
           while(node[x].fa != goal) {
             Rotate(x);
          }
           if (!goal) {
             root x;  // x到根了 根更新为x
          }
         }

        但是这样有可能每次调用在最坏情况下都是O(n)的,一种方法是使用双层伸展,即每次都向上两层。

      2. 双层伸展:分为三种情况:

        1. Zig/Zag:当x的父亲y已经是根的时候,只需要旋转一次即可,根据x和y的父子关系进行旋转。

        2. Zig-Zig / Zag-Zag:x和x的父亲y,以及y和y的父亲z的父子关系相同时,显然两次旋转方向相同时。

        3. Zig-Zag / Zag-Zig:x和x的父亲y,以及y和y的父亲z的父子关系相同时,显然两次旋转方向不同时。

        情况1和情况3的情况和逐层伸展完全一致。对于情况2,逐层伸展两次都是x在旋转,而双层伸展时先进行x的父亲y的旋转,再进行x的旋转。如下图所示:

         

        可以发现,双层伸展比逐层伸展得到的树高度更小,又基本操作的时间复杂度和树高成正比,所以效率更高。所以代码的思路为:对于情况1,旋转x;对于情况2,旋转y再旋转x;对于情况3,旋转两次x。

        板子:

         void Splay(int x, int goal) {
           while(node[x].fa != goal) {
             int node[x].fa, node[y].fa;
             if (!= goal) {  // 情况 2 & 3
              ((node[z].child[0]==y)^(node[y].child[0]==x)) Rotate(x):Rotate(y);  // 为真则父子关系不同,为情况3,两次都是旋转x;不然情况2需要旋转y
            }
             Rotate(x);  // 无论哪种情况,最后都要转一下x
          }
           if (!goal) {
             root x;  // x到根了 根更新为x
          }
         }

        Tarjan等人已经证明,双层伸展均摊时间为O(logn)。

      3. 查找:根据val查找节点,和二叉搜索树的查找一样,如果查找成功,则将val的点旋转到根。

        板子:

         bool findval(int val) {
           int root;
           while(true) {
             if (node[x].val == val) {  // 找到
               Splay(x, 0);  // 将x旋转到根
               return true;
            }
             if (node[x].child[node[x].val val]) {  // 根据x的值看往哪边找,如果还有点,则还可以继续找
               node[x].child[node[x].val val];
            } else {
               return false;
            }
          }
         }
      4. 插入:和二叉搜索树的插入一样,插入之后旋转到根节点。

        板子:

         void Insert(int val) {
           int x, nxt;
           for(root; node[x].child[node[x].val val]; node[x].child[node[x].val val]) {  // 不然非小即大
             node[x].size++;
             if (node[x].val == val) {  // 相等特判
               node[x].count++;
               return;
            }
          }
           node[x].child[node[x].val val] createNode(x, val);  // 以x为根创建一个值为val的新节点
           Splay(node[x].child[node[x].val val], 0);
         }
      5. 分裂:

        以val为界,将伸展树分裂为两棵小伸展树,即val节点的左右子树。即先找到val节点,调整为根节点,得到左右子树,删除根节点,分裂为两棵伸展树。

        板子:

         bool Split(int val, int &t1, int &t2) {
           if (findval(val)) {
             // find如果找到了会Splay,之后根节点就已经改过了
             t1 node[root].child[0];
             t2 node[root].child[1];
             node[t1].fa node[t2].fa 0;
             return true;
          }
           return false;
         }
      6. 合并:

        将两棵伸展树t1和t2合并为一个伸展树,必须保证t1的元素最大值小于t2的元素最小值。首先找到t1中的最大元素x,这个操作同时将x调整到伸展树的根,因为x是t1中最大的,所以显然没有右子树,然后将t2作为x的右子树,就得到了合并的伸展树root。

        板子:

         void Findmax(int t) {
           while (t) {  // 一直往右下找,直到没有点,前一个点就是最大节点
             pre t;
             node[t].child[1];
          }
           Splay(pre, 0);  // Splay后root已经为pre了
         }
         
         void Join(int t1, int t2) {
           if (t1) {  // 特判是不是空
             Findmax(t1);
             node[root].child[1] t2;  // 新的根节点的右儿子
             node[t2].fa root;  // t2认爹
          }
           else {
             root t2;
          }
         }
      7. 删除:如果count只为1,则将元素val从伸展树中删除,首先在伸展树中查找val,然后分裂为两棵子树,再合并。不然数量少一即可。

         void Delete(int val, int type) {  // type为0则数量少1,type为1则完全删掉所有值为这个值
           int t1 0, t2 0;
           if (findval(val)) {
             if (node[root].count==|| type == 1) {
               Split(val, t1, t2);
               Join(t1, t2);
            } else {
               node[root].count--;
            }
          }
         }
      8. 前驱&后继:

        1. 前驱板子:

           int GetPre(int val) {  // 找val在这棵树中的前驱
             int root;
             int res -inf;
             while(p) {
               if (node[p].val val) {
                 res node[p].val;
                 node[p].child[1];
              } else {
                 node[p].child[0];
              }
            }
             return res;
           }
        2. 后继板子:

           int GetNxt(int val) {  // 找val在这棵树中的后继
             int root;
             int res inf;
             while(p) {
               if (node[p].val val) {
                 res node[p].val;
                 node[p].child[0];
              } else {
                 node[p].child[1];
              }
            }
             return res;
           }
      9. 查找x子树中第k小元素:

        板子:

         int Findk(int x, int k) {
           if (node[x].size || k<=0) return inf;
           while(1) {
             int down node[x].child[0]?node[node[x].child[0]].size:0;
             int up node[x].child[0]?node[node[x].child[0]].size node[x].count:node[x].count;
             if (down && <= up) {
               return x;
            }
             if (<= down) {
               node[x].child[0];
            } else {
               -= up;
               node[x].child[1];
            }
          }
         }
      10. 其余操作等下次再说~

posted on   小染子  阅读(199)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 分享一个免费、快速、无限量使用的满血 DeepSeek R1 模型,支持深度思考和联网搜索!
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· ollama系列1:轻松3步本地部署deepseek,普通电脑可用
· 按钮权限的设计及实现
· 25岁的心里话
< 2025年3月 >
23 24 25 26 27 28 1
2 3 4 5 6 7 8
9 10 11 12 13 14 15
16 17 18 19 20 21 22
23 24 25 26 27 28 29
30 31 1 2 3 4 5

点击右上角即可分享
微信分享提示