Redis 跳表

跳表(SkipList,全称跳跃表)是用于有序元素序列快速搜索查找的一个数据结构,跳表是一个随机化的数据结构,实质上就是一种可以进行二分查找的有序链表。

跳表在原有的有序链表上增加了多级索引,通过索引来实现快速查找。跳表不仅能提高搜索性能,同时也可以提高插入和删除操作的性能。它在性能上和红黑树、AVL 树不相上下,但是跳表的原理非常简单,实现也比红黑树简单很多。

回顾链表,链表的痛点就是查询很慢很慢!每次查询都是 O(n) 复杂度的操作,下图是一个带头结点的链表(头结点相当于一个固定的入口,不存储有意义的值),每次查找都需要一个个枚举,相当得慢。

在这里插入图片描述

我们能不能稍微优化一下呢?答案是可以的,我们知道很多算法和数据结构是以空间换时间,我们在上面加一层索引,让部分节点在上层能够直接被定位到,这样链表的查询时间就近乎减少一半。

在这里插入图片描述

这样在查询某个节点时,首先会从上一层快速定位节点所在的一个范围,然后向下查找。在表结构设计上会增加一个向下的索引(指针)用来查找底层节点,平均查找速度为 O(n/2)。但是当节点数量很大的时候,它依旧很慢很慢。

我们都知道二分查找是每次都能近乎折半的去压缩查找范围,要是有序链表也能这么跳起来那就太完美了。

没错,跳表就能让链表拥有近乎接近二分查找效率的一种数据结构,其原理依然是给上面加若干层索引,优化查找速度。

在这里插入图片描述

如上图所示,通过这样的一个数据结构,对有序链表进行查找都能达到近乎二分的性能。在上面维护那么多层的索引,首先在最高级索引上查找最后一个小于当前查找元素的位置,然后再跳到次高级索引继续查找,直到跳到最底层为止,这时候已经十分接近要查找的元素的位置了(如果查找元素存在的话)。由于根据索引可以一次跳过多个元素,所以查找的速度也就变快了。

对于理想的跳表,每向上一层,索引节点数量都是下一层的 1/2。但是这样完美的结构真的存在吗?

大概率是不存在的,因为作为一个链表,少不了增删改查的一些操作。而删除和插入可能会改变整个结构,所以这是理想的结构。至于在插入的时候是否要添加上层索引,是个概率问题(1/2 的概率)。

跳表的增删改查

查询操作

查询流程很简单,设置一个临时节点 team=head。当 team 不为 null 时,其流程大致如下。

  1. 从 team 节点出发,如果当前节点的 key 与查询的 key 相等,那么直接返回当前节点(如果是修改操作,那么一直向下进行修改值即可)
  2. 如果 key 不相等,且右侧为 null,那么证明只能向下(结果可能出现在右下方),此时team=team.down
  3. 如果 key 不相等,且右侧不为 null,且右侧节点 key 小于待查询的 key。那么说明同级还可向右,此时 team=team.right
  4. 如果 key 不相等,且右侧不为 null,且右侧节点 key 大于待查询的 key 。那么说明如果有结果,则就在这个索引和下个索引之间,此时 team=team.down
  5. 最终将按照这个步骤返回正确的节点或者 null(说明没查到)

在这里插入图片描述

  1. 首先从 head 出发,发现右侧不为空,且 7 < 12,向右
  2. 右侧为 null,向下
  3. 节点 7 的右侧不为空,且 10 < 12,继续向右
  4. 节点 10 右侧为 null,向下
  5. 右侧为节点 12,小于等于,向右
  6. 发现节点值相等,返回节点,结束

删除操作

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

  • 删除当前节点,和这个节点的前后节点都有关系
  • 删除当前层节点之后,下一层该 key 的节点也要删除,一直删除到最底层为止

如果找到了当前节点,怎么查找它的前一个节点呢?总不能再遍历一遍吧。

使用四个方向的指针(上下左右)用来找到左侧节点是可以的,但是这里可以特殊处理一下 ,不直接判断和操作节点,而是先找到待删除节点的左侧节点,通过这个节点即可完成删除,然后这个节点直接向下去找下一层待删除的左侧节点。

