每日算法 — 使用java实现Splay树:自调整二叉搜索树的伸展操作与均摊分析

一、引言:为什么需要Splay树

二叉搜索树(BST)是计算机科学中最基础的数据结构之一,支持高效的查找、插入和删除操作。然而,普通BST有一个致命缺陷:当数据以有序或近似有序的方式插入时,树会严重失衡,退化成一条链,操作复杂度从理想的 $O(\log n)$ 恶化到 $O(n)$。

为了解决这个问题,研究者们提出了多种自平衡二叉搜索树,例如AVL树、红黑树和Treap。这些树通过严格的平衡条件或随机化策略,保证树高始终维持在 $O(\log n)$。但它们都需要在每个节点上维护额外的平衡信息(如高度、颜色、随机优先级),并且旋转操作相对复杂。

1985年,Daniel Sleator 和 Robert Tarjan 提出了一种全新的思路——Splay树(伸展树)。Splay树不强制维持任何显式的平衡条件,而是采用一种”自调整”策略:每次访问一个节点后,都通过一系列旋转操作将其移动到树的根部。这种看似简单的策略,竟能在均摊意义下保证所有操作的时间复杂度为 $O(\log n)$,而且实现比红黑树更为简洁。

本文将详细讲解Splay树的核心原理,并提供一份可直接运行的完整Java实现。

二、Splay树的核心思想

Splay树的灵魂在于 splay(伸展)操作。当我们访问树中的某个节点 $x$(无论是查找、插入还是删除),都会执行 splay 操作将 $x$ 旋转到根节点位置。

这种自调整策略带来了几个显著的好处:

  • 访问局部性:最近被访问的节点会靠近树根,如果某些节点被频繁访问,它们将长期处于树的上层,实际访问速度非常快。
  • 均摊效率:虽然单次操作最坏情况下可能是 $O(n)$,但任意连续 $m$ 次操作的均摊复杂度为 $O(m \log n)$。
  • 无需额外信息:节点不需要存储高度、颜色或优先级等平衡信息,内存开销更小。

三、旋转操作详解

Splay操作由一系列基本旋转组成。设待旋转的节点为 $x$,其父节点为 $p$,祖父节点为 $g$。根据 $x$、$p$、$g$ 的相对位置,旋转分为三种情况:

3.1 Zig(单旋)

当 $p$ 是根节点时,只需执行一次单旋:

  • 若 $x$ 是 $p$ 的左子节点,执行右旋
  • 若 $x$ 是 $p$ 的右子节点,执行左旋

这是splay操作的最后一步,执行后 $x$ 成为树根。

3.2 Zig-Zig(同向双旋)

当 $p$ 不是根,且 $x$ 和 $p$ 同为各自父节点的左子节点(或同为右子节点)时,执行两次同方向的旋转:

  • 先旋转 $p$(围绕 $g$);
  • 再旋转 $x$(围绕 $p$)。

注意:这里不能简单地连续两次单旋 $x$,而是要先旋转 $p$ 再旋转 $x$。这种”从祖父开始”的旋转方式,能更有效地降低树的高度。

3.3 Zig-Zag(异向双旋)

当 $x$ 和 $p$ 分别是各自父节点的不同方向子节点时(即 $x$ 是左子、$p$ 是右子,或反之),执行:

  • 先旋转 $x$(围绕 $p$);
  • 再旋转 $x$(围绕 $g$)。

这种情况下,$x$ 经过两次旋转后直接上升到祖父的位置。

四、完整Java实现

以下是一份完整的Splay树Java实现,包含插入、删除、查找、前驱、后继等常用操作,每个方法都配有详细注释。

import java.util.ArrayList;
import java.util.List;

/**
 * Splay树(伸展树)的Java实现
 * 
 * Splay树是一种自调整二叉搜索树,核心思想是:每次访问节点后,
 * 通过splay操作将该节点旋转到树根位置。均摊时间复杂度为O(log n)。
 */
public class SplayTree<T extends Comparable<T>> {

