参考
小林:https://xiaolincoding.com/redis/data_struct/data_struct.html#%E8%B7%B3%E8%A1%A8
cmu: https://www.cs.cmu.edu/~ckingsf/bioinfo-lectures/skiplists.pdf
wiki:https://en.wikipedia.org/wiki/Skip_list
跳表
这种数据结构是由William Pugh(音译为威廉·普)发明的,最早出现于他在1990年发表的论文《Skip Lists: A Probabilistic Alternative to Balanced Trees》。
https://dl.acm.org/doi/pdf/10.1145/78973.78977
链表在查找元素的时候,因为需要逐一查找,所以查询效率非常低,时间复杂度是O(N),于是就出现了跳表。跳表是在链表基础上改进过来的(相当于给链表加上索引),实现了一种「多层」的有序链表,这样的好处是能快读定位数据。跳表的优势是能支持平均 O(logN) 复杂度的节点查找。
查找例1
查找例2
完美的跳表:
- key 按序排列
- 一共有 O(logn) 层
- 每层包含的元素数量是它下面那一层的 1/2
- 头节点(哨兵)节点在每一层(如上面的 31 节点 1、2、3、4 层都在)
- 节点大小可变,可以包含从 1 到 O(logn) 个 next 指针(如 31 节点包含4个next 指针,第一层的只包含一个指针)
- 叫做跳表是因为更高层的 list 让你可以跳过很多元素
如果要 插入 或 删除,同时保持 每层包含的元素数量是它下面那一层的 1/2 的完美结构,那么每次插入或删除都可能要重排列整个跳表。
像完美二叉搜索树一样,完美跳跃列表过于结构化,无法支持高效更新。因此,解决方法:
- 放松 每层包含的元素数量是它下面那一层的 1/2 的条件
- 取而代之地,设计一种结构,期望 每层 1/2 的元素被提升到一个新的层级
- 跳表是一种随机化的数据结构:同样的 插入/删除的顺序可能会产生不同的结构,取决于随机结果
随机化
- 随机化就意味着,允许不严格满足 每层包含的元素数量是它下面那一层的 1/2 的条件
- 每个节点提升到下一个更高层次的概率为 1/2 (不一定是 1/2,可以是一个概率 p)
- 期望 1/2 的节点在 层级1
- 期望 1/4 的节点在 层级2
- 期望 1/8 的节点在 层级3
- ......
- 期望概率会使所有节点的分布接近完美跳表
所以可以在每插入一个新元素时,随机生成这个元素所在的层数,使每一层出现的概率满足上面的要求——幂次定律:越大的数(层数)出现的概率越小
例如 p 取 0.5,每次随机生成 [0,1) 之间的一个随机数,如果这个随机数小于 0.5 并且小于限制的最高层数,那么层数+1;再次生成一个随机数,如果这个随机数小于 0.5 并且小于限制的最高层数,那么层数+1;直到生成的随机数大于 0.5 或者层数已经超过最大层数限制,那么停止继续。
每个节点的平均层数为 1/(1-p)(也是每个节点的指针个数)
- 节点层数至少为1,大于1的节点层数满足一个概率分布。
- 节点层数恰好等于1的概率为 (p^0)(1-p)
- 节点层数恰好等于2的概率为 (p^1)(1-p)
- 节点层数恰好等于3的概率为 (p^2)(1-p) 【前两层随机到 p 向上升级,第三层随机到 1-p ,不再向上升级,停止】
- 节点层数恰好等于4的概率为 (p^3)(1-p)
- 依次递推节点层数恰好等于K的概率为 [p^(k-1)](1-p)
因此如果我们要求节点的平均层数,那么也就转换成了求概率分布的期望问题了
每个节点的层数期望:
1*(p^0)(1-p) + 2*(p^1)(1-p) + 3*(p^2)(1-p) + ... + k* [p^(k-1)](1-p)
= (1-p)*[1*p^0 + 2*p^1 + 3*p^2 + ... + k*p^(k-1)]
= (1-p) * (1/(1-p)^2)
= 1/(1-p)
插入例
删除例
跳表相对于平衡树的优势
- 从内存占用上来比较,跳表比平衡树更灵活一些。平衡树每个节点包含 2 个指针(分别指向左右子树),而跳表每个节点包含的指针数目平均为 1/(1-p),具体取决于参数 p 的大小。如果像 Redis里的实现一样,取 p=1/4,那么平均每个节点包含 1.33 个指针(1/(1-p)),比平衡树更有优势。
- 在做 ZRANGE 或 ZREVRANGE 范围查找 的时候,跳表比平衡树操作要简单。在平衡树上,我们找到指定范围的小值之后,还需要以中序遍历的顺序(中序遍历是有序的)继续寻找其它不超过大值的节点。如果不对平衡树进行一定的改造,这里的中序遍历并不容易实现。而在跳表上进行范围查找就非常简单,只需要在找到小值之后,对第 1 层链表进行若干步的遍历(最底层包含所有元素且有序)就可以实现。
- 从算法实现难度上来比较,跳表比平衡树要简单得多。平衡树的插入和删除操作可能引发子树的调整,逻辑复杂,而跳表的插入和删除只需要修改相邻节点的指针,操作简单又快速。
Redis 跳表
Redis 只有 Zset 对象的底层实现用到了跳表,跳表的优势是能支持平均 O(logN) 复杂度的节点查找。
zset 结构体
有两个数据结构:一个是跳表,一个是哈希表。这样的好处是既能进行高效的 范围查询(跳表),也能进行高效 单点查询 (哈希表)。
Zset 对象在执行数据插入或是数据更新的过程中,会依次在跳表和哈希表中插入或更新相应的数据,从而保证了跳表和哈希表中记录的信息一致。
typedef struct zset { dict *dict; zskiplist *zsl; } zset;
「跳表」结构体
定义哪个跳表节点是头节点
typedef struct zskiplist { struct zskiplistNode *header, *tail; unsigned long length; int level; } zskiplist;
「跳表节点」的数据结构
- 「元素」:sds 类型的 ele 变量
- 「元素的权重」:double 类型的 score 变量
- 「节点的 level 数组」:该节点每个层级指向下个节点的指针。leve[0] 表示第一层的 forward 指针,leve[1] 表示第二层的 forward 的指针。每个 level 元素,还包含 「跨度」span,跨度是用来记录两个节点之间的距离。
- 「后向指针」:指向(第一层的)前一个节点,目的是为了方便从跳表的尾节点开始访问节点,这样倒序查找时很方便。
typedef struct zskiplistNode { //Zset 对象的元素值 sds ele; //元素权重值 double score; //后向指针 struct zskiplistNode *backward; //节点的level数组,保存每层上的前向指针和跨度 struct zskiplistLevel { struct zskiplistNode *forward; unsigned long span; } level[]; } zskiplistNode;
跨度 实际上是为了 计算这个节点在跳表中的排位。具体怎么做的呢?因为跳表中的节点都是按序排列的,那么计算某个节点排位的时候,从头节点点到该结点的查询路径上,将沿途访问过的所有层的跨度累加起来,得到的结果就是目标节点在跳表中的排位。
Redis 里面随机化的概率 p 取 1/4 , 即 每个节点提升到下一个更高层次的概率为 1/4
标签:struct,元素,Redis,跳表,层数,节点,指针 From: https://www.cnblogs.com/suBlog/p/17419111.html