数据映射--跳表(skiplist)

2024-02-18 00:08
文章标签 数据 映射 跳表 skiplist

本文主要是介绍数据映射--跳表(skiplist),希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!

http://blog.sina.com.cn/s/blog_693f08470101n2lv.html

本周我要介绍的数据结构,是我非常非常喜欢的一个数据结构,因为咱也是吃过平衡二叉树的苦的人啊T_T 神马左旋,右旋,上旋,下旋,看原理的时候就已经晕晕乎乎的了,再看源码,发现比原理还复杂,心理就想,这东西是不是就是为了让我挂科给学校交重修费来拯救学校财政的东西啊?!。。

 

当然,现在再来看,这些东西有其非常重要的作用,只是确实有点复杂了,不过,突然有一天,有个结构进入了我的视野,第一次听说是在redis上面看到,后来发现原来java的并发包里也早就实现了这个结构,可以做到很高的并发度,几乎是全部无锁的结构,而最重要的是,这个结构的原理非常的简单! 这么漂亮的结构才是拯救地球的大舅星啊~~~

 

没错,今天我们来介绍skiplist跳表。

 

说起跳表,我们还是要从二分查找开始。二分查找的关键要求有两个,1,数据能够按照某种条件进行排序。2,可以通过某种方式,取出该数据集中任意子集的中间值。

能够满足的数据结构主要是有序数组,但对于数据量不断变化的场景来说,有序数组很难能够高效的进行写入。链表是一种最容易处理数据不断增加结构的有序数据结构,并且因为已经有了无锁完成多线程链表写入的算法,因此链表对于并发的支持度是非常好的(我们后面会介绍这个算法),然而链表却不能够进行二分查找,因为无法取到任意子集的中值。

所以人们又去想办法基于树来做能够既支持写入,又能够通过“预先找到中值并写到父节点的方式来提前将中值准备好,这就是平衡有序二叉树。不过,无论是AVL还是红黑树,这个预先找到中值并写入到父节点的操作的都是非常复杂的,对于复杂的操作来说,想使用常见的无锁操作就几乎不可能了。

最后,综合一下,链表结构能够做到并发无锁的增加新节点,但不能很容易的访问到中值(因为链表只能从头部遍历或尾部遍历)。平衡有序二叉树则相反,虽然很容易可以访问到全部数据的中值,但无法做到并发无锁的增加新节点。

90年代之前,人们一直以这就是生活 来安慰自己,认为鱼与熊掌不可兼得。但在90年代,William Pugh在他的论文中提出了一种新的数据结构,很巧妙的解决了这个矛盾,另外也八卦一下,其实目前Java领域很流行的find bugs静态代码分析工具也是william发明的~

首先我们先定义一个概念,叫层(level) ,为了方便理解,大家可以直接对应到平衡有序二叉树里面的树的高度。

 

每一层在逻辑上都是一个链表,既然是链表,那么自然也就只能从头部遍历或从尾部遍历咯。

一个标准的skiplist在内存中可能是这样的:

Level2:0,4

Level1:0,2,4,6,9

Level0:0,1,2,3,4,5,6,7,8,9

 

可以看到,层级越高,数据量越小,并且,高层级的元素都有一个到低层级元素的指针,这样他可以很容易的通过指针跳转到更底层的元素上面。

 

下面让我们来看看读取的逻辑,比如如果要读取6,那么从最高层级的链表的头部(从左向右)依次读取数据,发现6>4,于是在通过Level2 4 这个元素到level14这个元素的指针,跳跃到Level1,然后从Level14这个元素继续往右面找发现下一个元素就是6,于是将整个6所对应的元素返回。

那么要找3的话应该怎么操作呢?

仍然是从最高层级level2的头部开始遍历,发现0<3<4 . 于是利用level20这个元素到level10这个元素的指针,跳跃到level10元素,继续向右遍历,发现2<3<4。于是利用Level12这个元素到level02这个元素的指针,跳跃到level02这个元素上,继续向右遍历找到元素3,于是将整个3所对应的元素返回。

 

