由二分查找引出跳表
二分查找:二分查找针对的是一个有序的数据集合,查找思想有点类似分治思想。每次都通过跟区间的中间元素对比,将待查找的区间缩小为之前的一半,直到找到要查找的元素,或者区间被缩小为 0。
二分查找底层依赖数据结构:数组——即可以按照下标随机访问元素的数据结构
二分查找的时间复杂度:O(logn)
思考:一个有序的链表如何快速查找到需要的数据?
什么是跳表
对于单链表来说,即便链表中的数据是有序的,如果我们想要在其中查找某个数据,那么我们也只能从头到尾遍历链表,这样时间复杂度就会为O(n)。
那么如何提高我们的查找效率?有一种思路——我们是不是可以模拟数组的样子建立索引?什么意思呢?看图。
注意:其中down表示down指针,指向下一层。
这样我们对链表建立一级索引,现在假设我们需要查找10的节点的时候,我们在索引层先遍历到9的时候,由于索引层9的下一个数是11,比我们要查找的10大,这时候我们通过down指针下降到原始链表这一层,然后继续遍历。这个时候,我们只需要再遍历两个节点就可以了。那么我们如果没有一级索引的时候,我们需要遍历10个节点才能查找到我们的目标值,加上索引之后,只需要遍历7个节点。
从这个例子中,我们可以看出,加了一层索引之后,查找一个节点需要遍历的节点数减少了,说明我们的查找效率提高了。那么如果我们再加一层索引,查找的次数会不会进一步减少呢?答案是肯定的,我们再看一张图。
这次我们找到10的数据变为了6次,所以效率又一次提高了一点。
由于我讲的例子数据比较少,提高的效率不是很明显。那么我们再画一个比较明显的。64个数据,5级索引。
我们需要找到第62个节点,只需要11次遍历即可。所以当链表的长度比较大的时候,通过建立索引能够很明显的提高查找效率。像这样,我们把具有索引层级的链表称为跳表
跳表的查找效率的分析
查找效率我们可以用查找的时间复杂度来衡量。我们知道在一个单链表中,我们查找某个数据的时间复杂度是O(n)。我们接下来就分析跳表的查询的时间时间复杂度。
首先有n个节点的链表,我们需要建立多少层级的索引?
按照我们上面的分析,假设每两个节点都会抽出一个节点作为上一级的索引的节点,那么第一级索引就是n/2个节点,第二级索引的个数就是n/4,依次类推,第k级索引的节点数是第k-1层索引的一半。那么第k级索引的个数就是n/(2k)。
假设索引有h级,最高的索引只有2个节点,我们可以得到n/(2h) = 1。得出h = log2n-1,如果包含链表这一层那么整一个跳表的高度就是 log2n。我们再跳表中查询某个数据的时候,如果每一层都要遍历m个节点,那么跳表中查询一个数据的时间复杂度就是O( m*logn)。
假设我们需要查找的数据是x,在第k级索引中,我们遍历到y节点之后,发现x大于y,小于y后面的节点z,所以这时候我们通过y的down指针,从第k+1级索引下降到第k级索引。在第k层索引中y到z之间包含3个节点(包括y和z),所以在第k-1级最多遍历3个节点,依次类推,每一级索引最多只需要遍历3个节点。 所以跳表查询的时间复杂度为O( 3*logn)。由于时间复杂度一般忽略系数的影响,所以最终得到时间复杂度为O( logn)。结合图再来看一下
跳表的空间复杂度分析
看了以上的分析之后,我们可以分析跳表的空间复杂度,和上面一样第一级的索引数为n/2。依次类推第二,第三级索引数为n/4,n/8…这是一个等比数列,最终算得一共需要n/2+n/4+n/8+…+2 = n-2。所以跳表的空间复杂度为O(n),也就是说,如果将包含n个结点的单链表构建成一个跳表,我们需要额外再用接近n个结点的存储空间空间。那么我们有没有方法降低索引占用的内存空间呢?
我们前面都是每两个结点抽取一个结点,那现在我们3个结点或者5个结点再抽取一个节点的话,索引占据的空间是不是会降低。如果是每3个结点抽取一个节点那么最终的所需要的创建的节点数为n/2,这就大概减少了一半的存储空间。
实际上,这只是链表存储对象为整数的时候显得很浪费空间,如果链表对象很大,我们在索引中只需要存储比较的关键字和一些指针,不需要存储整个对象,所以如果对象很大的时候,那么索引的空间就可以忽略了。
高效的动态操作
实际上跳表不仅支持查找操作,它还支持动态的插入和删除操作,时间复杂度也是O( logn)。
下面我们来看一下它是如何做到插入的时间复杂度为O( logn) 的。我们知道链表在某个节点后插入一个结点,时间复杂度为O(1),也就是说如果我们找到需要插入那个位置的前一个结点,那么插入的操作就是O(1),也就是说找到节点的时间复杂度就是整个插入过程的时间复杂度。前面我们也知道查找的时间复杂度为O( logn),所以插入的时间复杂度为O( logn)。删除操作同理。
跳表的索引的动态更新
当我们不停的往跳表中不停的插入数据的时候,如果我们不更新缩影,那么就会可能出现2个索引之间出现非常多结点的情况。极端的情况下,跳表会退化成单链表。如图:
作为一种动态数据结构,我们需要某种手段来维护索引与原始链表大小之间的平衡,也就是说,如果链表中结点多了,索引结点就相应地增加一些,避免复杂度退化,以及查找、插入、删除操作性能下降。也就是说我们往跳表中插入数据的时候,我们可以选择可以选择同时将这个数据插入到部分索引层中。
那么如何选择加入哪些索引层呢?可以通过一个随机函数来决定这个结点插入到哪一级索引中,比如随机函数生成了值K,那么我们就将这个结点添加到第一级到底K级索引中。
如何实现跳表
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];//所有层级,存储newNode插入的前一个节点
for (int i = 0; i < level; ++i) {
update[i] = head;
}
// record every level largest value which smaller than insert value in update[]
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;// use update save node in search path
}
// in search path node next node become new node forwords(next)
//更新索引
for (int i = 0; i < level; ++i) {
newNode.forwards[i] = update[i].forwards[i];
update[i].forwards[i] = newNode;
}
// update node hight
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--;
}
}
/**
*理论来讲,一级索引中元素个数应该占原始数据的 50%,二级索引中元素个数占 25%,三级索引12.5% ,一直到最顶层。
*因为这里每一层的晋升概率是 50%。对于每一个新插入的节点,都需要调用 randomLevel 生成一个合理的层数。
*该 randomLevel 方法会随机生成 1~MAX_LEVEL 之间的数,且:
* 50%的概率返回 1
* 25%的概率返回 2
* 12.5%的概率返回 3 ...
*/
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];//一个结点拥有的索引,其中最后高级的forwards[maxLevel]为这一级索引的下一个索引,即类似next指针
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();
}
}
}