前端算法
首发于前端算法
伸展树(SplayTree)指针版

伸展树(SplayTree)指针版

伸展树(Splay Tree),也叫分裂树,另一种在ACM/OI`比赛比赛中很常用的平衡树,它能在O(log n)内完成插入、查找和删除操作。它由Daniel Sleator 和Robert Endre Tarjan 在1985年发明的。

PS:其实JS没什么指针而言,只存在属性赋值与变量修改,只是吸引那些要学习如何通过面向对象组织代码的同学过来罢了。网络上大量的算法教程都是以全局变量,全局数组来组织代码,维护性太差了。想认真学算法与数据结构,以后可以看我出版的书。

伸展树的许多操作都存在伸展操作,其出发点是这样的:考虑到 数据局部性原理,为了使整个查找时间更小,对那些访问频率较高的节点搬到离根节点较近的地方。伸展操作实质就是一个或多个转旋操作。每次对伸展树进行操作后,它均会通过旋转的方法把被访问节点旋转到树根的位置。为了将当前被访问节点旋转到树根,我们通常将节点自底向上旋转,直至该节点成为树根为止。


它的旋转过程比有旋Treap复杂一些,但是我们只要根据其与父节点,祖先节点的关系就可以决定采取哪种形态的旋转,因此不需要其他的冗余信息。

数据局部性的两个特征
1. 刚刚被查询的节点,极有可能在不久之后再次被查找。
2. 下一个要查询的节点,极有可能在于不久之前被查找的某个节点附近。
换言之。查询次数越多的节点,下次被查询的机率就越大。热点数据就具有这特征。伸展树的行为与LRU缓存很像,经常使用的会保存在缓存体在最前面,提高命中率,不使用的数据渐渐踢出缓存体。如果不了解LRU,也可以想像一样拼音输入法,总是把用户经常使用的词汇放到最前。

伸展

伸展树的伸展操作其实是用一个或多个旋转组合而成的。旋转的特点是不会破坏树的有序性。伸展树的伸展包含三种旋转:单旋转,一字形旋转和之字形旋转。为了便于解释,我们假设当前节点为X,X的父亲节点为Y,X的祖父节点为Z。

单旋转

又叫Zig或Zag。节点X与Y都是在同一条线上,X是Y的左孩子,Y是根。X要伸展到根的位置,需要做一次右旋(Y是在X的右边,因此要右旋);如果X是Y的右孩子,那么我们要做一次左旋操作。经过旋转,X成为二叉查找树T的根节点,调整结束。

一字型旋转

又叫Zig-Zig或Zag-Zag。节点X的父节点Y不是根,Y的节点Z才是我们想要伸展到的位置,并且X,Y,Z是在同一条线上。这里我们可以先旋转Y的父节点,将Y升上去,间接将X升上去,再将X旋转上去。这相当做两次左旋或右旋操作。

之字形旋转

又叫Zig-Zag或Zag-Zig。X,Y,Z不是在同一条线上,那么我们先将X旋转到Y的位置,然后再将X用另一个方向的旋转到X的位置。

所谓的单旋转,一字型,之字形,是根据父节点是否是根节点来划分的。

伸展操作带来的收益:

它不仅将访问的节点移动到根处,而且还有把沿途经过的节点也进行挪动,将它们的深度减少为原来的一半左右。

下图插入 节点1 后发生伸展操作的一系列分解图。在对节点1进行访问后(花费N-1个单元的时间),对节点2的访问只花费 N/2 个时间单元而不是 N - 2个时间单元。

它里面用到左旋右旋,相当于树堆的leftRotate、rightRotate,可以直接挪过来用。但是为了方便其他操作,我们需要维护每个被挪动的节点的size属性。


class Node{
   constructor(data){
      this.data = data;//by 司徒正美
      this.parent = null;
      this.left = null;
      this.right = null;
      this.size = 1;//用于排名
   }
   maintain(){
      var leftSize = this.left ? this.left.size : 0
      var rightSize = this.right ? this.right.size : 0
      this.size = leftSize + rightSize + 1;
   }
}
function rotateImpl(tree, node, dir, setParent) { //by 司徒正美
      var other = dir == "left" ? "right" : "left";
      if (!node[other]) {
        return;
      }
      var top = node[other]; //会上浮的子节点
      node[other] = top[dir]; //过继孩子

      if (setParent) {
        if (!node.parent) {
          tree.root = top;
        } else if (node == node.parent.left) {
          node.parent.left = top;
        } else {
          node.parent.right = top;
        }
        Object(top[dir]).parent = node; //父属性修正1
        top.parent = node.parent; //父属性修正2
        node.parent = top; //父属性修正3
      }
      top[dir] = node; //旋转
      node.maintain(); //先处理下面的再处理上面的
      top.maintain();
      return top;
}
class SplayTree{
   constructor(){
      this.root = null;
   }
   leftRotate(node) {
       return rotateImpl(this, node, 'left', true);
   }
   rightRotate(node) {
       return rotateImpl(this, node, 'right', true);
   }
   splay(node, goal) {
        if (node == goal) return; //by 司徒正美
        while (node.parent != goal) {
            var parent = node.parent;
            if (parent == goal) {//如果父节点是根节点
                if (parent.left == node) {
                    this.rightRotate(parent)
                } else {
                    this.leftRotate(parent)
                }
                break
            } else {//如果祖父节点是根节点
                var grandpa = parent.parent;
                var case1 = grandpa.left === parent ? "zig-" : "zag-";
                var case2 = parent.left === node ? "zig" : "zag";
                switch (case1 + case2) {
                    case "zig-zig": // 一字型,先父后子,由于我们的旋转操作都是针对于根,
                        // 那么操作node,即操作parent
                        this.rightRotate(grandpa);
                        this.rightRotate(parent);
                        continue
                    case "zag-zag": // 一字型,先父后子
                        this.leftRotate(grandpa);
                        this.leftRotate(parent);
                        continue
                    case "zig-zag": // 之字型
                        this.leftRotate(parent);
                        this.rightRotate(grandpa);
                        continue;
                    case "zag-zig": // 之字型
                        this.rightRotate(parent);
                        this.leftRotate(grandpa);
                        continue;
                }
            }
        }
    }
}

上面的伸转操作太复杂,记不住怎么办,我们可以精简一下。让旋转操作自动处理扭转的方向。

rotate(x) {//rotate具有决定左旋或右旋的功能
    var p = x.parent;
    if (x === p.left) {
        return this.rightRotate(p);
    } else {
        return this.leftRotate(p);
    }
}


然后我们根据之字型与一字型的特征简化为:

splay(node, goal) {//by 司徒正美
    if (node == goal) return;
    while (node.parent != goal) {
      var p = node.parent;
      if (p.parent == goal) {//如果祖先不存在,单旋
        this.rotate(node);
        break;
      }
    //如果是有两个可以旋转的父级节点,
     //那么判它们是否位于同一条线上,是就使用一字型旋转
     //否则就是之字型旋转
      var grandpa = p.parent;
      this.rotate(
        (node === p.left) === (p === grandpa.left) ? p : node
      );
      this.rotate(node);
    }
}

查找

与其他树差不多,就是找到后,将节点放进splay方法。

find(value) {
    var node = this.root;
    while (node) {
        var diff = value - node.data
        if (diff == 0) {
            break
        } else if (diff < 0) {
            node = node.left;
        } else {
            node = node.right;
        }
    }
    if (node) {
        this.splay(node)
        return node
    }
    return null
}

插入

insert(value) {
    if (!this.root) {
        this.root = new Node(value);//by 司徒正美
        return true
    }
    var node = this.root, parent = null, diff;
    while (node) {
        parent = node; //保存要插入的父节点
        diff = value - node.data
        if (diff == 0) {
            return false
        } else if (diff < 0) {
            node = node.left;//by 司徒正美
        } else {
            node = node.right;
        }
    }
    var node = new Node(value);
    node.parent = parent;
    if (diff < 0) {
        parent.left = node;
    } else {
        parent.right = node;
    }
    this.splay(node)
    return true;
}

测试,依次添加10, 50, 40, 30,20,60

var t = new SplayTree();
[10, 50, 40, 30,20,60].forEach(function (el, i) {
    t.insert(el, i)
})

删除

每种树的删除操作都是特别麻烦。我们先要在树中找到这个节点,并把它splay到根节点位置,将问题转换为删除根节点。

如果根节点没有左孩子,那么直接把根节点的右孩子作为新的根节点,完成删除操作;类似的,如果根节点没有右孩子,那么把根节点的左孩子作为新的根节点,完成删除操作。

如果根节点有两个孩子,先把根节点左子树中最大的节点旋转到左孩子的位置(这样做保证了左孩子的右孩子为空),然后把根节点的右孩子挂接到左孩子的右孩子位置。

如果根节点有两个孩子,先在左子树种找到最靠右(最大)的节点,把它旋转到根节点的儿子上,此时它一定没有右孩子,因为根节点的左子树中不存在任何一个元素比它更大,那么把根节点的右子树接在这个节点的右儿子上即可。

remove(value) {
    let p = this.find(value);
    if (!p) {
        return false;
    } else {
        if (p.count > 1) {
            p.count--;//by 司徒正美
            return true;
        }
    }
    if (p.left) {
        //新根有左右孩子
        if (p.right) {
            this.root = p.left;
            this.root.parent = null;//by 司徒正美
            let x = this.root;
            while (x.right) x = x.right;//取得它的后继节点
            x.right = p.right;
            p.right.parent = x;
            return true;
        }
        //新根只有左孩子
        this.root = p.left;
        this.root.parent = null;
        return true;
    }
    if (p.right) {
        //新根只有右孩子
        this.root = p.right;
        this.root.parent = null;
        return true;
    }
    this.root = null; //新根节点没有孩子
    return true;
}

区间删除

传入两个位置,删除对应的几个节点。

 removeRange(start, end) {
    if (start >= end) {
      throw 'start必须小于end' //by 司徒正美
    }
    var a = this.getKth(start - 1);
    var b = this.getKth(end + 1);
    a = this.find(a)
    b = this.find(b)
    if (!a && !b) {
      return
    }
    if (!a) {
      this.splay(b);
      b.left = null; //by 司徒正美
      b.maintain()
      return
    }
    if (!b) {
      this.splay(a);
      a.right = null;
      a.maintain()
      return
    } //by 司徒正美
    this.splay(a)
    this.splay(b, a)
    b.left = null
    b.maintain()
}

获取value的排名

由于有size,所以计算节点的排名就非常简单。将目标节点旋转到根节点,然后其左孩子的size + 1就是其排名。由于我们的查找操作自带旋转,因此代码短到爆。

getSize(node) {
   return node ? node.size : 0; 
}
getRank(value) {
   var node = this.find(value);
   if(node){
     return this.getSize(node.left) + 1
   }else{
     return 0
   }
}

根据排名找对应的数

getKth(k) {
    var node = this.root
    while (node){
        if (k <= this.getSize(node.left)){ //by 司徒正美
            node = node.left
          } else if (k > this.getSize(node.left)+ node.count){
            k -= (this.getSize(node.left)+ node.count)
            node = node.right
          }else{
            return node.data
        }
    }
    return null
}

求最大最小值

maxNode(node) { //by 司徒正美
    var cur = node || this.root;
    while (cur.right) {
      cur = cur.right
    }
    return cur;
}
minNode(node) {
    var cur = node || this.root;
    while (cur.left) {
      cur = cur.left //by 司徒正美
    }
    return cur;
}
getMax() {
    var node = this.maxNode()
    return node ? node.data : null
}
getMin() {
    var node = this.minNode()
    return node ? node.data : null
}

求前驱后继

getPrev(value) {//找value的前驱
    var node = this.find(value)
    if (node) {
      this.splay(node);
      return this.maxNode(node.right)
    }
}
getSuss(value) {//找value的后继
    var node = this.find(value)
    if (node) {
      this.splay(node);
      return this.minNode(node.left)
    }
}

合并

将伸展树的两个子树进行合并。其中 tree1 的所有元素都小于 tree2 的所有元素。首先,找到伸展树tree1中的最大元素x,再通过 splay(x) 将x调整为 tree1 的根,最后将 tree2 作为 x节点的右子树。

merge(first, second){
    if(!first){
        return second
    }
    if(!second){
        return first
    }
    var max = this.maxNode(first)
    this.splay(max, first);
    max.right = second
    second.parent = max;
    return max;
}

分拆

以value为界,将它的节点拆成 `<= value`与 `> value`的两部分,分别赋给两棵树。由于value对应的节点不一定存在,我们需要编写一个approximate来获取近拟value的节点值。

 approximate(node, value) { //小于或等于value
        if (!node) {
          return null
        }
        while (node.data != value) {
          if (value < node.data) {
            if (!node.left) break;
            node = node.left;
          }
          else {
            if (!node.right) break;
            node = node.right;
          }
        }
        return node.data
}
split(value){
   var trees = []
   var data = this.approximate(this.root, value);
   if(data == null){
      return trees
   }
    var tree1 = new SplayTree();
    var tree2 = new SplayTree();
	var node = this.find(data);
	//将前驱节点旋转为根节点
	this.splay(node);
	var root = this.root
	tree1.root = root;
	tree2.root = root.right;
	if(root.right != null){
		root.right.parent = null;
	}
	root.right = null;
	trees.push(tree1, tree2)
    return trees;
}

中序遍历

inOrder(cb) {
  function recursion(node) {
    if (node) {
      recursion(node.left);
      cb(node);
      recursion(node.right);
    }
  }
  recursion(this.root);
}
keys() {
  var ret = [];
  this.inOrder(function (p) {
    ret.push(p.data);
  });
  return ret;
}

上一点测试代码

    var array = [7, 11, 13, 8, 44, 78, 15, 9, 77, 89, 1, 2]  
    var t = new SplayTree();
    array.forEach(function (el) {
      t.insert(el)
    })

    var rank = t.find(44)
    console.log('t.find(44)', rank, t)
    var a = t.getRank(44)
    console.log('t.getRank(44)', a);
    var b = t.getKth(a)
    console.log(`t.getKth(${a})`, b);

    t.remove(11);
    t.remove(44);
    t.remove(77);
    console.log("移除11,44,77后");
    console.log(t.keys() + "");
    t.insert(11)
    t.insert(43)
    t.insert(76)
    t.insert(77)

    t.removeRange(4, 8)
    console.log(t.keys() + "");

此外伸展树最强的是区间操作,我就不一一展开了。

编辑于 2019-08-10

文章被以下专栏收录