深入理解跳表:多层索引加速查找的经典实现

#『Java分布式系统开发:从理论到实践』征文活动#

跳表(Skip List)是一种多层有序链表结构,通过引入多级索引加速查找,其核心设计类似于“立体高速公路系统”,底层是原始链表,上面有各种高度的"高架桥"。

高层道路跨度大,连接远方节点;底层道路密集,保留所有细节。

跳表不仅能提高搜索性能,同时也可以提高插入和删除操作的性能。

它在性能上和红黑树AVL树不相上下,但是跳表的原理非常简单,实现也比红黑树简单很多。

跳表就能让链表拥有近乎的接近二分查找的效率的一种数据结构,其原理依然是给上面加若干层索引,优化查找速度,典型的空间换取时间的方式
在这里插入图片描述

底层(Level 0)完整的有序单链表,包含所有元素,例如 1 → 3 → 5 → 7 → 9。

上层索引(Level 1~N)

  • 每层节点数约为下层的 1/2(概率性生成,如概率P=0.5时,一般情况下为:0.25(redis使用的就是0.25),表示的是上一层的节点数=当前层*0.25)。
  • 高层节点稀疏但跨度大,底层节点密集。

示例结构(3层跳表):

Level 2: 1 ---------------------> 9  
Level 1: 1 -----> 5 -----> 7 -----> 9  
Level 0: 13579  

每个跳表节点包含一个值一个指针数组forward

这个数组长度就是节点的层级数,指向同层下一个节点。

在Redis的实现中,节点还包含后退指针(backward)和跨度(span)信息

1.查询操作

非常的简单,思想就是二分思想
在这里插入图片描述

// search
public SkipNode<T> search(int key) {
    // 初始化开始节点为头节点
    SkipNode<T> p = head;
    // 在当前的节点不是空的情况下,有三种情况:
    while (p != null) {
        if (p.getKey() == key) {
            // 如果当前节点的key等于要查找的key,则返回当前节点
            return p;
        } else if (p.getNext() == null || p.getNext().getKey() >  key) {
            // 如果右侧没有了,或者右侧的key大于要查找的key,则继续往下找
            p = p.getDown();
        } else {
            p = p.getNext(); // 否则向右找
        }
    }
    return null;
}

2.删除操作

删除操作比起查询稍微复杂一些,但是比插入简单。

删除需要改变链表结构所以需要处理好节点之间的联系。对于删除操作你需要谨记以下几点:

  1. 删除当前节点和这个节点的前后节点都有关系
  2. 删除当前层节点之后,下一层该key的节点也要删除,一直删除到最底层
    在这里插入图片描述

例如上图删除10节点,流程如下:

  • team=head 从 team 出发,7 < 10 向右
  • 右侧为null只能向下;
  • 右侧为10在当前层删除10节点然后向下继续查找下一层10节点;
  • 8 < 10 向右;第五步右侧为10删除该节点并且team向下。
  • team为null说明删除完毕退出循环。
// delete
public void delete(int key) {
    SkipNode<T> term = head;  // 初始化当前节点
    while (term != null) {
        if (term.getNext() == null || term.getNext().getKey() > key) {
            // 如果当前节点的右侧没有节点,或者当前节点的右侧的key大于要删除的key
            // 则继续往下找
            term = term.getDown();
        } else {
            // 否则,当前节点的key等于要删除的key
            if (term.getNext().getKey() == key) {
                // 如果当前节点的key等于要删除的key,则将当前节点的next指向当前节点的next的next
                term.setNext(term.getNext().getNext());
                term = term.getDown();
            } else {
                // 否则,当前节点的key不等于要删除的key,则继续向右找
                term = term.getNext();
            }
        }
    }
}

3 插入操作

插入需要考虑是否插入索引,插入几层等问题。

由于需要插入删除所以我们肯定无法维护一个完全理想的索引结构,因为它耗费的代价太高。但我们使用随机化的方法去判断是否向上层插入索引。

即产生一个[0-1]的随机数如果小于0.5就向上插入索引,插入完毕后再次使用随机数判断是否向上插入索引。运气好这个值可能是多层索引,运气不好只插入最底层(这是100%插入的)。

