skiplist分析跟应用

skiplist分析和应用

skiplist介绍

    skiplist是一个比较又意思的数据结构,如果不是在分析一些nosql数据库的实现时,还一直忽略了它的存在。在对skiplist的详细定义和实现做讨论之前,我们可以来看看一些数据结构针对基本的增删查改等操作的时间复杂度。然后来对比它们之间性能和实现复杂程度的平衡。我们先看一个如下的表:

数据结构 增加 删除 查找 修改
Unsorted ArrayList O(N) O(N) O(N) O(N)
Unsorted LinkedList O(1) O(N) O(N) O(N)
Sorted ArrayList O(N) O(N) O(log N) O(log N)
Binary search Tree O(N) O(N) O(N) O(N)
2-3 Tree O(log N) O(log N) O(log N) O(log N)
HashMap O(1) O(N) O(N) O(N)

    这里列出了一些常用数据结构针对不同操作的时间复杂度。这里的时间复杂度是在最坏情况下的大致结果。从表中我们可以看到大多数的结构要么在查找或者在增加删除元素的时候速度很快,但是在其他方面的速度就相对慢一些。相对来说比较理想的结构是2-3 Tree,也就是我们常说的红黑树。它的各方面操作基本上都很均衡。但是在前面的一些文章中我们也看到,要正确的实现一个红黑树可是非常复杂的。光各种操作和平衡就非常折腾人。

    那么,就又没有其他简单点的数据结构,它能达到和红黑树一样的时间复杂度呢?这种结构确实存在,那就是skiplist,也就是我们常说的跳表。那么,跳表到底是个什么样的结构呢?它到底是基于什么样的想法推导出来的呢? 

 

结构分析

    从前面列表里我们可以看到,对于一个链表里面某个点进行增加删除的操作只是一个常量时间。它唯一的弱点就是如果我们要查找某个元素的时候,必须从链表的头开始,每次一个个的遍历到指定节点,然后进行操作。所以,这就是的整个的时间复杂度变成O(N)了。如果能够把这个查找和定位的时间复杂度给提上来,这不就是一个很理想的结构了吗?

skiplist分析跟应用

 

  从另外一个角度来看,我们知道对于一个排序的数组来说,如果要查找它们中间某个元素其实速度是很快的。我们可以通过二分法查找,每次取中间值,可以跳过一半的元素。所以它的时间复杂度为O(log N)。

  那么,我们又没有可能通过把二分法和链表结合起来呢?skiplist就是通过讲它们两者结合起来的产物。它的结构图如下:

 

skiplist分析跟应用

 

    这个图是基于这么的一个初步定义。在一个链表里,在它们的链接上面定义多个层次。比如说最低的第一层表示最原始的链表结构。它们表示的是相邻两个元素的关系。而上面一层的则表示相隔一个元素的元素之间的链接。这样依次向上,我们可以得到相隔2个元素,4个元素,8个元素等等,一直到一半元素的链接。按照这种关系,最多也就到logN这么多个链接就可以表示整个链表所有的层级关系。按照这么个描述,我们很容易定义出一个skiplist的结构:

 

public class SkipList {
    private static class SkipListNode {
        int data;
        SkipListNode[] next;

        SkipListNode(int d, int level) {
            data = d;
            next = new SkipListNode[level + 1];
        }
    }

    private int maxLevel;
    SkipListNode header;
    private static final int INFINITY = Integer.MAX_VALUE;

    SkipList(int maxLevel) {
        this.maxLevel = maxLevel;
        header = new SkipListNode(0, maxLevel);
        SkipListNode sentinel = new SkipListNode(INFINITY, maxLevel);
        for(int i = 0; i <= maxLevel; i++)
            header.next[i] = sentinel;
    }
}

  前面代码里定义了一个内部的SkiplistNode结构,我们通过构造函数传递参数来表示最多有多少个层次。这里额外定义了一个节点,作为最后的节点sentinel,我们将这个节点的值设置为Integer.MAX_VALUE,这种设置可以让后续一些比较的代码简化一些。 

 

查找

 有了前面的结构,如果要查找一个元素,其实就比较简单了。一般是从header节点沿着当前*往前查询,如果发现后面的节点比目标节点大,则到当前节点的下一级去查找。一个典型的示例如下图:

 

skiplist分析跟应用

 

 假设我们在这里要查找数字8,首先从header的*去看,后续连的是12,肯定超过了,于是直接往下一级,再看下一级的后续。按照红箭头指示的这样一级一级的比对下来。如果能找到当前则返回true,否则返回false。

 有了前面这些描述,实现查找的过程如下:

 

