引入
我们知道二叉搜索算法能够高效的查询数据,但是需要一块连续的内存,而且增删改效率很低。
跳表,是基于链表实现的一种类似“二分”的算法。它可以快速的实现增,删,改,查操作。
当我们要在该单链表中查找某个数据的时候需要的时间复杂度为O(n).
怎么提高查询效率呢?如果我们给该单链表加一级索引,将会改善查询效率。
如图所示,当我们每隔一个节点就提取出来一个元素到上一层,把这一层称作索引,其中的down指针指向原始链表。
当我们查找元素16的时候,单链表需要比较10次,而加过索引的两级链表只需要比较7次。当数据量增大到一定程度的时候,效率将会有显著的提升。
如果我们再加多几级索引的话,效率将会进一步提升。这种链表加多级索引的结构,就叫做跳表。
简介
与二分查找类似,跳跃表能够在 O(㏒n)的时间复杂度之下完成查找,与红黑树等数据结构查找的时间复杂度相同,但是相比之下,跳跃表能够更好的支持并发操作,而且实现这样的结构比红黑树等数据结构要简单、直观许多。
跳跃表以有序的方式在层次化的链表中保存元素,效率和平衡树媲美:查找、删除、添加等操作都可以在对数期望时间下完成。跳跃表体现了“空间换时间”的思想,
从本质上来说,跳跃表是在单链表的基础上在选取部分结点添加索引,这些索引在逻辑关系上构成了一个新的线性表,并且索引的层数可以叠加,生成二级索引、三级索引、多级索引,以实现对结点的跳跃查找的功能。
与二分查找类似,跳跃表能够在 O(㏒n)的时间复杂度之下完成查找,与红黑树等数据结构查找的时间复杂度相同,但是相比之下,跳跃表能够更好的支持并发操作,而且实现这样的结构比红黑树等数据结构要简单、直观许多。
跳跃表必须是完美的?
但是现在问题来了,上文我举的例子是一个很完美的跳跃表,它严格地按照二分法的思想建立了捷径。从理论上讲,单个跳跃表的层数按照严格二分的条件建立,层数就应该是 ㏒n 层(以2为底,n 为结点个数),但是在实际操作中,我们的数据会刚刚好是 2 的 n 次方吗?如果不是这么刚好的数据,没办法严格地二分,我要怎么开辟捷径呢?如果我对这个跳跃表进行插入或删除操作,破坏了严格地二分结构,又该怎么办?如果我们要强行解决这些问题,那就又要引入一大堆七七八八又难以实现的规则了,只怕这么做比建一棵树更为困难了。
既然我们没有办法很好地严格二分,也没有很好的规则去描述这些问题的处理方式,那么我们就不使用严格二分的方法就行了啊,不要一条绝路走到黑嘛!分析一下我们的目的,我们希望的事情是让查找操作的效率提升,如果我只开辟一条捷径,效率也确确实实是提升了的,如果能继续开辟捷径,如果最后我们能达到严格地二分,效率就会被提升,那也就是说我们并不是为了要实现二分,而是通过不断地努力去尽量地实现二分。
我们无法直接证明这个事实,但是我们可以通过“频率的稳定性”得到启发,提出了概率的定义,进而确定了事件的概率。从我举的例子里,我们不仅得到了启发,更是找到了解决问题的方法。也就是说,我们找到某种方式来实现捷径的开辟,这种方式在统计学的角度来说,可以往严格二分的情况趋近,在理论上实现 O(㏒n) 的时间复杂度。
建模
查询
跳跃表只需要从最上层开始遍历,由于每一层的链表都是有序的,因此当查找的“键”不存在于某一层中的时候,只需要在比查找目标的“键”要大的结点向下一次跳跃即可,重复操作,直至跳跃到最底层的链表。
1、先从顶层开始遍历,与16进行对比小,进入下一层。
2、与4进行比较,比4大,当前结点置为4结点,与16进行比较,进入下一层。
3、 与8进行比较,没有比8大,切换为当前结点4。
4、将节点4的下一个节点8和当前值进行比较,相同,取出。
插入
1、函数实现向跳跃表中插入一个“键”为 key,“值”为 value 的结点。由于我们进行插入操作时,插入结点的层数先要确定因此需要进行抛硬币实验确定占有层数。
2、由于新结点根据占有的层数不同,它的后继可能有多个结点,因此需要用一个指针通过“键”进行试探,找到对应的“键”的所有后继结点,在创建结点之后依次修改结点每一层的后继,不要忘了给结点判空。在插入操作时,“键”可能已经存在,此时可以直接覆盖“值”就行了,也可以让用户决定,可以适当发挥。
寻找节点的位置,获取到插入节点的前一个节点,
3、与链表的操作执行相同的节点操作,地址替换。
模拟插入操作
首先我们需要用一个试探指针找到需要插入的结点的前驱,即用红色的框框出来的结点。需要注意的是,由于当前的跳跃表只有 2 层,而新结点被 3 层占有,因此新结点在第 3 层的前驱就是头结点。
接下来的操作与单链表相同,只是需要同时对每一层都操作。如图所示,红色箭头表示结点之间需要切断的逻辑联系,蓝色的箭头表示插入操作新建立的联系。
插入的最终效果应该是如图所示的。
删除
由于需要删除的结点在每一层的前驱的后继都会因删除操作而改变,所以和插入操作相同,需要一个试探指针找到删除结点在每一层的前驱的后继,并拷贝。接着需要修改删除结点在每一层的前驱的后继为删除结点在每一层的后继,保证跳跃表的每一层的逻辑顺序仍然是能够正确描述。
1、根据删除的值找到当前值在跳表中的前驱结点 head 4
2、判断结点4的后驱结点的值是否为8,不是,直接跳出。当前值在跳表中不存在。
3、循环遍历每一层,执行地址变更。当前结点可能在其他层不存在结点,因此在变更的时候要判断是当前层是否存在该结点。
代码
// 跳表中存储的是正整数,并且存储的数据是不重复的
public class SkipListTest {
//最大索引层数
private static int MAX_LEVEL =16;
//头节点
private Node head;
//索引的层级数,默认为1
private int levelCount =1;
private Random random;
class Node{
//结点值
private int value;
//当前节点的所有后驱节点。1-maxlevel 层。
private Node[]nodes =new Node[MAX_LEVEL];
//当前节点的层数
private int maxLevel;
public Node(int value,int maxLevel) {
this.value = value;
this.maxLevel = maxLevel;
}
}
public Node get(int value){
//1、从最高层开始遍历
Node cur =head;
for (int i =levelCount-1; i >=0 ; i--) {
//找到比该值小的那个结点
while (cur.nodes[i]!=null && cur.nodes[i].value < value){
cur = cur.nodes[i];
}
//开始寻找下一层,直到找到最后一层
}
if(cur.nodes[0]!=null&&cur.nodes[0].value == value){
return cur.nodes[0];
}
return null;
}
public void insert(int number){
//1、获取要插入的索引层数量
int level = randomLevel();
//2、创建新节点
Node newNode =new Node(number,level);
//3、获取每一层的前驱结点
Node update[] =new Node[level];
//遍历索引层
Node c =head;
for (int i =level-1; i >=0 ; i--) {
while (c.nodes[i]!=null&&c.nodes[i].value
c = c.nodes[i];
}
update[i] = c;
}
//4、更新每一层的索引结构
for (int i =0; i
//当前结点的后驱结点
newNode.nodes[i] =update[i].nodes[i];
//当前结点的前驱
update[i].nodes[i] =newNode.nodes[i];
}
//5、更新索引层
if(levelCount
levelCount =level;
}
}
public void delete(int value){
//1、获取每一层比当前值小的前一个结点
Node[]update =new Node[levelCount];
Node p =head;
for(int i =levelCount -1; i >=0; --i){
while(p.nodes[i] !=null && p.nodes[i].value < value){
p = p.nodes[i];
}
update[i] = p;
}
//2、如果最后一层的结点的与当前值相同,进入变更指针操作。
if(p.nodes[0] !=null && p.nodes[0].value == value){
for(int i =levelCount -1; i >=0; --i){
//从最高层开始变更,如果值相等才进行变更
if(update[i].nodes[i] !=null &&update[i].nodes[i].value == value){
update[i].nodes[i] =update[i].nodes[i].nodes[i];
}
}
}
}
// 随机函数
private int randomLevel(){
int level =1;
for(int i =1; i
if(random.nextInt() %2 ==1){
level++;
}
}
return level;
}
}