【Redis】 数据结构:SDS、跳跃表等底层数据结构详解

news2024/12/23 23:55:13

【Redis】 数据结构:SDS、跳跃表等底层数据结构详解

文章目录

  • 【Redis】 数据结构:SDS、跳跃表等底层数据结构详解
    • 底层数据结构引入
    • Redis数据结构 - 动态字符串 SDS
      • SDS 概述
      • SDS动态扩容
      • 为什么使用SDS
      • 小结
    • Redis数据结构 - 整数集 intset
      • IntSet概述
      • 内存布局图
      • IntSet的升级
      • 小结
    • Redis数据结构 - 字典/哈希表 Dict
      • Dict概述
      • 添加元素过程
        • 解决哈希冲突
      • Dict的扩容
      • Dict的rehash
      • 渐近式 rehash
    • Redis数据结构 - 压缩列表 ZipList
      • ZipList概述
      • ZipListEntry概述
        • Encoding编码
      • 为什么ZipList特别省内存
      • ZipList的连锁更新问题
      • Ziplist的缺点
      • ZipList总结
    • Redis数据结构 - 快表 QuickList
      • QuickList引入
      • QuickList内存布局图
      • QuickList更多额外信息
      • QuickList总结
    • Redis数据结构 - 跳跃表 SkipList
      • 跳跃表概述
      • 为什么使用跳跃表
      • 本质是解决查找问题
      • 更进一步的跳跃表
      • 跳跃表的实现
      • SkipList的设计
      • SkipList总结

参考资料:

带你看Redis数据结构底层系列-SDS

Redis—5种基本数据结构

Redis—跳跃表

图解 Redis 数据结构

Redis 常见数据类型和应用场景

Reids—神奇的HyperLoglog解决统计问题

面试杀手锏:Redis源码之SDS

面试杀手锏:Redis源码之BitMap

底层数据结构引入

在对对象机制(redisObject)有了初步认识之后,我们便可以继续理解如下的底层数据结构部分:

image-20221109181048512

  • 简单动态字符串 - sds
  • 压缩列表 - ZipList
  • 快表 - QuickList
  • 字典/哈希表 - Dict
  • 整数集 - IntSet
  • 跳表 - ZSkipList

Redis数据结构 - 动态字符串 SDS

Redis 是用 C 语言写的,但是对于Redis的字符串,却不是 C 语言中的字符串(即以空字符’\0’结尾的字符数组),它是自己构建了一种名为 简单动态字符串(simple dynamic string,SDS)的抽象类型,并将 SDS 作为 Redis的默认字符串表示。

SDS 概述

这是一种用于存储二进制数据的一种结构, 具有动态扩容的特点. 其实现位于src/sds.h与src/sds.c中。

  • SDS的总体概览如下图:

image-20221109181437642

其中sdshdr是头部, buf是真实存储用户数据的地方. 另外注意, 从命名上能看出来, 这个数据结构除了能存储二进制数据, 显然是用于设计作为字符串使用的, 所以在buf中, 用户数据后总跟着一个\0. 即图中 "数据" + "\0"是为所谓的buf。

  • Redis是C语言实现的,其中SDS是一个结构体,源码如下:

image-20221109181527160

image-20230219205645124

例如,一个包含字符串“name”的sds结构如下:

image-20221109181543090

  • 如下是6.0源码中sds相关的结构

image-20221109181624513

通过上图我们可以看到,SDS有五种不同的头部. 其中sdshdr5实际并未使用到. 所以实际上有四种不同的头部, 分别如下:

image-20221109181642788

其中:

  • len 保存了SDS保存字符串的长度
  • buf[] 数组用来保存字符串的每个元素
  • alloc分别以uint8, uint16, uint32, uint64表示整个SDS, 除过头部与末尾的\0, 剩余的字节数.
  • flags 始终为一字节, 以低三位标示着头部的类型, 高5位未使用.

SDS动态扩容

SDS之所以叫做动态字符串,是因为它具备动态扩容的能力,例如一个内容为“hi”的SDS:

image-20221109181822509

假如我们要给SDS追加一段字符串“,Amy”,这里首先会申请新内存空间:

  • 如果新字符串小于1M,则新空间为扩展后字符串长度的两倍+1;

  • 如果新字符串大于1M,则新空间为扩展后字符串长度+1M+1。称为内存预分配。

    image-20221109181905865

思考:这种分配策略会浪费内存资源吗

答:执行过APPEND 命令的字符串会带有额外的预分配空间,这些预分配空间不会被释放,除非该字符串所对应的键被删除,或者等到关闭Redis 之后,再次启动时重新载入的字符串对象将不会有预分配空间。因为执行APPEND 命令的字符串键数量通常并不多,占用内存的体积通常也不大,所以这一般并不算什么问题。另一方面,如果执行APPEND 操作的键很多,而字符串的体积又很大的话,那可能就需要修改Redis 服务器,让它定时释放一些字符串键的预分配空间,从而更有效地使用内存。

