跳表
什么是跳表
跳表是优化之后之后的链表结构, 它把链表分层,越上层的链表跨度越大,根据范围来寻找数据变得更加方便了。
一般的链表我们需要从头查到尾,才能找到我们所需要的数据,而使用跳表,我们很快就能定位所需的数据的位置范围,再在这个小范围遍历就行了,速度肯定是比遍历整个链表要快的。
下面是我偷的一张图:
当查询时,先从最高级索引开始查,确定范围后进入下一层,直到最后一层进行范围遍历。
跳表的特点
- 跳表的查询时间复杂度为O(logn)
- 跳表的节点存储数据,指向下一个节点的指针,指向下层数据的下指针
- 虽然速度变快了,但是由于有下层指针,多个重复节点,多个头指针,所以所需内存比链表大
跳表的使用场景
- 需要快速插入、删除和查找的场合
- 当数据规模比较大,链表效率很低,换为跳表。
- 当数据频繁修改, 树结构重新平衡成本高时。
- redis排序集合也使用了跳表
Java中跳表的实现
Java中的ConcurrentSkipListMap是一种基于跳表实现的并发Map。
它的主要特点是:
- 基于跳表实现。数据结构是多层链表,查询效率高。
- 支持并发。任何时刻都可以有多个线程同时读写它,且操作是安全的。
- 键是有序的。遍历返回的迭代器是根据键的自然排序顺序。
- 键和值不能是null。
ConcurrentSkipListMap的用法和一般Map基本相同:
ini
ConcurrentSkipListMap<K, V> map = new ConcurrentSkipListMap<>();
map.put(key1, value1);
map.put(key2, value2);
V value = map.get(key1);
map.remove(key2);
// 遍历
for (K key : map.keySet()) {
V value = map.get(key);
}
不同的是:
- 基于跳表实现的,效率更高。
- 线程安全, 线程可以安全访问。
手写跳表
我的跳表只实现了最重要的查找和添加的方法。
跳表整体结构
上面是我实现的跳表的结构,依次是:
- random, 这是一个用于生成随机数的类
- p 生成上一层的概率
- maxLevel: 跳表的最大层数, 防止跳表太多层,导致过于细化,反而变慢
- nowlevel: 当前跳表的层数,起始状态层数为0
- cmp: 比较器,我们要把数据按照顺序排,比较器肯定不可少
- head: 头指针,跳表为链表,头指针指向最开始的位置
- 构造方法
- 通过key来查找
- 添加元素
- 展示跳表数据
- 节点的静态类
首先, random和p是结合起来用的: 因为我们维护这个跳表,刚开始都是0层的结构,我们每次添加数据时;要让这个数据进行随机数判断,如果随机数大于我们设定的p,那就让它在上一层也创建一个节点,这样可能不好理解,我画个图:
p 和 random就是用来进行上图的随机数判断。
节点设为静态内部类的好处
-
当节点被设置为静态内部类时,它的生命周期与外部类相同,它不需要外部类的实例化就可以访问。
-
其它类可以直接使用它,导入使用: 外部类.静态类
静态节点类
kotlin
static class Node<K, V> {
private final K key;
private V value;
private Node<K, V> down;
private Node<K, V> right;
Node(K key, V value) {
this.key = key;
this.value = value;
}
}
最基本的键值对,然后还有指向下一个节点的指针,指向右边节点的指针。
跳表构造函数
ini
SkipList(Comparator<K> comparator) {
this.cmp = comparator;
head = new Node<>(null, null);
random = new Random();
}
- 传入比较器
- 初始化随机数种子
- 初始化头节点,头结点我们默认是将其设为 key: null, value: null
查找结点方法
kotlin
// 查询
public Node<K, V> get(K key) {
Node<K, V> temp = head;
while (temp != null) {
// 找到了
if (temp.key != null && temp.key.equals(key)) return temp;
// 表示该层已经到尾部
if (temp.right == null) {
// 只能往下了
temp = temp.down;
// 当前层右边比要找的key大
} else if (cmp.compare(temp.right.key, key) > 0) {
// 也只能往下了
temp = temp.down;
}
// 剩下只能在当层是往右边找了
else temp = temp.right;
}
return null;
}
这个方法的返回值我设为结点,当然你可以只用返回value,不影响。
方法逻辑:
- 从头开始找,找到就返回,找不到再分两种情况:
- 本层找完了,没有,前往下层
- 本层找到比它大的,证明在一个区间里,前往下层遍历。
- 找不到,返回null
添加新节点
ini
// 添加新节点
public void add(Node<K, V> node) {
Node<K, V> find = get(node.key);
if (find != null) {
while (find != null) {
find.value = node.value;
find = find.down;
}
return;
}
// 跳表里没有要添加的数据
else {
Node<K, V> temp = head;
ArrayDeque<Node<K, V>> leftNodes = new ArrayDeque<>();
while (temp != null) {
// 该层已经没有了
if (temp.right == null) {
leftNodes.push(temp);
temp = temp.down;
} else if (cmp.compare(temp.right.key, node.key) > 0) {
leftNodes.push(temp);
temp = temp.down;
} else temp = temp.right;
}
Node<K, V> nowDown = null;
int level = 1;
// 此时temp为null。
while (!leftNodes.isEmpty()) {
Node<K, V> left = leftNodes.pop();
Node<K, V> newNode = new Node<>(node.key, node.value);
// 在末尾
if (left.right != null) {
newNode.right = left.right;
}
left.right = newNode;
newNode.down = nowDown;
nowDown = newNode;
// 上面是否需要节点
if (level > maxLevel) break;
if (random.nextDouble() > p) break;
level++;
// 这里表示
if (level > nowLevel) {
nowLevel = level;
//需要创建一个新的头节点
Node<K, V> highHeadNode = new Node<>(null, null);
highHeadNode.down = head;
head = highHeadNode;//改变head
leftNodes.push(highHeadNode);
}
}
}
}
这部分是整个代码的关键,很难很麻烦。
方法逻辑:
- 添加数据,先寻找有没有该数据,调用get方法;如果找到,就覆盖它的值,并把每一层和它相同的都覆盖掉,返回。找不到,就进行下面操作
- 我们在添加时是不是需要知道它的前一个结点和下面的结点,这样我才能成功添加数据,维持链表结构;所以我从头开始遍历,为的是寻找哪个地方适合我的值的插入,把每一层适合插入的地方放进栈里面。
栈里面放的就是每一层插入节点放入时的前驱节点。 3. 然后对栈里面的元素进行遍历,我们放的时候是从高到低,因此,取的时候就是从低到高层,最低层是肯定要插入的;至于是否有高层,我们使用随机数去判断,因此又分两种情况
- 如果随机数失败,那就不会有高层
- 如果成功,则再进行判断是否超过允许的最高层
- 如果超过,则不允许添加,代码结束
- 如果不超过最高层,则可以往高层添加,这时又要判断是否超过当前的层数
- 如果没超过当前层数,继续遍历栈里面的内容
- 如果超过当前层数,证明head指针需要改变了,head应该指向上一层;这时,创建一个新的head,将新的head下指针指向当前head,并且将该新头指针放入栈,这样可以继续遍历,因为此时栈肯定为空(因为现在的level已经超出当前高度了);不断这样,直到随机数失效。
遍历方法
ini
public void lists() {
Node<K, V> temp = head;
Node<K, V> headNode = head;
while (temp != null) {
if (temp.value == null) {
System.out.print("head -> ");
} else {
// 找最下层的节点
System.out.printf("%-2s->", temp.value);
if(temp.right == null){
System.out.print(" null");
}
}
if (temp.right == null) {
temp = headNode.down;
headNode = headNode.down;
System.out.println();
} else temp = temp.right;
}
}
很好理解,就是一层层遍历
方法测试
typescript
public static void main(String[] args) {
SkipList<String, String> stringStringSkipList = new SkipList<String, String>((o1, o2) -> {
if (o1 == null) return -1;
else {
return o1.compareTo(o2);
}
});
for (char c = 'a'; c <= 'z'; c++) {
stringStringSkipList.add(new Node<>("" + c, "" + c));
}
stringStringSkipList.lists();
System.out.println("下面进行add覆盖操作");
stringStringSkipList.add(new Node<>("b", "我原来是b,但现在我变了"));
stringStringSkipList.lists();
}
这里需要注意,传进去一个比较器,该比较器要让null值永远为最小,因为我的跳表类里的head的key是null,我要让它最小,所以只能出此下策。
输出分析
head -> x -> null
head -> x -> null
head -> e ->x -> null
head -> b ->d ->e ->o ->r ->x -> null
head -> b ->c ->d ->e ->g ->m ->n ->o ->q ->r ->s ->u ->w ->x ->y ->z -> null
head -> a ->b ->c ->d ->e ->f ->g ->h ->i ->j ->k ->l ->m ->n ->o ->p ->q ->r ->s ->t ->u ->v ->w ->x ->y ->z -> null
下面进行add覆盖操作:
head -> x -> null
head -> x -> null
head -> e ->x -> null
head -> 我原来是b,但现在我变了->d ->e ->o ->r ->x -> null
head -> 我原来是b,但现在我变了->c ->d ->e ->g ->m ->n ->o ->q ->r ->s ->u ->w ->x ->y ->z -> null
head -> a ->我原来是b,但现在我变了->c ->d ->e ->f ->g ->h ->i ->j ->k ->l ->m ->n ->o ->p ->q ->r ->s ->t ->u ->v ->w ->x ->y ->z -> null
分析:
- 我们输出发现键值对是按照我传入的规则来的,证明一般添加是成功的
- 然后验证覆盖操作,观察结果也是没毛病的。
至此,基本功能完成。
总结
- 跳表是一个优化的链表结构,查询,删除和修改都比较快O(logn)
- 跳表适合于需要快速查询,删除,修改的场景,redis的排序集合就使用跳表
- Java里的ConcurrentSkipMap就是使用的跳表来实现的,键和值都不能为null,而且它是线程安全的
- 手写跳表特别是add方法很麻烦,需要考虑很多,对于头节点的维护和添加数据的前驱节点的保存都是比较麻烦的。