跳表SkipList

  • 跳表SkipList
    • 1、背景
    • 2、定义
      • 2.1、SkipList基本数据结构及其实现
    • 3、实现
    • 4、使用方法
      • 4.1、跳表的创建
      • 4.2、跳表查找操作
      • 4.3、跳表插入操作
      • 4.4、跳表 删除操作
      • 4.5、跳表的销毁
      • 4.6、完整代码
  • 参考

跳表SkipList

1、背景

为什么选择跳表?

目前经常使用的平衡数据结构有:B树,红黑树,AVL树,Splay Tree, Treep等。跳表是平衡树的一种替代的数据结构,但是和红黑树不相同的是,跳表对于树的平衡的实现是基于一种随机化的算法的,这样也就是说跳表的插入和删除的工作是比较简单的。

用跳表吧,跳表是一种随机化的数据结构,目前开源软件 Redis 和 LevelDB 都有用到它,它的效率和红黑树以及 AVL 树不相上下,但跳表的原理相当简单,只要你能熟练操作链表,就能轻松实现一个 SkipList。

Redis为什么用跳表而不用平衡树?

2、定义

漫画:什么是跳表?

跳表(Skiplist)是一个特殊的链表,相比一般的链表,有更高的查找效率,可比拟二叉查找树,平均期望的查找、插入、删除时间复杂度都是O(logn),许多知名的开源软件(库)中的数据结构均采用了跳表这种数据结构。

  • Redis中的有序集合zset
  • LevelDB、RocksDB、HBase中Memtable
  • ApacheLucene中的TermDictionary、Posting List

我们先来看一下单向链表如何实现查找

假设我们有个有序链表,可知其查询和插入复杂度都为 O(n)。相比数组,链表不能进行二分查找的原因在于,不能用下标索引进行常数复杂度数据访问,从而不能每次每次快速的筛掉现有规模的一半。那么如何改造一下链表使之可以进行二分?

利用 map 构造一个下标到节点的映射?这样虽然可以进行二分查询了,但是每次插入都会引起后面所有元素的下标变动,从而需要在 map 中进行 O(n) 的更新。

增加指针使得从任何一个节点都能直接跳到其他节点?那得构造一个全连接图,指针耗费太多空间不说,每次插入指针的更新仍是 O(n) 的。

跳表给出了一种思路,跳步采样,构建索引,逐层递减。怎么提高查询效率呢?如果我们给该单链表加一级索引,将会改善查询效率。

如图所示,当我们每隔一个节点就提取出来一个元素到上一层,把这一层称作索引,其中的down指针指向原始链表。

当我们查找元素16的时候,单链表需要比较10次,而加过索引的两级链表只需要比较7次。当数据量增大到一定程度的时候,效率将会有显著的提升。

如果我们再加多几级索引的话,效率将会进一步提升。这种链表加多级索引的结构,就叫做跳表。

这基本上就是跳表的核心思想,其实是一种通过“空间来换取时间”的一个算法,通过在每个节点中增加了向前的指针(即层),从而提升查找的效率。

  • 跳跃列表是按层建造的。
  • 底层是一个普通的有序链表。每个更高层都充当下面列表的「快速跑道」,这里在层 i 中的元素按某个固定的概率 p
    (通常为0.5或0.25)出现在层 i+1 中。
  • 平均起来,每个元素都在 1/(1-p) 个列表中出现, 而最高层的元素(通常是在跳跃列表前端的一个特殊的头元素),在 O(log1/p n) 个列表中出现。

2.1、SkipList基本数据结构及其实现

一个跳表,应该具有以下特征:

  • 1,一个跳表应该有几个层(level)组成;
  • 2,跳表的第一层包含所有的元素;
  • 3,每一层都是一个有序的链表;
  • 4,如果元素x出现在第i层,则所有比i小的层都包含x;
  • 5,每个节点包含key及其对应的value和一个指向同一层链表的下个节点的指针数组