为什么使用SDS

  • 常数复杂度获取字符串长度

由于 len 属性的存在,我们获取 SDS 字符串的长度只需要读取 len 属性,时间复杂度为 O(1)。而对于 C 语言,获取字符串的长度通常是经过遍历计数来实现的,时间复杂度为 O(n)。通过 strlen key 命令可以获取 key 的字符串长度。

C语言对字符串长度的统计,就完全来自遍历,从头遍历到末尾,直到发现空字符就停止,以此统计出字符串的长度,这样获取长度的时间复杂度来说是0(n),大概就像下面这样:

但是这样的计数方式会留下隐患,所以Redis没有采用C的字符串,我后面会提到。

  • 杜绝缓冲区溢出

我们知道在 C 语言中使用 strcat 函数来进行两个字符串的拼接,一旦没有分配足够长度的内存空间,就会造成缓冲区溢出。而对于 SDS 数据类型,在进行字符修改的时候,会首先根据记录的 len 属性检查内存空间是否满足需求,如果不满足,会进行相应的空间扩展,然后在进行修改操作,所以不会出现缓冲区溢出。

字符串拼接是我们经常做的操作,在C和Redis中一样,也是很常见的操作,但是问题就来了,C是不记录字符串长度的,一旦我们调用了拼接的函数,如果没有提前计算好内存,是会产生缓存区溢出的。

比如本来字符串长这样:

image-20230219210646142

你现在需要在后面拼接 ,但是你没计算好内存,结果就可能这样了

image-20230219210802964

  • 减少修改字符串的内存重新分配次数

C语言由于不记录字符串的长度,所以如果要修改字符串,必须要重新分配内存(先释放再申请),因为如果没有重新分配,字符串长度增大时会造成内存缓冲区溢出,字符串长度减小时会造成内存泄露。

而对于SDS,由于len属性和alloc属性的存在,对于修改字符串SDS实现了空间预分配惰性空间释放两种策略:

1、空间预分配:对字符串进行空 间扩展的时候,扩展的内存比实际需要的多,这样可以减少连续执行字符串增长操作所需的内存重分配次数。

比如现在有这样的一个字符:

image-20230219212924889

我们调用了拼接函数,字符串边长了,Redis还会根据算法计算出一个free值给他备用:

image-20230219212934659

我们再继续拼接,你会发现,备用的free用上了,省去了这次的内存重分配:

image-20230219212942749

2、惰性空间释放:对字符串进行缩短操作时,程序不立即使用内存重新分配来回收缩短后多余的字节,而是使用 alloc 属性将这些字节的数量记录下来,等待后续使用。(当然SDS也提供了相应的API,当我们有需要时,也可以手动释放这些未使用的空间。)

还是一样的字符串:

image-20230219213034473

当我们调用了删减的函数,并不会马上释放掉free空间:

image-20230219213045310

如果我们需要继续添加这个空间就能用上了,减少了内存的重分配,如果空间不需要了,调用函数删掉就好了:

image-20230219213058783

  • 二进制安全

仔细看的仔肯定看到上面我不止一次提到了空字符也就是’\0‘,C语言是判断空字符去判断一个字符的长度的,但是有很多数据结构经常会穿插空字符在中间,比如图片,音频,视频,压缩文件的二进制数据,就比如下面这个单词,他只能识别前面的 不能识别后面的字符,那对于我们开发者而言,这样的结果显然不是我们想要的对不对。

image-20230219213352246

Redis就不存在这个问题了,他不是保存了字符串的长度嘛,他不判断空字符,他就判断长度对不对就好了,所以redis也经常被我们拿来保存各种二进制数据,我反正是用的很high,经常用来保存小文件的二进制。

image-20230219213419472

  • 兼容部分 C 字符串函数

虽然 SDS 是二进制安全的,但是一样遵从每个字符串都是以空字符串结尾的惯例,这样可以重用 C 语言库<string.h> 中的一部分函数。

小结

redis的字符串表示为sds,而不是C字符串(以\0结尾的char*), 它是Redis 底层所使用的字符串表示,它被用在几乎所有的Redis 模块中。可以看如下对比:

image-20221109182320410

一般来说,SDS 除了保存数据库中的字符串值以外,SDS 还可以作为缓冲区(buffer):包括 AOF 模块中的AOF缓冲区以及客户端状态中的输入缓冲区。

Redis数据结构 - 整数集 intset

IntSet概述

IntSet是Redis中set集合的一种实现方式,基于整数数组来实现,并且具备长度可变、有序等特征。结构如下:

