【数据结构】跳表介绍 + 手写简单的SkipList

跳表

什么是跳表

跳表是优化之后之后的链表结构, 它把链表分层,越上层的链表跨度越大,根据范围来寻找数据变得更加方便了。

一般的链表我们需要从头查到尾,才能找到我们所需要的数据,而使用跳表,我们很快就能定位所需的数据的位置范围,再在这个小范围遍历就行了,速度肯定是比遍历整个链表要快的。

下面是我偷的一张图:

当查询时,先从最高级索引开始查,确定范围后进入下一层,直到最后一层进行范围遍历。

跳表的特点

  1. 跳表的查询时间复杂度为O(logn)
  2. 跳表的节点存储数据,指向下一个节点的指针,指向下层数据的下指针
  3. 虽然速度变快了,但是由于有下层指针,多个重复节点,多个头指针,所以所需内存比链表大

跳表的使用场景

  1. 需要快速插入、删除和查找的场合
  2. 当数据规模比较大,链表效率很低,换为跳表。
  3. 当数据频繁修改, 树结构重新平衡成本高时。
  4. redis排序集合也使用了跳表

Java中跳表的实现

Java中的ConcurrentSkipListMap是一种基于跳表实现的并发Map。

它的主要特点是:

  1. 基于跳表实现。数据结构是多层链表,查询效率高。
  2. 支持并发。任何时刻都可以有多个线程同时读写它,且操作是安全的。
  3. 键是有序的。遍历返回的迭代器是根据键的自然排序顺序。
  4. 键和值不能是null。

ConcurrentSkipListMap的用法和一般Map基本相同:

ini 复制代码
ConcurrentSkipListMap<K, V> map = new ConcurrentSkipListMap<>();

map.put(key1, value1);
map.put(key2, value2);

V value = map.get(key1);

map.remove(key2);

// 遍历  
for (K key : map.keySet()) {
    V value = map.get(key);
}

不同的是:

  • 基于跳表实现的,效率更高。
  • 线程安全, 线程可以安全访问。

手写跳表

我的跳表只实现了最重要的查找和添加的方法。

跳表整体结构

上面是我实现的跳表的结构,依次是:

  • random, 这是一个用于生成随机数的类
  • p 生成上一层的概率
  • maxLevel: 跳表的最大层数, 防止跳表太多层,导致过于细化,反而变慢
  • nowlevel: 当前跳表的层数,起始状态层数为0
  • cmp: 比较器,我们要把数据按照顺序排,比较器肯定不可少
  • head: 头指针,跳表为链表,头指针指向最开始的位置
  • 构造方法
  • 通过key来查找
  • 添加元素
  • 展示跳表数据
  • 节点的静态类

首先, random和p是结合起来用的: 因为我们维护这个跳表,刚开始都是0层的结构,我们每次添加数据时;要让这个数据进行随机数判断,如果随机数大于我们设定的p,那就让它在上一层也创建一个节点,这样可能不好理解,我画个图:

p 和 random就是用来进行上图的随机数判断。

节点设为静态内部类的好处

  1. 当节点被设置为静态内部类时,它的生命周期与外部类相同,它不需要外部类的实例化就可以访问。

  2. 其它类可以直接使用它,导入使用: 外部类.静态类

静态节点类

kotlin 复制代码
static class Node<K, V> {
    private final K key;
    private V value;
    private Node<K, V> down;
    private Node<K, V> right;

    Node(K key, V value) {
        this.key = key;
        this.value = value;
    }
}

最基本的键值对,然后还有指向下一个节点的指针,指向右边节点的指针。

跳表构造函数

ini 复制代码
SkipList(Comparator<K> comparator) {
    this.cmp = comparator;
    head = new Node<>(null, null);
    random = new Random();
}
  • 传入比较器
  • 初始化随机数种子
  • 初始化头节点,头结点我们默认是将其设为 key: null, value: null

查找结点方法

