是什么让普通的链表也能达到二分查找的效率,你知道吗?

数据结构与算法 专栏收录该内容
6 篇文章 1 订阅

继承,是幸福的延续;重载,是幸福的重生。

数组与链表

数组

在计算机科学中,数组数据结构(英语:array data structure),简称数组(英语:Array),是由相同类型的元素(element)的集合所组成的数据结构,分配一块连续的内存来存储。利用元素的索引(index)可以计算出该元素对应的存储地址。

优点

  • 随机访问速度较快(基于下标访问)。
  • 实现简单,使用简单。
  • 内存地址连续,对cpu缓存很友好,比如高性能队列 disruptor 也是利用了cpu缓存+数组地址的连续性大大的优化了性能。

缺点

  • 内存连续可以是优点,也可以是缺点,如果在内存紧张的情况下,数组将会被大大限制。
  • 插入和删除的时候会导致元素的移动(数据拷贝),较慢。
  • 数组大小固定,大大的限制了元素的个数,对于很多动态的数据不友好。

链表

链表(Linked list)是一种常见的基础数据结构,是一种线性表,但是并不会按线性的顺序存储数据,而是在每一个节点里存到下一个节点的指针(Pointer)。由于不必须按顺序存储,链表在插入的时候可以达到O(1)的复杂度,比另一种线性表顺序表快得多,但是查找一个节点或者访问特定编号的节点则需要O(n)的时间。

优点

  • 链表结构可以充分利用计算机内存空间,实现灵活的内存动态管理。
  • 删除插入不用移动其他元素。
  • 不受元素大小限制,可以随意扩展。

缺点

  • 失去了数组随机读取的优点,同时链表由于增加了结点的指针域,空间开销比较大。
  • 随机访问效率相对数组来说较低。

时间复杂度分析

  • 随机访问
    数组:O(1)
    不支持随机访问

  • 插入,删除
    数组:有序数组 ->O(n),无序数组 ->(1)
    链表:O(1)

    链表又可以分为单链表和双向链表,不过这都不是这篇文章需要讨论的问题,这里讲一下数组和链表,只是做一下铺垫,因为下面讲到的跳表就会用到链表的相关知识,所以对链表还不怎么熟悉的同学,可以先学习一下,然后在开始今天的正文:跳表。

什么是跳表?

上文讲到了链表的概念,链表的查询时间复杂度为:O(n),即使是有序的链表,也得从链表的第一个元素开始遍历查找,时间复杂度偏高,那如果让你来优化,你有什么解决方案吗?这个时候跳表就出现了。

跳表,全程:跳跃链表,在计算机科学中,跳跃列表是一种数据结构。它使得包含n个元素的有序序列的查找和插入操作的平均时间复杂度都是:
l o g n logn logn
我们来画一个普通的链表结构
普通链表

这是一个普通的单链表结构,如果我们想查找出 58 这个节点,我们就得查询 1 -> 4 -> 7 -> 15 -> 20 -> 35 -> 50 -> 58 ,一共查找了8次,才将我们需要的结果查找出来,时间复杂度:O(n),效率可想而知,那我们一起试着来优化一下。

mysql在数据量大的时候查询效率也会急剧下降,他们是怎么来优化效率问题呢?那就是索引,这里我们也可以使用一个类似 “索引” 的东西来对这个普通的单链表进行优化。
file
我们将原始链表中每隔两个结点之后的节点复制一份出来,用于制作索引,方便数据的查找。

我们还是继续查找58,我们只需要经过:1 -> 7 -> 20 --> 50 -> 58,发现了没有,这里居然只搜索了5次就找到了我们想要的结点,效率是不是提升了一丢丢?我现在来解释一下这张图,由于我们建立了一层索引,所以查询的时候优先往第一层索引层搜索,搜索索引层时,发现 50 以及之前的节点都小于 58 ,遍历到 50 的时候,他的下一个节点 66 大于需要查询的节点 ,所以这个时候就会找到50这个节点中的down(原始链表节点为 50 的指针)节点,然后再往下遍历一个就就找到了 58 。

加了“索引” 也才少查询了三次,优化的并不是很明显啊,这个优化是否有存在的必要呢?我们这里之建立了一层“索引”,我们多建立几层”索引“之后呢?会是什么样呢?
file
这个时候我们发现只需要4次就能找到我们需要查找的节点 58 ,4 次相对于8次来说已经快了一半了,我这里的数据不是太多,当数据足够多的时候,效果将会更为明显。