typedef struct intset {
    uint32_t encoding;
    uint32_t length;
    int8_t contents[];
} intset;
  • encoding 表示编码方式,的取值有三个:其中的encoding包含三种模式,表示存储的整数大小不同:

    • image-20221110003129217
  • length 代表其中存储的整数的个数

  • contents 指向实际存储数值的连续内存区域, 就是一个数组;整数集合的每个元素都是 contents 数组的一个数组项(item),各个项在数组中按值得大小从小到大有序排序,且数组中不包含任何重复项。(虽然 intset 结构将 contents 属性声明为 int8_t 类型的数组,但实际上 contents 数组并不保存任何 int8_t 类型的值,contents 数组的真正类型取决于 encoding 属性的值)

内存布局图

其内存布局如下图所示:

image-20221110003218179

我们可以看到,content数组里面每个元素的数据类型是由encoding来决定的,那么如果原来的数据类型是int16, 当我们再插入一个int32类型的数据时怎么办呢?这就是下面要说的intset的升级。

IntSet的升级

为了方便查找,Redis会将intset中所有的整数按照升序依次保存在contents数组中,结构如图:

image-20221110003317227

现在,数组中每个数字都在int16_t的范围内,因此采用的编码方式是INTSET_ENC_INT16,每部分占用的字节大小为:

  • encoding:4字节
  • length:4字节
  • contents:2字节 * 3 = 6字节

image-20221110003344836

我们向该其中添加一个数字:50000,这个数字超出了int16_t的范围,intset会自动升级编码方式到合适的大小。

整个过程有三步:

  • 根据新元素的类型(比如int32),扩展整数集合底层数组的空间大小,并为新元素分配空间。

  • 将底层数组现有的所有元素都转换成与新元素相同的类型, 并将类型转换后的元素放置到正确的位上, 而且在放置元素的过程中, 需要继续维持底层数组的有序性质不变。

  • 最后改变encoding的值,length+1。

image-20221110003448318

那么如果我们删除掉刚加入的 50000 时,会不会做一个降级操作呢

  • 不会。主要还是减少开销的权衡。

升级源码如下:

image-20221110003538707

image-20221110003546997

小结

Intset可以看做是特殊的整数数组,具备一些特点:

  • Redis会确保Intset中的元素唯一、有序
  • 具备类型升级机制,可以节省内存空间
  • 底层采用二分查找方式来查询

Redis数据结构 - 字典/哈希表 Dict

Dict概述

我们知道Redis是一个键值型(Key-Value Pair)的数据库,我们可以根据键实现快速的 增删改查。而键与值的映射关系正是通过Dict来实现的。

Dict由三部分组成,分别是:哈希表(DictHashTable)、哈希节点(DictEntry)、字典(Dict)

image-20221110194331516

添加元素过程

当我们向Dict添加键值对时,Redis首先根据key计算出hash值(h),然后利用 h & sizemask来计算元素应该存储到数组中的哪个索引位置。我们存储k1=v1,假设k1的哈希值h =1,则1&3 =1,因此k1=v1要存储到数组角标1位置。

image-20221110194035599

解决哈希冲突

注意这里还有一个指向下一个哈希表节点的指针,我们知道哈希表最大的问题是存在哈希冲突,如何解决哈希冲突,有开放地址法和链地址法。这里采用的便是链地址法,通过next这个指针可以将多个哈希值相同的键值对连接在一起,用来解决哈希冲突

image-20221110194433931

Dict的扩容

Dict中的HashTable就是数组结合单向链表的实现,当集合中元素较多时,必然导致哈希冲突增多,链表过长,则查询效率会大大降低。
Dict在每次新增键值对时都会检查负载因子 ,满足以下两种情况时会触发哈希表扩容:

  • 触发扩容的条件

1、服务器目前没有执行 BGSAVE 命令或者 BGREWRITEAOF 命令,并且负载因子大于等于1。

2、服务器目前正在执行 BGSAVE 命令或者 BGREWRITEAOF 命令,并且负载因子大于等于5。

ps:负载因子 = 哈希表已保存节点数量 / 哈希表大小。

Dict的rehash

不管是扩容还是收缩,必定会创建新的哈希表,导致哈希表的size和sizemask变化,而key的查询与sizemask有关。因此必须对哈希表中的每一个key重新计算索引,插入新的哈希表,这个过程称为rehash。过程是这样的:

  • 当前状态

image-20221110195734040

  • 计算新hash表的realeSize,值取决于当前要做的是扩容还是收缩:

    image-20221110195852835

    • 如果是扩容,则新size为第一个大于等于dict.ht[0].used + 1的2^n
    • 如果是收缩,则新size为第一个大于等于dict.ht[0].used的2^n (不得小于4)
  • 按照新的realeSize申请内存空间,创建dictht,并赋值给dict.ht[1]