可以看到,利用这种结构如果我们能够比较准确的在链表里将数据排好序,并且level0中每两个元素中拿出一个元素推送到更高的层级level1中,然后在level1中也按照每两个元素拿出一个元素推送到更高层级的level2依此类推,就可以构建出一个查询时间复杂度为O(log2n)的查找数据结构了。

 

但这里有个关键的难在于:如何能够知道,当前写入的元素是否应该被推送到更高的层级呢?这也就对应了原来avl,红黑里面为什么要做如此复杂的旋转的原因。而在william的解决方案里,他选择了一条完全不相同的路来做到这一点。

 

这也是skiplist里面一个最大的创新点,就是引入了一个新条件:概率。与传统的根据临近元素的来决定是否上推的avl或红黑树相比。Skiplist则使用概率这个完全不需要依托集合内其他元素的因素来决定这个元素是否要上推。这种方式的最大好处,就是可以让每一次的插入都变得更独立,而不需要依托于其他元素插入的结果。 这样就能够让冲突只发生在数据真正写入的那一步操作上,而我们已经在前面的文章里面知道了,对于链表来说,数据的写入是能够做到无锁的写入新数据的,于是,利用skiplist,就能成功的做到无锁的有序平衡(多层级)结构。

 

下面我们就来看看如何利用概率来决定某个元素是否需要上推的。

让我们先用一个简单的模式来说明解决问题的思路,然后再探讨如何进行优化。

我们可以把skiplist的写入分为两个步骤,第一个步骤是找到元素在整个顺序列表中要写入的位置,这个步骤与我们上面讲到的读取过程是一致的。

然后下一个步骤是决定这个数据是否需要从当前层级上推到上一个层级,具体的做法是从最低层级level0开始,写入用户需要写入的值,并计算一个随机数,如果是0,则不上推到高一层,而如果是1,则上推到高一个层,然后指针跳跃到高一个层级,重复进行随机数计算来决定是否需要推到更高的层级,如果最高层中只有自己这个元素的时候,则也停止计算随机数(因为不需要再推到更高层了)。

最后,还有个问题就是如何解决并发写入的问题,为了阐述清楚如何能够做到并发写,我们需要先对什么叫”一致性的写”,进行一下说明。

一般的人理解数据的一致性写的定义可能是:如果写成功了你就让我看到,而如果没写成功,你就不让我看到呗。

但实际上这个定义在计算机里面是无法操作的,因为我们之前也提到过,计算机其实就是个打字机,一次只能进行一个操作,针对复杂的操作,只能通过加锁来实现一致性。但加锁本身的代价又很大,这就形成了个悖论,如何能够既保证性能,又能够实现一致性呢?

这时候就需要我们对一致性的定义针对多线程环境进行一下完善:在之前的定义,我们是把写入的过程分为两个时间点的,一个时间点是调用写入接口前,另一个时间点是调用写入接口后。但其实在多线程环境下,应该分为三个时间点,第一个是调用写入接口前,第二个是调用写入接口,但还未返回结果的那段时间,第三个是调用写入接口,返回结果后。

然后我们来看看,针对这三个时间点应该如何选择,才能保证数据的一致性:

对于第一个时间点,因为还没有调用写入接口,所以所有线程(包含调用写入的线程)都不应该能够从这个映射中读取到待写入的数据。

第二个时间点,也就是写入操作过程中,我们需要能够保证:如果数据已经被其他线程看到过了,那么再这个时间点之后的所有时间点,数据应该都能够被其他线程看到,也就是说不能出现先被看到但又被删掉的情况。

第三个时间点,这个写入的操作应该能够被所有人看到。


已经定义好了一致性的规范,下面就来看看这个无锁并发的skiplist是如何处理好并发一致性的。

首先我们需要先了解一下链表是如何能够做到无锁写入的:

对于链表类的数据结构来说,如果想做到无锁,主要就是解决以下的问题,如何能够让当前线程知道,目前要插入新元素的位置,是否有其他人正在插入? 如果有的话,那么就自旋等待,如果没有,那么就插入。利用这个原理,把原来的多步指针变更操作利用compare and set的方式转换为一个伪原子操作。这样就可以有效的减少锁导致的上下文切换开销,在争用不频繁的情况下,极大的提升性能。(这只是思路,关于linkedlist的无锁编程细节,可以参照A pragmatic implementation of non-blocking linked lists,这篇文章)

