跳表
一、基本概念
skiplist本质上也是一种查找结构,用于解决算法中的查找问题(Searching),即根据给定的key,快速查到它所在的位置(或者对应的value)。
我们可以想象一下我们之前学的很清晰的链表,它就是一个链式的结构,我们假如说是想要找到某一个值的话我们的算法复杂度为O(N),是不是太慢了,所以我们接下来的跳表就很好地实现优化算法复杂度的做法。
假如我们每相邻两个节点增加一个指针,让指针指向下下个节点:
这样所有新增加的指针连成了一个新的链表,但它包含的节点个数只有原来的一半(上图中是7, 19, 26)。现在当我们想查找数据的时候,可以先沿着这个新链表进行查找。当碰到比待查数据大的节点时,再回到原来的链表中进行查找。
我们经过上面的操作以后,发现了我们可以跳过一些结点,那么就很简单了,我们的算法复杂度整整下降了一半,跟二分查找有一定的类似之度。
利用同样的方式,我们可以在上层新产生的链表上,继续为每相邻的两个节点增加一个指针,从而产生第三层链表:
实际上,按照上面生成链表的方式,上面每一层链表的节点个数,是下面一层的节点个数的一半,这样查找过程就非常类似于一个二分查找,使得查找的时间复杂度可以降低到O(log n)。但是,这种方法在插入数据的时候有很大的问题。新插入一个节点之后,就会打乱上下相邻两层链表上节点个数严格的2:1的对应关系。如果要维持这种对应关系,就必须把新插入的节点后面的所有节点(也包括新插入的节点)重新进行调整,这会让时间复杂度重新蜕化成O(n)。删除数据也有同样的问题。 简单来讲就是多增加一个结点,其层数的分配必定打乱,不可能是二分查找的效率,百分百会导致时间复杂度下降。
所以,有一个很酷很厉害的方法,是William Pugh发明的,最早出现于他在1990年发表的论文《Skip Lists: A Probabilistic Alternative to Balanced Trees》这位大佬想到的,我们用一个随机的层数不行吗?这样不就能够完美解决了这个问题吗?每一个节点的层数(level)是随机出来的,而且新插入一个节点不会影响其它节点的层数,我们根本不用严格保持1:2的关系了,就不用调整那么多,只需要插入随机结点层数就行,而且这个随机结点层数还跟概率有关,我们先来计算一下每个节点所包含的平均指针数目(概率期望)。节点包含的指针数目,相当于这个算法在空间上的额外开销(overhead),可以用来度量空间复杂度:
节点层数至少为1。而大于1的节点层数,满足一个概率分布。
节点层数恰好等于1的概率为1-p。
节点层数大于等于2的概率为p,而节点层数恰好等于2的概率为p(1-p)。
节点层数大于等于3的概率为p2,而节点层数恰好等于3的概率为p2(1-p)。
节点层数大于等于4的概率为p3,而节点层数恰好等于4的概率为p3(1-p)。
......
因此,一个节点的平均层数(也即包含的平均指针数目):
当p=1/2时,每个节点所包含的平均指针数目为2;
当p=1/4时,每个节点所包含的平均指针数目为1.33。
插入删除过程:
二、Leetcode练习题
1、题目描述
2、结点构造
首先需要一个_nextv的指针数组(vector<Node*> _nextv)用来存放指向的下一排节点。再需要一个val用来存当前节点的值!
cpp
struct NumListNode
{
int _val; // 值
std::vector<NumListNode*> _nextv; // 存的是节点的指针
NumListNode(int val, int level)
: _val(val)
, _nextv(level, nullptr)
{}
};
class Skiplist
{
typedef NumListNode Node;
public:
Skiplist()
{
srand(time(NULL));
// 头结点,层数是1
_head = new NumListNode(-1, 1);
}
private:
Node* _head; // 头节点
size_t _maxLevel = 32; // 最大层数
double _p = 0.25; // 概率
};
3、search函数
这个函数当然是很简单的,我们根据上面的图例来进行分析能够看到,我们只需要跟当前节点的层数所指向的下一个结点的值进行比较,如果我目标值小,那么我们就继续往下面层数去看,直到层数找完为-1退出返回false,而如果我们的目标值大,我们就直接从当前层跳到下一个结点的当前层继续比较!
函数代码:
cpp
bool search(int target)
{
Node* cur = _head;
int level = cur->_nextv.size() - 1; // 节点指针个数-1
// 当target比下一个结点值要大,往右走
// 下一个结点是空/尾,此时target比下一个结点值要小,往下走
while (level >= 0)
{
if (cur->_nextv[level] && target > cur->_nextv[level]->_val)
{
// 往右走
cur = cur->_nextv[level];
}
else if (cur->_nextv[level] == nullptr || target < cur->_nextv[level]->_val)
{
--level;
}
else
{
return true;
}
}
return false;
}
4、add函数
cpp
void add(int num)
{
Node* cur = _head;
int level = cur->_nextv.size() - 1; // 节点指针个数-1
std::vector<Node*> prevv(level + 1 , _head);
// 当target比下一个结点值要大,往右走
// 下一个结点是空/尾,此时target比下一个结点值要小,往下走
while (level >= 0)
{
if (cur->_nextv[level] && num > cur->_nextv[level]->_val)
{
// 往右走
cur = cur->_nextv[level];
}
else if (cur->_nextv[level] == nullptr || num <= cur->_nextv[level]->_val)
{
// 更新prevv链接的前一个
prevv[level] = cur;
// 往下走
--level;
}
}
// 随机的层数
int n = RadomLevel();
Node* newnode = new Node(num, n);
// 如果n大于层数的话,就升高一下_head头结点的层数
if (n > _head->_nextv.size())
{
_head->_nextv.resize(n);
prevv.resize(n, _head);
}
// 前后进行链接
for (size_t i = 0; i < n; i++)
{
newnode->_nextv[i] = prevv[i]->_nextv[i];
prevv[i]->_nextv[i] = newnode;
}
return;
}
5、RadomLevel随机层数函数
执行插入操作时计算随机数的过程,是一个很关键的过程,它对skiplist的统计特性有着很重要的影响。这并不是一个普通的服从均匀分布的随机数,它的计算过程如下:
首先,每个节点肯定都有第1层指针(每个节点都在第1层链表里)。
如果一个节点有第i层(i>=1)指针(即节点已经在第1层到第i层链表中),那么它有第(i+1)层指针的概率为p。
节点最大的层数不允许超过一个最大值,记为MaxLevel。
randomLevel()的伪码中包含两个参数,一个是p,一个是MaxLevel。在Redis的skiplist实现中,这两个参数的取值为:
p = 1/4
MaxLevel = 32
cpp
// 二选一
int RadomLevel()
{
size_t level = 1;
while (rand() < RAND_MAX * _p && level < _maxLevel)
{
++level;
}
return level;
}
int RadomLevel()
{
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;
}
第二种方法是C++11库中给我们的样例,我们用下面代码来进行演示一下:
6、erase函数
cpp
std::vector<Node*> FindNode(int num)
{
Node* cur = _head;
int level = cur->_nextv.size() - 1; // 节点指针个数-1
std::vector<Node*> prevv(level + 1, _head);
// 当target比下一个结点值要大,往右走
// 下一个结点是空/尾,此时target比下一个结点值要小,往下走
while (level >= 0)
{
if (cur->_nextv[level] && num > cur->_nextv[level]->_val)
{
// 往右走
cur = cur->_nextv[level];
}
else if (cur->_nextv[level] == nullptr || num <= cur->_nextv[level]->_val)
{
// 更新prevv链接的前一个
prevv[level] = cur;
// 往下走
--level;
}
}
return prevv;
}
bool erase(int num)
{
std::vector<Node*> prevv = FindNode(num);
// 第一层下一个不是val,val不在表中
if (prevv[0]->_nextv[0] == nullptr || prevv[0]->_nextv[0]->_val != num)
{
return false;
}
else
{
Node* del = prevv[0]->_nextv[0];
// del节点每一层的前后指针链接起来
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;
}
}
先找到这个del删除的结点,然后从它前面prevv所存的前面节点的指向指向del当前指向的后面的结点,然后再删除这个del结点。
7、打印函数及展示成果
cpp
void Print()
{
Node* cur = _head;
while (cur)
{
printf("%2d\n", cur->_val);
// 打印每个每个cur节点
for (auto e : cur->_nextv)
{
printf("%2s", "↓");
}
printf("\n");
cur = cur->_nextv[0];
}
}
int main()
{
Skiplist sl;
int a[] = { 5, 2, 3, 8, 9, 6, 5, 2, 3, 8, 9, 6, 5, 2, 3, 8, 9, 6 };
// int a[] = { 5, 2, 3, 8, 9, 6 };
for (auto e : a)
{
sl.add(e);
}
sl.Print();
printf("--------------------------------------\n");
sl.erase(5);
sl.Print();
return 0;
}
由于是随机的,所以我们就来了两种情况,同时我直接倒着放了。
三、skiplist跟平衡搜索树和哈希表的对比
- skiplist相比平衡搜索树(AVL树和红黑树)对比,都可以做到遍历数据有序,时间复杂度也差不多。skiplist的优势是:a、skiplist实现简单,容易控制。平衡树增删查改遍历都更复杂。 b、skiplist的额外空间消耗更低。平衡树节点存储每个值有三叉链,平衡因子/颜色等消耗。skiplist中p=1/2时,每个节点所包含的平均指针数目为2;skiplist中p=1/4时,每个节点所包含的平均指针数目为1.33。
- skiplist相比哈希表而言,就没有那么大的优势了。相比而言a、哈希表平均时间复杂度是O(1),比skiplist快。b、哈希表空间消耗略多一点。skiplist优势如下:a、遍历数据有序 b、skiplist空间消耗略小一点,哈希表存在链接指针和表空间消耗。c、哈希表扩容有性能损耗。d、哈希表再极端场景下哈希冲突高,效率下降厉害,需要红黑树补足接力。