image-20221110200019912

  • 设置dict.rehashidx = 0,标示开始rehash

    image-20221110200104582

  • 将dict.ht[0]中的每一个dictEntry都rehash到dict.ht[1]

  • image-20221110200140256

  • 将dict.ht[1]赋值给dict.ht[0],给dict.ht[1]初始化为空哈希表,释放原来的dict.ht[0]的内存

    image-20221110200212729

    image-20221110200303487

  • 将rehashidx赋值为-1,代表rehash结束

    image-20221110200405134

  • 在rehash过程中,新增操作,则直接写入ht[1],查询、修改和删除则会在dict.ht[0]和dict.ht[1]依次查找并执行。这样可以确保ht[0]的数据只减不增,随着rehash最终为空

渐近式 rehash

什么叫渐进式 rehash?也就是说扩容和收缩操作不是一次性、集中式完成的,而是分多次、渐进式完成的。如果保存在Redis中的键值对只有几个几十个,那么 rehash 操作可以瞬间完成,但是如果键值对有几百万,几千万甚至几亿,那么要一次性的进行 rehash,势必会造成Redis一段时间内不能进行别的操作。所以Redis采用渐进式 rehash,这样在进行渐进式rehash期间,字典的删除查找更新等操作可能会在两个哈希表上进行,第一个哈希表没有找到,就会去第二个哈希表上进行查找。但是进行 增加操作,一定是在新的哈希表上进行的。

Redis数据结构 - 压缩列表 ZipList

ZipList概述

ZipList 是一种特殊的“双端链表” ,由一系列特殊编码的连续内存块组成。可以在任意一端进行压入/弹出操作, 并且该操作的时间复杂度为 O(1)。

1653985987327

1653986020491

属性类型长度用途
zlbytesuint32_t4 字节记录整个压缩列表占用的内存字节数
zltailuint32_t4 字节记录压缩列表表尾节点距离压缩列表的起始地址有多少字节,通过这个偏移量,可以确定表尾节点的地址。
zllenuint16_t2 字节记录了压缩列表包含的节点数量。 最大值为UINT16_MAX (65534),如果超过这个值,此处会记录为65535,但节点的真实数量需要遍历整个压缩列表才能计算得出。
entry列表节点不定压缩列表包含的各个节点,节点的长度由节点保存的内容决定。
zlenduint8_t1 字节特殊值 0xFF (十进制 255 ),用于标记压缩列表的末端。

ZipListEntry概述

ZipList 中的Entry并不像普通链表那样记录前后节点的指针,因为记录两个指针要占用16个字节,浪费内存。而是采用了下面的结构:

1653986055253

  • previous_entry_length:前一节点的长度,占1个或5个字节。

    • 如果前一节点的长度小于254字节,则采用1个字节来保存这个长度值
    • 如果前一节点的长度大于254字节,则采用5个字节来保存这个长度值,第一个字节为0xfe,后四个字节才是真实长度数据
  • encoding:编码属性,记录content的数据类型(字符串还是整数)以及长度,占用1个、2个或5个字节

  • contents:负责保存节点的数据,可以是字符串或整数

ZipList中所有存储长度的数值均采用小端字节序,即低位字节在前,高位字节在后。例如:数值0x1234,采用小端字节序后实际存储值为:0x3412

Encoding编码

ZipListEntry中的encoding编码分为字符串和整数两种:
字符串:如果encoding是以“00”、“01”或者“10”开头,则证明content是字符串

编码编码长度字符串大小
|00pppppp|1 bytes<= 63 bytes
|01pppppp|qqqqqqqq|2 bytes<= 16383 bytes
|10000000|qqqqqqqq|rrrrrrrr|ssssssss|tttttttt|5 bytes<= 4294967295 bytes

例如,我们要保存字符串:“ab”和 “bc”

1653986172002

ZipListEntry中的encoding编码分为字符串和整数两种:

  • 整数:如果encoding是以“11”开始,则证明content是整数,且encoding固定只占用1个字节
编码编码长度整数类型
110000001int16_t(2 bytes)
110100001int32_t(4 bytes)
111000001int64_t(8 bytes)
11110000124位有符整数(3 bytes)
1111111018位有符整数(1 bytes)
1111xxxx1直接在xxxx位置保存数值,范围从0001~1101,减1后结果为实际值

1653986282879

1653986217182

为什么ZipList特别省内存

  • ziplist节省内存是相对于普通的list来说的,如果是普通的数组,那么它每个元素占用的内存是一样的且取决于最大的那个元素(很明显它是需要预留空间的);
  • 所以ziplist在设计时就很容易想到要尽量让每个元素按照实际的内容大小存储,所以增加encoding字段,针对不同的encoding来细化存储大小;
  • 这时候还需要解决的一个问题是遍历元素时如何定位下一个元素呢?在普通数组中每个元素定长,所以不需要考虑这个问题;但是ziplist中每个data占据的内存不一样,所以为了解决遍历,需要增加记录上一个元素的length,所以增加了prelen字段