kotlin 复制代码
// 查询
public Node<K, V> get(K key) {
    Node<K, V> temp = head;
    while (temp != null) {
        // 找到了
        if (temp.key != null && temp.key.equals(key)) return temp;
        // 表示该层已经到尾部
        if (temp.right == null) {
            // 只能往下了
            temp = temp.down;
            // 当前层右边比要找的key大
        } else if (cmp.compare(temp.right.key, key) > 0) {
            // 也只能往下了
            temp = temp.down;
        }
        // 剩下只能在当层是往右边找了
        else temp = temp.right;
    }
    return null;
}

这个方法的返回值我设为结点,当然你可以只用返回value,不影响。

方法逻辑:

  • 从头开始找,找到就返回,找不到再分两种情况:
    1. 本层找完了,没有,前往下层
    2. 本层找到比它大的,证明在一个区间里,前往下层遍历。
  • 找不到,返回null

添加新节点

ini 复制代码
// 添加新节点
public void add(Node<K, V> node) {
    Node<K, V> find = get(node.key);
    if (find != null) {
        while (find != null) {
            find.value = node.value;
            find = find.down;
        }
        return;
    }
    // 跳表里没有要添加的数据
    else {
        Node<K, V> temp = head;

        ArrayDeque<Node<K, V>> leftNodes = new ArrayDeque<>();

        while (temp != null) {
            // 该层已经没有了
            if (temp.right == null) {
                leftNodes.push(temp);
                temp = temp.down;
            } else if (cmp.compare(temp.right.key, node.key) > 0) {
                leftNodes.push(temp);
                temp = temp.down;
            } else temp = temp.right;
        }
        Node<K, V> nowDown = null;
        int level = 1;
        // 此时temp为null。
        while (!leftNodes.isEmpty()) {
            Node<K, V> left = leftNodes.pop();

            Node<K, V> newNode = new Node<>(node.key, node.value);
            // 在末尾
            if (left.right != null) {
                newNode.right = left.right;
            }
            left.right = newNode;
            newNode.down = nowDown;
            nowDown = newNode;
            // 上面是否需要节点
            if (level > maxLevel) break;

            if (random.nextDouble() > p) break;
            level++;
            // 这里表示
            if (level > nowLevel) {
                nowLevel = level;
                //需要创建一个新的头节点
                Node<K, V> highHeadNode = new Node<>(null, null);
                highHeadNode.down = head;
                head = highHeadNode;//改变head
                leftNodes.push(highHeadNode);
            }
        }
    }
}

这部分是整个代码的关键,很难很麻烦。

方法逻辑:

  1. 添加数据,先寻找有没有该数据,调用get方法;如果找到,就覆盖它的值,并把每一层和它相同的都覆盖掉,返回。找不到,就进行下面操作
  2. 我们在添加时是不是需要知道它的前一个结点和下面的结点,这样我才能成功添加数据,维持链表结构;所以我从头开始遍历,为的是寻找哪个地方适合我的值的插入,把每一层适合插入的地方放进栈里面。

栈里面放的就是每一层插入节点放入时的前驱节点。 3. 然后对栈里面的元素进行遍历,我们放的时候是从高到低,因此,取的时候就是从低到高层,最低层是肯定要插入的;至于是否有高层,我们使用随机数去判断,因此又分两种情况

  • 如果随机数失败,那就不会有高层
  • 如果成功,则再进行判断是否超过允许的最高层
    • 如果超过,则不允许添加,代码结束
    • 如果不超过最高层,则可以往高层添加,这时又要判断是否超过当前的层数
      • 如果没超过当前层数,继续遍历栈里面的内容
      • 如果超过当前层数,证明head指针需要改变了,head应该指向上一层;这时,创建一个新的head,将新的head下指针指向当前head,并且将该新头指针放入栈,这样可以继续遍历,因为此时栈肯定为空(因为现在的level已经超出当前高度了);不断这样,直到随机数失效。

遍历方法

