数据结构:伸展树

一、核心定义

  • Splay树(伸展树)是一种自调整二叉搜索树(BST),无需额外存储平衡因子、颜色等信息,核心特性是「访问即调整」------每次对节点执行查询、插入、删除操作后,会通过「伸展操作(Splay)」将该节点移动到树根位置。
  • 资料:https://pan.quark.cn/s/43d906ddfa1bhttps://pan.quark.cn/s/90ad8fba8347https://pan.quark.cn/s/d9d72152d3cf

其设计理念是「局部性原理」:近期访问过的节点,未来大概率会被再次访问。通过伸展操作,将高频访问节点靠近树根,缩短后续访问路径,确保** amortized(均摊)时间复杂度为 O(log n)**(单次操作最坏 O(n),但长期平均高效)。

二、核心特性

  1. 继承BST特性:左子树所有节点值 < 根节点值,右子树所有节点值 > 根节点值,中序遍历为有序序列;
  2. 自调整机制:无固定平衡约束,依赖伸展操作动态调整树结构,高频访问节点自动靠近树根;
  3. 无额外空间开销:无需存储高度、颜色等辅助信息,仅需维护BST的父子关系;
  4. 均摊高效:查询、插入、删除的均摊时间复杂度为 O(log n),适合「访问局部性强」的场景(如缓存、数据压缩);
  5. 最坏情况不稳定:单次操作可能退化为 O(n)(如有序插入时树退化为链表),但多次操作后会自动调整。

三、核心操作:伸展(Splay)

伸展操作是Splay树的灵魂,目标是将指定节点 x 移动到树根,通过「旋转」实现,旋转规则根据 x 与其父节点(p)、祖父节点(g)的位置关系分为3种情况:

1. 旋转的3种场景(优先级:先处理祖父关系,再处理父子关系)

场景 条件(x 的位置) 旋转策略 核心目的
zig(单旋) x 是根节点的子节点(无祖父节点,或 g 是根) xp 执行一次旋转(左旋/右旋) x 直接提升为根
zig-zig(双旋) xp 的左子树,且 pg 的左子树(LL型);或 xp 的右子树,且 pg 的右子树(RR型) 1. 先对 pg 旋转; 2. 再对 xp 旋转 避免单旋导致树高增加,保持树的平衡性
zig-zag(双旋) xp 的右子树,且 pg 的左子树(LR型);或 xp 的左子树,且 pg 的右子树(RL型) 1. 先对 xp 旋转; 2. 再对 xg 旋转 快速将 x 提升两层,优化路径长度

2. 旋转示例(图文结合)

(1)zig-zig(LL型双旋)
复制代码
初始结构(x是p的左,p是g的左):
        g
       /
      p
     /
    x

旋转步骤:
1. 对 p 和 g 执行右旋转:
      p
     / \
    x   g
2. 对 x 和 p 执行右旋转:
    x
   / \
      p
       \
        g
结果:x 成为根节点,路径长度缩短
(2)zig-zag(LR型双旋)
复制代码
初始结构(x是p的右,p是g的左):
        g
       /
      p
       \
        x

旋转步骤:
1. 对 x 和 p 执行左旋转:
        g
       /
      x
     /
    p
2. 对 x 和 g 执行右旋转:
    x
   / \
  p   g
结果:x 成为根节点,结构更平衡
(3)zig(单旋)
复制代码
初始结构(x是p的左,p是根):
      p
     /
    x

旋转步骤:对 x 和 p 执行右旋转:
    x
     \
      p
结果:x 成为根节点

3. 伸展操作的完整流程

复制代码
函数 splay(x):
    while x 不是根节点:
        p = x 的父节点
        g = p 的父节点(可能为null)
        if g 是 null:
            zig(x)  # 无祖父,单旋
        elif (x 是 p 的左子树) and (p 是 g 的左子树):
            zig-zig(x)  # LL型双旋
        elif (x 是 p 的右子树) and (p 是 g 的右子树):
            zig-zig(x)  # RR型双旋(旋转方向相反)
        else:
            zig-zag(x)  # LR/RL型双旋
    return x  # x 成为新根

四、核心操作(查询、插入、删除)

所有操作的核心逻辑:先执行BST的常规操作,再对目标节点执行伸展操作,确保其成为树根。

1. 查询操作(find(val))

复制代码
流程:
1. 按BST规则查找值为 val 的节点 x(若不存在,找到最后访问的节点);
2. 对 x 执行伸展操作,将其移动到树根;
3. 返回 x(若 x.val == val 则存在,否则不存在)。

关键:即使查询失败,也会伸展最后访问的节点,优化后续访问路径。

2. 插入操作(insert(val))

复制代码
流程:
1. 按BST规则插入新节点 x(常规BST插入,新节点为叶子);
2. 对 x 执行伸展操作,将其移动到树根;
3. 返回新根。

关键:新节点插入后立即成为根,后续访问该节点时路径最短。

3. 删除操作(delete(val))