为什么我们去研究ziplist特别节省内存的数据结构? 在实际应用中,大量存储字符串的优化是需要你对底层的数据结构有一定的理解的,而ziplist在场景优化的时候也被考虑采用的首选。

ZipList的连锁更新问题

ZipList的每个Entry都包含previous_entry_length来记录上一个节点的大小,长度是1个或5个字节:
如果前一节点的长度小于254字节,则采用1个字节来保存这个长度值
如果前一节点的长度大于等于254字节,则采用5个字节来保存这个长度值,第一个字节为0xfe,后四个字节才是真实长度数据
现在,假设我们有N个连续的、长度为250~253字节之间的entry,因此entry的previous_entry_length属性用1个字节即可表示,如图所示:

1653986328124

ZipList这种特殊情况下产生的连续多次空间扩展操作称之为连锁更新(Cascade Update)。新增、删除都可能导致连锁更新的发生。

Ziplist的缺点

  • ziplist也不预留内存空间, 并且在移除结点后, 也是立即缩容, 这代表每次写操作都会进行内存分配操作。
  • 结点如果扩容, 导致结点占用的内存增长, 并且超过254字节的话, 可能会导致链式反应: 其后一个结点的entry.prevlen需要从一字节扩容至五字节. 最坏情况下, 第一个结点的扩容, 会导致整个ziplist表中的后续所有结点的entry.prevlen字段扩容. 虽然这个内存重分配的操作依然只会发生一次, 但代码中的时间复杂度是o(N)级别, 因为链式扩容只能一步一步的计算. 但这种情况的概率十分的小, 一般情况下链式扩容能连锁反映五六次就很不幸了. 之所以说这是一个蛋疼问题, 是因为, 这样的坏场景下, 其实时间复杂度并不高: 依次计算每个entry新的空间占用, 也就是o(N), 总体占用计算出来后, 只执行一次内存重分配, 与对应的memmove操作, 就可以了。

ZipList总结

ZipList特性:

  • 压缩列表的可以看做一种连续内存空间的"双向链表"
  • 列表的节点之间不是通过指针连接,而是记录上一节点和本节点长度来寻址,内存占用较低
  • 如果列表数据过多,导致链表过长,可能影响查询性能
  • 增或删较大数据时有可能发生连续更新问题

Redis数据结构 - 快表 QuickList

quicklist这个结构是Redis在3.2版本后新加的, 之前的版本是list(即linkedlist), 用于String数据类型中。

QuickList引入

  • ZipList虽然节省内存,但申请内存必须是连续空间,如果内存占用较多,申请内存效率很低。怎么办?

​ 答:为了缓解这个问题,我们必须限制ZipList的长度和entry大小。

  • 但是我们要存储大量数据,超出了ZipList最佳的上限该怎么办?

​ 答:我们可以创建多个ZipList来分片存储数据。

  • 数据拆分后比较分散,不方便管理和查找,这多个ZipList如何建立联系?

​ 答:Redis在3.2版本引入了新的数据结构QuickList,它是一个双端链表,只不过链表中的每个节点都是一个ZipList。

image-20221113174228800

QuickList内存布局图

image-20221113174317047

以下是QuickList的和QuickListNode的结构源码:

image-20221113174348015

我们接下来用一段流程图来描述当前的这个结构

image-20221113174400139

QuickList更多额外信息

为了避免QuickList中的每个ZipList中entry过多,Redis提供了一个配置项:list-max-ziplist-size来限制。
如果值为正,则代表ZipList的允许的entry个数的最大值
如果值为负,则代表ZipList的最大内存大小,分5种情况:

  • -1:每个ZipList的内存占用不能超过4kb
  • -2:每个ZipList的内存占用不能超过8kb
  • -3:每个ZipList的内存占用不能超过16kb
  • -4:每个ZipList的内存占用不能超过32kb
  • -5:每个ZipList的内存占用不能超过64kb

其默认值为 -2:

1653986642777

QuickList总结

QuickList的特点:

  • 是一个节点为ZipList的双端链表
  • 节点采用ZipList,解决了传统链表的内存占用问题
  • 控制了ZipList大小,解决连续内存空间申请效率问题
  • 中间节点可以压缩,进一步节省了内存

Redis数据结构 - 跳跃表 SkipList

跳跃表概述

SkipList(跳表)首先是链表,但与传统链表相比有几点差异:
元素按照升序排列存储
节点可能包含多个指针,指针跨度不同。

image-20230219172915186

1653986771309

为什么使用跳跃表