这种一层一层的索引组成的数据结构,我们称他为:跳表,现在相信你对什么是跳表应该有了比较深的理解了吧,这个时候你可能又有一个疑问了?我们为了建立索引层,肯定会消耗大量的空间,这是属于空间换时间的一种方式,但是在数据量大的时候,会不会存在空间不够的情况呢?

那我们现在一起来分析一下跳表的时间/空间复杂度吧

跳表分析

假设原始链表结点个数为:n,我们知道,我们每隔两个节点抽离一个结点作为索引层的一个结点,那么第一层索引层的结点个数应该是原始链表结点个数的1/2,也就是n/2,第二层索引的结点个数是第一层索引结点个数的1/2,是 1/4 原始链表结点个数,也就是 n/4,第三层索引的结点个数是第二层结点个数的1/2,是第一层索引结点个数的4/1,是原始链表结点个数的1/8,依次类推,在k层索引的节点个数是k-1层索引的1/2,那么k层索引的节点个数
n 2 k \frac {n}{2^k} 2kn
一个链表,假设我们都是每隔两个结点分配一个索引结点,最高层索引只有两个结点,索引层高度为:h,那么将会得到这么一个公式:
n 2 h = 2 \frac {n}{2^h}=2 2hn=2
我们求解一下 h:
2 h = n 2 2^h=\frac {n}{2} 2h=2n

h = l o g 2 ( n 2 ) h=log_2(\frac{n}{2}) h=log2(2n)

我们将这个公式拆分一下:
h = l o g 2 ( n ) − l o g 2 2 h=log_2(n) - log_22 h=log2(n)log22
所以 h 的值为:
h = log ⁡ 2 n − 1 h = \log_2n -1 h=log2n1
由于 h 是索引层的高度,我们算上原始链表,那么真个跳表的高度就是:
log ⁡ 2 n \log_2n log2n
现在我们一起来算算跳表查询的时间复杂度,跳表有许多层索引,每层索引都会进行相对应次数的遍历,假设每次索引遍历的最大次数为:x,那么一个查询需要遍历的次数为:
x ∗ log ⁡ 2 n x * \log_2n xlog2n
所以用 O 表示法表示,跳表的时间复杂度为:
O ( x ∗ log ⁡ ( n ) ) O(x * \log(n)) O(xlog(n))
为什么对数中的底数 2 没有了呢? 其实在时间复杂度分析中,分析的只是一种趋势,并不是固定的值,相对数级别的,底数可以忽略,具体相关的介绍,这里就不展开介绍了,感兴趣的可以百度一下,并不复杂,我们现在需要搞明白的是这个 x 到底是多少呢?

在前面所画的跳表结构图中,我们以每隔两个结点提取一个索引结点,所以,当我们需要查询跳表中某一个结点时,需要在索引层从上而下开始遍历,每层最多不会超过3个,为什么是 3 个而不是 4 个、5 个、6 个呢?我们画一个简单的图来说明一下。

file

我们需要查找 12 这个结点,当走到 9 这个结点时发现 12 > 9 而 < 13 所以下降一层索引,到达第一层索引,而在 9 - 13 之间只有三个结点,就算是下降到原始链表也是一样的,两个范围结点之前最多只有三个结点,所以 每层需要遍历的最多结点数就是这么得出来的,因为 x 是常数 3 ,可以直接省略,所以跳表最终的时间复杂度为:
log ⁡ ( n ) \log(n) log(n)
虽然查询的效率提高了非常多,但是这也存在一个非常重要的问题,那就是跳表会占用额外的内存空间,因为,它需要很多层的索引,这样这个数据结构还值不值得推荐使用呢?既然redis官方都在使用了,你还在怕什么呢?我们再来分析一下跳表的空间复杂度,看看是不是对内存有着极大的消耗呢?

假设原始链表长度为 n ,那么第一层索引长度为 n/2,第二层索引长度为:n/4,第三层索引长度:n/8,最后一层(最高层)索引长度:2,很明显这是一个等比数列:
n 2 , n 4 , n 8 , n 16 . . . . . . 8 , 4 , 2 \frac{n}{2},\frac{n}{4},\frac{n}{8},\frac{n}{16}......8,4,2 2n,4n,8n,16n......8,4,2
等比数列求和公式:
当 q ≠ 1 时 , S n = 1 − q n 1 − q = a 1 − a n ∗ q 1 − q 当q≠1时,Sn = \frac{1-q^n}{1-q}=\frac{a_1-a_n*q}{1-q} q=1Sn=1q1qn=1qa1anq

