数据结构_线性表_2

在这里插入图片描述

1.1 数组

数组(Array)是一种线性表数据结构。它用一组连续的内存空间,来存储一组具有相同类型的数据。
在这里插入图片描述
在这里插入图片描述
线性表就是数据排成像一条线一样的结构。每个线性表上的数据最多只有前和后两个方向。其实除了数组,链表、队列、栈等也是线性表结构。
在这里插入图片描述
它相对立的概念是非线性表,比如二叉树、堆、图等。之所以叫非线性,是因为,在非线性表中,数据之间并不是简单的前后关系。

第二个是连续的内存空间和相同类型的数据。正是因为这两个限制,它才有了一个堪称“杀手锏”的特性:“随机访问”。但有利就有弊,这两个限制也让数组的很多操作变得非常低效,比如要想在数组中删除、插入一个数据,为了保证连续性,就需要做大量的数据搬移工作。
我们拿一个长度为 10 的 int 类型的数组 int[] a = new int[10] 来举例。在我画的这个图中,计算机给数组 a[10] ,分配了一块连续内存空间 1000 ~ 1039 ,其中,内存块的首地址为 base_address = 1000 。
在这里插入图片描述
我们知道,计算机会给每个内存单元分配一个地址,计算机通过地址来访问内存中的数据。当计算机需要随机访问数组中的某个元素时,它会首先通过下面的寻址公式,计算出该元素存储的内存地址:a[i]_address = base_address + i * data_type_size其中 data_type_size 表示数组中每个元素的大小。我们举的这个例子里,数组中存储的是 int 类型数据,所以data_type_size 就为 4 个字节。这个公式非常简单,我就不多做解释了。

1.1.1 低效的 “ 插入 ” 和 “ 删除

数组为了保持内存数据的连续性,会导致插入、删除这两个操作比较低效。现在我们就来详细说一下,究竟为什么会导致低效?又有哪些改进方法呢?
我们先来看插入操作。
假设数组的长度为 n ,现在,如果我们需要将一个数据插入到数组中的第 k 个位置。为了把第 k 个位置腾出来,给新来的数据,我们需要将第 k ~ n 这部分的元素都顺序地往后挪一位。那插入操作的时间复杂度是多少呢?你可以自己先试着分析一下。
如果在数组的末尾插入元素,那就不需要移动数据了,这时的时间复杂度为 O(1) 。但如果在数组的开头插入元素,那所有的数据都需要依次往后移动一位,所以最坏时间复杂度是 O(n) 。 因为我们在每个位置插入元素的概率是一样的,所以平均情况时间复杂度为 (1+2+…n)/n=O(n) 。
如果数组中的数据是有序的,我们在某个位置插入一个新的元素时,就必须按照刚才的方法搬移 k 之后的数据。但是,如果数组中存储的数据并没有任何规律,数组只是被当作一个存储数据的集合。在这种情况下,如果要将某个数组插入到第 k 个位置,为了避免大规模的数据搬移,我们还有一个简单的办法就是,直接将第 k 位的数据搬移到数组元素的最后,把新的元素直接放入第 k 个位置。
为了更好地理解,我们举一个例子。假设数组 a[10] 中存储了如下 5 个元素: a , b , c , d , e 。
我们现在需要将元素 x 插入到第 3 个位置。我们只需要将 c 放入到 a[5] ,将 a[2] 赋值为 x 即可。最后,数组中的元素如下: a , b , x , d , e , c 。
利用这种处理技巧,在特定场景下,在第 k 个位置插入一个元素的时间复杂度就会降为 O(1) 。这个处理思想在快排中也会用到,我会在排序那一节具体来讲,这里就说到这儿。
在这里插入图片描述