设置一个临时节点 team=head,当 team 不为 null 时,具体循环流程如下。

  1. 如果 team 右侧为 null,那么 team=team.down(之所以敢直接这么判断,是因为左侧有头结点,所以不用担心特殊情况)
  2. 如果 team 右侧不为 null,并且右侧的 key 等于待删除的 key,那么先删除节点,再 team 向下,team=team.down,为了删除下层节点
  3. 如果 team 右侧不为 null,并且右侧 key 小于待删除的 key,那么 team 向右,team=team.right
  4. 如果 team 右侧不为 null,并且右侧 key 大于待删除的 key,那么 team 向下,team=team.down,在下层继续查找删除节点

在这里插入图片描述

  1. 首先 team=head,从 team 出发,7 < 10,向右(team=team.right 后面省略)
  2. 右侧为 null,只能向下
  3. 右侧为 10,在当前层删除 10 节点,然后向下继续查找下一层 10 节点
  4. 8 < 10,向右
  5. 右侧为 10,删除该节点,并且 team 向下
  6. team 为 null,说明删除完毕,退出循环

插入操作

插入操作实现起来是最麻烦的,需要的考虑的东西最多。回顾查询,不需要动索引,回顾删除,每层索引如果有,删除就是了。但是插入不一样,插入需要考虑是否插入索引,插入几层等问题。

由于需要插入删除所以我们肯定无法维护一个完全理想的索引结构,因为它耗费的代价太高。但我们使用随机化的方法去判断是否向上层插入索引。即产生一个[0-1]的随机数,如果小于 0.5 就向上插入索引,插入完毕后再使用随机数来判断是否再向上插入索引。

如果运气好,这个值可能是多层索引,但如果运气不好,则只插入最底层(因为这是 100% 要插入的)。

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

  1. 首先通过上面查找的方式,找到待插入的左节点。最底层肯定是需要插入的,通过链表插入节点(需要考虑是否是末尾节点)
  2. 插入完这一层,需要考虑上一层是否需要插入,首先判断当前索引层级,如果大于最大值,那么就停止(比如已经到达最高索引层了)。否则就设置一个随机数,以 1/2 的概率向上插入一层索引(因为理想状态下就是需要每 2 个向上建一个索引节点)
  3. 重复第二步的操作,直到概率退出或者索引层数大于最大索引层

在具体向上插入的时候,实际上还有非常重要的细节需要考虑。

首先是如何找到上层的待插入节点?

各个实现方法可能不同,如果有左、上指向的指针,那么可以向左向上找到上层需要插入的节点,但是如果只有右指向和下指向的指针,我们也可以巧妙地借助查询过程中记录的下降节点。因为曾经下降的节点倒序就是需要插入的节点,最底层也不例外(因为没有匹配值,会下降为 null,结束循环),可以使用栈或 List 数据结构进行存储。

在这里插入图片描述

其次是如果该层是目前的最高层索引,如何继续向上建立索引?

首先跳表最初肯定是没索引的,然后才慢慢添加节点,才有一层、二层索引,但是如果这个节点添加的索引突破了当前的最高层,该怎么办呢?

这时候需要注意,跳表的 head 需要改变了,新建一个 ListNode 节点作为新的 head,将它的 down 指向旧 head,并将这个 head 节点加入栈中(即这个节点作为下次后面要插入的节点)。例如上面的 9节点如果运气够好,会再往上建立一层节点,如下图所示。

在这里插入图片描述

简单总结,插入上层的时候,注意所有节点要新建(拷贝),除了 right 的指向,down 的指向也不能忘记。如果层数突破当前最高层,头 head 节点(入口)也需要改变。

'use strict';
// const _ = require('lodash');

class SkipNode {
    constructor(key, value, right = null, down = null) {
        this.key = key;
        this.down = down;
        this.right = right;
        this.value = value;
    }
}

class SkipList {
    constructor() {
        this.highLevel = 0;
        this.maxLevel = 32;
        this.headNode = new SkipNode(-1, null);
    }