利用上面链表的无锁写入,我们就能够保证,数据在每一个level内的写是保证无锁写入的。并且,因为每一次有新的数据写入的时候其他尝试写入的线程也都能感知的到,所以这些并行写入的数据可以通过不断相互比较的方式来了解到,自己这个要写入的数据与其他并行写入的数据之间的大小关系,从而可以动态的进行调整以保证在每一层内,数据都是绝对有序的。

同一个level的一致性解决了,那么不同level之间的一致性是如何得到解决的呢?这就与我们刚才定义的一致性规范紧密相关了。因为数据的写入是从低层级开始,一层一层的往更高的层级推送的。而数据读取的时候,则是从最高层级往下读取的。又因为数据是绝对有序的,那么我们就一定可以认为,只要最低层级(level0)内存在了的数据,那么他就一定能够被所有线程看到。而如果在上推的过程中出现了任何异常,其实都是没关系的,因为上推的唯一目的在于加快检索速度,所以就算因为异常没有上推,也只是降低了查询的效率,对数据的可见性完全没有影响。

这个设计确实是非常的巧妙~


这样,虽然每个元素的具体能够到达哪个层级是随机的,但从宏观上来看,低层元素的个数基本上是高层元素个数的一倍。从宏观上来看,如果按照我们上面定义的自最高层级依次往下遍历的读取模式,那么整个查询的时间复杂度就是O(log2n)

 

下面来介绍一些优化的思路,因为进行随机数的运算本身也是个很消耗cpu的操作,所以,一种最常见的优化就是,如果在插入的时候就能直接算出这个数据应该往高层推的总次数,那么就不需要算那么多次随机数了,每次写入只需要算一次就行了。

第二个优化的思路是如何能够实现一个高性能的随机数算法,这个各位可以自行搜索。

 

Skiplist是一个我个人很喜欢的数据结构,因为他足够简单,性能又好,除了运气非常差的时候效率很低,其他时候都能做到很好的查询效率,赌博什么的最喜欢了~~~最重要的是,它还足够简单和容易理解!


下面照例,我们使用一些通用的标准对skiplis进行一下简单的评价:

1.       是否支持范围查找

 

因为是有序结构,所以能够很好的支持范围查找。

 

2.       集合是否能够随着数据的增长而自动扩展

 

可以,因为核心数据结构是链表,所以是可以很好的支持数据的不断增长的

 

3.       读写性能如何

 

因为从宏观上可以做到一次排除一半的数据,并且在写入时也没有进行其他额外的数据查找性工作,所以对于skiplist来说,其读写的时间复杂度都是O(log2n)

 

4.       是否面向磁盘结构

 

磁盘要求顺序写,顺序读,一次读写必须是一整块的数据。而对于skiplist来说,查询中每一次从高层跳跃到底层的操作,都会对应一次磁盘随机读,而skiplist的层数从宏观上来看一定是O(log2n)层。因此也就对应了O(log2n)次磁盘随机读。

因此这个数据结构不适合于磁盘结构。

 

并行指标

终于来到这个指标了, skiplist的并行指标是非常好的,只要不是在同一个目标插入点插入数据,所有插入都可以并行进行,而就算在同一个插入点,插入本身也可以使用无锁自旋来提升写入效率。

因此skiplist是个并行度非常高的数据结构。

 

内存占用

与平衡二叉树的内存消耗基本一致。


unsafe_skiplist