我们再来看删除操作。
跟插入数据类似,如果我们要删除第 k 个位置的数据,为了内存的连续性,也需要搬移数据,不然中间就会出现空洞,内存就不连续了。和插入类似,如果删除数组末尾的数据,则最好情况时间复杂度为 O(1) ;如果删除开头的数据,则最坏情况时间复杂度为 O(n) ;平均情况时间复杂度也为 O(n) 。
实际上,在某些特殊场景下,我们并不一定非得追求数组中数据的连续性。如果我们将多次删除操作集中在一起执行,删除的效率是不是会提高很多呢?
在这里插入图片描述
我们继续来看例子。数组 a[10] 中存储了 8 个元素: a , b , c , d , e , f , g , h 。现在,我们要依次删除 a , b , c 三个元素。
为了避免 d , e , f , g , h 这几个数据会被搬移三次,我们可以先记录下已经删除的数据。每次的删除操作并不是真正地搬移数据,只是记录数据已经被删除。当数组没有更多空间存储数据时,我们再触发执行一次真正的删除操作,这样就大大减少了删除操作导致的数据搬移。

1.1.2 数组要从 0 开始编号,而不是从 1 开始呢?

从数组存储的内存模型上来看, “ 下标 ” 最确切的定义应该是 “ 偏移( offset ) ” 。前面也讲到,如果用 a 来表示数组的首地址, a[0] 就是偏移为 0 的位置,也就是首地址, a[k] 就表示偏移 k 个 type_size 的位置,所以计算 a[k] 的内存地址只需要用这个公式:a[k]_address = base_address + k * type_size但是,如果数组从 1 开始计数,那我们计算数组元素 a[k] 的内存地址就会变为:a[k]_address = base_address + (k-1)*type_size对比两个公式,我们不难发现,从 1 开始编号,每次随机访问数组元素都多了一次减法运算,对于 CPU 来说,就是多了一次减法指令。数组作为非常基础的数据结构,通过下标随机访问数组元素又是其非常基础的编程操作,效率的优化就要尽可能做到极致。所以为了减少一次减法操作,数组选择了从 0 开始编号,而不是从 1 开始。不过我认为,上面解释得再多其实都算不上压倒性的证明,说数组起始编号非 0 开始不可。所以我觉得最主要的原因可能是历史原因。
C 语言设计者用 0 开始计数数组下标,之后的 Java 、 JavaScript 等高级语言都效仿了 C 语言,或者说,为了在一定程度上减少 C 语言程序员学习 Java 的学习成本,因此继续沿用了从 0 开始计数的习惯。实际上,很多语言中数组也并不是从 0 开始计数的,比如 Matlab 。甚至还有一些语言支持负数下标,比如 Python 。

1.2 单链表

经典的链表应用场景:LRU 缓存淘汰算法
常见的策略有三种:先进先出策略 FIFO ( First In , First Out )、最少使用策略 LFU ( Least Frequently Used )、最近最少使用策略 LRU ( Least Recently Used )。
在这里插入图片描述
从图中我们看到,数组需要一块连续的内存空间来存储,对内存的要求比较高。如果我们申请一个100MB大小的数组,当内存中没有连续的、足够大的存储空间时,即便内存的剩余总可用空间大于 100MB ,仍然会申请失败。
而链表恰恰相反,它并不需要一块连续的内存空间,它通过“指针”将一组零散的内存块串联起来使用,所以如果我们申请的是100MB大小的链表,根本不会有问题。

