跳表
一、什么是跳表?
跳表,又叫做跳跃表、跳跃列表。
- 是一种对有序链式线性表的优化
- 在原始链表的基础上添加了多级索引链表
- 分为多层,从下往上分别是原始链表、一级索引、二级索引…
- 搜索时从上往下,实现了类似“二分查找”的功能
它的结构大概是这样的:
1 2 3 4 5 6 7 8
| ___ ___ | 1 | ----------------------------> | 5 | -- 二级索引 | | ___ ___ ___ | 1 | ----------> | 3 | ----------> | 5 | -- 一级索引 | | | ___ ___ ___ ___ ___ ___ | 1 | -> | 2 | -> | 3 | -> | 4 | -> | 5 | -> | 6 | -- 原始链表
|
跳表就是对有序链表的一种优化,通过添加多级索引来提升性能。
跳表本质上是一种利用空间来换时间的数据结构。
二、为什么要用跳表?
2.1 引入背景
对于链表来说,查找某个值,只能通过遍历的方式实现,时间复杂度是 O(n)
。
即使链表是有序的,依旧也只能是从头到尾遍历,完全没有用到数据的有序性。
1 2
| ___ ___ ___ ___ ___ ___ | 1 | -> | 2 | -> | 3 | -> | 4 | -> | 5 | -> | 6 |
|
在这种情况下,有没有办法用到链表的有序性?
比如说,链表的每2个节点就提取出一个索引节点:
1 2 3 4 5
| ___ ___ ___ | 1 | ----------> | 3 | ----------> | 5 | -- 索引 | | | ___ ___ ___ ___ ___ ___ | 1 | -> | 2 | -> | 3 | -> | 4 | -> | 5 | -> | 6 | -- 原始链表
|
通过索引先快速定位到区间 [1, 3]
、[3, 5]
以及 [5, +]
,缩小范围,再小范围遍历搜索。
比如说,查找数值 4:
- 首先在索引层遍历判断,发现 4 是在区间
[3, 5]
内的
- 然后找到了原始链表的 3,再从 3 开始往右遍历,直到找到 4
4 的搜索链路就是:1 -> 3 -> 4
,这定位速度就比遍历快多了。
有时候建第一层索引后,遍历范围还是很大(第一层索引的节点数量是 n/2)。
这个时候还可以再往上多建几层索引:
1 2 3 4 5 6 7 8
| ___ ___ | 1 | ----------------------------> | 5 | -- 二级索引 | | ___ ___ ___ | 1 | ----------> | 3 | ----------> | 5 | -- 一级索引 | | | ___ ___ ___ ___ ___ ___ | 1 | -> | 2 | -> | 3 | -> | 4 | -> | 5 | -> | 6 | -- 原始链表
|
这种在原始链表上建多层索引,实现快速查找的数据结构,就是跳表。
2.2 算法分析
原始有序链表的算法复杂度是:
- 搜索:时间
O(n)
,空间 O(1)
- 删除:时间
O(n)
,空间 O(1)
- 插入:时间
O(n)
,空间 O(1)
跳表的算法复杂度如何呢?以 2 个节点 1 个索引的跳表为例。
1)空间复杂度
跳表的多层索引,从下往上数的话,节点数量分别是:
总共是 n/2 + n/4 + ... + 2 = n - 2
,也就是说:
2)时间复杂度
根据上面的结构,跳表的层级数量很容易知道:
链表的插入和删除,实际上是依赖于搜索,所以只要分析搜索的时间复杂度即可。
因为每 2 个节点对应 1 个索引节点,所以每一层的搜索节点数量不会超过 3。
比如上一层索引确定了区间 [3, 5]
,那么往下一层搜索,搜索只能是 3,4,5
。
因此跳表的时间复杂度,依赖于每层遍历的节点数量:
- 搜索:时间
O(m * logn)
- 删除:时间
O(m * logn)
- 插入:时间
O(m * logn)
其中 m 是每层最多遍历的节点数量。
跳表将原始链表的时间复杂度从 O(n)
提升到了 O(logn)
级别。
当然,这是利用空间换时间才得到的,空间消耗变多了。
三、跳表如何实现?
跳表的实现方式可以分为2种:
- 数组实现:由一个数组实现,数组内部包含多层索引指针
- 链表实现:有多条链表实现,每条链表一层索引
它们的区别只在于底层存储不同,对外是一致的。
下面以链表实现为例,说明跳表的实现。
3.1 跳表的定义
3.1.1 结构定义
为了减少代码中的判空,采用了一个哨兵头节点,所以实际的跳表结构是这样的:
1 2 3 4 5 6 7 8 9 10 11 12
| head ___ | | ---------------------------------------------------------> null | ___ ___ ___ | | -> | 1 | ----------------------------> | 5 | ------------> null | | | ___ ___ ___ ___ | | -> | 1 | ----------> | 3 | ----------> | 5 | ------------> null | | | | ___ ___ ___ ___ ___ ___ ___ | | -> | 1 | -> | 2 | -> | 3 | -> | 4 | -> | 5 | -> | 6 | ---> null
|
顶层的 head
节点单独一层,并且这一层只有它一个非空节点。
3.1.2 接口定义
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
| public interface SkipList<T extends Comparable<T>> {
boolean search(T value);
void add(T value);
boolean erase(T value);
}
|
3.1.3 链式跳表的定义
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| public class LinkedSkipList<T extends Comparable<T>> implements SkipList<T> {
class Node { T value; Node right; Node down;
public Node(T value, Node right, Node down) { this.value = value; this.right = right; this.down = down; } }
}
|
3.2 跳表的搜索
3.2.1 代码实现
跳表的搜索,就是从顶层的索引开始,一层一层往下找,直到找对对应的节点为止。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| public boolean search(T value) { Node p = head, q = null; while (p != null) { while (p.right != null && value.compareTo(p.right.value) > 0) { p = p.right; } q = p; p = p.down; } return q != null && q.right != null && q.right.value.equals(value); }
|
3.2.2 结构示意
搜索过程就类似下面这样(暂时忽略其他线):
1 2 3 4 5 6 7 8 9 10 11 12
| head ___ | | | ___ ___ ___ | | -> | 1 | | 5 | | ___ ___ ___ ___ | | | 1 | ----------> | 3 | | 5 | | ___ ___ ___ ___ ___ ___ ___ | | | 1 | | 2 | | 3 | -> | 4 | | 5 | | 6 |
|
很简单,就是一层一层找符合范围的区间,直到最底层的原始链表。
3.3 跳表的插入
3.3.1 代码实现
插入节点,除了要知道插入点的位置,还要知道上一层跳到下一层的转折节点。
因为插入节点,不仅仅是插入最底层,上面的索引层也需要一起更新。
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
|
private List<Node> findProcessors(T value) { Deque<Node> stack = new ArrayDeque<>(); Node p = head; while (p != null) { while (p.right != null && value.compareTo(p.right.value) > 0) { p = p.right; } stack.push(p); p = p.down; }
List<Node> path = new ArrayList<>(stack.size()); while (!stack.isEmpty()) { path.add(stack.pop()); } return path; }
|
查找返回的是目标节点的前置路径:
- 前置路径是搜索过程中上一层跳到下一层的那个转折节点列表
比如上面搜索 4 时,搜索路径是 1->3->4
,那么得到的前置路径就是 [3, 1]
。
为什么前置路径是倒序的?
- 因为插入节点是从下往上插入的,为了方便就倒序返回了
找到前置路径后,就可以准备插入节点了:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| public void add(T value) { List<Node> processors = findProcessors(value); int levels = processors.size();
Node newNode = null; boolean insertUp = true; for (int i = 0; i < levels && insertUp; i++) { Node processor = processors.get(i); newNode = new Node(value, processor.right, newNode); processor.right = newNode;
insertUp = random.nextDouble() < FACTOR; }
if (insertUp) { levelUp(); } }
|
3.3.2 结构示意
比如插入前跳表的结构是这样的:
1 2 3 4 5 6 7 8 9 10 11 12
| head ___ | | ---------------------------------------------------------> null | ___ ___ ___ | | -> | 1 | ----------------------------> | 5 | ------------> null | | | ___ ___ ___ | | -> | 1 | ----------------------------> | 5 | ------------> null | | | ___ ___ ___ ___ ___ ___ | | -> | 1 | -> | 2 | ----------> | 4 | -> | 5 | -> | 6 | ---> null
|
此时插入一个 3,插入过程是从下往上的:
1)第一次插入
1 2 3 4 5 6 7 8 9 10 11 12
| head ___ | | ---------------------------------------------------------> null | ___ ___ ___ | | -> | 1 | ----------------------------> | 5 | ------------> null | | | ___ ___ ___ | | -> | 1 | ----------------------------> | 5 | ------------> null | | | ___ ___ ___ ___ ___ ___ ___ | | -> | 1 | -> | 2 | -> | 3 | -> | 4 | -> | 5 | -> | 6 | ---> null
|
2)第二次插入
1 2 3 4 5 6 7 8 9 10 11 12
| head ___ | | ---------------------------------------------------------> null | ___ ___ ___ | | -> | 1 | ----------------------------> | 5 | ------------> null | | | ___ ___ ___ ___ | | -> | 1 | ----------> | 3 | ----------> | 5 | ------------> null | | | | ___ ___ ___ ___ ___ ___ ___ | | -> | 1 | -> | 2 | -> | 3 | -> | 4 | -> | 5 | -> | 6 | ---> null
|
至于需要插入多少层,这个是通过随机数控制的。
采用了随机数 random.nextDouble() < FACTOR
,来判断要不要往上一层插入节点。
为什么采用随机数?
- 为了减少算法代码的复杂度
- 同时又尽量确保跳表的结构平衡
跳表本身代码就比较简单,如果为了维持结构而引入其他复杂算法,显得得不偿失。
随机数就很合适,使用不复杂,而且能大概率保证跳表结构不会严重退化。
3.3.3 提升层级
如果节点被插入到了顶层,这个时候需要提升整个跳表的层级。
比如 3 节点被插入到顶层后,跳表结构变成了这样:
1 2 3 4 5 6 7 8 9 10 11 12
| head ___ ___ | | -------------------> | 3 | ------------------------------> null | ___ ___ ___ ___ | | -> | 1 | ----------> | 3 | ----------> | 5 | ------------> null | | | | ___ ___ ___ ___ | | -> | 1 | ----------> | 3 | ----------> | 5 | ------------> null | | | | ___ ___ ___ ___ ___ ___ ___ | | -> | 1 | -> | 2 | -> | 3 | -> | 4 | -> | 5 | -> | 6 | ---> null
|
为了维持跳表头节点在单独一层的结构,就需要提升层级:
1 2 3 4 5 6 7
|
private void levelUp() { head = new Node(null, null, head); }
|
提升层级后,跳表的结构就恢复正常了:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| head ___ | | ---------------------------------------------------------> null | ___ ___ | | -------------------> | 3 | ------------------------------> null | ___ ___ ___ ___ | | -> | 1 | ----------> | 3 | ----------> | 5 | ------------> null | | | | ___ ___ ___ ___ | | -> | 1 | ----------> | 3 | ----------> | 5 | ------------> null | | | | ___ ___ ___ ___ ___ ___ ___ | | -> | 1 | -> | 2 | -> | 3 | -> | 4 | -> | 5 | -> | 6 | ---> null
|
3.4 跳表的删除
3.4.1 代码实现
删除和插入过程是一样的,都是先找到前置路径后,再处理节点:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| public boolean erase(T value) { List<Node> processors = findProcessors(value);
Node target = processors.get(0).right; if (target == null || !value.equals(target.value)) { return false; }
for (Node processor : processors) { Node delNode = processor.right; if (delNode == null || !delNode.value.equals(value)) { break; } processor.right = delNode.right; }
levelDown();
return true; }
|
3.4.2 结构示意
删除也和插入一样,是从下往上的。比如删除 3:
1)第一次删除
1 2 3 4 5 6 7 8 9 10 11 12
| head ___ | | ---------------------------------------------------------> null | ___ ___ ___ | | -> | 1 | ----------------------------> | 5 | ------------> null | | | ___ ___ ___ ___ | | -> | 1 | ----------> | 3 | ----------> | 5 | ------------> null | | | ___ ___ ___ ___ ___ ___ | | -> | 1 | -> | 2 | ----------> | 4 | -> | 5 | -> | 6 | ---> null
|
2)第二次删除
1 2 3 4 5 6 7 8 9 10 11 12
| head ___ | | ---------------------------------------------------------> null | ___ ___ ___ | | -> | 1 | ----------------------------> | 5 | ------------> null | | | ___ ___ ___ | | -> | 1 | ----------------------------> | 5 | ------------> null | | | ___ ___ ___ ___ ___ ___ | | -> | 1 | -> | 2 | ----------> | 4 | -> | 5 | -> | 6 | ---> null
|
3.4.3 降低层级
删除节点后,有可能导致跳表的上层节点变得比较少,此时需要降低层级。
比如把 1 和 5 都删掉后:
1 2 3 4 5 6 7 8 9 10 11 12
| head ___ | | ---------------------------------------------------------> null | ___ | | ---------------------------------------------------------> null | ___ | | ---------------------------------------------------------> null | ___ ___ ___ ___ | | ----------> | 2 | ----------> | 4 | ----------> | 6 | ---> null
|
上面几层链表都空了,浪费空间,这个时候需要将这些节点很少的层级删除:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
|
private void levelDown() { while (head.down != null) { if (head.right != null || head.down.right != null) { break; } Node p = head; head = head.down; p.down = null; } }
|
删除后,跳表又恢复成正常的结构了:
1 2 3 4 5 6
| head ___ | | ---------------------------------------------------------> null | ___ ___ ___ ___ | | ----------> | 2 | ----------> | 4 | ----------> | 6 | ---> null
|
四、跳表的应用场景
跳表设计的目的,是为了快速查找。所以它比较适合这些场景:
- 是有序链表,无序链表用不了
- 多次查询链表,注意是多次,只查一次的话不如直接遍历
- 较少的插入和删除,虽然跳表的插入删除性能不差,但是其目的不在于此
其实本质上,
所以有序数组二分查找适用的场景,基本适用于有序链表的跳表。
参考
https://leetcode.cn/problems/design-skiplist/
https://blog.csdn.net/yjw123456/article/details/105159817/
https://blog.csdn.net/weixin_45480785/article/details/116293416
https://baijiahao.baidu.com/s?id=1710441201075985657&wfr=spider&for=pc
附录
跳表接口
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
|
public interface SkipList<T extends Comparable<T>> {
boolean search(T value);
void add(T value);
boolean erase(T value);
}
|
链表实现
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 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171
|
public class LinkedSkipList<T extends Comparable<T>> implements SkipList<T> {
class Node { T value; Node right; Node down;
public Node(T value, Node right, Node down) { this.value = value; this.right = right; this.down = down; } }
static final int DEFAULT_MAX_LEVEL = 32;
static final double FACTOR = 0.25;
Node head;
int maxLevels;
Random random = new Random();
public LinkedSkipList() { this(DEFAULT_MAX_LEVEL); }
public LinkedSkipList(int maxLevels) { this.maxLevels = maxLevels; this.head = new Node(null, null, null); }
@Override public boolean search(T value) { Node p = head, q = null; while (p != null) { while (p.right != null && value.compareTo(p.right.value) > 0) { p = p.right; } q = p; p = p.down; } return q != null && q.right != null && q.right.value.equals(value); }
@Override public void add(T value) { List<Node> processors = findProcessors(value); int levels = processors.size();
Node newNode = null; boolean insertUp = true; for (int i = 0; i < levels && insertUp; i++) { Node processor = processors.get(i); newNode = new Node(value, processor.right, newNode); processor.right = newNode;
insertUp = random.nextDouble() < FACTOR; }
if (insertUp) { levelUp(); } }
@Override public boolean erase(T value) { List<Node> processors = findProcessors(value);
Node target = processors.get(0).right; if (target == null || !value.equals(target.value)) { return false; }
for (Node processor : processors) { Node delNode = processor.right; if (delNode == null || !delNode.value.equals(value)) { break; } processor.right = delNode.right; }
levelDown();
return true; }
private void levelUp() { head = new Node(null, null, head); }
private void levelDown() { while (head.down != null) { if (head.right != null || head.down.right != null) { break; } Node p = head; head = head.down; p.down = null; } }
private List<Node> findProcessors(T value) { Deque<Node> stack = new ArrayDeque<>(); Node p = head; while (p != null) { while (p.right != null && value.compareTo(p.right.value) > 0) { p = p.right; } stack.push(p); p = p.down; }
List<Node> path = new ArrayList<>(stack.size()); while (!stack.isEmpty()) { path.add(stack.pop()); } return path; }
}
|
数组实现
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 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168
|
public class ArraySkipList<T extends Comparable<T>> implements SkipList<T> {
class Node { T value; List<Node> forwards;
Node(T value, int levels) { this.value = value; forwards = new ArrayList<>(levels); for (int i = 0; i < levels; i++) { forwards.add(null); } } }
static final int DEFAULT_MAX_LEVEL = 32;
static final double FACTOR = 0.25;
Node head;
int levels = 0;
int maxLevels;
Random random = new Random();
public ArraySkipList() { this(DEFAULT_MAX_LEVEL); }
public ArraySkipList(int maxLevels) { this.maxLevels = maxLevels; head = new Node(null, maxLevels); }
@Override public boolean search(T value) { Node p = head, q = null; for (int i = levels - 1; i >= 0; i--) { Node right = p.forwards.get(i); while (right != null && value.compareTo(right.value) > 0) { p = right; right = p.forwards.get(i); } q = p; } if (q != null) { Node target = q.forwards.get(0); return target != null && value.equals(target.value); } return false; }
@Override public void add(T value) { List<Node> processors = findProcessors(value); int lv = randomLevel(); levels = Math.max(levels, lv); Node newNode = new Node(value, levels); for (int i = 0; i < lv; i++) { Node processor = processors.get(i); newNode.forwards.set(i, processor.forwards.get(i)); processor.forwards.set(i, newNode); } }
@Override public boolean erase(T value) { List<Node> processors = findProcessors(value); Node target = processors.get(0).forwards.get(0); if (target == null || !value.equals(target.value)) { return false; }
for (int i = 0; i < levels; i++) { Node processor = processors.get(i); Node delNode = processor.forwards.get(i); if (delNode == null || delNode != target) { break; } processor.forwards.set(i, delNode.forwards.get(i)); }
for (; levels > 1; levels--) { if (head.forwards.get(levels - 1) != null) { break; } }
return true; }
private List<Node> findProcessors(T value) { List<Node> path = new ArrayList<>(maxLevels); for (int i = 0; i < maxLevels; i++) { path.add(head); } Node p = head; for (int i = levels - 1; i >= 0; i--) { Node right = p.forwards.get(i); while (right != null && value.compareTo(right.value) > 0) { p = right; right = p.forwards.get(i); } path.set(i, p); } return path; }
private int randomLevel() { int lv = 1; while (lv < maxLevels && random.nextDouble() < FACTOR) { lv++; } return lv; }
}
|