跳表可视为水平排列(Level)垂直排列(Tower)的位置(Position,对Entry的访问抽象)的二维集合。每个Level是一个列表Si每个Tower包含存储连续列表中相同Entry的位置,跳表的各个位置可以通过如下方式进行遍历。

  • After(P):返回和P在同一Level的后面的一个位置,若不存在则返回NULL。

  • Before(P):返回和P在同一Level的前面的一个位置,若不存在则返回NULL。

  • Below(P):返回和P在同一Tower的下面的一个位置,若不存在则返回NULL。

  • Above(P):返回和P在同一Tower的上面的一个位置,若不存在则返回NULL。


    有顺序关系的多个Entry(K,V)集合M可以由跳表实现,跳表S由一系列列表{S0,S1,…,Sh}组成,

  • 其中h代表跳表的高度

  • 每个列表Si按Key顺序存储M项的子集,并额外增加两个特殊键,分别是-∞和+∞,其中-∞小于M中的每个键+∞大于M中的每个键

此外,S中的列表满足以下要求(不同实现版本要求会有不同)。

  • 列表S0包含集合M的每个Entry(加上带有键-∞和+∞的特殊Entry)。
  • 对于i=1,…,h−1,列表Si包含(包括-∞和+∞)列表Si−1中Entry的随机生成子集。
  • 列表Sh只包含-∞和+∞

Si中的Entry是从Si-1中的Entry集合中随机选择的,**对于Si-1中的每个Entry**,以1/2概率来决定是否需要拷贝到Si中,我们期望S1有大约n/2个Entry,S2有大约n/4个Entry,Si 有n/2 ^i

跳表的高度h大约是logn。从一个列表到下一个列表的Entry数减半并不是跳表的强制性要求,相反借助随机函数来达成。

3、实现

跳表基本数据结构

定义跳表数据类型:

//跳表结构
typedef struct skip_list
{int level;// 层数Node *head;//指向头结点
} skip_list;

其中level是当前跳表最大层数,head是指向跳表的头节。

跳表的每个节点的数据结构:

typedef struct node
{keyType key;// key值valueType value;// value值struct node *next[1];// 后继指针数组,柔性数组 可实现结构体的变长
} Node;


上面的每个结构体对应着图中的每个节点,如果一个节点是一层的节点的话(如7,12等节点),那么对应的next将指向一个只含一个元素的数组,以此类推。

对于这个结构体重点说说,struct node *next[1] 其实它是个柔性数组,主要用于使结构体包含可变长字段。我们可以通过如下方法得到包含可变

层数(n)的Node *类型的内存空间:

#define new_node(n)((Node*)malloc(sizeof(Node)+n*sizeof(Node*)))

通过上面我们可以根据层数n来申请指定大小的内存,从而节省了不必要的内存空间(比如固定大小的next数组就会浪费大量的内存空间)。

4、使用方法

4.1、跳表的创建

// 创建节点
Node *create_node(int level, keyType key, valueType val)
{Node *p=new_node(level);if(!p)return NULL;p->key=key;p->value=val;return p;
}

跳表的创建

列表的初始化需要初始化头部,并使头部每层(根据事先定义的MAX_LEVEL)指向末尾(NULL)

//创建跳跃表
skip_list *create_sl()
{skip_list *sl=(skip_list*)malloc(sizeof(skip_list));//申请跳表结构内存if(NULL==sl)return NULL;sl->level=0;// 设置跳表的层level,初始的层为0层(数组从0开始)Node *h=create_node(MAX_L-1, 0, 0);//创建头结点if(h==NULL){free(sl);return NULL;}sl->head = h;int i;// 将header的next数组清空for(i=0; i<MAX_L; ++i){h->next[i] = NULL;}srand(time(0));return sl;
}

4.2、跳表查找操作

目的:在跳跃表中查找一个元素x
在跳跃表中查找一个元素x,按照如下几个步骤进行:

  • 1、从最上层的链(Sh)的开头开始

  • 2、假设当前位置为p,它向右指向的节点为q(p与q不一定相邻),且q的值为y。将y与x作比较

       (1) x=y  输出查询成功及相关信息(2) x>y  从p向右移动到q的位置(3) x<y  从p向下移动一格
    
  • 3、如果当前位置在最底层的链中(S0),且还要往下移动的话,则输出查询失败

valueType *search(skip_list *sl, keyType key)
{Node *q,*p=sl->head;q=NULL;int i=sl->level-1;for(; i>=0; --i){while((q=p->next[i]) && q->key<key){p=q;}if(q && key==q->key)return &(q->value);}return NULL;
}

4.3、跳表插入操作

1、目的:向跳跃表中插入一个元素x