② 当 q = 1 时 , S n = n ∗ a 1 ②当q=1时,Sn = n * a_1 q=1Sn=na1

套入公式:
n 2 − 2 ∗ 1 2 1 − 1 2 = n 2 − 1 1 2 = 1 2 ( n − 2 ) 1 2 = n − 2 \frac{\frac{n}{2} - 2 * \frac{1}{2}}{1-\frac{1}{2}} = \frac{\frac{n}{2} - 1}{\frac{1}{2}}=\frac{\frac{1}{2}(n-2)}{\frac{1}{2}}=n-2 1212n221=212n1=2121(n2)=n2
索引层占用总大小为:n - 2,加上原始链表 n = 2n -2 ,所以跳表的空间复杂度为:O(n),常数可以省略,这是每个两个结点提取一个索引结点的空间复杂度,那多隔几个结点提取一个索引结点呢?比如每隔 3 个提取一个,每隔 5 个提取一个,计算方式和上面一样,结果空间复杂度也是:O(n),但是空间确实减少了很多。

查询我们讲完了,那跳表的插入和删除效果怎么样呢?会不会也表现得很优秀?我们一起来看看。

由于我们的链表是有序的,所以在插入的时候我们得先找到插入的位置,跳表的查询时间复杂度为O(logn ),找到了需要插入的位置之后就是插入操作,单链表的插入时间复杂度时O(1),这里就不证明了,所以整个插入的过程等于查找插入的位置+插入=O(logn)。

其实删除操作的时间复杂也是O(logn),为什么呢?其实和插入也是一样的,但是会多一个查找前驱结点的操作,因为删除,会导致前后驱结点的指针变动,后驱结点在当前删除的结点中存在,但是前前驱节点没有,查询也是O(logn),所以删除 O(logn)+O(logn),所以删除的时间复杂度最终还是:O(logn),但是删除有一点需要注意,如果需要删除的结点在索引层也存在,那么同时需要删除索引层的结点,关于前驱结点的问题,可以使用双向链表解决。

索引更新

跳表的查询、删除、添加在性能方面都比较优秀,那它能一直保持这么优秀吗?既然跳表也是用来存放数据的,那么肯定会伴随着频繁的新增和删除,假设在某一个相邻区间的索引结点之间被插入了较多的数据,那么就会导致数据分布不均匀,在某一个区间的查询时间效率降低,最坏情况可能会被退化成单链表,所有的数据都在这一个区间内,所以我们在数据插入的时候对索引层做实时更新维护,保证跳表的数据结构不会过度退化,那我们怎么维护索引层的变化呢?

其实也不难,跳表通过随机函数来保持数据的平衡性,也就是让数据保持相对均匀,当我们往跳表中添加数据的时候,通过随机函数生成一个随机数,这个随机数就是索引的层数,然后在这一层把需要添加的结点添加到这一层索引层节点中,假设我们需要插入的数据为 7 ,随机函数生成的随机数为: 2,那么插入的时候就会如下图所示:

file

这里就对随机函数的要求很高了,它可以很好地保证跳表的平衡性,不会让跳表的性能退化,这点和红黑树的左旋/右旋是一个道理。

代码实现

上面的都是理论基础,我们看完了理论,那一定要真刀真枪的干一下,不然不就白看了吗,那我们一起来看看跳表的代码如何实现

``

package com.lx.cloud.test;

import java.util.Random;

/**
 * @ClassName SkipList
 * @Description 跳表
 */
public class SkipList {
    private static final int MAX_LEVEL = 16;
    private int levelCount = 1;

    /**
     * 带头链表
     */
    private Node head = new Node(MAX_LEVEL);
    private Random r = new Random();

