跳表(skip list)

跳表(skip list)

  • LINDA
  • 2018/8/9

跳表可以作为AVL、红黑树等平衡二叉树或伸展树等自适应树的替代品。

对于跳表,任何数据集的查找、插入、删除操作都是O(lgn)

在普通的链表中,查找、插入、删除都是O(n),这是因为它是按顺序扫描的。如果可以增大扫描的步长(即跳过某些结点),我们就可以减少扫描的开销了。这就是跳表的基本思想。

跳表相对于普通链表有两点不同:

  1. 普通链表的结点只有一个next指针指向下一个结点,跳表的结点有多个next指针指向多个下个结点(也叫作向前指针(forward reference)。)
  2. 对于一个给定的结点,它的向前指针的个数是随机的。

我们可以说一个跳表的结点是有层数的(levels),一层就有一个向前指针。所以,结点的层数被称为是结点的大小

基本思想

为了介绍跳表的数据结构,我们先来看下面这三个可以”跳“的表数据结构,但还不是真正的跳表。在遍历过程中,第一个允许跳过一个,第二个允许跳过四个,第三个允许跳过八个结点,如果继续下去,我们就可以得到可以跳过2^i结点的表结构了。

对于跳表,我们知道头结点和其他结点都是不一定有相同的向前指针的。有的有一个,有的有多个。

那么,我们先假设它的结点的数据结构是这样的:

struct Node {
    int key;  // 值
    vector<Node*> next; // 向前指针的集合
};

那么,查找操作的例程应该是这样的:

Node* find(const int x) {
    Node* p = head; // 头结点
    int levels = p->next.size();
    if(x < p->key)
        return nullptr; // 表示没找到
    for(int i = 0; i < levels; ++i) {
        if(p->next[i]->key == x) {
            return p->next[i];
        }

        if(p->next[i]->key > x) { // 如果目标值比当前结点值小,查找下一层
            continue;
        }

        if(p->next[i]->key < x) { // 如果目标值比当前结点值大,查找该层的下一个结点
            p = p->next[i];
            i = 0;
            levels = p->next.size();
        }
    }
    return nullptr; // 查找不到
}

我们从头结点的最高层开始,如果目标值比当前结点小,我们就向下一层查找,如果目标值比当前值大,我们就向该层的下一个结点查找,找到则返回该结点,找不到返回空。

跳过每两个结点

图一表示了一个16结点的链表结构,每两个结点就有两个向前指针,存储值表示在结点下面。头结点有两层,且不能小于最大的结点。2结点有一个向前指针指向4结点,相似的有468,等。每两个就有一个拥有两个向前指针。

Figure1

显然,它的查找操作并不需要一个一个结点查找。可以每次都先跳过一个结点,直到无法跳过,再找下一个结点就好了。所以,需要查找的结点不会超过 n2+1 ⌈ n 2 ⌉ + 1 。例如,查找15,它只需要查找2,4,6,8,10,12,14,16,15,总共 162+1=9 ⌈ 16 2 ⌉ + 1 = 9

跳过每两个和四个结点

第二个例子,就是一个链表的每两个结点能一次向前两个结点,每四个结点能一次向前四个结点,如图2,头结点必须不小于最大(层数)结点(的层数)。

Figure2

这样,查找操作就可以以更大的步长来查找了。可以每次跳过四个结点直到无法跳过,在查找接着的最多3个结点。所以,查找的结点不会超过 n4+3 ⌈ n 4 ⌉ + 3 。例如,查找15,它只需要查找4,8,12,16,14,16,15,总共 164+3=7 ⌈ 16 4 ⌉ + 3 = 7

跳过每 2i 2 i 个结点

这最后一个例子,就是一个允许跳过更大的步长的链表:

Figure3

每第 2i 2 i 个结点 i=1,lgn i = 1 , … ⌈ lg ⁡ n ⌉ ,就可以向前 2i 2 i 个结点。例如每个第2结点就可以向前两个结点,每第八个结点就可以向前8个结点,等等。同样的,头结点不能比最大结点小。

假设图3的链表拥有32个结点,那么我们要查找一个数,就从头结点的最高层开始,先比较第16个结点,如果比它小则进入前半段查找,如果比它大这进入后半段查找,如此反复,直到查找到,显然,这是一个折半查找,时间复杂度为O(lgn)

这个数据结构看来已经很完美了,其实,它在插入或删除操作上有很严重的问题。它要求插入一个数或删除一个数后,重新调整好整张表,这个操作的复杂度为O(n)

所以,真正的跳表就采用了概率的方法来操作整张表。一个跳表被要求结点大小要有一致的分布,但不需要固定的结点大小。所以,也就不需要在删除插入后做相应的结点调整了。

跳表——概率的方法

图4可以看做是图3的各结点的重新组织。结点大小的分布和图3是一样的,仅仅是位置不同而已。在这个例子中,这种方法要求有一个向前指针的结点有50%,有两个向前指针的结点有25%,有三个向前指针的结点有12.5%,等等。这个分布是固定的,但像图3的严格的按顺序分布则不需要。

Figure4

图4只是这种概率下的一种可能的情况而已。在满足概率分布的条件下,其他情况也是允许的。

当插入一个新值,我们按概率选择一个新结点的大小。每个跳表有一个先确定的概率p,来决定结点的概率分布。也可以说,拥有r个向前指针的结点的p倍的结点数拥有r+1个向前指针。在一个新结点插入后,跳表不需要再重新组织。

结点的分布