2、首先明确,向跳跃表中插入一个元素,相当于在表中插入一列从S0中某一位置出发向上的连续一段元素。有两个参数需要确定,即插入列的位置以及它的“高度”。

3、 关于插入的位置,我们先利用跳跃表的查找功能,找到比x小的最大的数y。根据跳跃表中所有链均是递增序列的原则,x必然就插在y的后面。

4、跳表是一种随机化数据结构,其随机化体现在插入元素的时候元素所占有的层数完全是随机的。而插入列的“高度”较前者来说显得更加重要。为了使插入数据之后,保持该数据结构进行各种操作均为O(logn)复杂度的性质,我们引入随机化算法(Randomized Algorithms)。

  • 相当与做一次丢硬币的实验,如果遇到正面(rand产生奇数),继续丢,遇到反面,则停止,用实验中丢硬币的次数level作为元素占有的层数。
  • 显然随机变量 level 满足参数为 p = 1/2 的几何分布level 的期望值 E[level] = 1/p = 2.
    就是说,各个元素的层数,期望值是 2 层。

随机决策过程:

  • 1、产生一个0到1的随机数r , r ← random()

  • 2、如果r小于一个常数p,则执行方案A, if r<p then do A,否则,执行方案B else do B

  • 3、初始时列高为1。插入元素时,不停地执行随机决策模块。如果要求执行的是A操作,则将列的高度加1,并且继续反复执行随机决策模块。直到第i次,模块要求执行的是B操作,我们结束决策,并向跳跃表中插入一个高度为i的列。

//插入元素的时候元素所占有的层数完全是随机算法
int randomLevel()
{int level=1;while (rand()%2)level++;level=(MAX_L>level)? level:MAX_L;return level;
}

由于跳表数据结构整体上是有序的,所以在插入时,需要首先查找到合适的位置,然后就是修改指针(和链表中操作类似),然后更新跳表的level变量。 跳表的插入总结起来需要三步:

  • 1:查找到待插入位置, 每层跟新update数组;
  • 2:需要随机产生一个层数;
  • 3:从高层至下插入,与普通链表的插入完全相同;

比如插入key为25的节点,如下图

  • 对于步骤1,我们需要对于每一层进行遍历并保存这一层中下降的节点(其后继节点为NULL或者后继节点的key大于等于要插入的key),如下图,节点中有白色星花标识的节点保存到update数组。
  • 对于步骤2我们上面已经说明了是通过一个随机算法产生一个随机的层数,但是当这个随机产生的层数level大于当前跳表的最大层数时,我们此时需要更新当前跳表最大层数到level之间的update内容,这时应该更新其内容为跳表的头节点head。然后就是更新跳表的最大层数。

  • 对于步骤3就和普通链表插入一样了,只不过现在是对每一层链表进行插入节点操作。最终的插入结果如图所示,因为新插入key为25的节点level随机数值为4,大于插入前的最大层数,所以此时跳表的层数为4。

实现代码如下:

//跳表的插入需要三个步骤,第一步需要查找到在每层待插入位置,然后需要随机产生一个层数,最后就是从高层至
//下插入,插入时算法和普通链表的插入完全相同。
bool insert(skip_list *sl, keyType key, valueType val)
{Node *update[MAX_L];Node *q=NULL,*p=sl->head;//q,p初始化int i=sl->level-1;/******************step1*******************///从最高层往下查找需要插入的位置,并更新update//即把降层节点指针保存到update数组for( ; i>=0; --i){while((q=p->next[i])&& q->key<key)p=q;update[i]=p;}if(q && q->key == key)//key已经存在的情况下{q->value = val;return true;}/******************step2*******************///产生一个随机层数levelint level = randomLevel();//如果新生成的层数比跳表的层数大if(level>sl->level){//在update数组中将新添加的层指向headerfor(i=sl->level; i<level; ++i){update[i]=sl->head;}sl->level=level;}//printf("%d\n", sizeof(Node)+level*sizeof(Node*));/******************step3*******************///新建一个待插入节点,一层一层插入q=create_node(level, key, val);if(!q)return false;//逐层更新节点的指针,和普通链表插入一样for(i=level-1; i>=0; --i){q->next[i]=update[i]->next[i];update[i]->next[i]=q;}return true;
}

4.4、跳表 删除操作

删除节点操作和插入差不多,找到每层需要删除的位置,删除时和操作普通链表完全一样。不过需要注意的是,如果该节点的level是最大的,则需要更新跳表的level。

