【数据结构】跳表介绍 + 手写简单的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方法很麻烦,需要考虑很多,对于头节点的维护和添加数据的前驱节点的保存都是比较麻烦的。
相关推荐
K神9 分钟前
Spring Cloud Gateway实现分布式限流和熔断降级
后端
晴空闲雲41 分钟前
数据结构与算法-线性表-线性表的应用
数据结构
import_random1 小时前
[macos]rocketmq(安装)
后端
程序员小假2 小时前
你会不会使用 SpringBoot 整合 Flowable 快速实现工作流呢?
java·后端
明月与玄武2 小时前
快速掌握Django框架设计思想(图解版)
后端·python·django
陪我一起学编程2 小时前
关于ORM增删改查的总结——跨表
数据库·后端·python·django·restful
wangjialelele2 小时前
双向链表——(有头双向循环链表)
数据结构·链表
南囝coding2 小时前
这个 361K Star 的项目,一定要收藏!
前端·后端·github
梦境虽美,却不长2 小时前
数据结构 学习 链表 2025年6月14日08点01分
数据结构·学习·链表
虎鲸不是鱼3 小时前
Spring Boot3流式访问Dify聊天助手接口
java·spring boot·后端·大模型·llm