目录
1. 什么是SkipList跳表
skiplist基于链表进行升级,用于查找,与平衡搜索树和哈希表的作用是一致的,可以作为key或者k/v的查找模型(kv存储引擎redis,leveldb使用了skiplist)
skiplist是由William Pugh发明的,最早出现于他在1990年发表的论文《Skip Lists: A Probabilistic Alternative to Balanced Trees》。
感兴趣的同学可以阅读一下论文,这里我总结了William Pugh的思路:
- 假设每相邻的两个节点升高一层,增加一个指针,让指针指向下下个节点。如图b,此时因为增高操作而产生的指针连成了一个新的链表,但是包含的节点个数只有原来的一半,由于增加了新的指针,我们不需要在遍历原链表查找了,需要比较的节点数大概只有原来的一半。
- 依此类推,我们不断增高链表,每次节点都变为下一层的一半(如图c),进一步增加查找效率(思想与二分一致)
按照上面的思想,SkipList可以简单理解为一个多层的链表。不过实际上,按照上面的拓展方式,每层之间的节点数量关系是严格1/2的,是一个标准的二分查找模型,时间复杂度是O(log_2 N)。
但是,这个结构面临增删场景时会将十分棘手,插入一个节点,或者删除一个节点,都会打乱数量规则,此时,为了恢复数量关系,需要对新插入的节点后面的所有节点进行调整,意味着时间复杂度降为O(n);
为此,William Pugh进行了一个大胆的处理,即不再严格要求对应的比例关系,而是在插入节点的时候随机出一个层数,此时每次插入,删除不需要考虑其他节点层数了,处理难度大大简化。
2. SkipList的效率分析与保证
2.1 插入随机数的确定
这个大胆的决定看上去有一点随意,这样的设计真的能抱证搜索的时候的效率吗?
skiplist插入一个节点的时候随机出一个层数,但是这里的随机也不是存粹的随机,这里规定:
- 跳表存在一个最大层数maxlevel的限制
- 会设置一个 增加一层的概率p
此时计算随机层数的伪代码为:
在我们熟知的redis中,其内存管理使用了跳表,其参数取指是:
- p = 1/4
- maxlevel = 32
这样的设计其实很好理解:产生越高的节点层数,概率是越低的:
- 节点层数至少为1。而大于1的节点层数,满足几何分布。
- 节点层数恰好等于1的概率为1-p。
- 节点层数大于等于2的概率为p,而节点层数恰好等于2的概率为p(1-p)。
- 节点层数大于等于3的概率为p^2,而节点层数恰好等于3的概率为 p*p *(1-p)。
- 节点层数大于等于4的概率为p^3,而节点层数恰好等于4的概率为ppp*(1-p)。
因此,一个节点的平均层数(也即包含的平均指针数目),计算如下:
也就是说,当p=1/2,每个节点的平均指针数为2
2.2 时间复杂度的计算
跳表的时间复杂度可以保证为O(logN),但是由于其复杂性,推导过程比较麻烦,有兴趣的同学,可以自行了解:
铁蕾大佬的博客
3. SkipList的实现
skiplist的实现在leetcode正好有相应习题,同学们可以尝试去AC一下:1206. 实现跳表
这里给出我的设计思路与方案。
3.1 节点的设计
之前我们说将节点增高,并不需要将节点复制一份,这样会造成值的冗余,实际上只需要设置多个指针,每个指针处于不同的层即可,即_nextV数组,每个指针的索引即为层数level.
根据上图,最右边是头结点,不存储数据,所以我们在初始化头结点的时候,设置_val=-1,同时将高度设置为1,即初始只有一层。
struct SkipListNode
{
int _val;
vector<SkipListNode*>_nextV;
SkipListNode(int val,int level)
:_val(val)
,_nextV(level,nullptr)//将_nextV数组初始化为长度level的数组,即初始level层
{}
};
SkipList初始化代码:
SkipList(){
//头结点,初始层数为1
_head = new SkipListNode(-1,1);
srand(time(0));
}
3.2 功能的实现
节点查找
查找操作比较简单,每次查找一定会查找到最低一层,每次要么向右走,要么向下走。
对于给定的目标值,当目标值比当前节点的下一个节点的值更大,则向右走;如果下一个节点为空(即当前节点是尾结点),或者目标节点的值比下一个节点的值更小,则向下走。
eg: 查找19
- 我们从head的最高层即第四层开始搜索,发现下一个节点值为6,19 > 6,则向右走
- 发现下一个节点是空的,向下走,到达节点6的第三层
- 发现节点6第三层的下个节点为25,19<25,向下走,到达节点6的第二层
- 发现节点6的第二层的下个节点为9,19>9,向右走,到达节点9
- 发现节点9的第二层的下个节点为17,19>17,向右走,到达节点17
- 发现节点17的第二层的下个节点为25,19<25,向下走,到达节点17第一层
- 发现节点17第一层的下个节点为19,找到了。
如果对应的节点没有被走到,level最终会降至-1层。
bool search(int target){
Node* cur = _head;
int level = _head->_nextV.size()-1;
while(level >= 0)
{
if(cur->_nextV[level] && cur->_nextV[level]->_val < target){ //向右走
cur = cur->_nextV[level];
}else if(!cur->_nextV[level] || cur->_nextV[level]->_val > target){
level--;
}else{
return true;
}
}
return false;
}
层数随机
层数随机根据之前的原理,写出来即可,这里提供两种写法,第二种是基于C++11的库函数实现的:
int RandomLevel()
{
size_t level = 1;
while(rand()<RAND_MAX*_p && level <_maxLevel)
{
++level;
}
return level;
}
int RandomLevel2()
{
static default_random_engine generator(chrono::system_clock::now().time_since_epoch().count());
static uniform_real_distribution<double> distribution(0.0,1.0);
size_t level = 1;
while(distribution(generator)<=_p && level<_maxLevel)
{
++level;
}
return level;
}
添加节点
插入节点的思想在于找到待插入位置的前驱节点,这样就能够插入新节点了,和单链表是一致的。
下面代码中的prevV数组存储了待添加节点每一层的前驱节点是谁,这样每层的链接关系都可以顺利更新。
vector<Node*> FindPrevNode(int num)
{
Node*cur = _head;
int level = _head->_nextV.size()-1;
vector<Node*>prevV(level+1,_head);//默认前驱节点
while(level >= 0) {
if(cur->_nextV[level] && cur->_nextV[level]->_val < num){ //向右走
cur = cur->_nextV[level];
}else if(!cur->_nextV[level] || cur->_nextV[level]->_val >= num){//向下走
prevV[level] = cur;//更新前驱
level--;
}else{
}
}
return prevV;
}
void add(int num){
auto prevV = FindPrevNode(num);
int n = RandomLevel();
Node*newnode = new Node(num,n);
//如果当前n大于最大高度,则更新最大高度
if(n>_head->_nextV.size()){
_head->_nextV.resize(n,nullptr);
prevV.resize(n,_head);
}
for(size_t i=0;i<n;i++)
{
newnode->_nextV[i] = prevV[i]->_nextV[i];
prevV[i]->_nextV[i] = newnode;
}
}
删除节点
与add操作基本一致,不做赘述:
bool erase(int num){
auto prevV = FindPrevNode(num);
/*
* 第一层的下一个不是val,val不在表中
*/
if(prevV[0]->_nextV[0]==nullptr ||prevV[0]->_nextV[0]->_val!=num)
{
return false;
}else{
Node* del = prevV[0]->_nextV[0];
for(size_t i = 0;i<del->_nextV.size();i++)
{
prevV[i]->_nextV[i] = del->_nextV[i];
}
delete del;
//若最高层无节点,则降低头结点层数
int i =_head->_nextV.size()-1;
while(i>=0){
if(_head->_nextV[i]==nullptr)
--i;
else
break;
}
_head->_nextV.resize(i+1);
return true;
}
}
4. SkipList与平衡树和哈希表的比较
ps:对于平衡树和哈希表不熟悉的同学可以翻我之前的博客来学习() 。
skiplist相比平衡搜索树(AVL树和红黑树)对比,都可以做到遍历数据有序,时间复杂度也差
不多。
skiplist的优势是:
- skiplist实现简单,容易控制。平衡树增删查改遍历都更复杂。
- skiplist的额外空间消耗更低。平衡树节点存储每个值有三叉链,平衡因子/颜色等消耗, skiplist中p=1/2时,每个节点所包含的平均指针数目为2;skiplist中p=1/4时,每个节点所包
含的平均指针数目为1.33;
skiplist相比哈希表而言,就没有那么大的优势了。
相比而言:
- 哈希表平均时间复杂度是O(1),比skiplist快。
- 哈希表空间消耗略多一点。
skiplist优势如下:
- 遍历数据有序
- skiplist空间消耗略小一点,哈希表存在链接指针和表空间消耗。
- 哈希表扩容有性能损
耗。 - 哈希表再极端场景下哈希冲突高,效率下降厉害,需要红黑树补足接力。