删除操作分为以下三个步骤:

  • 在跳跃表中查找到这个元素的位置,如果未找到,则退出
  • 将该元素所在整列从表中删除
  • 将多余的“空链”删除

    删除节点操作和插入差不多,找到每层需要删除的位置,删除时和操作普通链表完全一样。不过需要注意的是,如果该节点的level是最大的,则需要更新跳表的level。
bool erase(skip_list *sl, keyType key)
{Node *update[MAX_L];Node *q=NULL, *p=sl->head;int i = sl->level-1;for(; i>=0; --i){while((q=p->next[i]) && q->key < key){p=q;}update[i]=p;}//判断是否为待删除的keyif(!q || (q&&q->key != key))return false;//逐层删除与普通链表删除一样for(i=sl->level-1; i>=0; --i){if(update[i]->next[i]==q)//删除节点{update[i]->next[i]=q->next[i];//如果删除的是最高层的节点,则level--if(sl->head->next[i]==NULL)sl->level--;}}free(q);q=NULL;return true;
}

4.5、跳表的销毁

上面分别介绍了跳表的创建、节点插入、节点删除,其中涉及了内存的动态分配,在使用完跳表后别忘了释放所申请的内存,不然会内存泄露的。

// 释放跳跃表
void sl_free(skip_list *sl)
{if(!sl)return;Node *q=sl->head;Node *next;while(q){next=q->next[0];free(q);q=next;}free(sl);
}

4.6、完整代码

skiplist.h

//参考:Skip lists: a probabilistic alternative to balanced trees
#ifndef _SKIPLIST_H_
#define _SKIPLIST_H_#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>#define MAX_L 16 //最大层数  //new_node生成一个Node结构体,同时生成包含n个Node *元素的数组
#define new_node(n) ((Node*)malloc(sizeof(Node)+n*sizeof(Node*)))
//定义key和value的类型
typedef int keyType;
typedef int valueType;//每个节点的数据结构
typedef struct node
{keyType key;// key值valueType value;// value值struct node *next[1];// 后继指针数组,柔性数组 可实现结构体的变长
} Node;
//跳表结构
typedef struct skip_list
{int level;// 最大层数Node *head;//指向头结点
} skip_list;/*创建节点,成功返回Node*类型指针,否则返回NULLlevel 节点层数key   节点关键字vlal  节点的值
*/
Node *create_node(int level, keyType key, valueType val);
/*创建跳跃表,成功返回skip_list*类型指针,否则返回NULL
*/
skip_list *create_sl();
/*
插入元素的时候元素所占有的层数完全是随机算法,返回随机层数
*/
int randomLevel();
/*插入节点,插入成功返回true,否则返回falsesl     跳表指针key   节点关键字vlal  节点的值
*/
bool insert(skip_list *sl, keyType key, valueType val);
/*删除节点,成功返回true,否则返回falsesl     跳表指针key   节点关键字
*/
bool erase(skip_list *sl, keyType key);
/*查找节点,成功返回valueT*类型的指针,否则返回NULLsl     跳表指针key   节点关键字
*/
valueType *search(skip_list *sl, keyType key);
/*
从最高层开始逐层打印
sl 跳表指针
*/
void print_sl(skip_list *sl);
/*
释放跳跃表
sl 跳表指针
*/
void sl_free(skip_list *sl);#endif

skiplist.cpp