复制代码
流程:
1. 调用 find(val) 查找节点 x(若不存在,直接返回);
   - 此时 x 已被伸展为树根(若存在);
2. 若 x 无左子树:将 x 的右子树设为新根,断开 x 的连接;
3. 若 x 无右子树:将 x 的左子树设为新根,断开 x 的连接;
4. 若 x 有左右子树:
   a. 找到 x 左子树的最大值节点 y(中序前驱);
   b. 对 y 执行伸展操作,将其移动到 x 左子树的根(此时 y 无右子树);
   c. 将 x 的右子树设为 y 的右子树;
   d. 将 y 设为新根,断开 x 的连接;
5. 返回新根。

关键:删除根节点后,通过伸展左子树的最大值节点,保持树的BST特性和自调整能力。

五、时间复杂度分析

操作 最坏时间复杂度 均摊时间复杂度 说明
查询 O(n) O(log n) 单次有序查询可能退化为链表,多次后自调整
插入 O(n) O(log n) 有序插入时树暂时退化,后续插入会伸展
删除 O(n) O(log n) 依赖查询和伸展操作,均摊高效

均摊复杂度证明核心

Splay树的均摊复杂度基于「势能函数(Potential Function)」分析:定义势能为树中所有节点的「深度的对数和」,每次伸展操作会减少势能,而单次操作的总代价(实际代价+势能变化)被证明为 O(log n),因此长期均摊复杂度为 O(log n)。

六、Splay树 vs AVL树 vs 红黑树(面试高频对比)

对比维度 Splay树 AVL树 红黑树
平衡机制 自调整(访问即伸展) 严格平衡(平衡因子≤1) 近似平衡(黑高一致)
额外空间开销 无(仅BST结构) 有(高度字段) 有(颜色字段)
单次操作最坏情况 O(n) O(log n) O(log n)
均摊时间复杂度 O(log n) O(log n) O(log n)
旋转次数 多次(取决于节点位置) 插入最多2次,删除较多 插入最多2次,删除最多3次
适用场景 访问局部性强(缓存、压缩、编辑器) 查询密集型(数据库辅助索引) 插入删除频繁(集合、映射)
工业界应用 较少(特定场景) 较少 广泛(Java集合、Linux内核)

七、代码实现示例(Java:Splay树核心逻辑)

java 复制代码
// Splay树节点定义
class SplayNode {
    int val;
    SplayNode left, right, parent;

    public SplayNode(int val) {
        this.val = val;
        this.left = this.right = this.parent = null;
    }
}

// Splay树核心实现
class SplayTree {
    private SplayNode root;

    // 右旋转(x是p的左子树)
    private void rightRotate(SplayNode x) {
        SplayNode p = x.parent;
        SplayNode g = p.parent;

        // 1. x的右子树设为p的左子树
        p.left = x.right;
        if (x.right != null) {
            x.right.parent = p;
        }

        // 2. x的父节点设为g
        x.parent = g;
        if (g == null) {
            root = x;
        } else if (g.left == p) {
            g.left = x;
        } else {
            g.right = x;
        }

        // 3. p的父节点设为x,x的右子树设为p
        x.right = p;
        p.parent = x;
    }

    // 左旋转(x是p的右子树)
    private void leftRotate(SplayNode x) {
        SplayNode p = x.parent;
        SplayNode g = p.parent;

        // 1. x的左子树设为p的右子树
        p.right = x.left;
        if (x.left != null) {
            x.left.parent = p;
        }

        // 2. x的父节点设为g
        x.parent = g;
        if (g == null) {
            root = x;
        } else if (g.left == p) {
            g.left = x;
        } else {
            g.right = x;
        }

        // 3. p的父节点设为x,x的左子树设为p
        x.left = p;
        p.parent = x;
    }

    // 伸展操作:将x移动到根
    private void splay(SplayNode x) {
        while (x.parent != null) {
            SplayNode p = x.parent;
            SplayNode g = p.parent;

            if (g == null) {
                // zig:单旋
                if (p.left == x) {
                    rightRotate(x);
                } else {
                    leftRotate(x);
                }
            } else if (g.left == p && p.left == x) {
                // zig-zig(LL型):先旋p,再旋x
                rightRotate(p);
                rightRotate(x);
            } else if (g.right == p && p.right == x) {
                // zig-zig(RR型):先旋p,再旋x
                leftRotate(p);
                leftRotate(x);
            } else {
                // zig-zag(LR/RL型):先旋x,再旋x
                if (p.left == x) {
                    rightRotate(x);
                    leftRotate(x);
                } else {
                    leftRotate(x);
                    rightRotate(x);
                }
            }
        }
    }

    // 查询操作:查找val,找到后伸展到根
    public SplayNode find(int val) {
        SplayNode curr = root;
        SplayNode prev = root; // 记录最后访问的节点(用于查询失败时伸展)

        while (curr != null) {
            prev = curr;
            if (val < curr.val) {
                curr = curr.left;
            } else if (val > curr.val) {
                curr = curr.right;
            } else {
                splay(curr); // 找到,伸展到根
                return curr;
            }
        }

        // 查询失败,伸展最后访问的节点
        if (prev != null) {
            splay(prev);
        }
        return null;
    }