链表通过指针将一组零散的内存块串联在一起。其中,我们把内存块称为链表的“结点”。为了将所有的结点串起来,每个链表的结点除了存储数据之外,还需要记录链上的下一个结点的地址。如图所示,我们把这个记录下个结点地址的指针叫作后继指针next
在这里插入图片描述
两个结点是比较特殊的,它们分别是第一个结点和最后一个结点。我们习惯性地把第一个结点叫作头结点,把最后一个结点叫作尾结点。其中,头结点用来记录链表的基地址。有了它,我们就可以遍历得到整条链表。而尾结点特殊的地方是:指针不是指向下一个结点,而是指向一个空地址NULL,表示这是链表上最后一个结点。
在进行数组的插入、删除操作时,为了保持内存数据的连续性,需要做大量的数据搬移,所以时间复杂度是 O(n) 。而在链表中插入或者删除一个数
据,我们并不需要为了保持内存的连续性而搬移结点,因为链表的存储空间本身就不是连续的。针对链表的插入和删除操作,我们只需要考虑相邻结点的指针改变,所以对应的时间复杂度是 O(1)
在这里插入图片描述
链表要想随机访问第 k 个元素,就没有数组那么高效了。因为链表中的数据并非连续存储的,所以无法像数组那样,根据首地址和下标,通过寻址公式就能直接计算出对应的内存地址,而是需要根据指针一个结点一个结点地依次遍历,直到找到相应的结点。你可以把链表想象成一个队伍,队伍中的每个人都只知道自己后面的人是谁,所以当我们希望知道排在第 k 位的人是谁的时候,我们就需要从第一个人开始,一个一个地往下数。所以,链表随机访问的性能没有数组好,需要 O(n) 的时间复杂度。

2.3 循环链表

在这里插入图片描述
循环链表是一种特殊的单链表。实际上,循环链表也很简单。它跟单链表唯一的区别就在尾结点。我们知道,单链表的尾结点指针指向空地址,表示这就是最后的结点了。而循环链表的尾结点指针是指向链表的头结点。从我画的循环链表图中,你应该可以看出来,它像一个环一样首尾相连,所以叫作 “ 循环 ” 链表。
循环链表的优点是从链尾到链头比较方便。当要处理的数据具有环型结构特点时,就特别适合采用循环链表。比如著名的约瑟夫问题。尽管用单
链表也可以实现,但是用循环链表实现的话,代码就会简洁很多

2.4 双向链表

单向链表只有一个方向,结点只有一个后继指针 next 指向后面的结点。而双向链表,顾名思义,它支持两个方向,每个结点不止有一个后继指针 next 指向后面的结点,还有一个前驱指针 prev 指向前面的结点
在这里插入图片描述
从我画的图中可以看出来,双向链表需要额外的两个空间来存储后继结点和前驱结点的地址。所以,如果存储同样多的数据,双向链表要比单链表占用更多的内存空间。虽然两个指针比较浪费存储空间,但可以支持双向遍历,这样也带来了双向链表操作的灵活性。那相比单链表,双向链表适合解决哪种问题呢?
从结构上来看,双向链表可以支持 O(1) 时间复杂度的情况下找到前驱结点,正是这样的特点,也使双向链表在某些情况下的插入、删除等操作都要比单链表简单、高效。你可能会说,我刚讲到单链表的插入、删除操作的时间复杂度已经是 O(1) 了,双向链表还能再怎么高效呢?别着急,刚刚的分析比较偏理论,很多数据结构和算法书籍中都会这么讲,但是这种说法实际上是不准确的,或者说是有先决条件的。我再来带你分析一下链表的两个操作。
我们先来看删除操作。
在实际的软件开发中,从链表中删除一个数据无外乎这两种情况:
删除结点中“值等于某个给定值”的结点;
删除给定指针指向的结点。
对于第一种情况,不管是单链表还是双向链表,为了查找到值等于给定值的结点,都需要从头结点开始一个一个依次遍历对比,直到找到值等于给定值的结点,然后再通过我前面讲的指针操作将其删除。
尽管单纯的删除操作时间复杂度是 O(1) ,但遍历查找的时间是主要的耗时点,对应的时间复杂度为 O(n) 。根据时间复杂度分析中的加法法则,删除值等于给定值的结点对应的链表操作的总时间复杂度为 O(n) 。
对于第二种情况,我们已经找到了要删除的结点,但是删除某个结点 q 需要知道其前驱结点,而单链表并不支持直接获取前驱结点,所以,为了找到前驱结点,我们还是要从头结点开始遍历链表,直到 p->next=q ,说明 p 是 q 的前驱结点。
但是对于双向链表来说,这种情况就比较有优势了。因为双向链表中的结点已经保存了前驱结点的指针,不需要像单链表那样遍历。所以,针对第二种情况,单链表删除操作需要 O(n) 的时间复杂度,而双向链表只需要在 O(1) 的时间复杂度内就搞定了!
同理,如果我们希望在链表的某个指定结点前面插入一个结点,双向链表比单链表有很大的优势。双向链表可以在 O(1) 时间复杂度搞定,而单向链表需要 O(n) 的时间复杂度。你可以参照我刚刚讲过的删除操作自己分析一下。
除了插入、删除操作有优势之外,对于一个有序链表,双向链表的按值查询的效率也要比单链表高一些。因为,我们可以记录上次查找的位置 p ,每次查询时,根据要查找的值与 p 的大小关系,决定是往前还是往后查找,所以平均只需要查找一半的数据。

