Post

Splay 树:均摊时间下的平衡二叉搜索树

Splay 树的基本定义

关于平衡二叉搜索树,我们介绍过 AVL 树。AVL 树通过旋转来保证了相对严格的自平衡,这样操作复杂度是 O(logN)

今天我们要介绍的 Splay 树也是一个平衡二叉树,但是它并没有像 AVL 树那样去严格地保证树的平衡性,每次当 AVL 树的结构发生变化,就找到相应的节点进行调整。对于 Splay 树来说,不管是什么样的操作,读、写、更新以及删除,Splay 树均会对树的结构进行调整,这个调整会通过类似 AVL 树的旋转的方式把被操作的节点给移到根节点处,在移动的过程中,树的整体结构被调整了,从而能保证 O(logN) 时间复杂度。但是这个时间复杂度是均摊时间复杂度,也就是说,对于连续的 M 个操作,Splay 保证最后的时间复杂度是 O(MlogN),但是对于单一的操作,Splay 树并不保证 O(logN)

由于 Splay 树的对平衡性的定义,Splay 的结构并没有严格的规定,任何结构的树都可以是 Splay 树,让我们来看一个例子:

在这个例子中,当我们去获取 2 这个节点的信息,树的结构就会被调整成下图,可以很清晰地看到调整之后的树的整体的深度变小了,比如之前 4 这个节点要通过 16 次操作才能访问到,但是现在仅需 8 次操作:

我们继续去获取 3 的节点信息,就会得到下图:

然后是获取 4 的节点信息:

通过上面这些图,我们可以直观地感受到 Splay 树并不仅仅针对某个节点或者某个子树进行调整,而是调整某个节点到根节点这条链上的所有节点,可以说是整个树的调整。而这样做有什么好处呢?

第一个好处就是简单直观,增删改查不会被区别对待,在后面的实现方式上,你也可以体会到这种简单。

第二个好处是在某些情况下,实际的时间开销会比较小。这个怎么理解呢?在实际生产中,当获取某个节点后,这个节点近期被再次获取的概率会很大,而 Splay 树把获取的节点移到根节点就会降低再次获取的时间。

Splay 树的实现

Splay 树的有多种实现方式,但是我们这次介绍的是一种 从上到下 的方式。就是当开始获取某个节点时,在向下寻找的过程中,我们就一遍定位,一边对树的结构进行调整。相比传统的 “找到节点,然后向上移动调整” 的方式,从上到下这种方式有 2 大好处:

  • 我们不需要在节点中存放和维护额外的信息,诸如,父节点的引用,节点的高度等,实现会变的比较简单
  • 在实际应用上,这种方式的运行效率更高,并且它也保证了 O(logN) 的均摊时间

旋转情况讨论

和 AVL 树类似,Splay 树也是通过旋转来对树节点进行移动和调整。其实 Splay 可以套用 AVL 树的旋转,但是这是对传统的从下往上的方式来说的。当我们选择了从上到下的调整方式,还需对其做一些改变,不然试想一下,你如何旋转才能将最后访问到的节点放到根节点上呢?

我们先来看第一种情况:

这里假设我们当前遍历到的节点是 X,我们需要去到 Y,我们想要做的是把 Y 的位置移到靠上面些。如果这里沿用之前的 AVL 树的旋转方式,势必树的结构会发生改变,从而导致接下来的遍历没法很好地进行。这里采取的措施是把以 Y 为根节点的这整颗树作为当前遍历的树,然后把 X 连同剩下的部分移到 R 下面。这里的 LR 是我们创建出来的辅助树,L 存放的是树上比当前节点 X 小的部分,R 存放的是比当前节点 X 大的部分,在遍历开始时,LR 均为空,在不断地向下遍历,不断地调整,LR 上就会不断地有子树被加上。这种情况还有一个对称的情况,就是 YX 的右边,我们只需把 X 挂到 L 的最右边即可。