假设我们有一个给定概率为p的无限长的跳表。也就是第i层的结点的p倍为第i+1层的结点(和上面同个意思)。设 Lk L k 为拥有k个向前指针的结点数,那么, Lk=pLk1 L k = p L k − 1
并且

L1=1i=2Li L 1 = 1 − ∑ i = 2 ∞ L i

因为
i=2Li=L2+L3+=L2+pL2+p2L2+ ∑ i = 2 ∞ L i = L 2 + L 3 + ⋯ = L 2 + p L 2 + p 2 L 2 + ⋯

所以,我们可以这样,

L1=1L2i=0pi L 1 = 1 − L 2 ∑ i = 0 ∞ p i

几何级数的和 i=0pi ∑ i = 0 ∞ p i 可以表示为:
i=0pi=11p ∑ i = 0 ∞ p i = 1 1 − p

因此,
L1=1L21p L 1 = 1 − L 2 1 − p

又因为 L2=pL1 L 2 = p ∗ L 1 ,所以
L1=1pL11pL1=1p L 1 = 1 − p L 1 1 − p ⇒ L 1 = 1 − p

在图4的情况, p=0.5。因此,有 1-1/2=1/2的结点拥有一个向前指针,它的 1/2的结点拥有两个向前指针,等等。

选择一个结点概率

当我们需要插入一个结点时,结点的大小通过一个随机数生成器r来选取。这是有确定概率p和最大层数maxLevel的跳表的一种选择方法:

int generateNodeLevel(double p, int maxLevel) {
    int level = 1;
    while(drand48() < p) // drand48()产生一个0~1之间的随机数
        ++level;
    return (level > maxLevel)? maxLevel : level;
}

注意,每个新结点的层数是独立于跳表中已经存在的结点的。每个结点的选择仅仅基于跳表的概率p查找操作需要比较的平均次数为

1+log1pnp+11p 1 + log 1 p ⁡ n p + 1 1 − p

例如,大小为65536的跳表,在 p=1/4时,平均查找结点数为 34.3,在 p=1/2时,平均查找结点数为 35,这比相同的普通链表的平均查找次数 n/2=32768要好很多。

头结点的层数

头结点的层数是整个跳表被允许的最大的层数。Pugh(跳表的发明者) 认为最大的层数应该被设为 log1pn log 1 p ⁡ n 。因此,对于 p=12 p = 1 2 ,拥有最多65536个元素的跳表的最大高度应该不小于 lg265536=16 lg 2 ⁡ 65536 = 16

关于性能的思考

查找一个元素(或插入、删除)的期望时间是O(lgn)。有时候,如果结点的层数分布不好,将会导致某个操作的时间增加。因为结点的大小是随机生成的,它可能会产生一个不好的大小值。例如,它可能对每个结点都生成相同的大小,这就等价于生成了一个普通链表。一个不好的大小值将导致比期望时间大的查找(插入或删除)时间。显然,一个不好的大小值将和跳表的长度成反比,也就是随着结点数的增加,性能会逐步增加。

一个操作的时间将会比期望的时间长的概率是和跳表相关的概率p有函数关系的。例如,Pugh 计算过一个p = 0.5,拥有4096个元素的跳表,实际时间超过期望时间的3倍的概率小于2亿分之一。

跳表相关的时间和空间性能依赖于这个表的层数的概率。Push 建议更多情况下使用p=0.25。如果性能伸缩更重要的,它建议使用p=0.5(伸缩性随着概率的增加而减少)。有趣的是,当p=0.25时,每个结点的向前指针平均值为1.33,而一个平衡二叉树,每个结点需要两个指针,所以,跳表可能更节省空间

跳表的实现

template <class Comparable>
class SkipList {
private:
   class SkipListNode {
       public:
           void setDatum(const Comparable & datum); // 设置数据
           void setForward(int i, SkipListNode* f); // 设置向前指针
           void setSize(int sz); // 设置大小
           SkipListNode();
           SkipListNode(const Comparable& datum, int levels);
           SkipListNode(const SkipListNode& );
           ~SkipListNode();

           const Comparable& getDatum() const;
           int getSize() const;
           SkipListNode * getForword(int level);
       private:
           int _levels; // 该结点的层数
           vector<SkipListNode* > _forward; // 向前指针
           Comparable _datum; // 数据
   }; // SkipListNode
public:
   SkipList(); // max_node_size=16, probab=0.25
   SkipList(int max_node_size, double probab);
   SkipList(const SkipList &);
   ~SkipList();

   int getHighNodeSize() const; // 获取当前跳表的最大结点的最高层
   int getMaxNodeSize() const; // 获取该跳表允许的最大结点大小
   double getProbability() const; // 获取概率
   // 插入
   void insert(const Comparable& item);

   // 查找,如果找到,返回该项,并设`success`为true,失败,返回空项,success为false
   const Comparable& find(const Comparable& item, bool& success);
   // 删除
   void remove(const Comparable& item);
private:
   // 查找,从`startnode`开始,通常是header,如果找到,返回结点,否则返回前一个结点。前提条件:startnode非空。
   SkipListNode* find(const Comparable& item, SkipListNode* startnode);
   SkipListNode* getHeader() const;

   SkipListNode *findInsertPoint(const Comparable& item, int nodesize);
   void insert(const Comparable& item, int nodesize, bool& success);
   int _high_node_size; // 当前跳表中最大结点的层数
   int _max_node_size; // 跳表允许的最大层数
   double _prob; // 概率
   SkipListNode* _head; // 头结点
}; // SkipList

具体实现后续放上。

翻译自(有部分改动)

Skip Lists

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值