数据结构与算法之美(笔记7)跳表

如何理解跳表?

对于一个单链表来说,即使数据是有序的,我们查找一个数据的时间复杂度也是O(n)。

我们知道,单链表不能使用二分查找,其实我们通过改造链表,就能实现类似二分查找的算法。

像图中一样,如果对链表建立一级“索引”,每两个结点提取一个结点到上一级,我们把抽取出来的那一级叫作索引或索引层。图中的down表示down指针,指向下一级结点。

 如果我们要查找16,可以先在索引层进行遍历,当遍历到索引层中值为13的结点时,我们发现下一个结点是17,那要查找的16肯定在两个结点之间,然后我们通过down指针,下降到原始链表这一层,继续遍历。这个时候,我们只需要再遍历2个结点,就可以找到值等于16的这个结点了。从中,加了一层索引之后,查找一个结点的需要遍历的结点个数就少了,也就是查找效率提高了,那再加一层呢?

跟前面一样,我们在第一级索引基础之上,每两个结点就抽出一个结点到第二级索引。 现在再来查找16,只需要6个结点。

前面讲的这种链表加多级索引的结构,就是跳表。接下来分析一下时间复杂度和空间复杂度。

 

时间复杂度:

我们先来看,一个具有n个结点的跳表,有多少层呢?

如果按照我们刚才讲的,在原始链表上,每两个结点抽取一个结点作为上一级索引的结点,那第一级索引的结点个数就是n/2。第二季索引的结点个数就是n/4,第三层结点的个数就是n/8,依次类推,第k层的结点数是n/2^k。当n/2^k = 2的时候,是最后一层,也就是k = log2n -1。如果加上原始链表,那整个跳表的层数及时log2n。

我们在跳表中查询某个数据的时候,如果每一层都要遍历m个结点,那在跳表中查询一个数据的时间复杂度就是O(mlogn)。那这个m是多少呢?

假设我们要查找的数据是x,在第k层索引中,我们遍历到y结点之后,发现x大于y,小于后面的结点z,所以我们通过y的down指针,从第k层索引下降到第k-1层索引。在第k-1层索引中,y和z之间只有3个结点(包含y和z),所以,我们在k-1层索引中最多只需要遍历3个结点,所以,每一级索引最多只需要遍历3个结点。

所以在跳表中查询任意数据的时间复杂度是O(logn)。这个查找的时间复杂度和二分查找是一样的。不过这种效率的提升,前提是建立了很多的索引,也就是我们的空间换时间的设计思路。

 

空间复杂度:

比起单纯的单链表,跳表需要很多的索引。第k层索引是第k-1层的一半,也就是说,如果我们把每层索引的结点数写出来,就是一个等比数列。

 

这是一个等比数列,总和是n-2。所以,跳表的空间复杂度就是O(n)。那有没有办法降低占用的内存呢?

我们前面都是每两个结点抽取一个结点到上一级索引,如果我们抽取3个或者5个,那是不是不用那么多了呢?

 

从图中,我们知道,第一级需要大约n/3个结点,第二级是n/9个节点。每一层都除以3,也是一个等比数列。 为了方便计算,我们假设最后一个结点个数为1。

通过求和,我们知道,总的索引结点是n/2。尽管空间复杂度还是O(n),但实际占用的比每两个结点抽取一个减少了一半的索引结点存储空间。但是,时间复杂度也变了,如果每3个抽取一个的话,那么在查询的过程中,每一层最多要遍历的次数就是4,时间复杂度前面的系数就是4。

实际上,在软件开发中,原始链表中存储的有可能是很大的对象,而索引节点只需要存储关键值和几个指针,并不需要存储对象,所以当对象比索引结点大很多的时候,那索引占用的额外空间就可以忽略了。

 高效的插入与删除操作

实际上,跳表这个动态数据结构,不仅支持查找操作,还支持动态的插入、删除操作,而且插入、删除操作的时间复杂度也是O(logn)。

我们知道,链表的插入和删除操作的时间复杂度是O(1)。但我们需要找到待插入或者删除的位置,这个时间会比较耗时。现在,我们有跳表,查找的时间复杂度是O(logn),那删除和插入的时间复杂度就是O(logn)了。

跳表索引动态更新

当我们不停地往跳表中插入数据的时候,如果我们不更新索引,就有可能出现某2个索引结点之间数据非常多的情况。极端情况下,跳表还有可能退化成单链表。

 

作为一种动态数据结构,我们需要某种手段来维护索引与原始链表大小之间的平衡,也就是说,如果链表中结点多了,索引结点就相应增加一些,避免复杂度退化,以及查找,插入,删除的性能下降。

红黑树,AVL树这样的平衡二叉树,它们是通过左右旋的方式保持左右子树的大小平衡,而跳表是通过随机函数来维护前面提到的“平衡性” 。