2.5 链表 VS 数组性能大比拼

在这里插入图片描述

2.5.1 如何轻松写出正确的链表代码

在编写链表代码的时候,我们经常会有这样的代码: p->next=q 。这行代码是说, p 结点中的 next 指针存储了 q 结点的内存地址。
还有一个更复杂的,也是我们写链表代码经常会用到的: p->next=p->next->next 。这行代码表示, p 结点的 next 指针存储了 p 结点的下下一个结点的内存地址。
掌握了指针或引用的概念,你应该可以很轻松地看懂链表代码。恭喜你,已经离写出链表代码近了一步!
技巧一:理解指针或引用的含义
将某个变量赋值给指针,实际上就是将这个变量的地址赋值给指针,或者反过来说,指针中存储了这个变量的内存地址,指向了这个变量,通过指针就能找到这个变量。
技巧二:警惕指针丢失和内存泄漏
例如:需要将b元素插入到a元素和c元素之间)插入元素时先将b元素next的地址指向c元素,然后再将a元素的next地址指向b元素。
在p元素后面插入新的元素。(now_node->next=p->next;p—>next=now_node)
当我们要向一个空链表中插入第一个结点,刚刚的逻辑就不能用了if (head == null) {head = new_node;}
删除p节点的后继节点:
p->next = p->next->next;
删除最后一个节点:
if(noed->p ==null){node=null}
技巧三:利用哨兵简化实现难度
从前面的一步一步分析,我们可以看出,针对链表的插入、删除操作,需要对插入第一个结点和删除最后一个结点的情况进行特殊处理。这样代码实现起来就会很繁琐,不简洁,而且也容易因为考虑不全而出错。
哨兵就要登场了。哨兵,解决的是国家之间的边界问题。同理,这里说的哨兵也是解决 “ 边界问题 ” 的,不直接参与业务逻辑。

package com.sinosun.springbootdemo.core.algorithm;

/**
 * 哨兵简化编程(插入排序、归并排序、动态规划)
 */
public class Demo01 {
    public static void main(String[] args) {

        int [] arr = new int[]{1,2,3,4,5,6,7,8,9,10};
        int result = find(arr,arr.length,3);
        System.out.println(result);

        int result1 = find(arr,arr.length,0);
        System.out.println(result1);
    }

    /**
     * 在数组arr中,查找key,返回key所在的位置
     *
     * @param a   数组
     * @param n   表示数组的长度
     * @param key 查找的值
     * @return 返回下标
     */
    public static int test(int[] a, int n, int key) {

        if (a == null || n <= 0) {
            System.out.println("数组错误");
            return -1;
        }

        int i = 0;
        // 这里有两个比较操作:i<n和a[i]==key.
        while (i < n) {
            if (a[i] == key) {
                return i;
            }
            ++i;
        }
        return -1;
    }