    public Node find(int value) {
        Node p = head;
        // 从最大层开始查找,找到前一节点,通过--i,移动到下层再开始查找
        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;
        }
    }

    /**
     * 优化了作者zheng的插入方法
     *
     * @param value 值
     */
    public void insert(int value) {
        int level = head.forwards[0] == null ? 1 : randomLevel();
        // 每次只增加一层,如果条件满足
        if (level > levelCount) {
            level = ++levelCount;
        }
        Node newNode = new Node(level);
        newNode.data = value;
        Node update[] = new Node[level];
        for (int i = 0; i < level; ++i) {
            update[i] = head;
        }

        Node p = head;
        // 从最大层开始查找,找到前一节点,通过--i,移动到下层再开始查找
        for (int i = levelCount - 1; i >= 0; --i) {
            while (p.forwards[i] != null && p.forwards[i].data < value) {
                // 找到前一节点
                p = p.forwards[i];
            }
            // levelCount 会 > level,所以加上判断
            if (level > i) {
                update[i] = p;
            }

        }
        for (int i = 0; i < level; ++i) {
            newNode.forwards[i] = update[i].forwards[i];
            update[i].forwards[i] = newNode;
        }

    }

    /**
     * 优化了作者zheng的插入方法2
     *
     * @param value 值
     */
    public void insert2(int value) {
        int level = head.forwards[0] == null ? 1 : randomLevel();
        // 每次只增加一层,如果条件满足
        if (level > levelCount) {
            level = ++levelCount;
        }
        Node newNode = new Node(level);
        newNode.data = value;
        Node p = head;
        // 从最大层开始查找,找到前一节点,通过--i,移动到下层再开始查找
        for (int i = levelCount - 1; i >= 0; --i) {
            while (p.forwards[i] != null && p.forwards[i].data < value) {
                // 找到前一节点
                p = p.forwards[i];
            }
            // levelCount 会 > level,所以加上判断
            if (level > i) {
                if (p.forwards[i] == null) {
                    p.forwards[i] = newNode;
                } else {
                    Node next = p.forwards[i];
                    p.forwards[i] = newNode;
                    newNode.forwards[i] = next;
                }
            }

        }

    }

    /**
     * 作者zheng的插入方法,未优化前,优化后参见上面insert()
     *
     * @param value
     * @param level 0 表示随机层数,不为0,表示指定层数,指定层数
     *              可以让每次打印结果不变动,这里是为了便于学习理解
     */
    public void insert(int value, int level) {
        // 随机一个层数
        if (level == 0) {
            level = randomLevel();
        }
        // 创建新节点
        Node newNode = new Node(level);
        newNode.data = value;
        // 表示从最大层到低层,都要有节点数据
        newNode.maxLevel = level;
        // 记录要更新的层数,表示新节点要更新到哪几层
        Node update[] = new Node[level];
        for (int i = 0; i < level; ++i) {
            update[i] = head;
        }

        /**
         *
         * 1,说明:层是从下到上的,这里最下层编号是0,最上层编号是15
         * 2,这里没有从已有数据最大层(编号最大)开始找,(而是随机层的最大层)导致有些问题。
         *    如果数据量为1亿,随机level=1 ,那么插入时间复杂度为O(n)
         */
        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]表示当前层节点的前一节点,因为要找到前一节点,才好插入数据
            update[i] = p;
        }

        // 将每一层节点和后面节点关联
        for (int i = 0; i < level; ++i) {
            // 记录当前层节点后面节点指针
            newNode.forwards[i] = update[i].forwards[i];
            // 前一个节点的指针,指向当前节点
            update[i].forwards[i] = newNode;
        }

        // 更新层高
        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];
                }
            }
        }
    }

    /**
     * 随机 level 次,如果是奇数层数 +1,防止伪随机
     *
     * @return
     */
    private int randomLevel() {
        int level = 1;
        for (int i = 1; i < MAX_LEVEL; ++i) {
            if (r.nextInt() % 2 == 1) {
                level++;
            }
        }
        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 void printAll_beautiful() {
        Node p = head;
        Node[] c = p.forwards;
        Node[] d = c;
        int maxLevel = c.length;
        for (int i = maxLevel - 1; i >= 0; i--) {
            do {
                System.out.print((d[i] != null ? d[i].data : null) + ":" + i + "-------");
            } while (d[i] != null && (d = d[i].forwards)[i] != null);
            System.out.println();
            d = c;
        }
    }

    /**
     * 跳表的节点,每个节点记录了当前节点数据和所在层数数据
     */
    public class Node {
        private int data = -1;
        /**
         * 表示当前节点位置的下一个节点所有层的数据,从上层切换到下层,就是数组下标-1,
         * forwards[3]表示当前节点在第三层的下一个节点。
         */
        private Node forwards[];

        /**
         * 这个值其实可以不用,看优化insert()
         */
        private int maxLevel = 0;

        public Node(int level) {
            forwards = new Node[level];
        }

        @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();
        }
    }

    public static void main(String[] args) {
        SkipList list = new SkipList();
        list.insert(1, 3);
        list.insert(2, 3);
        list.insert(3, 2);
        list.insert(4, 4);
        list.insert(5, 10);
        list.insert(6, 4);
        list.insert(8, 5);
        list.insert(7, 4);
        list.printAll_beautiful();
        list.printAll();

        SkipList list2 = new SkipList();
        list2.insert2(1);
        list2.insert2(2);
        list2.insert2(6);
        list2.insert2(7);
        list2.insert2(8);
        list2.insert2(3);
        list2.insert2(4);
        list2.insert2(5);
        System.out.println();
        list2.printAll_beautiful();

    }


}