ini 复制代码
public void lists() {
    Node<K, V> temp = head;
    Node<K, V> headNode = head;

    while (temp != null) {
        if (temp.value == null) {
            System.out.print("head -> ");
        } else {
            // 找最下层的节点
            System.out.printf("%-2s->", temp.value);
            if(temp.right == null){
                System.out.print(" null");
            }
        }
        if (temp.right == null) {
            temp = headNode.down;
            headNode = headNode.down;
            System.out.println();
        } else temp = temp.right;
    }
}

很好理解,就是一层层遍历

方法测试

typescript 复制代码
public static void main(String[] args) {
    SkipList<String, String> stringStringSkipList = new SkipList<String, String>((o1, o2) -> {
        if (o1 == null) return -1;
        else {
            return o1.compareTo(o2);
        }
    });
    for (char c = 'a'; c <= 'z'; c++) {
        stringStringSkipList.add(new Node<>("" + c, "" + c));
    }
    stringStringSkipList.lists();
    System.out.println("下面进行add覆盖操作");
    stringStringSkipList.add(new Node<>("b", "我原来是b,但现在我变了"));
    stringStringSkipList.lists();
}

这里需要注意,传进去一个比较器,该比较器要让null值永远为最小,因为我的跳表类里的head的key是null,我要让它最小,所以只能出此下策。

输出分析

head -> x -> null

head -> x -> null

head -> e ->x -> null

head -> b ->d ->e ->o ->r ->x -> null

head -> b ->c ->d ->e ->g ->m ->n ->o ->q ->r ->s ->u ->w ->x ->y ->z -> null

head -> a ->b ->c ->d ->e ->f ->g ->h ->i ->j ->k ->l ->m ->n ->o ->p ->q ->r ->s ->t ->u ->v ->w ->x ->y ->z -> null

下面进行add覆盖操作:

head -> x -> null

head -> x -> null

head -> e ->x -> null

head -> 我原来是b,但现在我变了->d ->e ->o ->r ->x -> null

head -> 我原来是b,但现在我变了->c ->d ->e ->g ->m ->n ->o ->q ->r ->s ->u ->w ->x ->y ->z -> null

head -> a ->我原来是b,但现在我变了->c ->d ->e ->f ->g ->h ->i ->j ->k ->l ->m ->n ->o ->p ->q ->r ->s ->t ->u ->v ->w ->x ->y ->z -> null

分析:

  1. 我们输出发现键值对是按照我传入的规则来的,证明一般添加是成功的
  2. 然后验证覆盖操作,观察结果也是没毛病的。

至此,基本功能完成。

总结

  1. 跳表是一个优化的链表结构,查询,删除和修改都比较快O(logn)
  2. 跳表适合于需要快速查询,删除,修改的场景,redis的排序集合就使用跳表
  3. Java里的ConcurrentSkipMap就是使用的跳表来实现的,键和值都不能为null,而且它是线程安全的
  4. 手写跳表特别是add方法很麻烦,需要考虑很多,对于头节点的维护和添加数据的前驱节点的保存都是比较麻烦的。
相关推荐
2401_857636392 分钟前
计算机课程管理平台:Spring Boot与工程认证的结合
java·spring boot·后端
也无晴也无风雨1 小时前
深入剖析输入URL按下回车,浏览器做了什么
前端·后端·计算机网络
李元豪3 小时前
【智鹿空间】c++实现了一个简单的链表数据结构 MyList,其中包含基本的 Get 和 Modify 操作,
数据结构·c++·链表
我不是星海3 小时前
1.集合体系补充(1)
java·数据结构
2401_857610034 小时前
多维视角下的知识管理:Spring Boot应用
java·spring boot·后端
代码小鑫4 小时前
A027-基于Spring Boot的农事管理系统
java·开发语言·数据库·spring boot·后端·毕业设计
颜淡慕潇6 小时前
【K8S问题系列 | 9】如何监控集群CPU使用率并设置告警?
后端·云原生·容器·kubernetes·问题解决
独泪了无痕6 小时前
WebStorm 如何调试 Vue 项目
后端·webstorm
Darkwanderor7 小时前
用数组实现小根堆
c语言·数据结构·二叉树·
怒放吧德德7 小时前
JUC从实战到源码:JMM总得认识一下吧
java·jvm·后端