    /**
     * 在数组arr中,查找key,返回key所在的位置(哨兵简化编程)
     *
     * @param a   数组
     * @param n   表示数组的长度
     * @param key 查找的值
     * @return 返回下标
     */
    public static int find(int[] a, int n, int key) {
        // a = {4, 2, 3, 5, 9, 6} n=6 key = 7
        // a = {4, 2, 3, 5, 9, 6} n=6 key = 6
        if (a == null || n <= 0) {
            return -1;
        }

        // 这里因为要将a[n-1]的值替换成key,所以要特殊处理这个值
        if (a[n - 1] == key) {
            return n - 1;
        }

        // 把a[n-1]的值临时保存在变量tmp中,以便之后恢复。tmp=6。
        // 之所以这样做的目的是:希望find()代码不要改变a数组中的内容
        int tmp = a[n - 1];
        // 把key的值放到a[n-1]中,此时a = {4, 2, 3, 5, 9, 7}
        a[n - 1] = key;

        int i = 0;
        // while 循环比起代码test()方法,少了i<n这个比较操作
        while (a[i] != key) {
            ++i;
        }
        // 恢复a[n-1]原来的值,此时a= {4, 2, 3, 5, 9, 6}
        a[n - 1] = tmp;
        if (i == n - 1) {
            // 如果i == n-1说明,在0...n-2之间都没有key,所以返回-1
            return -1;
        } else {
            // 否则,返回i,就是等于key值的元素的下标
            return i;
        }
    }

}

技巧四:重点留意边界条件处理
技巧五:举例画图,辅助思考
技巧六:多写多练,没有捷径

2.6 栈

先进者后出,这就是典型的“栈”结构(平常生活方碟子)
实际上,栈既可以用数组来实现,也可以用链表来实现。用数组实现的栈,我们叫作顺序栈,用链表实现的栈,我们叫作链式栈。具体代码可以自行百度

2.6.1 栈在函数调用中的应用

我们知道,操作系统给每个线程分配了一块独立的内存空间,这块内存被组织成 “ 栈 ” 这种结构 , 用来存储函数调用时的临时变量。每进入一个函数,就会将临时变量作为一个栈帧入栈,当被调用函数执行完成,返回之后,将这个函数对应的栈帧出栈。为了让你更好地理解,我们一块来看下这段代码的执行过程。

int main() {
        int a = 1;
        int ret = 0;
        int res = 0;
        ret = add(3, 5);
        res = a + ret;
        return 0;
    }
    int add(int x, int y) {
        int sum = 0;
        sum = x + y;
        return sum;
    }

在这里插入图片描述

2.7 队列

先进者先出,这就是典型的“队列”(排队买票,先来先买)
用数组实现的栈叫作顺序栈,用链表实现的栈叫作链式栈。同样,用数组实现的队列叫作顺序队列,用链表实现的队列叫作链式队列。(代码自行百度)

package com.sinosun.springbootdemo.core.datastructure.queue;

/**
 * 队列
 */
public class ArrayQueue {
    // 数组:items,数组大小:n
    private String[] items;
    private int n = 0;
    // head表示队头下标,tail表示队尾下标
    private int head = 0;
    private int tail = 0;

    // 申请一个大小为capacity的数组
    public ArrayQueue(int capacity) {
        items = new String[capacity];
        n = capacity;
    }

    // 入队
    public boolean enqueue(String item) {
        // 如果tail == n 表示队列已经满了
        if (tail == n) {
            return false;
        }
        items[tail] = item;
        ++tail;
        return true;
    }

    // 出队
    public String dequeue() {
        // 如果head == tail 表示队列为空
        if (head == tail) {
            return null;
        }
        // 为了让其他语言的同学看的更加明确,把--操作放到单独一行来写了
        String ret = items[head];
        ++head;
        return ret;
    }