打印结果

`null:15-------
null:14-------
null:13-------
null:12-------
null:11-------
null:10-------
5:9-------
5:8-------
5:7-------
5:6-------
5:5-------
5:4-------8:4-------
4:3-------5:3-------6:3-------7:3-------8:3-------
1:2-------2:2-------4:2-------5:2-------6:2-------7:2-------8:2-------
1:1-------2:1-------3:1-------4:1-------5:1-------6:1-------7:1-------8:1-------
1:0-------2:0-------3:0-------4:0-------5:0-------6:0-------7:0-------8:0-------
{ data: 1; levels: 3 } { data: 2; levels: 3 } { data: 3; levels: 2 } { data: 4; levels: 4 } { data: 5; levels: 10 } { data: 6; levels: 4 } { data: 7; levels: 4 } { data: 8; levels: 5 }

null:15-------
null:14-------
null:13-------
null:12-------
null:11-------
null:10-------
null:9-------
null:8-------
null:7-------
null:6-------
null:5-------
5:4-------
3:3-------5:3-------
3:2-------4:2-------5:2-------7:2-------8:2-------
2:1-------3:1-------4:1-------5:1-------6:1-------7:1-------8:1-------
1:0-------2:0-------3:0-------4:0-------5:0-------6:0-------7:0-------8:0-------

`

代码来源github,并非本人编写:https://github.com/wangzheng0822/algo/blob/master/java/17_skiplist/SkipList2.java

总结

  • 什么是跳表?

跳表,全程:跳跃链表,在计算机科学中,跳跃列表是一种数据结构。它使得包含n个元素的有序序列的查找和插入操作的平均时间复杂度都是:
l o g n logn logn

  • 跳表的时间/空间复杂度

时间复杂度:O(logn),空间复杂度:O(n),空间复杂度会随着索引间隔距离的增大而减少,但是空间复杂度的趋势还是O(n)不变,由于索引层存储的信息相对于原始链表来说会少很多,所以即使是O(n)的空间复杂度,也是可以接受的。

  • 跳表的应用

1.redis的有序集合。

2.google开源的key/value存储引擎。

3.HBase MemStore 内部存储结构

  • 跳表的动态更新

由于跳表是由多层索引组成的,所以再频繁插入的时候会导致某一端相邻索引结点之前的数据过多,最坏情nixuefeileam况下,会导致所有的数据都集中在某一段相邻索引结点之间,这将会导致跳表退化成普通链表,时间复杂度也会退化到O(n),所以这个时候就需要动态的去更新跳表中的索引结点,目前通过随机函数实现。

  • 为什么redis采用跳表实现有序集合?

1.跳表是链表+多级索引组成的数据结构,通过空间换时间的设计思路,实现了基于链表的“二分查找”,查找、删除、插入时间复杂度皆为:O(logn)。

2.跳表实现方式相对于B树、红黑树、AVL树等简单许多,这几种树的平衡维护相当麻烦,而跳表的动态索引更新相对来说就简单很多了。

3.跳表的区间查找要比红黑树等优秀。

朋友,你学废了吗?

本文由博客群发一文多发等运营工具平台 OpenWrite 发布

  • 28
    点赞
  • 5
    评论
  • 4
    收藏
  • 打赏
    打赏
  • 扫一扫,分享海报

表情包
插入表情
评论将由博主筛选后显示,对所有人可见 | 还能输入1000个字符
©️2022 CSDN 皮肤主题:技术黑板 设计师:CSDN官方博客 返回首页

打赏作者

流星007

你的鼓励将是我创作的最大动力

¥2 ¥4 ¥6 ¥10 ¥20
输入1-500的整数
余额支付 (余额:-- )
扫码支付
扫码支付:¥2
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、C币套餐、付费专栏及课程。

余额充值