首先,因为 zset 要支持随机的插入和删除,所以它 不宜使用数组来实现,关于排序问题,我们也很容易就想到 红黑树/ 平衡树 这样的树形结构,为什么 Redis 不使用这样一些结构呢?

  1. 性能考虑: 在高并发的情况下,树形结构需要执行一些类似于 rebalance 这样的可能涉及整棵树的操作,相对来说跳跃表的变化只涉及局部 (下面详细说)
  2. 实现考虑: 在复杂度与红黑树相同的情况下,跳跃表实现起来更简单,看起来也更加直观;

基于以上的一些考虑,Redis 基于 William Pugh 的论文做出一些改进后采用了 跳跃表 这样的结构。

本质是解决查找问题

我们先来看一个普通的链表结构:

image-20230219173644705

我们需要这个链表按照 score 值进行排序,这也就意味着,当我们需要添加新的元素时,我们需要定位到插入点,这样才可以继续保证链表是有序的,通常我们会使用 二分查找法,但二分查找是有序数组的,链表没办法进行位置定位,我们除了遍历整个找到第一个比给定数据大的节点为止 (时间复杂度 O(n)) 似乎没有更好的办法。

但假如我们每相邻两个节点之间就增加一个指针,让指针指向下一个节点,如下图:

image-20230219192857571

这样所有新增的指针连成了一个新的链表,但它包含的数据却只有原来的一半 (图中的为 3,11)

现在假设我们想要查找数据时,可以根据这条新的链表查找,如果碰到比待查找数据大的节点时,再回到原来的链表中进行查找,比如,我们想要查找 7,查找的路径则是沿着下图中标注出的红色指针所指向的方向进行的:

image-20230219192958024

这是一个略微极端的例子,但我们仍然可以看到,通过新增加的指针查找,我们不再需要与链表上的每一个节点逐一进行比较,这样改进之后需要比较的节点数大概只有原来的一半。

利用同样的方式,我们可以在新产生的链表上,继续为每两个相邻的节点增加一个指针,从而产生第三层链表:

image-20230219193012594

在这个新的三层链表结构中,我们试着 查找 13,那么沿着最上层链表首先比较的是 11,发现 11 比 13 小,于是我们就知道只需要到 11 后面继续查找,从而一下子跳过了 11 前面的所有节点。

可以想象,当链表足够长,这样的多层链表结构可以帮助我们跳过很多下层节点,从而加快查找的效率。

更进一步的跳跃表

跳跃表 skiplist 就是受到这种多层链表结构的启发而设计出来的。按照上面生成链表的方式,上面每一层链表的节点个数,是下面一层的节点个数的一半,这样查找过程就非常类似于一个二分查找,使得查找的时间复杂度可以降低到 O(logn)

但是,这种方法在插入数据的时候有很大的问题。新插入一个节点之后,就会打乱上下相邻两层链表上节点个数严格的 2:1 的对应关系。如果要维持这种对应关系,就必须把新插入的节点后面的所有节点 (也包括新插入的节点) 重新进行调整,这会让时间复杂度重新蜕化成 O(n)。删除数据也有同样的问题。

skiplist 为了避免这一问题,它不要求上下相邻两层链表之间的节点个数有严格的对应关系,而是 为每个节点随机出一个层数(level)。比如,一个节点随机出的层数是 3,那么就把它链入到第 1 层到第 3 层这三层链表中。为了表达清楚,下图展示了如何通过一步步的插入操作从而形成一个 skiplist 的过程:

image-20230219200654598

从上面的创建和插入的过程中可以看出,每一个节点的层数(level)是随机出来的,而且新插入一个节点并不会影响到其他节点的层数,因此,插入操作只需要修改节点前后的指针,而不需要对多个节点都进行调整,这就降低了插入操作的复杂度。

现在我们假设从我们刚才创建的这个结构中查找 23 这个不存在的数,那么查找路径会如下图:

image-20230219200718968

跳跃表的实现

Redis—跳跃表

SkipList的设计

image-20221113014326196

/* ZSETs use a specialized version of Skiplists */
typedef struct zskiplistNode {
    sds ele;
    double score;
    struct zskiplistNode *backward;
    struct zskiplistLevel {
        struct zskiplistNode *forward;
        unsigned int span;
    } level[];
} zskiplistNode;

typedef struct zskiplist {
    struct zskiplistNode *header, *tail;
    unsigned long length;
    int level;
} zskiplist;

其内存布局如下图:

image-20221113014403697