#include "skiplist.h"// 创建节点
Node *create_node(int level, keyType key, valueType val)
{Node *p=new_node(level);if(!p)return NULL;p->key=key;p->value=val;return p;
}//创建跳跃表
skip_list *create_sl()
{skip_list *sl=(skip_list*)malloc(sizeof(skip_list));//申请跳表结构内存if(NULL==sl)return NULL;sl->level=0;// 设置跳表的层level,初始的层为0层(数组从0开始)Node *h=create_node(MAX_L-1, 0, 0);//创建头结点if(h==NULL){free(sl);return NULL;}sl->head = h;int i;// 将header的next数组清空for(i=0; i<MAX_L; ++i){h->next[i] = NULL;}srand(time(0));return sl;
}
//插入元素的时候元素所占有的层数完全是随机算法
int randomLevel()
{int level=1;while (rand()%2)level++;level=(MAX_L>level)? level:MAX_L;return level;
}
/*
step1:查找到在每层待插入位置,跟新update数组
step2:需要随机产生一个层数
step3:从高层至下插入,与普通链表的插入完全相同。
*/
bool insert(skip_list *sl, keyType key, valueType val)
{Node *update[MAX_L];Node *q=NULL,*p=sl->head;//q,p初始化int i=sl->level-1;/******************step1*******************///从最高层往下查找需要插入的位置,并更新update//即把降层节点指针保存到update数组for( ; i>=0; --i){while((q=p->next[i])&& q->key<key)p=q;update[i]=p;}if(q && q->key == key)//key已经存在的情况下{q->value = val;return true;}/******************step2*******************///产生一个随机层数levelint level = randomLevel();//如果新生成的层数比跳表的层数大if(level>sl->level){//在update数组中将新添加的层指向headerfor(i=sl->level; i<level; ++i){update[i]=sl->head;}sl->level=level;}//printf("%d\n", sizeof(Node)+level*sizeof(Node*));/******************step3*******************///新建一个待插入节点,一层一层插入q=create_node(level, key, val);if(!q)return false;//逐层更新节点的指针,和普通链表插入一样for(i=level-1; i>=0; --i){q->next[i]=update[i]->next[i];update[i]->next[i]=q;}return true;
}
// 删除节点
bool erase(skip_list *sl, keyType key)
{Node *update[MAX_L];Node *q=NULL, *p=sl->head;int i = sl->level-1;for(; i>=0; --i){while((q=p->next[i]) && q->key < key){p=q;}update[i]=p;}//判断是否为待删除的keyif(!q || (q&&q->key != key))return false;//逐层删除与普通链表删除一样for(i=sl->level-1; i>=0; --i){if(update[i]->next[i]==q)//删除节点{update[i]->next[i]=q->next[i];//如果删除的是最高层的节点,则level--if(sl->head->next[i]==NULL)sl->level--;}}free(q);q=NULL;return true;
}
// 查找
valueType *search(skip_list *sl, keyType key)
{Node *q,*p=sl->head;q=NULL;int i=sl->level-1;for(; i>=0; --i){while((q=p->next[i]) && q->key<key){p=q;}if(q && key==q->key)return &(q->value);}return NULL;
}//从最高层开始逐层打印
void print_sl(skip_list *sl)
{Node *q;int i=sl->level-1;for(; i>=0; --i){q=sl->head->next[i];printf("level %d:\n", i+1);while(q){printf("key:%d val:%d\t", q->key, q->value);q=q->next[i];}printf("\n");}
}// 释放跳跃表
void sl_free(skip_list *sl)
{if(!sl)return;Node *q=sl->head;Node *next;while(q){next=q->next[0];free(q);q=next;}free(sl);
}


test.cpp

#include "skiplist.h"int main()
{skip_list *sl=create_sl();int i=1;for(;i<11111; ++i){insert(sl, i, i);}for(i=11; i<11111; ++i){if(!erase(sl, i))printf("No!\n");}print_sl(sl);int *p=search(sl, 10);if(p)printf("value of key 10 is %d\n", *p);sl_free(sl);return 0;
}

参考

1、https://blog.csdn.net/daniel_ustc/article/details/20218489
2、https://www.zhihu.com/search?type=content&q=%E8%B7%B3%E8%A1%A8
3、https://zhuanlan.zhihu.com/p/113227225
4、http://www.cppblog.com/mysileng/archive/2013/04/06/199159.html