    // 搜索节点
    search(key) {
        let tempNode = this.headNode;

        // 当节点不为null时, 遍历搜索
        while (tempNode !== null) {
            // 找到节点
            if (key === tempNode.key) return tempNode;
            // 节点右侧为null或key值比搜索的大, 则往下一层搜索
            if (tempNode.right === null || tempNode.right.key > key) {
                tempNode = tempNode.down;
            } else {
                // 节点右侧的key比搜索的小, 说明节点可能还在右侧
                tempNode = tempNode.right;
            }
        }

        return null;
    }

    // 删除节点
    delete(key) {
        let tempNode = this.headNode;

        // 当节点不为null时, 遍历搜索
        while (tempNode !== null) {
            // 节点右侧为null或节点右侧key值比搜索的大, 则往下一层搜索
            if (tempNode.right === null || tempNode.right.key > key) {
                tempNode = tempNode.down;
            } else if (key === tempNode.right.key) {
                // 找到节点, 删除并继续往下一层搜索
                tempNode.right = tempNode.right.right;
                tempNode = tempNode.down;
            } else {
                // 节点右侧key值比搜索的小, 说明节点可能还在右侧
                tempNode = tempNode.right;
            }
        }
    }

    // 添加节点
    add(node) {
        const key = node.key;
        const findNode = this.search(key);
        // 找到节点, 修改value值
        if (findNode !== null) {
            findNode.value = node.value;
            return;
        }

        // 数组记录从上往下搜索节点的过程, 其右侧可能需要添加
        const stack = [];
        let tempNode = this.headNode;
        while (tempNode !== null) {
            // 节点右侧为null或key值比搜索的大, 记录并往下一层搜索
            if (tempNode.right === null || tempNode.right.key > key) {
                stack.push(tempNode);
                tempNode = tempNode.down;
            } else {
                // 节点右侧key值小于等于搜索的, 说明还可继续往右搜索
                tempNode = tempNode.right;
            }
        }

        // 设置初始层数, 其值不可大于阈值
        let level = 1;
        // 设置初始前驱节点
        let downNode = null;

        // 从下往上倒推
        while (stack.length) {
            tempNode = stack.pop();
            // 添加的新节点
            const newNode = new SkipNode(node.key, node.value);
            newNode.down = downNode;
            downNode = newNode;

            // 节点右侧为null, 说明添加位置在末尾
            if (tempNode.right === null) {
                tempNode.right = newNode;
            } else {
                // 节点右侧key值不为null, 说明添加位置在两个节点中间
                newNode.right = tempNode.right
                tempNode.right = newNode;
            }

            // 层级超过阈值, 退出
            if (level > this.maxLevel) break;

            // 概率性退出
            // if (_.random(0, 1) < 1) break;
            if (Math.random() > 0.5) break;

            level++;
            // 比当前最大高度要高但依然在允许范围内
            if (level > this.highLevel) {
                this.highLevel = level;
                // 尝试往上一层添加
                const newHeadNode = new SkipNode(-1, null);
                newHeadNode.down = this.headNode;
                this.headNode = newHeadNode;
                stack.push(this.headNode);
            }
        }
    }

    // 打印节点
    printList() {
        let tempNode = this.headNode;
        let lastNode = tempNode;

        // 找到最底层的节点
        while (lastNode.down !== null) {
            lastNode = lastNode.down;
        }

        while (tempNode !== null) {
            // 右侧节点
            let lastRightNode = lastNode.right;
            let tempRightNode = tempNode.right;

            let line = 'head->';

            // 右侧节点都有值时
            while (lastRightNode !== null && tempRightNode !== null) {
                // 右侧节点key值相同时, 记录key值
                if (lastRightNode.key === tempRightNode.key) {
                    line = `${line}${lastRightNode.key}->`;
                    lastRightNode = lastRightNode.right;
                    tempRightNode = tempRightNode.right;
                } else {
                    // 右侧节点key值不相同时, 最底层节点往右侧搜索
                    lastRightNode = lastRightNode.right;
                    line = `${line}   `;
                }
            }

            // 继续往下一层搜索, 并打印该层节点
            tempNode = tempNode.down;
            console.log(line);
        }
    }
}

function test() {
    const list = new SkipList();
    for (let i = 0; i < 20; i++) {
        list.add(new SkipNode(i, 123));
    }
    list.printList();
    list.delete(13);
    list.printList();
}
test();
  • 1
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值