    /**
     * 当队列的 tail 指针移动到数组的最右边后,如果有新的数据入队,
     * 我们可以将 head 到 tail 之间的数据,整体搬移到数组中 0 到 tail-head 的位置
     *
     * @param item
     * @return
     */
    // 入队操作,将item放入队尾
    public boolean enqueue2(String item) {
        // tail == n表示队列末尾没有空间了
        if (tail == n) {
            // tail ==n && head==0,表示整个队列都占满了
            if (head == 0) {
                return false;
            }
            // 数据搬移
            for (int i = head; i < tail; ++i) {
                // items[0] = items[head]
                // items[1] = items[head+1]
                // items[2] = items[head+1+1]
                items[i - head] = items[i];
            }
            // 搬移完之后重新更新head和tail
            // tail = tail - head;
            tail -= head;
            head = 0;
        }
        items[tail] = item;
        ++tail;
        return true;
    }
}

enqueue2()主要解决数据搬移问题。
在这里插入图片描述

2.8 循环队列

解决了数据搬移的操作。
我们刚才用数组来实现队列的时候,在 tail==n 时,会有数据搬移操作,这样入队操作性能就会受到影响。
在这里插入图片描述

package com.sinosun.springbootdemo.core.datastructure.queue;

/**
 * 循环队列
 */
public class CircularQueue {
    // 数组:items,数组大小:n
    private String[] items;
    private int n = 0;

    // head表示队头下标,tail表示队尾下标
    private int head = 0;
    private int tail = 0;

    // 申请一个大小为capacity的数组
    public CircularQueue(int capacity) {
        items = new String[capacity];
        n = capacity;
    }

    // 入队
    public boolean enqueue(String item) {
        // 队列满了
        if ((tail + 1) % n == head) {
            return false;
        }
        items[tail] = item;
        tail = (tail + 1) % n;
        return true;
    }

    // 出队
    public String dequeue() {
        // 如果head == tail 表示队列为空
        if (head == tail) return null;
        String ret = items[head];
        head = (head + 1) % n;
        return ret;
    }
}

2.9 阻塞队列

简单来说,就是在队列为空的时候,从队头取数据会被阻塞。因为此时还没有数据可取,直到队列中有了数据才能返回;如果队列已经满了,那么插入数据的操作就会被阻塞,直到队列中有空闲位置后再插入数据,然后再返回。
应用:生产者 - 消费者模型

2.10 并发队列:

线程安全的队列我们叫作并发队列。最简单直接的实现方式是直接在enqueue()、dequeue()方法上加锁,但是锁粒度大并发度会比较低,同一时刻仅允许一个存或者取操作。实际上,基于数组的循环队列,利用 CAS 原子操作,可以实现非常高效的并发队列。这也是循环队列比链式队列应用更加广泛的原因

2.10.1 线程池没有空闲线程时,新的任务请求线程资源时,线程池该如何处理

一种是非阻塞的处理方式,直接拒绝任务请求;另一种是阻塞的处理方式,将请求排队,等到有空闲线程时,取出排队的请求继续处理。那如何存储排队的请求呢?
我们希望公平地处理每个排队的请求,先进者先服务,所以队列这种数据结构很适合来存储排队请求。我们前面说过,队列有基于链表和基于数组这两种实现方式。这两种实现方式对于排队请求又有什么区别呢?

基于链表的实现方式,可以实现一个支持无限排队的无界队列( unbounded queue ),但是可能会导致过多的请求排队等待,请求处理的响应时间过长。所以,针对响应时间比较敏感的系统,基于链表实现的无限排队的线程池是不合适的。
而基于数组实现的有界队列( bounded queue ),队列的大小有限,所以线程池中排队的请求超过队列大小时,接下来的请求就会被拒绝,这种方式对响应时间敏感的系统来说,就相对更加合理。不过,设置一个合理的队列大小,也是非常有讲究的。队列太大导致等待的请求太多,队列太小会导致无法充分利用系统资源、发挥最大性能。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值