    /**
     * 树节点定义
     */
    private static class Node<T> {
        T key;           // 节点键值
        Node<T> left;    // 左子节点
        Node<T> right;   // 右子节点
        Node<T> parent;  // 父节点(可选,便于实现)

        Node(T key) {
            this.key = key;
        }
    }

    private Node<T> root;  // 树根
    private int size;      // 节点数量

    public SplayTree() {
        this.root = null;
        this.size = 0;
    }

    /**
     * 获取树中节点数量
     */
    public int size() {
        return size;
    }

    /**
     * 判断树是否为空
     */
    public boolean isEmpty() {
        return root == null;
    }

    /**
     * 左旋操作:将节点x向上旋转
     * 
     *   p               x
     *  / \             / \
     * a   x    =>    p   c
     *    / \        / \
     *   b   c      a   b
     */
    private void rotateLeft(Node<T> x) {
        Node<T> p = x.parent;
        Node<T> b = x.left;

        // x的左子节点变为p的右子节点
        p.right = b;
        if (b != null) b.parent = p;

        // p成为x的左子节点
        x.left = p;
        x.parent = p.parent;
        p.parent = x;

        // 更新p的原父节点对x的引用
        if (x.parent != null) {
            if (x.parent.left == p) {
                x.parent.left = x;
            } else {
                x.parent.right = x;
            }
        } else {
            root = x;
        }
    }

    /**
     * 右旋操作:将节点x向上旋转
     * 
     *     p             x
     *    / \           / \
     *   x   c   =>   a   p
     *  / \              / \
     * a   b            b   c
     */
    private void rotateRight(Node<T> x) {
        Node<T> p = x.parent;
        Node<T> b = x.right;

        // x的右子节点变为p的左子节点
        p.left = b;
        if (b != null) b.parent = p;

        // p成为x的右子节点
        x.right = p;
        x.parent = p.parent;
        p.parent = x;

        // 更新p的原父节点对x的引用
        if (x.parent != null) {
            if (x.parent.left == p) {
                x.parent.left = x;
            } else {
                x.parent.right = x;
            }
        } else {
            root = x;
        }
    }

    /**
     * Splay操作:将节点x旋转到树根
     * 这是Splay树的核心操作,决定了树的自调整特性
     */
    private void splay(Node<T> x) {
        while (x.parent != null) {
            Node<T> p = x.parent;
            Node<T> g = p.parent;

            if (g == null) {
                // Zig情况:p是根,只需一次单旋
                if (x == p.left) {
                    rotateRight(x);
                } else {
                    rotateLeft(x);
                }
            } else if (x == p.left && p == g.left) {
                // Zig-Zig情况(左左):先右旋p,再右旋x
                rotateRight(p);
                rotateRight(x);
            } else if (x == p.right && p == g.right) {
                // Zig-Zig情况(右右):先左旋p,再左旋x
                rotateLeft(p);
                rotateLeft(x);
            } else if (x == p.right && p == g.left) {
                // Zig-Zag情况(左右):先左旋x,再右旋x
                rotateLeft(x);
                rotateRight(x);
            } else {
                // Zig-Zag情况(右左):先右旋x,再左旋x
                rotateRight(x);
                rotateLeft(x);
            }
        }
    }

    /**
     * 查找键值为key的节点,并将其splay到根
     * 若不存在,则将最后访问的节点splay到根
     */
    public boolean contains(T key) {
        if (root == null) return false;

        Node<T> curr = root;
        Node<T> last = null;

        while (curr != null) {
            last = curr;
            int cmp = key.compareTo(curr.key);
            if (cmp == 0) {
                splay(curr);
                return true;
            } else if (cmp < 0) {
                curr = curr.left;
            } else {
                curr = curr.right;
            }
        }

        // 未找到,将最后访问的节点splay到根
        if (last != null) {
            splay(last);
        }
        return false;
    }

