高效的查找结构之跳跃表(Skip List)
说实话跳跃表是我非常喜欢的数据结构,虽然很多数据结构和算法的书中并没有讲它,为什么喜欢呢?因为我觉得它最好玩,为什么好玩呢?我也不知道,就是觉得好玩。好了,说了几句废话,现在言归正传。我们之前说过单链表的结构,对于单链表而言,它对于某个元素的查找的时间复杂度是O(n),无论单链表中的数据是有序还是无序都必须要遍历链表上的结点。OK,那我们知道折半查找的性能是非常高的(它实际上也是分而治之的算法思想,由于折半查找比较简单,生活中也随处可见它的案例,所以我没讲这部份内容,不知道的可以去看一下相信你一看就懂了,虽然简单但是想要写出一个好的折半查找的代码也不是件容易的事情),它可以在O(logn)的时间复杂度找到某个元素,logn是什么概念呢?大家都知道在棋盘上放米粒的故事吧,不用我多说了吧。但是折半查找也是有限制的,一是要求序列中的元素必须有序;二是由于有数组的索引支持它才可以很好地工作。那我们就想能不能通过改造链表的结构,让一个有序的链表也能像折半查找一样高效呢?(当然是可以的,不然我写这篇博客的意义何在?)这就是跳跃表(Skip List)。
其实redis中的有序集合(Sorted Set)就是使用hash table和skip List共同实现的。跳跃表是一种性能很高的动态数据结构,它可以很好地支持插入,删除,查找等操作。
假如现在我们有一个升序排列的有序链表,为了能够提高它的查找效率,我们对它的结构做如下的改造,为这个链表建立一个索引,假如现在我们对每两个结点提取出一个结点到上一级作为索引,那么第一次提取的这个索引就称为 “一级索引”,改造之后它的结构如下:
我们可以看到在以及索引结构中有两个指针,分别为指向索引结点中的下一个结点和指向原始链表中对应的那个结点。OK,现在我们来看看假如要查找10这个结点的流程。首先在一级索引中依次遍历,当遍历到9这个结点的时候,发现下一个结点为11,大于我们要找的结点,因此我们从9这个结点通过它的向下指针,下降到原始链表这一层继续遍历,直到找到这个结点。因此我们看到,在原始链表不增加索引的情况下,我们找到10这个结点需要遍历10次,但是增加了一级索引后只需要遍历7次就能找目标结点,确实遍历的次数少了。那你可能会说也没减少多少啊。确实,在这个例子中确实也没提高多少,如果是只有这些个少数的结点也不需要再花费空间资源建立索引了,直接遍历不就行了吗。但是当数据量大的时候呢?假如是100个结点呢?如果要找最后一个结点需要遍历100次,但是如果通过这种索引的话就可以在五十几次找到,几乎缩小了一半,要是1000呢?10000呢?况且我们这还只是只建立了一层索引。
假如现在我们在一级索引的基础上再建立一个二级索引,结构就变成这样了:
当我们再次查找10这个结点的时候,遍历的次数就变成6次了,又减少了。这里性能提高不明显是由于数据量不大,所以不明显。假如现在我们对一个100个数的结点建立一个6级索引(事实上按照这个方式它也只能建立6级索引),现在我们来查找第100个结点,按照索引的方式查找,每一级索引需要遍历两个结点,也就是 6 * 2 + 2 = 14次。怎么样?本来需要100次的操作14次就完成了。性能是不是提高了,我这里举得例子比较极端啊,实际上在所有的查找中查找最后一个元素的概率是比较小的,我这里只是为了说明性能的问题。
像这样的为链表添加多级索引的数据结构就叫做跳跃表,我们从例子中看到跳跃表确实能够提高查找的性能,但是相对的,由于加入了索引,这些索引是需要占据额外的存储空间的,因此它的空间开销会比较大,接下来我们就来分析一下它的复杂度。
对于跳跃表的时间复杂度分析比较复杂,因为它有多级的索引结构,但是客官您别急我们慢慢来看。其实你估计也能知道它的时间复杂度是O(logn),但是我们如何严密地计算呢?
假如链表中有n个结点,根据跳跃表索引的划分结构,一级索引结点的个数是n/2,二级索引的结点个数是n/4,三级索引的结点个数是n/8,那么对于第 m级索引结点个数是多少呢?我们按照这种推算可以得出第m级索引结点个数为:n/(2^m),那么对于一个结点个数为n的链表,它一共有几级索引我们就能够求出来,最后一级的索引结点个数为2,因此 n/(2 ^ m) = 2,可以得到m = (log2n) - 1(这里表示为:以2为底,n的对数)。这就是索引的级数。OK,假如我们要查找某个元素,每一级索引需要遍历多少个元素呢?首先我们从最后一级开始遍历,由于最后一级索引只有两个结点,要么从第一个结点下沉,要么从第二个结点下沉,当下沉到低一级的索引中时,由于我们是按照两个结点为分界划分的,因此要查找的元素位于上一级索引的中间,因此在这一级的索引上我们又会下沉到更低一级的索引,因此无论如何我们都只会在一级索引上最多遍历3个元素。所以,刚才我们计算出n个结点的链表共有(log2n)-1 级索引,因此在索引上需要遍历的次数就是[3 * ((log2n)-1)],如果将链表上的结点也算作0级索引的话,那么需要遍历的次数就是[3 *((log2n)-1)] + 3,最后计算得到3 * log2n, 因此时间复杂度T(n) = O(logn)。是不是很不可思议,居然做到了折半查找的性能。
我们看到跳跃表的结构在查找的时候性能相当好,但是凡事都有两面性,虽然时间复杂度很低,但是我们看到这些索引的存在,无疑多增加了很多内存的使用,下面我们就来看看跳跃表的空间复杂度。
其实空间复杂度很好计算的,我们在分析时间复杂度的时候已经分析过,每一级索引上的结点个数了,n/2 + n/4 + n/8 + …+ 2 = n -2 ,因此空间复杂度为O(n)。可以看到它的空间复杂度是线性增长的,那你可能会说,啊空间开销好大啊,需要和原数据一样的空间开销。实际上,我们在开发中往往是给一组对象排序,而我们的索引存储的只是两个指针外加上了对象的key,所以并非和原来的数据一样的空间开销。
但是呢,你也是可以有一些方法来优化空间的,例如我们是选择的没两个结点划分,你可以每3个结点划分,当你使用3个结点划分的时候,空间消耗计算一下是n/2,虽然空间复杂度依然是O(n),但是确实空间的消耗减少了一半,不过相应的时间开销也会增加(如果不忽略常数)。
我们看到跳跃表在查找数据时的高效性,那么在插入和删除的操作,它的性能怎样呢?我们知道在单链表的插入和删除操作中,对于这个操作本身只需要O(1)的时间复杂度,但是对于一个有序的链表,你也必须先找到这个元素的位置,找位置本身就是一个查找操作,因此单链表会有遍历操作,因此整个过程的时间复杂度是O(n)。对于跳跃表而言由于本身它是链表,因此本身的插入,删除操作也是O(1),而在查找阶段时间复杂度为O(logn),因此跳跃表的Caruso和删除操作也是O(logn)的复杂度。
我用一个图来表示插入操作,例如我们需要将22这个元素插入链表中。
OK,接下来我们看看删除操作,注意删除操作有写不一样的地方,如果这个结点在索引中也有出现,我们除了要删除原始链表中的结点,还要删除索引的结点。因为单链表中的删除操作需要拿到要删除结点的前驱结点,然后通过指针操作完成删除。所以在查找要删除的结点的时候,一定要获取前驱结点。当然,如果我们用的是双向链表,就不需要考虑这个问题了。
请大家思考一下这个问题,我们上面在讲插入操作时,随着插入操作地增多,原本两个结点划分为一个索引,可是随着结点插入增多,索引之间的元素就越来越多,当索引之间的元素结点约来越多达到某个量级,就退化成了单链表,这就要求我们对索引进行更新。
在开篇我们说到跳跃表是一种动态的数据结构,因此为了不让跳跃表在结点插入的情况下,性能急速下降,我们必须采用某种策略调整索引的结构,让它依然能维持在较好的性能下。在二叉查找树中我们为了不让查找退化为线性查找,需要对二叉树进行旋转操作,那么我们怎么来维护索引结构呢?
当我们往跳表中插入数据的时候,我们可以选择同时将这个数据插入到部分索引层中。如何选择加入哪些索引层呢?一般通过一个随机函数,来决定将这个结点插入到哪几级索引中,比如随机函数生成了值 K,那我们就将这个结点添加到第一级到第 K 级这 K 级索引中。因此随机函数怎么选择就很重要了,它必须保证性能不会过度退化,并且是每次插入操作都对它进行更新还是在一个合适条件下触发。关于这一点还是比较复杂的,有兴趣可以了解一下redis中Sorted Set数据类型的底层,它就是使用了跳跃表。
跳跃表的实现还是有点复杂的,这里给出代码的实现,可以参考参考
public class SkipList {
private static final float SKIPLIST_P = 0.5f;
private static final int MAX_LEVEL = 16;
private int levelCount = 1;
private Node head = new Node(); // 带头链表
public Node find(int value) {
Node p = head;
for (int i = levelCount - 1; i >= 0; --i) {
while (p.forwards[i] != null && p.forwards[i].data < value) {
p = p.forwards[i];
}
}
if (p.forwards[0] != null && p.forwards[0].data == value) {
return p.forwards[0];
} else {
return null;
}
}
public void insert(int value) {
int level = randomLevel();
Node newNode = new Node();
newNode.data = value;
newNode.maxLevel = level;
Node update[] = new Node[level];
for (int i = 0; i < level; ++i) {
update[i] = head;
}
Node p = head;
for (int i = level - 1; i >= 0; --i) {
while (p.forwards[i] != null && p.forwards[i].data < value) {
p = p.forwards[i];
}
update[i] = p;
}
for (int i = 0; i < level; ++i) {
newNode.forwards[i] = update[i].forwards[i];
update[i].forwards[i] = newNode;
}
if (levelCount < level) levelCount = level;
}
public void delete(int value) {
Node[] update = new Node[levelCount];
Node p = head;
for (int i = levelCount - 1; i >= 0; --i) {
while (p.forwards[i] != null && p.forwards[i].data < value) {
p = p.forwards[i];
}
update[i] = p;
}
if (p.forwards[0] != null && p.forwards[0].data == value) {
for (int i = levelCount - 1; i >= 0; --i) {
if (update[i].forwards[i] != null && update[i].forwards[i].data == value) {
update[i].forwards[i] = update[i].forwards[i].forwards[i];
}
}
}
while (levelCount>1&&head.forwards[levelCount]==null){
levelCount--;
}
}
// 随机函数
private int randomLevel() {
int level = 1;
while (Math.random() < SKIPLIST_P && level < MAX_LEVEL)
level += 1;
return level;
}
public void printAll() {
Node p = head;
while (p.forwards[0] != null) {
System.out.print(p.forwards[0] + " ");
p = p.forwards[0];
}
System.out.println();
}
public class Node {
private int data = -1;
private Node forwards[] = new Node[MAX_LEVEL];
private int maxLevel = 0;
@Override
public String toString() {
StringBuilder builder = new StringBuilder();
builder.append("{ data: ");
builder.append(data);
builder.append("; levels: ");
builder.append(maxLevel);
builder.append(" }");
return builder.toString();
}
}
}