当我们往跳表中插入数据的时候,我们可以选择同时将这个数据插入到部分索引中。我们通过一个随机函数,来决定将这个结点插入到哪几级索引中,比如随机函数生成了K,那我们就将这个结点添加到第一级到第K级这K个索引中。

随机函数的选择很有讲究,从概率上讲,能够保证跳表的索引大小和数据大小平衡性,不至于性能过度退化。

Redis为什么要用跳表来实现有序集合,而不是使用红黑树?

Redis中的有序集合主要是通过跳表来实现,其实还有很多。我们先忽略其他的数据结构。我们知道,Redis中的有序集合支持的核心操作主要有下面几个:

  • 插入一个数据
  • 删除一个数据
  • 查找一个数据
  • 按照区间查找数据(比如查找值在[100,356] 之间的数据)
  • 迭代输出有序序列

其中,插入,删除,查找,迭代输出有序序列,红黑树也可以完成。时间复杂度跟跳表是一样的。但是,按照区间来查找数据这个操作,红黑树的效率没有跳表高。

对于按照区间查找数据的这个操作,跳表可以做到O(logn)的时间复杂度定位区间的起点,然后在原始链表中顺序往后遍历就可以了。另外,Redis之所以使用跳表,还因为实现比红黑树要简单。而且,跳表更加灵活,它可以通过改变索引构建策略,有效平衡执行效率和内存消耗。 

 

这里我根据王争老师的java代码,用c++ 实现了一遍,其中的forward数组相当于一个数的所有层的下一个指针。

#ifndef SKIPLIST_H
#define SKIPLIST_H
#include <iostream>
#include <random>
#include <cstdlib>
#include <time.h>
using namespace std;
#define MAX_LEVEL 8
#define NO_DATA -65535

// 跳表的结点数据结构
class skNode{
public:
    int data = NO_DATA;// 要存储的数据
    skNode** forward = new skNode*[MAX_LEVEL];// 一个存储所有层下一跳的指针数组
    int maxLevel = 0;// 该结点的最大层数
    skNode(){
        for(int i=0;i<MAX_LEVEL;++i){
            forward[i] = NULL;
        }
    }
};

class skipList{
private:
    skNode* head = new skNode;// 带头链表
    int levelCount = 1;// 跳表的层数

private:
    int randomLevel(){
        srand((unsigned int)time(0));
        int level = 1;
        for(int i=1;i<MAX_LEVEL;++i){
            int randNum = rand()%10;
            if(randNum<5){
                level++;
            }
        }
        return level;
    }
public:
    skNode* find(int elem){
        skNode* p = head;
        for(int i=levelCount-1;i>=0;--i){// 从最顶层开始查找
            while(p->forward[i] != NULL && p->forward[i]->data < elem){
                p = p->forward[i];
            }
        }
        if(p->forward[0] != NULL && p->forward[0]->data == elem){
            return p->forward[0];
        }else{
            return NULL;
        }
    }

    void insert(int elem){
        int level = randomLevel();// 随机生成一个层数
        skNode* newskNode = new skNode;
        newskNode->data = elem;
        newskNode->maxLevel = level;// 创建一个新的节点
        skNode** update = new skNode*[level];// 使用临时skNode指针数组update实现插入

        skNode* p = head;
        for(int i=level-1;i>=0;--i){// 从顶层开始,查找要插入的位置的前驱节点
            while(p->forward[i] != NULL && p->forward[i]->data < elem){
                p = p->forward[i];
            }
            update[i] = p;// 保存前驱结点
        }

        for(int i=0;i<level;++i){
            newskNode->forward[i] = update[i]->forward[i];// 相当于单链表的newskNode->next = ppre->next
            update[i]->forward[i] = newskNode;// 相当与单链表的ppre->next = newskNode
        }

        if(levelCount < level) levelCount = level;// 更新最大层数
    }
    void Delete(int elem){
        skNode** update = new skNode*[levelCount];
        skNode* p = head;
        for(int i=levelCount-1;i>=0;--i){// 找到前驱的层
            while(p->forward[i] != NULL && p->forward[i]->data < elem){
                p = p->forward[i];
            }
            update[i] = p;
        }
        if(p->forward[0] != NULL && p->forward[0]->data == elem){// 判断是否找到
            for(int i=levelCount-1;i>=0;--i){// 该值的每一层都要删除
                if(update[i]->forward[i] != NULL && update[i]->forward[i]->data == elem){// 判断是否存在等于给定值的节点
                    update[i]->forward[i] = update[i]->forward[i]->forward[i];// 相当与单链表的删除操作
                }
            }
        }
    }

    void printAll(){
        skNode* p = head;
        while(p->forward[0] != NULL){
            cout << p->forward[0]->data << " ";
            p = p->forward[0];
        }
        cout << endl;
        cout << levelCount << endl;
    }

};
#endif // SKIPLIST_H

 

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值