    /**
     * 插入一个新键值
     * 插入后将新节点splay到根
     */
    public void insert(T key) {
        if (root == null) {
            root = new Node<>(key);
            size++;
            return;
        }

        Node<T> curr = root;
        Node<T> parent = null;

        while (curr != null) {
            parent = curr;
            int cmp = key.compareTo(curr.key);
            if (cmp == 0) {
                // 键已存在,将其splay到根并返回
                splay(curr);
                return;
            } else if (cmp < 0) {
                curr = curr.left;
            } else {
                curr = curr.right;
            }
        }

        Node<T> newNode = new Node<>(key);
        newNode.parent = parent;

        int cmp = key.compareTo(parent.key);
        if (cmp < 0) {
            parent.left = newNode;
        } else {
            parent.right = newNode;
        }

        size++;
        splay(newNode);
    }

    /**
     * 删除指定键值的节点
     * 策略:先将待删除节点splay到根,然后合并其左右子树
     */
    public boolean delete(T key) {
        if (!contains(key)) {
            return false;
        }

        // 此时key对应的节点已在根部
        Node<T> delNode = root;
        Node<T> leftTree = delNode.left;
        Node<T> rightTree = delNode.right;

        if (leftTree != null) leftTree.parent = null;
        if (rightTree != null) rightTree.parent = null;

        if (leftTree == null) {
            root = rightTree;
        } else if (rightTree == null) {
            root = leftTree;
        } else {
            // 左右子树均非空:将左子树的最大节点splay到左子树的根
            root = leftTree;
            // 找到左子树的最大节点(最右节点)
            Node<T> maxNode = leftTree;
            while (maxNode.right != null) {
                maxNode = maxNode.right;
            }
            splay(maxNode);
            // 此时maxNode成为左子树的根,且没有右子节点
            maxNode.right = rightTree;
            rightTree.parent = maxNode;
        }

        size--;
        return true;
    }

    /**
     * 获取树中的最小值
     */
    public T getMin() {
        if (root == null) return null;
        Node<T> curr = root;
        while (curr.left != null) {
            curr = curr.left;
        }
        splay(curr);
        return curr.key;
    }

    /**
     * 获取树中的最大值
     */
    public T getMax() {
        if (root == null) return null;
        Node<T> curr = root;
        while (curr.right != null) {
            curr = curr.right;
        }
        splay(curr);
        return curr.key;
    }

    /**
     * 中序遍历,返回有序列表
     */
    public List<T> inorderTraversal() {
        List<T> result = new ArrayList<>();
        inorderHelper(root, result);
        return result;
    }

    private void inorderHelper(Node<T> node, List<T> result) {
        if (node == null) return;
        inorderHelper(node.left, result);
        result.add(node.key);
        inorderHelper(node.right, result);
    }

    /**
     * 打印树的结构(用于调试)
     */
    public void printTree() {
        System.out.println("Splay Tree (size=" + size + "):");
        printHelper(root, "", true);
    }

    private void printHelper(Node<T> node, String prefix, boolean isRight) {
        if (node == null) return;
        System.out.println(prefix + (isRight ? "└── " : "├── ") + node.key);
        printHelper(node.left, prefix + (isRight ? "    " : "│   "), false);
        printHelper(node.right, prefix + (isRight ? "    " : "│   "), true);
    }

    // ============ 主方法:测试与演示 ============

