跳表的基本原理介绍我是从极客学院王争老师的课程中了解到的,由于其实现相比红黑树简单,并且有与之媲美的性能,便想要实现一下,但纸上得来终觉浅,实际编码过程中遇到了不少的困难,希望本文对大家实现跳表有所帮助。
原理
待补充,可先移步至csdn相关博客,内容和王争老师文章差别不大
实现
本文采用java语言实现,为了让删除的时间复杂度达到O(1),采用了双链表结构,代码地址在我的Github数据结构专栏。
节点数据结构
首先说下节点的数据结构代码1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34/**
* 用双链表存储
*/
class Node {
private Integer data;
private Node[] forwards;
// 为删除等操作提供O(1)
private Node[] previous;
public Node(Integer data, int level) {
this.data = data;
// ++level , level层有level+1个后继节点
forwards = new Node[level];
previous = new Node[level];
}
public String toString() {
return data == null ? "" : String.valueOf(data);
}
/**
* 获取节点在level层的下一个节点
*/
public Node boyNextDoor(int level) {
return forwards[level];
}
public Node previous(int level) {
return previous[level];
}
}
可以看到有两个数组,相信一开始很多人和我一样百思不得解两个数组是干什么的,相信大家见到的跳表差不多是这样的
上图红线给出了查找20的路径,一开始我也觉得很好理解,但随即两个问题困扰了我很久
- 图中有必要每层都存数据域吗,比如图中的10,为什么有5个?一个数据域+N和前后指针就可以了啊
- 向下的箭头怎么表示? 再来个down指针?
在找跳表相关文章时,看到一位作者引用了跳表的论文,点进去一看有种恍然大悟的感觉,跳表其实应该是这样的
于是我又手绘了一张跳表的结构图(省略了前驱指针),一边画一边思考,发现问题1和问题2解决之后变得很简单
最终得到了上面的代码,而forwards正是红色部分表示的后驱指针,这个后驱指针数组的长度完全取决于它所在的层数,但HEAD节点默认初始化为最大层数
可以看到previous和boyNextDoor(手动斜眼)两个方法提供了获取一个节点在x层上获取前驱和后继节点的能力
初始化及成员变量介绍
成员变量如下
1 | /** |
构造函数
1 | /** |
公共方法findFirstGreater
在讲解插入,删除,查找之前,先介绍findFirstGreater方法,这是一个简单且重要的公共方法1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23/**
*
* @param data 要查找的数据
* @param current 查询的起始节点
* @param level 要查找的层数
* @return 如果当前节点的下一个节点比data大(不能包含等于),返回当前节点
*/
private Node findFirstGreater(int data, Node current, int level) {
Node nextDoor = current.boyNextDoor(level);
// current在后,nextDoor在前,两个同时往右走, 就像两个快慢指针
// 只要发现nextDoor比data大,直接返回current
while (nextDoor != null) {
// 这个if写到while里也可以
if (data < nextDoor.data) {
break;
}
// 往前走一个
current = nextDoor;
nextDoor = current.boyNextDoor(level);
}
// 当前层没有比data大的,但是下一层可能有
return current;
}
无论是查找,插入,删除,我们都要找到第一个比data大的节点的前一个节点,假设data=5,链表为 2 3(5) 7, 第一个大于5的是7,则返回3(5),该值<=5
插入
插入的思路是这样的,首先通过随机函数决定节点有多高,当然最高不超过MAX_LEVEL,这个随机算法直接从redis源码里拿过来的,然后在每一层通过findFirstGreater方法找到第一个比data大的节点的前一个节点,剩下的就是双链表的插入了
1 | public void insert(int data) { |
contains方法
contains的实现很简单,就是调用了查找方法1
2
3
4public boolean contains(int data) {
Node node = find(data);
return node != null && node.data != null && data == node.data;
}
随机算法
返回1到MAX_LEVEL之间的随机数,符合正态分布1
2
3
4
5
6
7
8
9private int randomLevel() {
// the following implementation is basically as same as Redis ZSET implementation
// see https://github.com/antirez/redis/blob/4.0/src/t_zset.c
int newLevel = 1;
while ((random.nextInt() & 0xFFFF) < (0xFFFF >> 2)) {
newLevel++;
}
return (newLevel < MAX_LEVEL) ? newLevel : MAX_LEVEL;
}
查找
查找就是我们所熟知的跳表查询方法1
2
3
4
5
6
7
8
9
10
11
12
13/**
* 查找节点
*/
public Node find(int data) {
Node node = head;
int level = height - 1;
// 必须找到最后一层
while (level >= 0) {
node = findFirstGreater(data, node, level);
level--;
}
return node;
}
删除
删除相比查找,需要判断findFirstGreater返回的节点值等于data才能删,然后就是简单的双链表删除操作,过程也很简单1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24public void remove(int data) {
if (!contains(data)) {
return;
}
Node current = head;
int level = height - 1;
while (level >= 0) {
current = findFirstGreater(data, current, level);
if (current != head && current.data == data) {
// 双链表删除
Node previous = current.previous(level);
Node nextDoor = current.boyNextDoor(level);
System.out.println(previous.data + "-->" + current.data + "-->" + (nextDoor == null ? "" : nextDoor.data));
previous.forwards[level] = nextDoor;
if (nextDoor != null) {
nextDoor.previous[level] = previous;
}
size--;
}
// 下一层
level--;
}
}
打印
说实话最耽误时间的反而是这个打印功能,我希望打印出如下形式,这我心目中这是最直观的,但这就要考虑两个节点间的空格数量,看我的代码会比较复杂,但只要自己沉下心来认真想一下,相信大家都能很快有思路1
2
3level 2: 29
level 1: 29 57 148
level 0: 2 13 18 29 48 50 51 57 131 132 148
我的方式不一定适合每个人,遇到两个节点只是简单的从level0遍历,大数据量不适合1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62public void easyPrint() {
for (int i = height - 1; i >= 0; i--) {
System.out.print("level " + i + ": ");
Node current = head.forwards[i];
Node next = current.boyNextDoor(i);
StringBuilder builder = new StringBuilder();
if (current.data != head.forwards[0].data) {
builder.append(whiteSpaceHelper(current.data));
}
builder.append(current.data);
while (next != null) {
builder.append(whiteSpaceHelper(current.data, next.data)).append(next.data);
current = current.boyNextDoor(i);
next = next.boyNextDoor(i);
}
System.out.println(builder.toString());
}
}
private String whiteSpaceHelper(int pre, int next) {
Node current = head.boyNextDoor(0);
while (current != null) {
if (current.data == pre) {
break;
}
current = current.boyNextDoor(0);
}
Node node = current.boyNextDoor(0);
int count = 0;
while (node != null) {
count++;
if (node.data == next) {
break;
}
count += node.data.toString().length();
node = node.boyNextDoor(0);
}
StringBuilder builder = new StringBuilder();
for (int i = 0; i < count; i++) {
builder.append(" ");
}
return builder.toString();
}
private String whiteSpaceHelper(int next) {
Node node = head.forwards[0];
int count = 0;
while (node != null) {
if (node.data == next) {
break;
}
count += node.data.toString().length();
count++;
node = node.boyNextDoor(0);
}
StringBuilder builder = new StringBuilder();
for (int i = 0; i < count; i++) {
builder.append(" ");
}
return builder.toString();
}
测试用例
依次对跳表的插入,删除,打印查找做测试1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27public static void main(String[] args) {
SkipList skipList = new SkipList();
/*skipList.insert(300);
skipList.insert(54);
skipList.insert(14);
skipList.insert(1010);
skipList.insert(23);
skipList.insert(8);
skipList.insert(325);
skipList.find(325);
*/
for (int i = 0; i < 100; i++) {
int number = skipList.random.nextInt(1000);
skipList.insert(number);
if (i == 15) {
skipList.insert(666);
}
}
skipList.easyPrint();
skipList.remove(666);
System.out.println("===================split line=================");
skipList.easyPrint();
System.out.println(skipList.find(666));
System.out.println(skipList.find(1024));
}
回顾
纵观跳表的插入,查找,删除操作,只涉及了简单的查找,双链表的增加,删除节点等基本操作,比起红黑树的实现简单太多,但红黑树在java中有jdk实现,跳表却没有,有些遗憾
跳表与红黑树
跳表与红黑树相比有以下特性
- 跳表采用的是空间换时间策略,也就是多了两个指针数组,如果对内存空间十分敏感的场景不太适合,即使是redis也是基于大内存+分布式
- 跳表和红黑树都有O(logN)的时间复杂度,并且跳表更易于维护
- 跳表适合做区间查找,且十分高效