简单字符串
先简单了解一下C语言是怎么处理字符串的:
在C语言中,字符串结束的标识是空字符,也就是’\0’,这会有一个问题,就是字符串的内容可能包括空字符串,这个时候是不是就没办法正确存取字符串的内容了,它有可能中途读取一半就完了。
除此之外,它还不记录字符串的长度,这也会有一系列问题,
如果需要获取字符串的长度通过遍历计数来获取的,这会导致它的时间复杂度会比较高。
如果需要修改字符串,就要重新分配内存,不重新分配的话,字符串长度增大,超出给定的长度,这个时候会造成内存缓冲区溢出,字符串长度减小还会造成内存泄露。
如果需要对两个字符串进行拼接,是通过调用strcat函数来实现的,如果没有给它分配足够长度的内存空间,就会直接导致缓冲区溢出。
既然C语言处理字符串有这么多的弊端,那么Rdis它是怎么处理字符串的呢?
Rdis专门创建了一种数据结构SDS,什么意思呢?simpldynamicstring,简单字符串。
官方代码:
structsdshdr{
intln;
intfr;
charbuf[];
}
这个对象有三个属性:
ln表示字符串的长度
fr表示还有多少长度剩余,就是下面buf数组中还有多少字符串未使用的字节数量
buf[]表示存储的字符串
问题一:这种数据结构有什么优势呢?跟C语言相比,改进了哪些问题?
长度和内存重新分配问题,C语言是不记录长度,而SDS它有ln属性和fr属性。
ln记录了字符串的长度,直接取值就可以了,不像C语言需要遍历。如果需要对字符串进行修改的话,也不需要像C语言一样,直接重新分配内存,
它可以通过ln属性检查内存空间是不是需要进行扩展内存,如果字符串长度增加,长度超过了ln,就会增加相应的内存,接着修改。
如果字符串长度缩短了,它也不会立马就重新分配内存,而是有一个fr属性记录下来,等你后面什么时候用了,重新计算或者分配内存。
结尾标识问题,C语言是以空字符串结尾标识的,而SDS是以ln长度作为结尾标识的,避免了C语言无法正确读取字符串的问题。
链表
Rdis的list类型的键值对底层数据结构是由链表构成的,那么链表是什么呢?
它是由一连串节点组成,没有顺序,不是连续的,每个节点由数据和一或两个用来指向上一个或下一个节点位置的链接组成,在每一个节点里存到下一个节点的指针,通过链表中的指针链接次序可以实现逻辑顺序。
链表也分好几种:单向链表、双端链表、双向链表、有序链表以及有迭代器的链表
单向链表:用户的操作(添加、删除、遍历)只能从链表头开始。向一个方向遍历,查找一个节点的时候从第一个节点开始访问下一个节点,一直访问到需要的位置,最后一个节点存储地址的部分指向空值。
双端链表:双端链表相对于单端链表多了一个特性:对最后一个链接点的引用
双向链表:单端链表只能从链表头开始正向遍历,双向链表可以逆向遍历,每个节点需要保存前一个节点和后一个节点的引用
有序链表:插入元素时,将插入的元素与头结点及其后面的结点比较,找到合适的位置插入。
有迭代器的链表:单链表的基本操作中,大部分要用到依次遍历单链表中的每一个元素。当你新增一个对单链表的操作并需要使用遍历时,你就得重新写一个for循环而实现遍历。所以将迭代(遍历)作为一种基本的ADT(抽象数据类型)操作。链表中用于处理遍历、访问和更新的方法封装到一个新的迭代器类中。
跳跃表
跳跃表:跳跃表基于有序链表的扩展,在链表上建索引,每两个结点提取一个结点到上一级,我们把抽出来的那一级叫作索引,每个跳跃表节点的层高都是1至2之间的随机数。
举例说明:
比如给一个长度为7的有序链表,节点值依次是1--4-5。取出所有值为奇数的节点作为关键节点(索引),这个时候要插入一个值是2的新节点,就不需要将节点一个个比较,只要比较1,,5,确定了值在1和之间,就可以快速插入。
加一层索引之后,查找一个结点需要遍历的结点个数减少了,虽然增加了50%的额外空间,但是查找效率提高了,同理再加一级索引,这种链表加多级索引的结构,就是跳跃表。
索引是占内存的,原始链表中存储的可能是大的对象,索引结点只要存储关键值和几个指针,并不需要存储对象,当节点本身比较大或者元素数量比较多的时候,优势必然会被放大,而缺点则可以忽略。
问题:当大量的新节点通过逐层比较,最终插入到原链表之后,上层的索引节点会慢慢的不够用,那么这个时候要怎么选取一部分节点提到上一层呢?
抛硬币法:随机决定新节点是否选拔,每向上提拔一层的几率是50%。
原因:跳跃表的删除和添加节点是无法预测的,不能保证索引绝对分步均匀,不过可以让大体趋于均匀。
插入节点的工作流程:跳跃表插入操作的时间复杂度是O(logN),空间复杂度是O(N)。
第一步:新节点和上层索引节点逐个比较,找到原链表的插入位置,时间复杂度为O(logN)
第二步:把索引插入到原链表,时间复杂度为O(1)
第三步:随机决定新节点是否提升为上一级索引,结果为"正面"则提升,继续抛硬币,结果为"反面"则停止,时间复杂度为O(logN)
删除节点的工作流程:跳跃表删除操作的时间复杂度是O(logN)
第一步:自上而下,查找第一次出现节点的索引,并逐层找到每一层对应的节点。时间复杂度为O(logN)
第二步:删除每一层查找到的节点,如果该层只剩下1个节点,删除整个一层(原链表除外)。时间复杂度为O(logN)
跳跃表由zskiplistNod和skiplist两个结构组成,zskiplistNod用于表示跳跃表节点,zskiplist用于保存跳跃表节点的相关信息,比如节点的数量,以及指向表头节点和表尾节点的指针等等。
字典
字典,顾名思义,通过字典(牛津字典等)前面的目录快速定位到所要查找的单词。
在C语言中没有这种数据结构,所以这种数据结构是Rdis自己创造的,字典中的键都是唯一的,通过键可以对值来进行查询或更改。
底层是通过哈希表实现的,而哈希表又基于数组,类似于ky-valu的结构形式进行存储的,它的值通过哈希函数映射为数组的下标。
那什么是哈希函数呢?不急,我们慢慢道来。
前面我们讲了通过数组的方式存储值,那么数组的值和数组的下标怎么建立关联关系呢?或者说,我们怎么通过数组的下标找到数组的值呢?
在学习ASCII编码的时候,我们知道,a可以用97这个数值表示,b可以用98这个数值表示,以此类推,我们就可以通过单个字母用数字表达。
有了字母,那么一个单词由多个字母组成,它又该如何表达呢?假设我有一本字典,它有个单词,我其中一个单词就是ab,使用ASCII编码进行表达。
ab=97+98=那么存储在数组中的下标为,这就是字母表达的基本原理,但是如果只是这样还是远远不够的,因为会出现一个数组存储多个单词的情况。
举例说明:假设有个单词有10个字母,那么字典的某个单词为zzzzzzzzzz,转换为数字:zzzzzzzzzz=26*10=。
补充说明:这个时候会发现我一本字典里个单词,在这个范围内肯定是不够存储个单词的,/=9(8.4补一位),一个数组项它要存储9个单词。
解决方案:为了保证数值的唯一,让每个数组都能够只存储一个单词,进行升级,将单词表示的数拆开,27的幂乘以这些位数,有26个可能的字符,以及空格,一共27个。
ab=97乘以27的一次幂加上98乘以27的零次幂=27*97+98=。解决了数组存储多个单词的问题,又引出新的问题数组分配大空间太多了。
举例说明:假设有个单词有10个字母,那么字典的某个单词为zzzzzzzzzz,转换为数字:zzzzzzzzzz=26的9次幂=0
补充说明:数组中只有小部分存放了单词,其他空间都是空着的
解决方案:将巨大的整数范围压缩到可接受的数组范围内,可以通过取余解决,一个整数被另一个整数除后的余数。
举例说明:假设要把从0-99的数字(用larg表示),压缩为从0-9的数字(用numbr表示),后者有10个数,所以变量rang的值为10,这个转换的表达式为:
补充说明:numbr=larg%rang。当一个整数被10整除时,余数是在0-9之间,把从0-99的数压缩为从0-9的数,压缩率为10:1。
使用哈希函数向数组插入数据后,这个数组就是哈希表,它的值就是通过上面这种方式映射到数组的下标上的。
这也就是哈希函数的工作模式,它把一个大范围的数字哈希转化成一个小范围的数字,这个小范围的数对应着数组的下标。
但是这种工作模式会有一点问题:把大的数字范围压缩到小的数字范围,会有几个不同的单词哈希化到同一个数组下标,这就是所谓的哈希冲突。
问题:那么如何解决哈希冲突呢?
开放地址法:指定的数组范围大小是存储数据的两倍,有一半的空间是空的。
当冲突产生时,通过(线性探测、二次探测以及再哈希法)方法找到数组的一个空位,把单词填入,不用哈希函数得到数组的下标。
线性探测中,如果哈希函数计算的原始下标是x,线性探测就是x+1,x+2,x+,以此类推,而在二次探测中,探测的过程是x+1,x+4,x+9,x+16。这二种方式都会有聚集情况。
什么是聚集呢?当哈希表快要满的时候,每插入新的数据,都要频繁的探测插入位置,很多位置都被前面插入的数据所占用了,这称为聚集。
再哈希法:依赖关键字的探测序列,把关键字用不同的哈希函数再做一遍哈希化,用这个结果作为步长,步长在整个探测中是不变的,不过不同的关键字使用不同的步长。
链地址法:数组的每个数据项都创建一个子链表或子数组,那么数组内不直接存放单词,当产生冲突时,新的数据项直接存放到这个数组下标表示的链表中。
整数集合:顾名思义,用来保存整数值类型的集合,保证元素不会重复。
定义:
typdfstructintst{
//编码方式
uint2_tncoding;
//集合包含的元素数量
uint2_tlngth;
//保存元素的数组
int8_tcontnts[];
}intst;
contnts数组声明为int8_t类型,但是contnts数组并不保存任何int8_t类型的值,真正类型由ncoding决定。比如:
ncoding属性的值为INTSET_ENC_INT16,contnts是int16_6类型的数组,数组里的每个项是int16_t类型的是整数值。
ncoding属性的值为INTSET_ENC_INT2,contnts是int2_t类型的数组,数组里的每个项是int2_t类型的整数值。
ncoding属性的值为INTSET_ENC_INT64,contnts是int64_t类型的数组,数组里的每个项是int64_t的整数值。
新增的元素类型比原集合元素类型的长度大的时候,根据新元素类型增加整数集合底层数组的容量,给新元素分配空间,
将底层数组现有的所有元素都转成与新元素相同类型的元素,把转换后的元素放到正确的位置,整个元素顺序是有序的,能极大地节省内存。
压缩列表
压缩列表,它是特殊编码的连续内存块组成的顺序型数据结构,压缩列表有任意多个节点(ntry),每个节点有一个字节数组或者一个整数值。
压缩列表不是用某种算法对数据进行压缩,它将数据按照一定规则编码,放在一块连续的内存区域,目的是节省内存。
压缩列表包含以下:
zlbyts:记录整个压缩列表占用的内存字节数。
zltail:记录压缩列表表尾节点距离压缩列表的初始地址有多少字节。
zlln:记录压缩列表包含的节点数量。
zlnd:用来标记压缩列表的末端。
ntryX:列表的节点,包含
prvious_ntry_ngth:记录压缩列表前一个字节的长度。
ncoding:节点的ncoding保存的是节点的contnt的内容类型以及长度。
contnt:contnt区域用于保存节点的内容,节点内容类型和长度由ncoding决定。
总结:
简单字符串:SDS作为rdis专门为字符串存取开发的数据结构,有获取字符串长度快,杜绝了缓存区的溢出,减少了修改字符串长度时所需的内存重分配次数,二进制安全,兼容部分C函数
链表:用作列表键、发布与订阅、慢查询、监视器等功能实现。
字典:用哈希表实现,字典有两个哈希表,一个正常使用,另一个用于rhash时使用,链地址法解决哈希冲突。
跳跃表:表中的节点按照分值大小进行排序。
整数集合:底层由数组构成,升级特性能尽可能的节省内存。
压缩列表:顺序型数据结构。