#include <iostream>
#include <stdlib.h>
#include <time.h>
using namespace std;typedef int key_t;
typedef int value_t;
#define MAX_LEVEL 16
#define SKIPLIST_P 0.25struct node_t {key_t key;value_t val;node_t *forward[];
};class SkipList {protected:int m_level;int m_length;node_t *header;node_t *creatNode(int level, key_t key, value_t val) {node_t *node = (node_t*)malloc(sizeof(node_t)+level*sizeof(node_t*));if (node == NULL) {return NULL;}node->key = key;node->val = val;srand(time(NULL));return node;}public:SkipList() {header = creatNode(MAX_LEVEL, 0, 0);if (header == NULL)exit(-1);m_length = 0;m_level = 0;for (int i = 0; i < MAX_LEVEL; ++i) {header->forward[i] = NULL;}}value_t *getValue(key_t key) {int beg = m_level - 1;node_t *p = header;for (; beg >=0; --beg) {while (p->forward[beg] && p->forward[beg]->key <= key) {if (p->forward[beg]->key == key)return &p->forward[beg]->val;p = p->forward[beg];}//p = p->forward[beg-1];}return NULL;}int randomLevel() {int level = 1;while ((rand()&0xffff) < 0xffff*SKIPLIST_P)++level;if(level > MAX_LEVEL) level = MAX_LEVEL;return level;}void insert(key_t key, value_t val) {node_t *update[MAX_LEVEL];int beg = m_level - 1;node_t *p = header;node_t *last = NULL;for (; beg >=0; --beg) {while( (last = p->forward[beg]) && last->key < key) {p = p->forward[beg];}update[beg] = p;}if (last && last->key == key) {last->val = val;return;}m_length++;int level = randomLevel();if (level > m_level) {for(int i = m_level; i < level; ++i)update[i] = header;m_level = level;}node_t *node = creatNode(level, key, val);for (beg = level - 1; beg >=0; --beg) {node->forward[beg] = update[beg]->forward[beg];update[beg]->forward[beg] = node;}}void erase(key_t key) {node_t *update[MAX_LEVEL];int beg = m_level - 1;node_t *p = header;node_t *last = NULL;for (; beg >=0; --beg) {while ((last = p->forward[beg]) && last->key < key) {p = p->forward[beg];}}if (last && last->key != key)return;for (beg = m_level; beg >=0; --beg) {if (update[beg]->forward[beg] == last){update[beg]->forward[beg] = last->forward[beg];if (header->forward[beg] == NULL)m_level--;}}free(last);m_length--;}void display() {node_t *p = header->forward[0];while (p) {cout << p->key << ":"<<p->val<<" ";p = p->forward[0];}cout <<endl;}~SkipList() {node_t *p = header;while (p){node_t *next = p->forward[0];free(p);p = next;}    }};int main() {SkipList sl;/*for (int i = 0; i < 1000; ++i){cout << i <<endl;sl.insert(rand(),i);}*/sl.insert(0,10);sl.insert(5, 50);sl.insert(6, 60);sl.insert(0, 11);sl.insert(5, 51);sl.insert(7,70);sl.insert(3, 30);sl.insert(4,40);sl.insert(3,31);sl.display();return 1;
}




这篇关于数据映射--跳表(skiplist)的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!



http://www.chinasem.cn/article/719411

相关文章

大模型研发全揭秘:客服工单数据标注的完整攻略

在人工智能(AI)领域,数据标注是模型训练过程中至关重要的一步。无论你是新手还是有经验的从业者,掌握数据标注的技术细节和常见问题的解决方案都能为你的AI项目增添不少价值。在电信运营商的客服系统中,工单数据是客户问题和解决方案的重要记录。通过对这些工单数据进行有效标注,不仅能够帮助提升客服自动化系统的智能化水平,还能优化客户服务流程,提高客户满意度。本文将详细介绍如何在电信运营商客服工单的背景下进行

基于MySQL Binlog的Elasticsearch数据同步实践

一、为什么要做 随着马蜂窝的逐渐发展,我们的业务数据越来越多,单纯使用 MySQL 已经不能满足我们的数据查询需求,例如对于商品、订单等数据的多维度检索。 使用 Elasticsearch 存储业务数据可以很好的解决我们业务中的搜索需求。而数据进行异构存储后,随之而来的就是数据同步的问题。 二、现有方法及问题 对于数据同步,我们目前的解决方案是建立数据中间表。把需要检索的业务数据,统一放到一张M

关于数据埋点,你需要了解这些基本知识

产品汪每天都在和数据打交道,你知道数据来自哪里吗? 移动app端内的用户行为数据大多来自埋点,了解一些埋点知识,能和数据分析师、技术侃大山,参与到前期的数据采集,更重要是让最终的埋点数据能为我所用,否则可怜巴巴等上几个月是常有的事。   埋点类型 根据埋点方式,可以区分为: 手动埋点半自动埋点全自动埋点 秉承“任何事物都有两面性”的道理:自动程度高的,能解决通用统计,便于统一化管理,但个性化定

使用SecondaryNameNode恢复NameNode的数据

1)需求: NameNode进程挂了并且存储的数据也丢失了,如何恢复NameNode 此种方式恢复的数据可能存在小部分数据的丢失。 2)故障模拟 (1)kill -9 NameNode进程 [lytfly@hadoop102 current]$ kill -9 19886 (2)删除NameNode存储的数据(/opt/module/hadoop-3.1.4/data/tmp/dfs/na

异构存储(冷热数据分离)

异构存储主要解决不同的数据,存储在不同类型的硬盘中,达到最佳性能的问题。 异构存储Shell操作 (1)查看当前有哪些存储策略可以用 [lytfly@hadoop102 hadoop-3.1.4]$ hdfs storagepolicies -listPolicies (2)为指定路径(数据存储目录)设置指定的存储策略 hdfs storagepolicies -setStoragePo

Hadoop集群数据均衡之磁盘间数据均衡

生产环境,由于硬盘空间不足,往往需要增加一块硬盘。刚加载的硬盘没有数据时,可以执行磁盘数据均衡命令。(Hadoop3.x新特性) plan后面带的节点的名字必须是已经存在的,并且是需要均衡的节点。 如果节点不存在,会报如下错误: 如果节点只有一个硬盘的话,不会创建均衡计划: (1)生成均衡计划 hdfs diskbalancer -plan hadoop102 (2)执行均衡计划 hd

【Prometheus】PromQL向量匹配实现不同标签的向量数据进行运算

✨✨ 欢迎大家来到景天科技苑✨✨ 🎈🎈 养成好习惯,先赞后看哦~🎈🎈 🏆 作者简介:景天科技苑 🏆《头衔》:大厂架构师,华为云开发者社区专家博主,阿里云开发者社区专家博主,CSDN全栈领域优质创作者,掘金优秀博主,51CTO博客专家等。 🏆《博客》:Python全栈,前后端开发,小程序开发,人工智能,js逆向,App逆向,网络系统安全,数据分析,Django,fastapi

烟火目标检测数据集 7800张 烟火检测 带标注 voc yolo

一个包含7800张带标注图像的数据集,专门用于烟火目标检测,是一个非常有价值的资源,尤其对于那些致力于公共安全、事件管理和烟花表演监控等领域的人士而言。下面是对此数据集的一个详细介绍: 数据集名称:烟火目标检测数据集 数据集规模: 图片数量:7800张类别:主要包含烟火类目标,可能还包括其他相关类别,如烟火发射装置、背景等。格式:图像文件通常为JPEG或PNG格式;标注文件可能为X

pandas数据过滤

Pandas 数据过滤方法 Pandas 提供了多种方法来过滤数据,可以根据不同的条件进行筛选。以下是一些常见的 Pandas 数据过滤方法,结合实例进行讲解,希望能帮你快速理解。 1. 基于条件筛选行 可以使用布尔索引来根据条件过滤行。 import pandas as pd# 创建示例数据data = {'Name': ['Alice', 'Bob', 'Charlie', 'Dav

SWAP作物生长模型安装教程、数据制备、敏感性分析、气候变化影响、R模型敏感性分析与贝叶斯优化、Fortran源代码分析、气候数据降尺度与变化影响分析

查看原文>>>全流程SWAP农业模型数据制备、敏感性分析及气候变化影响实践技术应用 SWAP模型是由荷兰瓦赫宁根大学开发的先进农作物模型,它综合考虑了土壤-水分-大气以及植被间的相互作用;是一种描述作物生长过程的一种机理性作物生长模型。它不但运用Richard方程,使其能够精确的模拟土壤中水分的运动,而且耦合了WOFOST作物模型使作物的生长描述更为科学。 本文让更多的科研人员和农业工作者