一 跳表到底是为了解决什么问题?跳表(SkipList)是一种随机化的数据结构,由William Pugh在1989年发表的论文《Skip lists: a probabilistic alternative to balanced trees》中提出,它通过在有序链表上增加多级索引实现快速查询的效果。
本文从跳表到底是为了解决什么问题、单线程下跳表的查询&删除&添加、多线程下跳表的查询&删除&添加、跳表和红黑树的对比、跳表在Havenask&redis&开源软件&业务场景中的应用、跳表常见的面试题等十三个方面进行全面解析。
跳表的发明是为了解决链表在查找效率上的问题。
传统的链表在查找元素时需要从头节点开始遍历整个链表,时间复杂度为O(n)。而跳表通过增加多级索引,使得查找元素时可以沿着多个路径进行跳跃,从而提高了查找效率。跳表的查找时间复杂度可以达到O(log n),与二分查找的时间复杂度相同。跳表的空间复杂度是O(n)。
跳表的发明对于数据结构的发展具有重要的意义,它提供了一种新的思路和方法来解决链表在查找效率上的问题。跳表不仅提高了搜索性能,同时也可以提高插入和删除操作的性能。因此,跳表得到了广泛的应用。
二 单线程下跳表的添加如果是单链表只需要找出待插入节点的前一个节点即可,但在跳表中不光要插入到原始链表中,在他上面的某些层也有可能需要插入,实现方式有随机性和确定性两种选择,其中随机性一般比较常见。比如要在跳表中插入一个元素,可以随机生成一个数字 level ,从 level 层往下每层都要插入。所以跳表中节点不光有 next 属性,还有 down 属性,他指向下一个节点的引用,先来看下跳表的节点类。
在跳表中我们还需要定义一个最大值 MAX_LEVEL ,就是跳表的最大层级数不能超过这个值。我们再来看下索引层级的随机函数,他主要用于在插入节点的时候从第几层开始插入,他是随机的,越往上机率越小,这也符合跳表的特性,越往上节点越少,最大值不能超过 MAX_LEVEL 。
插入操作较为复杂,核心点是需要考虑索引的维护,如果严格按照上一层索引个数是当前层索引个数的 1/2 规则,在插入的过程中会频繁涉及大范围索引重建,对性能影响较大。
跳表舍弃了理想状态的索引结构,而是采用随机的方式决定新增节点在上层是否需要建立索引,在保证一定索引格式的同时也极大缩小了索引维护的性能开销。
插入节点的过程一般是由下往上,从底层开始插入,如何找到待插入节点的前驱节点进行插入操作,这边可以使用栈将定位待插入位置的过程中途经的下行节点记录下来。另外如果插入的元素不断向上建立索引,最终导致索引层数超过了原跳表的最大层数,需要同时维护新的 head 指针。流程如下:
下图展示了插入【节点 6 】的过程,其中因为新增节点的索引高度超出了原来索引的最大高度,所以需要对应新增一个 head 节点;绿色节点则作为下行节点被记录下来。
三 单线程下跳表的查询查找节点的过程相对简单,设置一个临时节点 cur = head,流程如下:
下图中红色节点展示了查找【节点 8】 的路线:
四 单线程下跳表的删除删除操作的核心有两点:
设置 cur = head,流程如下:
下图展示了【节点 7】被删除的过程,其中红色节点是查找前驱节点的路线,自顶向下逐层删除节点。
五 多线程下跳表的查询跳表线程安全的操作往往借助CAS操作来完成,CAS操作的底层是乐观锁的机制,在进行修改操作以前,会记录修改前的数值,修改操作生效时会对比这个值,如果相同,CAS操作才能生效,反之进行失败重试。
一个线程安全的跳表在进行查询时,需要确保在查询过程中不会被其他线程修改,以避免数据不一致的问题。以下是一种可能的线程安全的跳表查询方法:
需要注意的是,在多线程环境下,查询操作可能会被其他线程的修改操作打断,因此需要确保在查询过程中不会被其他线程修改。此外,为了提高查询效率,可以在跳表中使用哈希表等其他数据结构来存储元素,以便快速定位目标元素。
六 多线程下跳表的添加对于跳表的数据结构而言,节点的增加是分层进行的,首先,最底层的链表储存有所有的节点,所以在这一层一定会新增一个节点,而上层链表是否需要增加节点是由随机数决定的,并不能在插入操作的瞬间就决定出来。如果想增加k值为4的节点,我们就需要优先锁定其前面的所有节点,如下图所示:
红色部分圈起来的是所有需要锁定的节点,锁定之后,先插入最底层的节点,之后随机从下往上确定是否需要继续差插入节点。插入过程结束后,解锁锁定的红色节点。
七 多线程下跳表的删除在跳表中,节点的删除是从上往下进行的,并且在锁定被删除节点的同时,还需要锁定其前面的节点。我们以单链表为例来说明这个问题:
如下所示的链表,在不锁定前置节点的情况下,如果我们需要并发删除B与C节点,可能会出现以下问题:
为了解决上面出现的问题,我们就需要同时锁定删除节点的上一个节点。推广到跳表的结构中也是一样的,在删除一个节点时,我们需要自上而下的目标节点,在删除某一节点的同时,锁定其前一个节点。
以删除节点3为例,我们需要首先锁定蓝色框中的两个节点,在完成删除操作之后,锁定之后删除红色框中的两个节点,以保证并发的安全性。
八 跳表和红黑树的对比“Redis为什么用跳表用不红黑树”在百度搜索中有1200万条相关结果
归纳总结下,跳表大概有四类优势
但比较尴尬的是,这些说法并没有数据支持,有些仅泛泛而谈。本文针对如上提到的四类跳表优势,进行量化分析。
1 跳表比红黑树性能好或相当
理由:跳表在新增删除时只需要链接前后节点,而红黑树则需要多轮的左旋和右旋
验证方案:用1000万个不重复的随机数分别进行增删查等操作
验证代码:语言使用Java,红黑树使用TreeMap,跳表使用ConcurrentSkipListMap
验证结果:
验证结论:跳表的耗时在增删查时,均比红黑树慢
但,ConcurrentSkipListMap是线程安全的,里面涉及到乐观锁和CAS锁,这也会增加一部分耗时,那么,再次使用普通的SkipListMap进行测试
再次验证结果:
再次验证结论:除了删除时跳表比红黑树快之外,在新增和查询时均慢于红黑树
即:跳表比红黑树性能好或相当, 不成立
2 跳表的范围查找更快
理由:跳表是排好序的,范围查找可以持续往下遍历,红黑树就相对麻烦些,如先上再往右,性能就变低啦
验证方案:生成1000万个不重复的随机数,然后分别构建红黑树和跳表,然后各自取出第100万-200万间的数据
验证结果:跳表比红黑树少耗时27us,即0.027ms,差异可忽略不计
即:跳表的范围查找更快,也貌似不怎么靠谱
3 跳表更节约内存
理由:跳表平均包含1.33个指针,红黑树则是2个指针。但该理由好像并站不住脚,如下是跳表和红黑树的节点结构
验证方案:生成1000万个不重复的随机数,然后分别构建红黑树和跳表
验证操作:在控制台打开JVIS,选择测试的进程
TreeMap$Entry是红黑树的内存占用情况,总计为27%
ConcurrentSkipListMap$Node和ConcurrentSkipListMap$Index是跳表的内存占用情况,总计为28.5%
即:跳表更节约内存,不成立
4 跳表更容易实现
这个属于难者不会,会者不难,还是看看redis原作者给的说法吧,redis的作者是Antirez(安提雷兹),全名Sandro hawser,是一位意大利的程序员,他给出的理由如下:
其中就提到,跳表更容易实现、调试,且还举了个例子:由于跳表的简单性,作者收到一个补丁(已经在redis master中),其中包含在O(logN)中实现ZRANK的增强跳表,它只需要对代码进行少量修改。且作者认为关于Append Only的持久性和速度,以更多代码和更多复杂性为代价去优化redis并不是一个好主意。
最终的结论:
在redis中使用跳表而非红黑树的原因是跳表比红黑树更容易实现,而非性能和内存。另外,跳表很容易实现无锁并发,而红黑树很难做到
九 跳表在阿里开源搜索引擎Havenask的应用化繁为简,将复杂的查询变为只是查询包含A或者B这样单个字的文档。我们直接以查询的字作为 key 去倒排索引表中检索,得到的 posting list 就是结果了。
若要检索A且B的文档,则可以先分别用两个 key 去倒排索引中检索,这样会得到两个不同的 posting list。第一个posting list中的文档都包含了A字,第二个posting list中文档都包含了B字。那么,如果一个文档在两个posting list都存在,则找出了A且B的文档。
在这个过程中,posting list的查询则是很耗费时间的事情,在此采用的则是通过跳表来加速查询
十 跳表在Redis的应用ZSet结构同时包含一个字典和一个跳跃表,跳跃表按score从小到大保存所有集合元素。字典保存着从member到score的映射。这两种结构通过指针共享相同元素的member和score,不会浪费额外内存。
typedef struct zset {
dict *dict;
zskiplist *zsl;
} zset;
ZSet中的字典和跳表布局:
跳表是一个概率型的数据结构,元素的插入层数是随机指定的。Willam Pugh在论文中描述了它的计算过程如下:
重复第 2 步,直至生成的r >p 为止,此时的 lvl 就是要插入的层数。
论文中生成随机层数的伪码:
在Redis中对跳表的实现基本上也是遵循这个思想的,只不过有微小差异,看下Redis关于跳表层数的随机源码src/z_set.c:
/* Returns a random level for the new skiplist node we are going to create.
* The return value of this function is between 1 and ZSKIPLIST_MAXLEVEL
* (both inclusive), with a powerlaw-alike distribution where higher
* levels are less likely to be returned. */
int zslRandomLevel(void) {
int level = 1;
while ((random()&0xFFFF) < (ZSKIPLIST_P * 0xFFFF))
level = 1;
return (level<ZSKIPLIST_MAXLEVEL) ? level : ZSKIPLIST_MAXLEVEL;
}
其中两个宏的定义在redis.h中:
#define ZSKIPLIST_MAXLEVEL 32 /* Should be enough for 2^32 elements */
#define ZSKIPLIST_P 0.25 /* Skiplist P = 1/4 */
可以看到while中的:
(random()&0xFFFF) < (ZSKIPLIST_P*0xFFFF)
第一眼看到这个公式,因为涉及位运算有些诧异,需要研究一下Antirez为什么使用位运算来这么写?
最开始的猜测是random()返回的是浮点数[0-1],于是乎在线找了个浮点数转二进制的工具,输入0.5看了下结果:
可以看到0.5的32bit转换16进制结果为0x3f000000,如果与0xFFFF做与运算结果还是0,不符合预期。
我印象中C语言的math库好像并没有直接random函数,所以就去Redis源码中找找看,于是下载了3.2版本代码,也并没有找到random()的实现,不过找到了其他几个地方的应用:
看到这里的取模运算,后知后觉地发现原以为random()是个[0-1]的浮点数,但是现在看来是uint32才对,这样Antirez的式子就好理解了。
ZSKIPLIST_P*0xFFFF
由于ZSKIPLIST_P=0.25,所以相当于0xFFFF右移2位变为0x3FFF,假设random()比较均匀,在进行0xFFFF高16位清零之后,低16位取值就落在0x0000-0xFFFF之间,这样while为真的概率只有1/4,更一般地说为真的概率为1/ZSKIPLIST_P。
对于随机层数的实现并不统一,重要的是随机数的生成,在LevelDB中对跳表层数的生成代码是这样的:
template <typename Key, typename Value>
int SkipList<Key, Value>::randomLevel() {
static const unsigned int kBranching = 4;
int height = 1;
while (height < kMaxLevel && ((::Next(rnd_) % kBranching) == 0)) {
height ;
}
assert(height > 0);
assert(height <= kMaxLevel);
return height;
}
uint32_t Next( uint32_t& seed) {
seed = seed & 0x7fffffffu;
if (seed == 0 || seed == 2147483647L) {
seed = 1;
}
static const uint32_t M = 2147483647L;
static const uint64_t A = 16807;
uint64_t product = seed * A;
seed = static_cast<uint32_t>((product >> 31) (product & M));
if (seed > M) {
seed -= M;
}
return seed;
}
可以看到leveldb使用随机数与kBranching取模,如果值为0就增加一层,这样虽然没有使用浮点数,但是也实现了概率平衡。
十一 跳表在开源软件中的应用跳表算法在实际应用中有广泛的用途,特别是在需要高效的有序集合操作的场景。以下是一些跳表算法的常见应用:
题目1:跳表中的每一层代表什么?为什么需要多层次?
答案:跳表中的每一层代表了数据在内存中的不同分布情况。在跳表中,每一层都保存了前一层中某些元素的指针,从而形成了层次结构。多层次的原因是为了在插入、删除和查找操作中提供更好的时间复杂度。通过增加层次,可以减少需要搜索的元素数量,从而提高效率。
题目2:在跳表中查找元素时,为什么只需查看最底层即可找到元素?
答案:在跳表中,每一层都保存了前一层中某些元素的指针。因此,通过从最底层开始,沿着指针向上搜索,可以逐步缩小搜索范围,最终找到目标元素。在最底层中,元素是按照链表的形式排列的,因此只需查看最底层即可找到元素。
题目3:请解释一下跳跃表的时间复杂度,以及在哪些场景下它的效率最高?
答案:跳跃表的时间复杂度取决于查找、插入和删除操作的复杂度。在最坏情况下,跳跃表的查找、插入和删除操作的时间复杂度为O(n),其中n为跳跃表的长度。但在平均情况下,跳跃表的时间复杂度为O(log n)。跳跃表适用于需要频繁进行插入、删除和查找操作的数据结构,且元素之间具有部分有序性或全有序性。
题目4:如果一个跳跃表的长度为n,那么它最多需要多少个指针来保存跳跃表?
答案:如果一个跳跃表的长度为n,那么它最多需要log n层。在最坏情况下,每一层都需要保存n个指针。因此,最多需要log n层 * n个指针 = n * log n个指针来保存跳跃表。
题目5:在跳跃表中插入元素时,如何确定应该插入到哪一层?
答案:在跳跃表中插入元素时,可以根据元素的值来决定应该插入到哪一层。通常,将元素插入到最底层中,然后根据元素的值逐步向上插入到其他层中。这样可以保证元素的插入顺序正确,并减少插入操作的复杂度。
题目6:跳跃表在实现删除操作时需要注意哪些问题?
答案:在跳跃表中实现删除操作时,需要注意以下几点:
找到要删除的元素所在的层;删除该层中的元素;调整该层中其他元素的指针;如果该层中所有元素的指针都为空,则可以删除该层。需要注意的是,在删除操作中需要保持跳跃表的平衡性,避免出现空指针或不平衡的情况。
题目7:跳跃表中的每一层高度如何确定?对于不同层数的跳跃表,它们的时间复杂度有何不同?
答案:跳跃表中的每一层高度可以根据需要确定,通常与元素值的范围有关。例如,如果元素值在1到100之间,那么可以设置3层跳跃表,每层的高度分别为2、4和8。不同层数的跳跃表的时间复杂度有所不同。在最坏情况下,跳跃表的时间复杂度为O(n)。随着层数的增加,时间复杂度逐渐降低,但同时内存消耗也会增加。因此,需要根据实际情况选择合适的层数。
题目8:在实现跳跃表时,如何保证内存的利用率?
答案:在实现跳跃表时,可以通过以下方法保证内存的利用率:
尽可能减少不必要的内存消耗;对于空指针的层或链表,可以将其合并或删除;在进行插入和删除操作时,及时调整内存的使用情况。
题目9:请解释一下跳跃表的平衡因子,以及如何通过平衡因子来调整跳跃表的性能?
答案:跳跃表的平衡因子是指每一层的高度与该层中元素的数量的比例。通过调整平衡因子的大小,可以影响跳跃表的性能。如果平衡因子设置得较小,则可以减少内存消耗和提高查询效率;但如果平衡因子设置得较大,则可能会导致查询效率降低。因此,需要根据实际情况选择合适的平衡因子大小。
题目10:请谈谈你对跳跃表的理解,以及在什么场景下会使用跳跃表?
答案:跳跃表是一种高效的数据结构,它可以在O(log n)的时间复杂度内完成插入、删除和查找操作。跳跃表适用于需要频繁进行插入、删除和查找操作的数据结构,且元素之间具有部分有序性或全有序性。在实际应用中,跳跃表可以用于实现缓存、索引、数据库等场景中
十四 参考资料[1] 图解|深入理解跳表及其在Redis中的应用:https://mp.weixin.qq.com/s/0Pm1n-tHGSRUht8bWVGjWQ
[2]【迎难学字】跳表(Skip List)原理、操作、特点及58个常见应用场景:https://mp.weixin.qq.com/s/9puKkN2rjlETfMhWNwfFZQ
[3] 搜索引擎系列004-倒排索引:https://mp.weixin.qq.com/s/VgpV_478I58LClkJKF_-9w
[4] 动画解析Redis为什么用跳表而不是红黑树:https://www.bilibili.com/video/BV1wv411c71N
[5] 跳跃表的基本实现原理:https://mp.weixin.qq.com/s/a8S1abzeEwNeiRZ37iJRAA
[6] SkipList跳表介绍与并发安全:https://juejin.cn/post/7107280326858113060
Copyright © 2024 妖气游戏网 www.17u1u.com All Rights Reserved