第二种情况和第一种很类似,不过这时我们需要去到更下面一层:

你可能会说,这种情况其实就是两次第一种情况啊,为什么要单独放出来讨论?因为如果都按照第一种情况,在特定的情况下并不能很好地压缩树结构,最后也就没法保证 O(logN) 的均摊时间。同理,和第一种情况一样,这里也有一个对称的情况,这里就不展开了,左右颠倒就好了。

最后是第三种情况:

但其实这种情况,就是两个(对称的)第一种情况叠加而成的,我们只需将其看成是第一种情况就好了,不然情况太多,在实现的时候我们需要做很多的判断,这会增加代码的复杂度不说,还会影响程序的运行效率:

所以,整个旋转情况,其实就变成了 2 种(第一种与第二种)以及它们的对称情况。遍历到了最后,也就是找到了需要找的节点,我们还需要一个操作,就是将当前树与 L 树,还有 R 树进行合并,这个其实挺直观的,直接根据二叉搜索树的定义拼接起来即可:

这样,最后遍历到的节点就会成为新的树的根节点,在遍历的过程中,我们也依照相应的情况对树的结构进行了改变。

我们来看一个具体的案例,来体会一下这个算法给整个树带来的变化。有如下这颗树,我们想要查找值为 19 的树节点,一开始的时候我们的位置在根节点,也就是 12 处,两边的 LR 树均为空:

下一步我们要去的是节点 25,也就是我们之前讨论的第一种情况的对称情况,所以我们可以将树变成下面这样:

然后我们要去到节点 25,也就是我们之前讨论的第二种情况,旋转后可得:

最后我们要去到节点 18,这里还是第一种情况的对称情况,旋转可得:

虽然我们没有找到 19,但是我们的遍历终止在 18 处,这时我们进行最后的拼接就得到最后的树:

将变换后的树和原来的树比较,我们可以很容易看到树的深度被缩小了。

具体实现

在给出具体操作的实现前,我们先来定义一些辅助变量,这些辅助变量会让实现变得更加容易。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class SplayTree<AnyType extends Comparable<? super AnyType>> {
  private class BinaryNode<AnyType> {
    BinaryNode(AnyType theElement) {
      this(theElement, null, null);
    }

    BinaryNode(AnyType theElement, BinaryNode<AnyType> lt, BinaryNode<AnyType> rt) {
      element = theElement;
      left = lt;
      right = rt;
    }

    AnyType element;
    BinaryNode<AnyType> left;
    BinaryNode<AnyType> right;
  }

  private BinaryNode<AnyType> root;
  private BinaryNode<AnyType> nullNode;
  private BinaryNode<AnyType> header = new BinaryNode<>(null);
  private BinaryNode<AnyType> newNode = null;
}

这里,我们定义了一个树节点 BinaryNode,就是普通的二叉树节点。紧接着,我们定义了几个变量,root 表示的是当前树的根节点,代表整颗树。nullNode 是一个哨兵节点,用来表示空,它可以简化代码。header 是用来存放我们上面讨论的 LR 这两个中间生成的子树的。最后的 newNode 则是用来存放插入节点的,因为这里我们默认不重复插入相同的元素,这个变量可以看做是暂存。

查询

splay 树的查询操作是基础,它将我们需要查找的元素移到根节点处,以此同时树的结构也会发生改变

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
private BinaryNode<AnyType> splay(AnyType x, BinaryNode<AnyType> t) {
  BinaryNode<AnyType> leftTreeMax, rightTreeMin;

  header.left = header.right = nullNode;
  leftTreeMax = rightTreeMin = header;

  nullNode.element = x;

  while (true) {
    if (x.compareTo(t.element) < 0) {        // 情况一
      if (x.compareTo(t.left.element) < 0) { // 情况二
        t = rotateWithLeftChild(t);
      }
      if (t.left == nullNode) {
        break;
      }
      // 将遍历过的部分放到 R 上,以此同时在 R 的左子树上继续遍历
      // 也就是说 R 的左边空出来用于下次存放
      rightTreeMin.left = t;
      rightTreeMin = t;
      t = t.left;
    } else if (x.compareTo(t.element) > 0) {  // 情况一的对称情况
      if (x.compareTo(t.right.element) > 0) { // 情况二的对称情况
        t = rotateWithRightChild(t);
      }
      if (t.right == nullNode) {
        break;
      }
      leftTreeMax.right = t;
      leftTreeMax = t;
      t = t.right;
    } else { // 找到目标元素,停止查找
      break
    }
  }

  // 最终整合
  leftTreeMax.right = t.left;
  rightTreeMin.left = t.right;
  t.left = header.right;
  t.right = header.left;

  return t;
}