zskiplist的核心设计要点

  • 头节点不持有任何数据, 且其level[]的长度为32
  • 每个结点
    • ele字段,持有数据,是sds类型
    • score字段, 其标示着结点的得分, 结点之间凭借得分来判断先后顺序, 跳跃表中的结点按结点的得分升序排列.
    • backward指针, 这是原版跳跃表中所没有的. 该指针指向结点的前一个紧邻结点.
    • level字段, 用以记录所有结点(除过头节点外);每个结点中最多持有32个zskiplistLevel结构. 实际数量在结点创建时, 按幂次定律随机生成(不超过32). 每个zskiplistLevel中有两个字段
      • forward字段指向比自己得分高的某个结点(不一定是紧邻的), 并且, 若当前zskiplistLevel实例在level[]中的索引为X, 则其forward字段指向的结点, 其level[]字段的容量至少是X+1. 这也是上图中, 为什么forward指针总是画的水平的原因.
      • span字段代表forward字段指向的结点, 距离当前结点的距离. 紧邻的两个结点之间的距离定义为1

image-20221113014555243

SkipList总结

SkipList的特点:

  • 跳跃表是一个双向链表,每个节点都包含score和ele值
  • 节点按照score值排序,score值一样则按照ele字典排序
  • 每个节点都可以包含多层指针,层数是1到32之间的随机数
  • 不同层指针到下一个节点的跨度不同,层级越高,跨度越大
  • 增删改查效率与红黑树基本一致,实现却更简单

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/358980.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

从JDK源码来看XXE的触发原理和对应的防御手段

前言 这几天继续在重写GadgetInspector工具&#xff0c;进一步的增强该自动化工具的source点和sink点&#xff0c;同时增强过程中的漏报和误报的问题。 这里主要是对其中有关于XXE中的两点sink进行几点分析。 sinks DocumentBuilder类 这个JDK中内置的类是一种的DOM型的解…

基础组件之内存池

内存池技术 操作系统在运行进程的过程中&#xff0c;会产生内存碎片&#xff0c;降低了内存的使用率。内存池技术就是为了解决/减少内存碎片的一种方法&#xff0c;内部底层的具体实现根据不同业务场景使用不要的方式&#xff0c;以下是一种好理解的方式&#xff0c;供大家一起…

光学分辨率光声显微镜中基于深度学习的运动校正算法

在这项研究中&#xff0c;我们提出了一种基于深度学习的方法来校正光学分辨率光声显微镜 (OR-PAM) 中的运动伪影。该方法是一种卷积神经网络&#xff0c;它从具有运动伪影的输入原始数据建立端到端映射&#xff0c;以输出校正后的图像。首先&#xff0c;我们进行了仿真研究&…

PYTHON爬虫基础

一、安装package 在使用爬虫前&#xff0c;需要先安装三个包&#xff0c;requests、BeautifulSoup、selenium。 输入如下代码&#xff0c;若无报错&#xff0c;则说明安装成功。 import requests from bs4 import BeautifulSoup import selenium二、Requests应用 了解了原理…

关于IDEA中Thread.activeCount()的问题记录

回顾&#xff1a; Thread类的activeCount()方法用于返回当前线程的线程组中活动线程的数量。返回的值只是一个估计值&#xff0c;因为当此方法遍历内部数据结构时&#xff0c;线程数可能会动态更改。总之它返回当前线程的线程组中活动线程的数量。 在研究多线程对同一变量的修…

【FAQ】集成分析服务的常见问题及解决方案

常见问题一&#xff1a;如何验证Analytics是否上报/接入成功&#xff1f;以及关键日志含义是什么&#xff1f; 在初始化Analytics SDK前添加SDK日志开关如下&#xff1a; HiAnalyticsTools.enableLog (); 2.初始化SDK代码如下&#xff1a; HiAnalyticsInstance instance Hi…

kettle安装部署_简单认识_Spoon勺子界面---大数据之kettle工作笔记002

然后我们来看一下这个kettle的安装,很简单,下载解压就可以了 上面的地址是官网很烂 下面的地址好一些 这个是官网可以看到很慢,很不友好 这个是下面那个地址,可以看到 最新的是9.0了,一般都用 一般都用8.2 这里下载这个就可以了 下载以后可以看到有个pdi

【代码随想录训练营】【Day19休息】【Day20】第六章|二叉树|654.最大二叉树|617.合并二叉树|700.二叉搜索树中的搜索|98.验证二叉搜索树

最大二叉树 题目详细&#xff1a;LeetCode.654 这道题在题目几乎就说明了解题的思路了&#xff1a; 创建一个根节点&#xff0c;其值为 nums 中的最大值&#xff1b;递归地在最大值左边的子数组上构建左子树&#xff1b;递归地在最大值右边的子数组上构建右子树&#xff1b;…

计算机系统基础知识

计算机的基本组成 计算机组成逻辑图 计算机部件作用 一级部件作用 运算器&#xff1a;计算机的执行部件&#xff0c;受控制器控制&#xff0c;执行算术运算或逻辑运算控制器&#xff1a;决定计算机运行过程的自动化。不仅能保证程序指令的正确执行&#xff0c;还能处理异常事…