跳跃表(skiplist )详解及其C++编程实现相关推荐

  1. Redis 为什么这么快? Redis 的有序集合 zset 的底层实现原理是什么? —— 跳跃表 skiplist

    Redis有序集合 zset 的底层实现--跳跃表skiplist Redis简介 Redis是一个开源的内存中的数据结构存储系统,它可以用作:数据库.缓存和消息中间件. 它支持多种类型的数据结构,如 ...

  2. python import io_详解Python IO编程

    文件读写 读文件 try: # windows下utf8 f = open('./README.md', 'r', encoding='utf8', errors='ignore') print(f. ...

  3. MySQL 表分区详解MyiSam引擎和InnoDb 区别(实测)

    MySQL 表分区详解MyiSam引擎和InnoDb 区别(实测) 一.什么是表分区 通俗地讲表分区是将一大表,根据条件分割成若干个小表.mysql5.1开始支持数据表分区了. 如:某用户表的记录超过 ...

  4. js入门·表单详解一(修改表单属性,修改表单元素值)

    实在javascript入门·Document对象入门讲解(访问表单,创建新页,获取页标题) 一文中,我们已经把表单的一些基本访问等弄清楚了,下面我们深入的学下表单的属性以及对表单元素的简单操作! 演 ...

  5. mysql 社区版 innodb_MySQL 表分区详解MyiSam引擎和InnoDb 区别(实测)

    MySQL 表分区详解MyiSam引擎和InnoDb 区别(实测) / --------------------         写在最前面            ------------------ ...

  6. 详解Java多线程编程中LockSupport类的线程阻塞用法

    转载自  详解Java多线程编程中LockSupport类的线程阻塞用法 LockSupport类是Java6(JSR166-JUC)引入的一个类,提供了基本的线程同步原语.LockSupport实际 ...

  7. mysql多表查询详解_MySQL多表查询详解上

    时光在不经意间,总是过得出奇的快.小暑已过,进入中暑,太阳更加热烈的绽放着ta的光芒,...在外面被太阳照顾的人们啊,你们都是勤劳与可爱的人啊.在房子里已各种姿势看我这篇这章的你,既然点了进来,那就由 ...

  8. c语言线性表库函数大全,数据结构(C语言版)-线性表习题详解

    <数据结构(C语言版)-线性表习题详解>由会员分享,可在线阅读,更多相关<数据结构(C语言版)-线性表习题详解(23页珍藏版)>请在人人文库网上搜索. 1.数 据 结 构 ,线 ...

  9. php里面的socket编程,详解PHP Socket 编程过程

    详解PHP Socket 编程过程 作者:PHPYuan 时间:2019-03-13 03:41:20 概述 Socket用于进程间通信.进程间通信通常基于客户端-服务端模型.此时,客户端-服务端是可 ...

  10. Discuz3.数据库数据表字典详解(完整版)

    Discuz3.数据库数据表字典详解(完整版) 转自:https://discuzt.cr180.com/discuzcode-db.html pre_common_admincp_cmenu后台菜单 ...

最新文章

  1. C++中重载赋值运算符=
  2. 独家|盘点5个TensorFlow和机器学习课程,程序员福利(附资源)
  3. 远程管理客户端--SCCM
  4. Android传感器-开发指南
  5. 损失函数(Loss function) 和 代价函数(Cost function)
  6. 互联网日报 | 美团市值突破万亿港元;北京恢复二级响应;滴滴货运23日上线;微信开放MCN入驻...
  7. Vue-- $attrs与$listeners的详解
  8. UC伯克利黑科技:用语音数据预测说话人手势
  9. 微服务 java9模块化_Java9系列第8篇-Module模块化编程
  10. Phonetics: Lecture Three 语音 第三课 Teacher:Patrick
  11. fragment--总结
  12. 主机与虚拟机ping通
  13. 1.Spring Boot使用Apache Curator实现服务的注册和发现「第四章 ZooKeeper Curator应用场景实战」「架构之路ZooKeeper理论和实战」
  14. 021-云E办_职位管理功能实现
  15. 等比数列求和python_Python科学计算
  16. MySQL知道出生日期如何计算年龄
  17. 解决jmeter压测试时java.net.SocketException: Socket closed异常
  18. 10G 82599EB 网卡测试优化 ethtool
  19. 100多个常用的Python函数!(部分函数含代码解读)
  20. java的round函数加点差_【JAVA】Math.Round()函数常见问题“四舍5入”

热门文章

  1. 安卓分屏神器_【实用工具】一款鲜为人知的电脑神器,内置300多…找了很久了!...
  2. 青果教务php,拟物校园 | 拟物校园,一个高校教务系统爬虫,现支持正方教务、青果教务。...
  3. 优优聚:美团,又要抢抖音的短视频生意
  4. 微信小程序数据拼接_微信小程序字符串和变量如何拼接
  5. Redis和MySQL保持数据一致性
  6. 【学英语】老友记S1E1
  7. 只利用热点事件就能吸粉引流?白日做梦!
  8. 我对计算机基础的认识,我对计算机的认识
  9. (ONENET+阿里云+datav+node+js+MYSQL+STM32)基于ONENET的大数据可视化平台搭建
  10. PhpSpreadsheet读写Excel文件