//      t          tl          t          tl   
//     / \     ->  / \    ->  / \    ->   / \  
//    tl  tr     tll tlr     tlr tr     tll  t
//   / \                                    / \
// tll tlr                                 tlr tr
private BinaryNode<AnyType> rotateWithLeftChild(BinaryNode<AnyType> t) {
  BinaryNode<AnyType> tl = t.left;
  t.left = tl.right;
  tl.right = t;
  return tl;
}

private BinaryNode<AnyType> rotateWithRightChild(BinaryNode<AnyType> t) {
  BinaryNode<AnyType> tr = t.right;
  t.right = tr.left;
  tr.left = t;
  return tr;
}

代码其实就是将之前的 2 种情况及其对称情况加以实现,重点在于节点的旋转以及变量的交替。

插入

有了查询,插入无非是先查询看要插入的节点是否存在于树中,如果有的话将其放置到根节点,没有的话也将临近的元素放到根节点,剩下的就是拼接了:

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
32
33
34
35
public void insert(AnyType x) {
  if (newNode == null) {
    newNode = new BinaryNode<>(null);
  }
  newNode.element = x;

  // 如果此时的树为空,那么插入的元素就是根节点
  if (root == nullNode) {
    newNode.left = newNode.right = nullNode;
    root = newNode;
  } else {
    // 先做查找,将相同或者接近的元素放到根节点处
    root = splay(x, root);

    // 查找在 root 处停止,说明
    // root 左子树上的元素一定比 x 小,root 右子树上的元素一定比 x 大,否则会继续查找

    if (x.compareTo(root.element) < 0) {
      newNode.left = root.left;
      newNode.right = root;
      root.left = nullNode;
      root = newNode;
    } else if (x.compareTo(root.element) > 0) {
      newNode.right = root.right;
      newNode.left = root;
      root.right = nullNode;
      root = newNode;
    } else {
      return;    // 为了保证树上没有重复的元素,直接返回
    }
  }

  // 将中间变量置空以便下次插入
  newNode = null;
}
删除

和插入操作类似,删除操作也是借助查询操作将对应的节点放置到根节点处。如果要删除的元素不存在,直接跳过。如果要删除的元素存在,直接将根节点删除,然后在左子树或者右子树中找一个元素来当作新的根节点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public void remove(AnyType x) {
  BinaryNode<AnyType> newTree;

  root = splay(x, root);

  // 没有找到,直接返回
  if (root.element.compareTo(x) != 0) {
    return;
  }

  // 这里我们会在左子树中找一个最大的元素来当作根节点
  // 如果左子树为空,直接返回右子树
  if (root.left == nullNode) {
    newNode = root.right;
  } else {
    newTree = root.left;

    // 左子树中的所有元素均比 x 小,所以查询操作后新的左子树根节点一定是左子树中最大的
    // 其没有右子树,我们直接将删除操作后的右子树拼接到右边即可
    newTree = splay(x, newTree);
    newTree.right = root.right;
  }
  root = newTree;
}

splay 树的时间复杂度为均摊的 O(logN),这个时间复杂度的证明需要复杂的数学公式推导,这部分内容我们以后找时间补上。

This post is licensed under CC BY 4.0 by the author.