12款适合小团队协作、任务管理和进度跟踪的在线任务管理的工具推荐?

国内外12款主流任务管理软件测评: 1.开发任务管理PingCode; 2.多合一项目任务管理Worktile;3.个人和小团队项目任务管理Notion; 4.企业任务管理平台SmartTask; 5.小团队任务管理Teambition;6.IT任务追踪管理Jira等。无论是做好工作任务管理还是个人任务管理&#xff0c;从来都不…

web网页如何实现响应式导航栏--移动端导航栏

背景&#xff1a; 一提到响应式导航栏&#xff0c;大家第一反应可能就是bootstrap响应式导航栏&#xff0c;这个响应式的一般是针对屏幕变小时&#xff0c;视口出现导航栏&#xff0c;可是&#xff0c;展示到移动端的时候&#xff0c;并没有变化&#xff1f;&#xff1f;&#…

LabVIEW利用矢量量化直方图开发人脸识别

LabVIEW利用矢量量化直方图开发人脸识别通常&#xff0c;人脸识别系统会检查场景的静止图像或视频图像&#xff0c;然后使用存储的人脸数据库识别或验证场景中的一个或多个人。我程序专注于静止图像人脸识别&#xff0c;使用来自众所周知的人脸数据库的人脸图像&#xff0c;用于…

Prometheus之pushgateway

Pushgateway简介 Pushgateway是Prometheus监控系统中的一个重要组件&#xff0c;它采用被动push的方式获取数据&#xff0c;由应用主动将数据推送到pushgateway&#xff0c;然后Prometheus再从Pushgateway抓取数据。使用Pushgateway的主要原因是&#xff1a; Prometheus和targ…

Teradata 离场,企业数据分析平台如何应对变革?

近日大数据分析和数仓软件巨头 Teradata&#xff08;TD&#xff09;宣布基于中国商业环境的评估&#xff0c;退出在中国的直接运营。TD 是全球最大的专注于大数据分析、数仓和整合营销管理解决方案的供应商之一&#xff0c;其早在 1997 年就进入中国&#xff0c;巅峰期占据半数…

基于卷积神经网络图像风格迁移系统的设计与实现(flask系统)

1.摘要 Leon Gatys 等人研发的深度神经网络使用神经的表达来分离任意图片的内容和风格&#xff0c;为生成艺术图片提供一个神经算法。本文基于Style Transfer算法&#xff0c;使用风格成本函数训练CNN&#xff0c;用卷积神经网络提取图像特征&#xff0c;依次提取内容图像的内…

数据库面试——锁的12连问,赶紧收藏!

目录 1. 为什么需要加锁 2. InnoDB有哪些锁&#xff1f; 2.1 共享/排他锁 2.2 意向锁 2.3 记录锁&#xff08;Record Lock&#xff09; 2.4 间隙锁&#xff08;Gap Lock&#xff09; 2.5 临键锁(Next-Key Lock) 2.6 插入意向锁 2.7 自增锁 3. 什么是死锁&#xff1f;如…

从红队视角看AWD攻击

AWD的权限维持 攻防兼备AWD模式是一种综合考核参赛团队攻击、防御技术能力、即时策略的比赛模式。在攻防模式中&#xff0c;参赛队伍分别防守同样配置的虚拟靶机&#xff0c;并在有限的博弈时间内&#xff0c;找到其他战队的薄弱环节进行攻击&#xff0c;同时要对自己的靶机环…

20230220华南金牌主板u盘启动

20230220华南金牌主板u盘启动 2023/2/20 10:29 百度搜索&#xff1a;华南金牌主板u盘启动 https://www.zhihu.com/question/498121895?utm_id0 华南金牌主板b85u盘启动怎么设置? 华南金牌主板b85u盘启动怎么设置 海的那边 上小学后才发现还是幼儿园好混…… 华南一般是F7和F1…

ADRC自抗扰控制总结

目录 前言 1.ADRC形式 1.1形一 1.2形二 2.被控对象 3.仿真分析 3.1仿真模型 3.2仿真结果 4.学习问题 前言 前面的3篇文章依次介绍了微分跟踪器TD、状态观测器ESO和非线性状态误差反馈NLSEF三部分内容&#xff0c;至此ADRC的结构已经介绍完毕&#xff0c;现在对分块学习…

【数据结构与算法】2.八大经典排序

文章目录简介1.分析排序算法2.插入排序2.1.直接插入排序2.2.希尔排序3.选择排序3.1.直接选择排序3.2.堆排序3.2.1.堆的数据结构3.2.2.算法实现4.交换排序4.1.冒泡排序4.2.快速排序5.归并排序6.基数排序7.八大排序算法总结简介 排序对于任何一个程序员来说&#xff0c;可能都不会…