    // 插入操作:BST插入 + 伸展
    public void insert(int val) {
        SplayNode newNode = new SplayNode(val);
        if (root == null) {
            root = newNode;
            return;
        }

        SplayNode curr = root;
        SplayNode parent = null;

        // 1. BST插入
        while (curr != null) {
            parent = curr;
            if (val < curr.val) {
                if (curr.left == null) {
                    curr.left = newNode;
                    newNode.parent = parent;
                    break;
                }
                curr = curr.left;
            } else if (val > curr.val) {
                if (curr.right == null) {
                    curr.right = newNode;
                    newNode.parent = parent;
                    break;
                }
                curr = curr.right;
            } else {
                // 已存在,伸展后返回
                splay(curr);
                return;
            }
        }

        // 2. 伸展新节点到根
        splay(newNode);
    }

    // 删除操作:查找 + 伸展 + 删除根
    public void delete(int val) {
        SplayNode target = find(val);
        if (target == null || target.val != val) {
            return; // 不存在
        }

        // 此时target是根节点
        if (target.left == null) {
            // 左子树为空,右子树成为新根
            root = target.right;
            if (root != null) {
                root.parent = null;
            }
        } else if (target.right == null) {
            // 右子树为空,左子树成为新根
            root = target.left;
            if (root != null) {
                root.parent = null;
            }
        } else {
            // 左右子树都存在:找左子树最大值(中序前驱)
            SplayNode leftMax = target.left;
            while (leftMax.right != null) {
                leftMax = leftMax.right;
            }

            // 伸展leftMax到左子树的根(此时leftMax无右子树)
            splay(leftMax);

            // 将target的右子树设为leftMax的右子树
            leftMax.right = target.right;
            if (target.right != null) {
                target.right.parent = leftMax;
            }

            // leftMax成为新根
            root = leftMax;
            leftMax.parent = null;
        }

        // 断开target的连接
        target.left = target.right = target.parent = null;
    }

    // 中序遍历(验证有序性)
    public void inorder(SplayNode node) {
        if (node != null) {
            inorder(node.left);
            System.out.print(node.val + " ");
            inorder(node.right);
        }
    }

    // 获取根节点(供遍历调用)
    public SplayNode getRoot() {
        return root;
    }
}

// 测试代码
public class SplayTreeTest {
    public static void main(String[] args) {
        SplayTree splayTree = new SplayTree();
        int[] nums = {10, 20, 30, 15, 5, 25};
        for (int num : nums) {
            splayTree.insert(num);
        }

        System.out.println("插入后中序遍历(有序):");
        splayTree.inorder(splayTree.getRoot()); // 输出:5 10 15 20 25 30

        // 查询20(会伸展到根)
        splayTree.find(20);
        System.out.println("\n查询20后根节点:" + splayTree.getRoot().val); // 输出:20

        // 删除15
        splayTree.delete(15);
        System.out.println("删除15后中序遍历:");
        splayTree.inorder(splayTree.getRoot()); // 输出:5 10 20 25 30
    }
}

八、扩展知识

  1. Splay树的变种
    • 加权Splay树(Weighted Splay Tree):节点带权重,伸展时考虑权重,优化区间查询;
    • 拓扑Splay树(Top-down Splay Tree):非递归实现,避免栈溢出,更适合大规模数据;
  2. 与B树的对比:Splay树是二叉树,适合内存数据;B树是多路树,适合磁盘数据(减少IO);
  3. 实际应用案例
    • 早期的Linux内核虚拟内存管理;
    • 文本编辑器Vim的查找功能优化;
    • 缓存系统中热点数据的快速访问。
相关推荐
hweiyu0037 分钟前
数据结构:平衡二叉树
数据结构
Undergoer_TW37 分钟前
20251204_线程安全问题及STL数据结构的存储规则
数据结构·c++·哈希算法
9523641 分钟前
并查集 / LRUCache
数据结构·算法
potato_may9 小时前
链式二叉树 —— 用指针构建的树形世界
c语言·数据结构·算法·链表·二叉树
Mz12219 小时前
day07 和为 K 的子数组
数据结构
Albert Edison11 小时前
【项目设计】C++ 高并发内存池
数据结构·c++·单例模式·哈希算法·高并发
小许学java14 小时前
数据结构-模拟实现顺序表和链表
java·数据结构·链表·arraylist·linkedlist·顺序表模拟实现·链表的模拟实现
稚辉君.MCA_P8_Java15 小时前
Gemini永久会员 C++返回最长有效子串长度
开发语言·数据结构·c++·后端·算法
dragoooon3416 小时前
[优选算法专题十.哈希表 ——NO.55~57 两数之和、判定是否互为字符重排、存在重复元素]
数据结构·散列表