public boolean find(int key) {
        SkipListNode current = header;

        for(int i = maxLevel; i >= 0; i--) {
            SkipListNode next = current.next[i];
            while(next.data < key) {
                current = next;
                next = current.next[i];
            }
        }
        current = current.next[0];

        if(current.data == key) return true;
        else return false;
    }
  这部分代码要注意的就是每次判断的时候节点移动和引用的设置,总的来说还算相对简单的。

 

增加

 增加一个元素的过程就是首先在一个有序的结构里找到这个要加入的元素应该插入的位置,然后新建一个节点把这个节点加入进去。同时,还有一个要考虑的就是如果查找到里面存在一个和目标元素相同的节点。我们可以考虑直接覆盖原来的元素。有这种考虑是因为在又的情况下,我们进行查找的是针对每个元素的key,而不是具体的value值。key是专门用来查找的。所以就类似于HashMap之类的字典结构,需要考虑。不过这里是通过直接的data进行对比,相对可以省略这个步骤。

 增加一个新的节点进来还有一个需要考虑的问题就是要调整整个level的引用。比如说,该定义这个节点的级别。在前面结构描述的时候是提到了一种理想的情况。比如一级的对应整个链表。二级的对应每2个元素。而实际上我们的实现还是有点不同,我们并不是严格按照这个来。而是用一种随机选择level的方式。

 整个插入元素的过程如下图:

skiplist分析跟应用

  详细的实现代码如下:

 

public void insert(int searchKey, int newValue) {
        SkipListNode[] update = new SkipListNode[maxLevel + 1];
        SkipListNode current = header;
        
        for(int i = maxLevel; i >= 0; i--) {
            SkipListNode next = current.next[i];
            while(next.data < searchKey) {
                current = next;
                next = current.next[i];
            }
            update[i] = current;
        }
        current = current.next[0];
        if(current.data == searchKey) 
            current.data = newValue;
        else {
            int v = generateRandomLevel();
            SkipListNode node = new SkipListNode(newValue, maxLevel);
            for(int i = 0; i <= v; i++) {
                node.next[i] = update[i].next[i];
                update[i].next[i] = node;
            }
            update = null;
        }
    }

  这里为了保证插入了元素后可以调整每个级别(level)的引用。专门定义了一个SkiplistNode[] update。当每次查找的时候,遍历的当前level i的节点引用就保存在对应update[i]里面。后面方便进行更新。

  我们选择新建node的level是通过一个随机的方法,它的定义如下:

 

private int generateRandomLevel() {
        int newLevel = 0;
        while(newLevel < maxLevel && Math.random() < 0.5) newLevel++;
        return newLevel;
    }

  因为这里不是一个严格的完美skiplist,但是它可以达到一个概率上的理想效果。关于这方面的分析,在文章引用的一些参考材料里有详细分析。

 

删除

 和前面的情况类似,当我们需要删除一个元素的时候。我们首先也是要查找到它,然后再进行删除操作。当找不到这个元素的时候,就直接返回false,而找到之后则讲该节点删除,并调整它前面元素所引用的位置。

  这里的实现也是一样,通过SkiplistNode[] update来保存所有前面经历过的引用。在后面删除的时候,针对这个删除节点从下到上的level进行调整。同时讲一些引用设置为null,防止出现内存泄露。详细代码实现如下:

 

public boolean delete(int searchKey) {
        SkipListNode[] update = new SkipListNode[maxLevel + 1];
        SkipListNode current = header;
        for(int i = maxLevel; i >= 0; i--) {
            SkipListNode next = current.next[i];
            while(next.data < searchKey) {
                current = next;
                next = current.next[i];
            }
            update[i] = current;
        }
        current = current.next[0];
        if(current.data == searchKey) {
            for(int i = 0; i <= maxLevel; i++) {
                if(update[i].next[i] == current) {
                    update[i].next[i] = current.next[i];
                    current.next[i] = null;
                } else
                    current.next[i] = null;
            }

            return true;
        }

        return false;
    }

  总体来说,这里访问和修改元素的时间复杂度也是O(log N)。

 

总结

 SkipList是一个结合链表和二分查找思想的结构。它在每个节点设定了多级的链接,这样可以加速查找的过程。再加上链表里增加和删除元素的便利,整体的性能达到一个非常理想的状态。文中给出的代码只是一个简单的参考,没有针对所有常用类型做一个泛型定义。另外,对于结构定义里面结合概率的算法复杂度分析这里也没有进一步讨论。

 

参考材料

http://igoro.com/archive/skip-lists-are-fascinating/

http://www.cse.yorku.ca/~ruppert/papers/lfll.pdf

ftp://ftp.cs.umd.edu/pub/skipLists/skiplists.pdf