但是索引也不能不限制高度,我们一般会设置索引最高值如果大于这个值就不往上继续添加索引了。

具体插入步骤:

步骤1:随机决定层数java

// 为新节点随机决定层数
int insertLevel = randomLevel(); // 比如随机得到3层
private int randomLevel() {
    int level = 1;
    // 每次有1/2的概率继续向上
    while (Math.random() < 0.5 && level < MAX_LEVEL) {
        //MAX_LEVEL:系统设定的最高层,不是当前的最高层
        level++;
    }
    return level;
}

步骤2:从最高层开始查找

// 从跳表的最高层开始查找,记录每层的插入位置
Node[] update = new Node[MAX_LEVEL];
Node current = header;

// 从最高层向下查找
for (int i = MAX_LEVEL - 1; i >= 0; i--) {
    while (current.next[i] != null && current.next[i].value < targetValue) {
        current = current.next[i];
    }
    update[i] = current; // 记录第i层的插入位置
}

步骤3:逐层插入java

// 从底层开始逐层插入
for (int i = 0; i < insertLevel; i++) {
    newNode.next[i] = update[i].next[i];
    update[i].next[i] = newNode;
}

具体示例演示:

  • 随机层数在当前层数内
初始跳表:
Level 3: header ---------------------> 9
Level 2: header -----> 5 -----------> 9  
Level 1: header -----> 5 -----> 7 --> 9
Level 0: header -> 3 -> 5 -> 7 -> 9

插入值6,随机层数为2:

步骤1:随机决定层数 = 2

步骤2:从最高层开始查找
- Level 3: header -> 9 (6 < 9),记录update[3] = header
- Level 2: header -> 5 -> 9 (6 > 5),记录update[2] = 5
- Level 1: header -> 5 -> 7 (6 < 7),记录update[1] = 5
- Level 0: header -> 5 -> 7 (6 < 7),记录update[0] = 5

步骤3:逐层插入(只在Level 1Level 0插入)
- Level 1: 5 -> 6 -> 7
- Level 0: 5 -> 6 -> 7

结果:
Level 3: header ---------------------> 9
Level 2: header -----> 5 -----------> 9  
Level 1: header -----> 5 -> 6 -----> 7 --> 9
Level 0: header -> 3 -> 5 -> 6 -> 7 -> 9
  • 随机层数超过最高层
初始跳表:
Level 3: header ---------------------> 9
Level 2: header -----> 5 -----------> 9  
Level 1: header -----> 5 -----> 7 --> 9
Level 0: header -> 3 -> 5 -> 7 -> 9

插入值6,随机层数为5,当前最高层:4

步骤1:随机决定层数 = 5

步骤2:从最高层开始查找
- Level 4: header -> null (6 < null),记录update[4] = header
- Level 3: header -> 9 (6 < 9),记录update[3] = header
- Level 2: header -> 5 -> 9 (6 > 5),记录update[2] = 5
- Level 1: header -> 5 -> 7 (6 < 7),记录update[1] = 5
- Level 0: header -> 5 -> 7 (6 < 7),记录update[0] = 5

步骤3:创建新节点
Node newNode = new Node(6, 5); // 值6,层数5

步骤4:从底层开始逐层插入
//由于随机插入的层数是新的最高层
//所以逐层插入(在Level 4、Level 3、Level 2、Level 1、Level 0插入))

- Level 4: header -> 6 -> null
- Level 3: header -> 6 -> 9
- Level 2: header -> 5 -> 6 -> 9
- Level 1: header -> 5 -> 6 -> 7 -> 9
- Level 0: header -> 3 -> 5 -> 6 -> 7 -> 9

为何这样设计?

  1. 查找需要从高层开始,因为高层跨度大
  2. 插入需要从底层开始,因为需要维护指针关系

为何要限制最高层数:

  1. 内存使用:防止层数过多导致内存浪费
  2. 性能考虑:层数过多反而影响性能
  3. 实用性:实际应用中很少需要超过32层
  4. 概率分析:32层已经足够处理大量数据
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

ZNineSun

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值