目录
一、概念
二、实现
三、对比
一、概念
skiplist是由William Pugh发明的,最早出现于他在1990年发表的论文《Skip Lists: A Probabilistic Alternative to Balanced Trees》
skiplist本质上是一种查找结构,用于解决算法中的查找问题,跟平衡搜索树和哈希表的价值是一样的,可以作为key或者key/value的查找模型。skiplist是一个list,是在有序链表的基础上发展起来的。若是一个有序的链表,查找数据的时间复杂度是
William Pugh开始的优化思路
1. 假如每相邻两个结点升高一层,增加一个指针,让指针指向下下个结点,这样所有新增加的指针连成了一个新的链表,但包含的结点个数只有原来的一半。由于新增加的指针,不再需要与链表中每个结点逐个进行比较了,需要比较的结点数大概只有原来的一半
2. 以此类推,可以在第二层新产生的链表上,继续为每相邻的两个节点升高一层,增加一个指针,从而产生第三层链表,这样搜索效率就进一步提高了
3. skiplist正是受这种多层链表的启发而设计出来的。按照上面生成链表的方式,上面每一层链表的节点个数,是下面一层的结点个数的一半,这样查找过程就非常类似二分查找,使得查找的时间复杂度可以降低到。但是这个结构在插入删除数据的时候有很大的问题,插入或者删除一个结点后,就会打乱上下相邻两层链表上节点个数严格的2:1的对应关系。若要维持这种对应关系,就必须把新插入结点后面的所有结点(也包括新插入的结点)重新进行调整,这会让时间复杂度重新退化成
4. skiplist的设计为了避免这种问题,做了一个大胆的处理,不再严格要求对应比例关系,而是插入一个结点时随机出一个层数。这样每次插入和删除都不需要考虑其他结点的层数
skiplist的效率如何保证?
skiplist插入一个节点时随机出一个层数,听起来如此随意,如何保证搜索时的效率呢?
一般跳表会设计最大层数maxLevel的限制,其次会设置一个多增加一层的概率p,伪代码如下:
在Redis的skiplist实现中,这两个参数的取值为:maxLevel = 32,p = 1/4
- 结点层数至少为1。而大于1的结点层数,满足一个概率分布
- 结点层数恰好等于1的概率为
- 结点层数大于等于2的概率为 ,而节点层数恰好等于2的概率为
- 结点层数大于等于3的概率为 ,而节点层数恰好等于3的概率为
- 结点层数大于等于4的概率为 ,而节点层数恰好等于4的概率为
一个结点的平均层数(即包含的平均指针数目)
现在可以计算出:
- 当p = 1/2时,每个结点所包含的平均指针数目为2
- 当p = 1/4时,每个结点所包含的平均指针数目为1.33
跳表的平均时间复杂度为,推导过程较为复杂,需要一定数学功底,有兴趣可以参考以下大佬文章中的讲解
Redis内部数据结构详解(6)——skiplist - 铁蕾的个人博客 Redis内部数据结构详解(6)——skiplist - 铁蕾的个人博客 - 作者:张铁蕾http://zhangtielei.com/posts/blog-redis-skiplist.html
二、实现
https://leetcode.cn/problems/design-skiplist/description/
结点设计
struct SkiplistNode
{
SkiplistNode(int value, int level)
:_value(value) ,_nextVector(level, nullptr) {}
int _value;
vector<SkiplistNode*> _nextVector;
};
Skiplist成员变量和构造函数
class Skiplist
{
typedef SkiplistNode Node;
public:
Skiplist()
{
srand(time(0)); //设置随机数种子,后续随机生成结点层数时使用
_head = new SkiplistNode(-1, 1);//头结点初始层数为1
}
private:
Node* _head;
size_t maxLevel = 32; //最大层数限制
double _p = 0.25; //多增加一层的概率
};
search函数
- 记录当前所在结点以及所在结点的层数
- 只有level >=0 时查找才有效,否则返回false
- 若当前结点的值 > 目标值则向右走
- 若当前结点的值 < 目标值 或者 同一层下一个结点为空(下标--) ,则向下走
bool search(int target)
{
Node* current = _head;
int levelIndex = _head->_nextVector.size() - 1;
while (levelIndex >= 0)
{
//目标值比下一个结点的值大
if (current->_nextVector[levelIndex] != nullptr && current->_nextVector[levelIndex]->_value < target)
current = current->_nextVector[levelIndex]; //向右走
//下一个结点是空(尾)
//目标值比下一个结点要小
else if (current->_nextVector[levelIndex] == nullptr || current->_nextVector[levelIndex]->_value > target)
--levelIndex; //向下走
else
return true; //找到了
}
return false;
}
FindPrevNode函数
设计该函数的目的:
- add函数添加新结点要找到新结点每一层的前一个结点进行连接
- erase函数删除结点要找到该结点每一层前一个结点 与 每一层的后一个结点进行连接
- 使代码简洁,设计了FindPrevNode函数,实现代码的复用
vector<Node*> FindPrevNode(int number)
{
Node* current = _head;
int levelIndex = _head->_nextVector.size() - 1;
//待插入结点或待删除结点 的每一层的前一个结点的指针
vector<Node*> prevVector(levelIndex + 1, _head);
while (levelIndex >= 0)
{
if (current->_nextVector[levelIndex] != nullptr && current->_nextVector[levelIndex]->_value < number)
current = current->_nextVector[levelIndex];
else if (current->_nextVector[levelIndex] == nullptr || current->_nextVector[levelIndex]->_value >= number)
{
prevVector[levelIndex] = current;
--levelIndex;
}
}
return prevVector;
}
该函数基本与search函数相同,需要注意的是:当current->_nextVector[levelIndex]->_value >= number时,记录current结点。比search多一个等于,因为最低层的指针也需要修改链接
add函数
- 获取要添加结点的前一个Node的集合
- 随机获取层数,构建新结点并初始化
- 若随机获取的层数超过当前最大的层数,那就升高一下_head的层数
- 利用前一个Node集合 prevVector 和 当前结点的每一层建立连接关系
void add(int number)
{
//获取要添加数据的前一个Node的集合
vector<Node*> prevVector = FindPrevNode(number);
//随机获取层数,构建新结点并初始化
int level = RandomLevel();
Node* newNode = new Node(number, level);
//若随机获取的层数超过当前最大的层数,那就升高一下_head的层数
if (level > _head->_nextVector.size()) {
_head->_nextVector.resize(level, nullptr);
prevVector.resize(level, _head);
}
//链接前后结点
for (int i = 0; i < level; ++i) {
newNode->_nextVector[i] = prevVector[i]->_nextVector[i];
prevVector[i]->_nextVector[i] = newNode;
}
}
为什么不一开始就将_head头结点的层数设为最高呢?
一开始设为最高,后序查找有很多是无用的,所以不直接将_head设为最高,且利用一个变量记录最高层,当新插入数据的层数 > 最高层时才增加层数
erase函数
- 获取要删除结点的前一个Node的集合prevVector
- 若prevVector[0]->_nextVector[0] == nullptr || prevVector[0]->_nextVector[0]->_value != number 即未找到该数据,返回false
- 否则记录要删除的Node ,去除前后连接关系,然后delete释放资源
- 若删除的是最高层节点,重新调整头结点层数,下次查找时就不会从无用的最高层开始查找 (这个过程做不做都行,提升不太大)
bool erase(int number)
{
//获取要删除结点的前一个Node的集合
vector<Node*> prevVector = FindPrevNode(number);
if (prevVector[0]->_nextVector[0] == nullptr || prevVector[0]->_nextVector[0]->_value != number)
return false;
else
{
Node* deleteNode = prevVector[0]->_nextVector[0];
// deleteNode结点每一层的前后指针链接起来
for (int i = 0; i < deleteNode->_nextVector.size(); ++i)
prevVector[i]->_nextVector[i] = deleteNode->_nextVector[i];
delete deleteNode;
//若删除的是最高层节点,重新调整头结点层数
int headLevel = _head->_nextVector.size() - 1;
while (headLevel >= 0)
{
if (_head->_nextVector[headLevel] == nullptr)
--headLevel;
else break;
}
_head->_nextVector.resize(headLevel + 1);
}
return true;
}
获取随机数
方法一:C语言
int RandomLevel()
{
size_t level = 1;
// rand() ->[0, RAND_MAX]之间,将[0,RAND_MAX]看作为[0,1]
while (rand() <= RAND_MAX * _p && level < _maxLevel)
++level;
return level;
}
方法二:C++
std::uniform_real_distribution<double> distribution(0.0, 1.0) ,随机生成0.0 - 1.0的数,生成的数是均匀分布的
int RandomLevelCPP()
{
static std::default_random_engine generator(std::chrono::system_clock::now().time_since_epoch().count());
static std::uniform_real_distribution<double> distribution(0.0, 1.0);
size_t level = 1;
while (distribution(generator) <= _p && level < _maxLevel)
++level;
return level;
}
三、对比
skiplist与红黑树、AVL树对比
- skiplist和平衡搜索树(AVL树和红黑树)都可以做到遍历数据有序,时间复杂度也差不多
- 但skiplist实现简单,容易控制。平衡树增删查改遍历都更复杂.
- 并且skiplist的额外空间消耗更低。平衡树结点存储每个值有三叉链,平衡因子/颜色等消耗。skiplist中 p=1/2 时,每个结点所包含的平均指针数目为2;skiplist中 p=1/4 时,每个结点所包含的平均指针数目为1.33
skiplist与哈希表对比
skiplist与哈希表对比,就没有那么大的优势了。哈希表平均时间复杂度是 ,比skiplist快,但是哈希表空间消耗略多一点
- 哈希表扩容有性能损耗
- 哈希表在极端场景下哈希冲突高,效率下降厉害,需要红黑树补足接力