    public static void main(String[] args) {
        SplayTree<Integer> tree = new SplayTree<>();

        System.out.println("=== 插入操作测试 ===");
        int[] values = {50, 30, 70, 20, 40, 60, 80};
        for (int v : values) {
            tree.insert(v);
            System.out.println("插入 " + v + " 后,根节点为: " + tree.root.key);
        }

        System.out.println("\n=== 中序遍历结果 ===");
        System.out.println(tree.inorderTraversal());

        System.out.println("\n=== 查找操作测试 ===");
        int[] searchValues = {20, 50, 80};
        for (int s : searchValues) {
            boolean found = tree.contains(s);
            System.out.println("查找 " + s + ": " + found + ", 查找后根节点: " + tree.root.key);
        }

        System.out.println("\n=== 最值查询测试 ===");
        System.out.println("最小值: " + tree.getMin() + ", 查询后根节点: " + tree.root.key);
        System.out.println("最大值: " + tree.getMax() + ", 查询后根节点: " + tree.root.key);

        System.out.println("\n=== 删除操作测试 ===");
        int[] deleteValues = {30, 50, 70};
        for (int d : deleteValues) {
            boolean removed = tree.delete(d);
            System.out.println("删除 " + d + ": " + removed);
            System.out.println("  删除后中序遍历: " + tree.inorderTraversal());
            System.out.println("  当前根节点: " + (tree.root != null ? tree.root.key : "null"));
        }

        System.out.println("\n=== 连续访问测试(展示局部性优化)==="");
        SplayTree<Integer> localTree = new SplayTree<>();
        for (int i = 1; i <= 10; i++) {
            localTree.insert(i);
        }
        System.out.println("初始根节点: " + localTree.root.key);
        // 连续访问节点 5
        for (int i = 0; i < 5; i++) {
            localTree.contains(5);
            System.out.println("第" + (i + 1) + "次访问5后,根节点: " + localTree.root.key);
        }
    }
}

五、复杂度分析

Splay树的时间复杂度分析是其最精彩的部分。与AVL树和红黑树保证单次操作 $O(\log n)$ 的最坏情况复杂度不同,Splay树保证的是均摊复杂度。

5.1 均摊 $O(\log n)$

使用势能分析法(Potential Method)可以证明:对于任意包含 $n$ 个节点的Splay树,任意连续 $m$ 次操作(查找、插入、删除)的总时间复杂度为 $O(m \log n)$。这意味着单次操作的均摊复杂度为 $O(\log n)$。

势能函数定义为所有节点”秩”的总和:$\Phi = \sum_{x} r(x)$,其中 $r(x) = \log_2 |S(x)|$,$S(x)$ 是以 $x$ 为根的子树的大小。

可以证明,将节点 $x$ splay到根的实际代价加上势能变化,其均摊代价不超过 $3(r'(x) – r(x)) + 1 = O(\log n)$。

5.2 与静态最优树的比较

Splay树还有一个更强的性质——静态最优性:对于任意访问序列,Splay树的总访问时间与其他任意静态BST(包括最优静态BST)相比,最多只差一个常数因子。这意味着Splay树能自动适应访问模式,频繁访问的元素会自动上浮到树根附近。

5.3 实际性能

尽管理论上Splay树的常数因子比红黑树大,但在许多实际场景中(尤其是具有明显访问局部性的场景),Splay树的表现非常优秀,甚至优于红黑树。

六、应用场景

Splay树独特的自调整特性使其在以下场景中表现出色:

  • 缓存实现:最近访问的数据自动靠近根节点,天然符合缓存的局部性原理。
  • 数据压缩:自适应霍夫曼编码(Adaptive Huffman Coding)的核心数据结构就是Splay树的变种。
  • 动态集合维护:需要频繁查询和修改的有序集合,且访问模式具有局部性时,Splay树是理想选择。
  • 网络路由:某些网络路由算法利用Splay树的自调整特性优化频繁访问路径的查询效率。
  • 内存管理:某些操作系统的内存页面置换策略借鉴了Splay树的访问频率统计思想。

七、总结

Splay树是一种优雅而自洽的数据结构。它用最少的额外信息(无需高度、颜色或优先级),仅通过splay这一核心操作,就实现了均摊 $O(\log n)$ 的效率。其自调整特性使它能自动适应访问模式,频繁访问的数据自动上浮,非常适合具有局部性的应用场景。

与AVL树和红黑树相比,Splay树的实现更为简洁,不需要维护复杂的平衡条件。虽然在最坏情况下单次操作可能退化到 $O(n)$,但在均摊意义和所有连续操作序列上,它都表现出优秀的性能保证。理解Splay树,不仅掌握了一种实用的数据结构,更能深刻体会均摊分析和自调整策略在算法设计中的强大威力。