Redis数据结构底层知识总结
2018-03-09 21:16
816 查看
Redis数据结构底层知识总结
本篇文章是基于作者黄建宏写的书Redis设计与实现而做的笔记数据结构与对象
Redis中数据结构的底层实现包括以下对象:对象 | 解释 |
---|---|
简单动态字符串 | 字符串的底层实现 |
链表 | 列表的底层实现 |
字典 | 运用在多个方面,包括Hash的实现等 |
跳跃表 | 有序集合的底层实现 |
整数集合 | 集合的底层实现之一 |
压缩字典 | 列表键和哈希键的底层实现之一 |
String
Redis中并没有直接使用C语言中的字符串,而是在其基础之上实现了字符串的数据结构,叫做简单动态字符串(SDS)。其内部的定义为:
/* Redis简单动态字符串的数据结构 */ struct sdshdr { //字符长度,记录buf数组中已使用的字节数量 unsigned int len; //当前可用空间,记录buf数组中未使用的字节数量 unsigned int free; //具体存放字符的buf char buf[]; };
SDS和C字符串的区别
常数复杂度获取字符串长度因为SDS纪录了自身字符串中已经使用的长度和未使用的长度,所以可以在O(1)的时间复杂度内获取到字符串长度,然而C字符串不得不通过遍历整个字符串才能获取到长度,其花费的则是O(N)。
杜绝缓冲区溢出和C字符串不同的是,SDS会利用纪录下来的长度去检查自身是否还有足够的空间去容纳新的需求,如果不满足的话,会先进行扩容,然后才执行新的操作。
减少修改字符串时带来的内存重分配次数C字符串中每次进行增加和缩短的操作时,都会涉及到内存的重新分配,SDS利用未使用空间来实现空间预分配和惰性空间释放这两种优化策略。空间预分配用于优化SDS的字符串增长操作:当要涉及到对SDS进行空间扩展的时候,程序不仅仅会为SDS分配修改所需要的空间,还会为SDS分配额外的未使用空间,好处在于在下次扩容的时候,如果未使用空间还足够使用的话,就使用未使用空间进行扩容。
惰性空间用于优化SDS的字符串缩短操作:当要缩短SDS保存的字符串时,程序并不会立即使用内存重分配来回收缩短后多出来的字节,而是将其回收起来纪录到free空间中,以便将来继续使用。
二进制安全C字符串中判断结束的条件是遇见空字符,不同的是,SDS则选择了通过自身的len属性的值来判断字符串是否结束,这样做的目的在于使得SDS不仅仅能够存储字符串,还能存储二进制。
兼容部分C字符串函数通过遵循C字符串以空字符结尾的惯例,SDS可以在有需要的时候重用C语言中的string函数库,比如对比函数,追加函数等等,从而实现代码的重用。
链表
链表提供了高效的结点重排能力,以及顺序性的结点访问方式,并且可以通过增删结点来灵活的调整链表的长度。链表结点
每个链表结点使用的是一个listNode结构表示:typedef struct listNode{ // 前置结点 struct listNode *prev; // 后置结点 struct listNode * next; // 结点值 void * value; }
链表
在此基础之上,Redis通过封装了listNode实现双端链表,如下:typedef struct list{ //表头节点 listNode * head; //表尾节点 listNode * tail; //链表长度 unsigned long len; //节点值复制函数 void *(*dup) (void *ptr); //节点值释放函数 void (*free) (void *ptr); //节点值对比函数 int (*match)(void *ptr, void *key); }list结构为链表提供了表头指针head、表尾指针tail,以及链表长度计数器len,而dup、free和match成员则是用于实现多态链表所需的类型特定函数:dup函数用于
复制链表结点所保存的值;
free函数用于
释放链表结点所保存的值;
match函数用于
对比链表结点所保存的值和另一个输入值是否相等;
Redis的链表实现的特性总结如下:双端:链表节点带有prev 和next 指针,获取某个节点的前置节点和后置节点的时间复杂度都是O(1)。
无环:表头节点的 prev 指针和表尾节点的next 都指向NULL,对链表的访问时以NULL来做判断是否截止。
带表头指针和表尾指针:因为链表带有head指针和tail 指针,程序获取链表头结点和尾节点的时间复杂度为O(1)。
带链表长度计数器:链表中存有记录链表长度的属性 len。
多态:链表节点使用 void* 指针来保存节点值,并且可以通过list 结构的dup 、 free、 match三个属性为节点值设置类型特定函数,所以链表可以用来保存各种不同类型的值。
字典
字典由哈希表组成,而哈希表又由哈希结点组成。哈希表结点
和链表一样,Redis也自己实现了哈希表结点结构和哈希表结构,如下:哈希表结点:typeof struct dictEntry{ //键 void *key; //值 union{ void *val; uint64_tu64; int64_ts64; } struct dictEntry *next; }每个dictEntry结构都保存着一个键值对,分别对应属性key和value,同时,next属性是指向另外一个哈希表结点的指针,作用就是将多个哈希值相同的哈希结点连接起来,以此来解决键冲突的问题。
哈希表
Redis在dictEntry的基础之上封装实现了哈希表,如下:typedef struct dictht { //哈希表数组 dictEntry **table; //哈希表大小 unsigned long size; //哈希表大小掩码,用于计算索引值 unsigned long sizemask; //该哈希表已有节点的数量 unsigned long used; }其中需要提到的是sizemark,这个属性和哈希值一起决定一个键应该被放到table数组中的哪个索引上面。
字典
Redis在哈希表的基础上封装了dictht实现字典,如下:typedef struct dict { // 类型特定函数 dictType *type; // 私有数据 void *privedata; // 哈希表 dictht ht[2]; // rehash 索引 int rehashidx; }type属性和privdata属性是针对不同类型的键值对,为创建多态字典而设置的。ht属性是一个包含两个项的数组,数组中的每个项都是一个dictht哈希表,一般情况下,字典只使用ht[0]哈希表,ht[1]哈希表只会在对ht[0]哈希表进行rehash时使用,而rehashidx则决定了rehash的进度,如果没有进行rehash,其值则为-1。
哈希算法
当要把一个新的键值对添加到字典里面时,程序先要根据键值对中的键值计算出哈希值,再计算出索引值,然后将包含新键值对的哈希表结点放到哈希表数组的指定索引上面。Redis使用murmurhash算法来计算哈希值
解决键冲突
键冲突:存在两个或者两个以上的键被分配到了哈希表数组的同一个索引上面。解决办法:Redis的哈希表使用开链法来解决冲突,每个哈希表结点都存在一个next指针,多个哈希表值可以用next指针来构成一个单向链表,被分配到同一个索引上的多个结点可以用这个单向链表连接起来,从而解决键冲突问题。需要注意的是,因为考虑到下次方便再次读取,因此总是将冲突的新结点插入到链表的表头位置,也就是已有其他结点的前面。
rehash
当哈希表的负载因子(已有数量/表数量)达到一个阀值以后,再次保存新的键值对时,冲突的几率将逐渐增加,因此需要进行响应的扩展(收缩)。以扩展为例,程序需要经过以下步骤(腾笼换鸟):扩展空间:为字典的ht[1]哈希表分配空间,其空间将会被扩展到第一个大于等于ht[0].used*2^n的整数值;
数据迁移:将保存在ht[0]上的所有键值对迁移到ht[1]中;
交换:迁移完后,释放掉ht[0],将现在的ht[1]设置为ht[0],并且为ht[1]新创建一个空白哈希表,实现了相互交换的过程;
渐进式rehash
为了避免一次性交换所造成的性能影响,Redis采用的是渐进式rehash,也就是说,将会分多次、渐进式的完成数据的迁移。所以会同时存在两个哈希表数组,并不会急着一次性的将数ht[0]的数据迁移到ht[1]中,而是在每次操作的同时,将部分的ht[0]中的数据保存到ht[1]中,采用愚公移山的方式最终将ht[0]中的数据搬完,为了避免ht[0]中的数据不断增加,相关的增加的操作都会作用在ht[1]之上,最后,搬完后的操作和之前的操作是一致的。它的优点在于:采用了分而治之的方式,将rehash键值对所需的操作均摊到字典的每个添加、删除、查找和更新操作之上,从而避免集中式rehash而带来的庞大计算量。我认为它的缺点也是存在的,譬如在查询的时候,可能在ht[0]中查找不到,还得跑到ht[1]中查找,无形中增加了开销。跳跃表
跳跃表是一种有序数据结构,它通过在每个结点中维持多个指向其它结点的指针,从而达到快速访问结点的目的。其查找的时间复杂度平均可以达到O(logn),最坏O(N),还可以通过顺序性操作来批量处理结点。目前,Redis中只有两个地方用到了跳跃表,一个是有序集合键,另外一个是集群结点中用作内部数据结构。跳跃表由多个跳跃结点组成:跳跃表结点
typedef struct zskiplistNode{ //层 struct zskiplistLevel{ //前进指针 struct zskiplistNode *forward; //跨度 unsigned int span; } level[]; //后退指针 struct zskiplistNode *backward; //分值 double score; //成员对象 robj *obj; }层:level 数组可以包含多个元素,每个元素都包含一个指向其他节点的指针。
前进指针:用于指向表尾方向的前进指针
跨度:用于记录两个节点之间的距离
后退指针:用于从表尾向表头方向访问节点
分值和成员:跳跃表中的所有节点都按分值从小到大排序。成员对象指向一个字符串,这个字符串对象保存着一个SDS值
跳跃表
typedef struct zskiplist { //表头节点和表尾节点 structz skiplistNode *header,*tail; //表中节点数量 unsigned long length; //表中层数最大的节点的层数 int level; }zskiplist;其搜索的步骤为,先通过头结点定位到跳跃表结点,然后通过层去定位到下一个跳跃表结点的位置,直到找到给定分值的结点。
整数集合
前提:当一个集合只包含整数值元素,并且这个集合的元素数量不多时。typedef struct intset{ //编码方式 uint32_t enconding; // 集合包含的元素数量 uint32_t length; //保存元素的数组 int8_t contents[]; }因为可能存在存入的整数不符合已存在集合中的编码格式,因此需要使用升级策略来解决。扩展空间:根据新元素的类型,扩展整数集合底层数组的空间大小,并为新元素分配空间
转换编码:将底层数组现有的所有元素都转换成新的编码格式,重新分配空间
添加:将新元素加入到底层数组中
一旦对数组进行了升级,编码就会一直保存升级后的状态。
压缩列表
压缩列表是Redis为了节约内存而开发的,是由一系列特殊编码的连续内存块组成的顺序型数据结构。一个压缩列表可以包含任意多个结点,每个结点可以保存一个字节数或者一个整数值。详细讲解redis数据结构(内存模型)以及常用命令
Redis数据类型
与Memcached仅支持简单的key-value结构的数据记录不同,Redis支持的数据类型要丰富得多,常用的数据类型主要有五种:String、List、Hash、Set和Sorted Set。Redis数据类型内存结构分析
Redis内部使用一个redisObject对象来表示所有的key和value。redisObject主要的信息包括数据类型(type)、编码方式(encoding)、数据指针(ptr)、虚拟内存(vm)等。type代表一个value对象具体是何种数据类型,encoding是不同数据类型在redis内部式。String类型
字符串是Redis值的最基础的类型。Redis中使用的字符串是通过包装的,基于c语言字符数组实现的简单动态字符串(simple dynamic string, SDS)一个抽象数据结构。其源码定义如下: struct sdshdr { int len; //len表示buf中存储的字符串的长度。 int free; //free表示buf中空闲空间的长度。 char buf[]; //buf用于存储字符串内容。};C语言字符串内存结构示意图1 假设上图是”hello”字符串的内存结构,这个时候len=5,free=2那么redis包装后(sds)其长度为: sizeof(struct sdshdr) + len + free + 1 其中buf的大小为: len + free + 1 1表示1个字节是用来存储结束符’\0’的。Redis字符串是二进制安全的,因为二进制数据通常会有中间某个字节存储’\0’的这种情况,这意味着一个Redis字符串可以包含任何种类的数据,例如一个JPEG图像或者一个序列化的Ruby对象。二进制是否安全,简单的理解就是能不能在字符串中间有‘\0’,如下图:
C语言字符串内存结构示意图2 对于上图,sds认为这个字符串是“hello world”,而C语言的字符处理函数认为这个字符串是“hello”。
应用场景
String是最常用的一种数据类型,普通的key/value存储都可以归为此类。常用命令
(1)set——设置key对应的值为String类型的value(2)get——获取key对应的值 192.168.2.129:6379> setnx name lisi(integer) 0192.168.2.129:6379> setnx name1 wangwu(integer) 1192.168.2.129:6379> get name"zhangsan"192.168.2.129:6379> get name1"wangwu"192.168.2.129:6379> (3)mget——批量获取多个key的值,如果可以不存在则返回nil 192.168.2.129:6379> mget name name11) "zhangsan"2) "wangwu"192.168.2.129:6379> mget name name1 name21) "zhangsan"2) "wangwu"3) (nil)192.168.2.129:6379> (4)incr && incrby——incr对key对应的值进行加加操作,并返回新的值;incrby加指定值 192.168.2.129:6379> get age"20"192.168.2.129:6379> incr age(integer) 21192.168.2.129:6379> set age1 "20"OK192.168.2.129:6379> get age1"20"192.168.2.129:6379> incr age1(integer) 21192.168.2.129:6379> incrby age 3(integer) 24 从上面的结果可以看出,我们对int型的age和string型的age1都能进行incr操作时,实际上type=string代表value存储的是一个普通字符串,那么对应的encoding可以是raw或者是int,如果是int则代表实际redis内部是按数值型类存储和表示这个字符串的,当然前提是这个字符串本身可以用数值表示,比如"20"这样的字符串,当遇到incr、decr等操作时会转成数值型进行计算,此时redisObject的encoding字段为int。如果你试图对name进行incr操作则报错。 192.168.2.129:6379> incr name(error) ERR value is not an integer or out of range (5)decr && decrby——decr对key对应的值进行减减操作,并返回新的值;decrby减指定值 192.168.2.129:6379> decr age(integer) 23192.168.2.129:6379> decrby age 3(integer) 20192.168.2.129:6379> (6)其他命令命令 | 说明 |
setnx | 设置key对应的值为String类型的value,如果key已经存在则返回0 |
setex | 设置key对应的值为String类型的value,并设定有效期 |
setrange | 设置key对应value的子字符串 |
getrange | 获取key对应value的子字符串 |
mset | 批量设置多个key的值,如果成功表示所有值都被设置,否则返回0表示没有任何值被设置 |
msetnx | 同mset,不存在就设置,不会覆盖已有的key |
getset | 设置key的值,并返回key旧的值 |
append | 给指定key的value追加字符串,并返回新字符串的长度 |
strlen | 取指定key的value的长度 |
Hash类型
Hash是一个String类型的field和value之间的映射表,即redis的Hash数据类型的key(hash表名称)对应的value实际的内部存储结构为一个HashMap,因此Hash特别适合存储对象。相对于把一个对象的每个属性存储为String类型,将整个对象存储在Hash类型中会占用更少内存。Hash 数据类型内部结构示意图 当前HashMap的实现有两种方式:当HashMap的成员比较少时Redis为了节省内存会采用类似一维数组的方式来紧凑存储,而不会采用真正的HashMap结构,这时对应的value的redisObject的encoding为zipmap,当成员数量增大时会自动转成真正的HashMap,此时encoding为ht。
应用场景
用一个对象来存储用户信息,商品信息,订单信息等等。常用命令
(1)hset——设置key对应的HashMap中的field的value(2)hget——获取key对应的HashMap中的field的value 192.168.2.129:6379> hset myhash name zhangsan(integer) 1192.168.2.129:6379> hset myhash age 20(integer) 1192.168.2.129:6379> hget myhash name"zhangsan"192.168.2.129:6379> hget myhash age"20"192.168.2.129:6379> (3)hgetall——获取key对应的HashMap中的所有field的value 192.168.2.129:6379> hgetall myhash1) "name"2) "zhangsan"3) "age"4) "20"192.168.2.129:6379> (4)其它命令命令 | 说明 |
hsetnx | 设置key对应的HashMap中的field的value,如果不存在则先创建 |
hmset | 批量设置key对应的HashMap中的field的value |
hmget | 批量获取key对应的HashMap中的field的value |
hincrby | 给key对应的HashMap中的field的value加指定的值 |
hexits | 测试key对应的HashMap中的field是否存在 |
hlen | 返回key对应的HashMap中的field的数量 |
hdel | 删除key对应的HashMap中的field |
hkeys | 返回key对应的HashMap中所有的field |
hvals | 返回key对应的HashMap中所有的field的value |
List类型
Redis的List类型其实就是每一个元素都是String类型的双向链表。我们可以从链表的头部和尾部添加或者删除元素。这样的List既可以作为栈,也可以作为队列使用。List数据结构内部示意图
应用场景
如好友列表,粉丝列表,消息队列,最新消息排行等。常用命令
(1)lpush——在key对应的list的头部添加一个元素。(2)lrange——获取key对应的list的指定下标范围的元素,-1表示获取所有元素。(3)lpop——从key对应的list的尾部删除一个元素,并返回该元素。 192.168.2.129:6379> lpush newlist news1 news2 news3(integer) 3192.168.2.129:6379> lrange newlist 0 -11) "news3"2) "news2"3) "news1"192.168.2.129:6379> lpop newlist"news3"192.168.2.129:6379> lrange newlist 0 -11) "news2"2) "news1"192.168.2.129:6379> 从上面的操作可以看出,lpush、lpop从表头操作。(4)rpush——在key对应的list的尾部添加一个元素。(5)rpop——从key对应的list的尾部删除一个元素,并返回该元素。 192.168.2.129:6379> rpush newlist2 news1 news2 news3(integer) 3192.168.2.129:6379> lrange newlist2 0 -11) "news1"2) "news2"3) "news3"192.168.2.129:6379> rpop newlist2"news3"192.168.2.129:6379> 从上面的操作可以看出,rpush、rpop从表尾操作。
(6)其他命令
命令 | 说明 |
linsert | 在key对应的list的特定元素的前或后插入元素 |
lset | 设置key对应的list中指定下标元素的值 |
lrem | 从key对应的list中删除n个和value相同的元素 |
ltrim | 保留key对应的list中指定范围的元素 |
rpoplpush | 从第一个list的尾部移除一个元素并添加到第二个list的头部 |
llen | 返回key对应的list的长度 |
lindex | 返回key对应的list中index的元素 |
Set类型
Redis 集合(Set类型)是一个无序的String类型数据的集合,类似List的一个列表,与List不同的是Set不能有重复的数据。实际上,Set的内部是用HashMap实现的,Set只用了HashMap的key列来存储对象。我们来看看java中HashSet的源码: public class HashSet<E> extends AbstractSet<E> implements Set<E>, Cloneable, java.io.Serializable{ static final long serialVersionUID = -5024744406713321676L; private transient HashMap<E,Object> map; // Dummy value to associate with an Object in the backing Map private static final Object PRESENT = new Object(); /** * Constructs a new, empty set; the backing <tt>HashMap</tt> instance has * default initial capacity (16) and load factor (0.75). */ public HashSet() { map = new HashMap<>();}...... /** * Returns an iterator over the elements in this set. The elements * are returned in no particular order. * * @return an Iterator over the elements in this set * @see ConcurrentModificationException */ public Iterator<E> iterator() { return map.keySet().iterator(); } 可见创建一个HashSet的时候实际上创建了一个HashMap;Set中的元素,只是存放在了底层HashMap的key上,底层HashMap的value列为空,遍历HashSet的时候从HashMap中取出keySet来遍历。Set底层结构示意图
应用场景
集合有取交集、并集、差集等操作,因此可以求共同好友、共同兴趣、分类标签等。常用命令
(1)sadd——在key对应的set中添加一个元素。(2)smembers——获取key对应的set的所有元素。(3)spop——随机返回并删除key对应的set中的一个元素。 192.168.2.129:6379> sadd myset news1 news2 news3(integer) 3192.168.2.129:6379> smembers myset1) "news3"2) "news2"3) "news1"192.168.2.129:6379> spop myset"news3"192.168.2.129:6379> (4)sdiff——求给定key对应的set与第一个key对应的set的差集 192.168.2.129:6379> smembers myset1) "news3"2) "news2"3) "news1"192.168.2.129:6379> sadd myset2 news3 news4 news5(integer) 3192.168.2.129:6379> smembers myset21) "news4"2) "news3"3) "news5"192.168.2.129:6379> sdiff myset myset21) "news1"2) "news2"192.168.2.129:6379> (5)suion——求给定key对应的set并集 192.168.2.129:6379> sunion myset myset21) "news3"2) "news1"3) "news2"4) "news4"5) "news5"192.168.2.129:6379> (6)sinter——求给定key对应的set交集 192.168.2.129:6379> sinter myset myset21) "news3"192.168.2.129:6379> (7)其他命令命令 | 说明 |
srem | 删除key对应的set中的一个元素 |
sdiffstore | 求给定key对应的set与第一个key对应的set的差集,并存储到另一个key对应的set中 |
sinterstore | 求给定key对应的set交集,并存储到另一个key对应的set中 |
suionstore | 求给定key对应的set并集,并存储到另一个key对应的set中 |
somve | 从第一个key对应的set中删除指定元素并添加到第二个key对应的set中 |
scard | 返回key对应的set的元素个数 |
sismember | 测试某个元素是否为key对应的set中的元素个数 |
srandmember | 随机返回key对应的set中的一个元素,但不删除元素 |
SortSet
SortSet顾名思义,是一个排好序的Set,它在Set的基础上增加了一个顺序属性score,这个属性在添加修改元素时可以指定,每次指定后,SortSet会自动重新按新的值排序。sorted set的内部使用HashMap和跳跃表(SkipList)来保证数据的存储和有序,HashMap里放的是成员到score的映射,而跳跃表里存放的是所有的成员,排序依据是HashMap里存的score。应用场景
如按时间排序的时间轴。常用命令
(1)zadd ——在key对应的zset中添加一个元素(2)zrange——获取key对应的zset中指定范围的元素,-1表示获取所有元素 192.168.2.129:6379> zadd myzset 1 "one" 2 "two" 3 "three"(integer) 3192.168.2.129:6379> zrange myzset 0 -11) "one"2) "two"3) "three"192.168.2.129:6379> zrange myzset 0 -1 withscores1) "one"2) "1"3) "two"4) "2"5) "three"6) "3"192.168.2.129:6379> (3)zrem——删除key对应的zset中的一个元素 192.168.2.129:6379> zrem myzset one(integer) 1192.168.2.129:6379> zrange myzset 0 -1 withscores1) "two"2) "2"3) "three"4) "3"192.168.2.129:6379> (4)其它命令命令 | 说明 |
zincrby | 如果key对应的zset中已经存在元素member,则对member的score属性加指定的值 |
zrank | 返回key对应的zset中指定member的排名。其中member按score值递增(从小到大);排名以0为底,也就是说,score值最小的成员排名为0 |
zrevrank | 获得成员按score值递减(从大到小)排列的排名 |
zrevrange | 返回有序集key中,指定区间内的成员。其中成员的位置按score值递减(从大到小)来排列 |
zrangebyscore | 返回有序集key中,指定分数范围的元素列表 |
zcount | 返回有序集key中,score值在min和max之间(默认包括score值等于min或max)的成员 |
zcard | 返回key的有序集元素个数 |
Redis常用命令
键值常用命令
keys/exits/del/expire/ttl/move/persist/randomkey/rename/type服务器常用命令
ping/echo/select/quit/dbsize/info/config get/flushdb/flushall这些命令都很容易使用,就不举例说明了。到此,redis的数据类型以及常用命令已经介绍完毕,下一篇我们将学习redis的一些高级特性。相关文章推荐
- Redis数据结构底层知识总结
- Redis底层数据结构总结
- redis 底层数据结构 压缩列表 ziplist
- 面试知识:操作系统、计算机网络、设计模式、Linux编程,数据结构总结
- [综合面试] 牛人整理分享的面试知识:操作系统、计算机网络、设计模式、Linux编程,数据结构总结
- [综合面试] 牛人整理分享的面试知识:操作系统、计算机网络、设计模式、Linux编程,数据结构总结
- redis内部数据结构总结(7)intset
- redis 底层数据结构 整数集合intset
- Spring-data-redis操作redis知识总结
- REDIS系列之底层数据结构
- 牛人整理分享的面试知识:操作系统、计算机网络、设计模式、Linux编程,数据结构总结 转载
- 数据结构的知识总结-概论(1)
- [综合面试] 牛人整理分享的面试知识:操作系统、计算机网络、设计模式、Linux编程,数据结构总结
- Redis数据结构总结
- [综合面试] 牛人整理分享的面试知识:操作系统、计算机网络、设计模式、Linux编程,数据结构总结
- 牛人整理分享的面试知识:操作系统、计算机网络、设计模式、Linux编程,数据结构总结
- 牛人整理分享的面试知识:操作系统、计算机网络、设计模式、Linux编程,数据结构总结
- redis+php微博功能的redis数据结构设计总结(四)
- 《数据结构》第一章 绪论 知识总结导图 物联1132-12
- 数据结构知识总结