数据结构与算法之美专栏笔记_数组链表栈篇


摘要


在具体的算法题解之前,我会先简要的描述一些知识点,这些知识点都是基于数据结构与算法专栏的总结,对于详细的推理不再涉及,仅当做笔记使用。

我们所熟识的数组

1.1 从一个问题开始

为什么大部分编程语言中,数组要从0开始编号,而不是从1开始?

从数组存储的内存模型来看,数组的下标实际上指的是内存地址的偏移,假设用a表示数组的首地址,将数组元素的类型所占字节数计为type_size,那么a[k]表示的含义就是存储在内存空间k*typesize+base_addr处的元素,如果从1开始编号的,公式就变成了这样——
( k − 1 ) ∗ t y p e s i z e + b a s e a d d r (k-1)*typesize+base_addr (k1)typesize+baseaddr
也就是说,每次随机访问数组元素时多了一个减法操作,CPU多了一次减法指令的执行。

1.2 数组概述

数组是一种线性表数据结构,它用一组连续的内存空间,来存储一组具有相同类型的数据

数组的特性之一就是数组支持随机访问,根据下标随机访问的时间复杂度是O(1).而不基于下标访问数组元素最优的方式是二分查找,平均时间复杂度为O(logn).

1.3 低效的插入与删除

由于数组使用的是连续的内存空间,所以其在插入和删除元素时实际上需要两步:

  1. 进行数据的搬移
  2. 实际的数据操作

因为涉及到元素的搬移,因此他们的平均时间复杂度是O(N),相对链表只操作指针就可以完成插入和删除来说,低效得多。

1.4 令人头疼的数组越界问题

数组在初始化时必须要指定数组的长度,但在使用时,并没有机制约束操作数组长度以外的元素,这就导致了越界问题,在java中,当处理的数组越界时,会抛出java.lang.ArrayIndexOutOfBoundsException异常,而在C语言中,就比较头疼了,需要自己时刻谨慎以防止数组越界。

1.5 容器可以代替数组吗?

容器ArrayList在数组的基础上封装了增删改查及动态扩容的操作,简化了开发流程。但同时也多了限制,即容器中只能存储对象,这就需要基本数据类型的装箱与拆箱,降低了性能。那什么情况下建议使用数组呢——

  1. 如果考虑性能,则使用数组
  2. 若数组大小事先已知且涉及到的数组操作非常简单,可以使用数组
  3. 对于底层的开发,如网络框架,性能优化,选择数组

1.6 面试常考的练习题集锦

1.7 小练习

如何理解JVM中标记清除垃圾回收算法的思想?

我们知道删除数组元素是非常耗费性能的,如果涉及到多次删除,就需要多次搬移元素。这时可以先记录下已经删除的元素,但并不搬移数据,当数组没有更多的存储空间时,再触发真正的数据搬移。

而关于jvm标记清除算法的详情,就请移步https://segmentfault.com/a/1190000037685570

二.链表知识大派送


摘要

确认过眼神,是熟悉的链表环节。从内存模型的角度来看,众多的数据结构都是在数组和链表的基础上衍生的。

在链表的学习小节中,我们首先从一个通用的解决方案出发,接着谈及几种链表的基本结构,常用操作,以及不同结构的链表是怎样自然递进的。最后还将对数组与链表做一对比。话不多说,让我们启程吧——

2.1 从一个问题开始

我们知道,在操作系统中,为了解决从硬盘读取和从内存读写的速度不匹配问题,常常引入缓存这一概念。缓存家族可谓人丁兴旺,寄存器,浏览器缓存,数据库缓存大行其道。那缓存实现的机制是什么呢?

那就是LRU缓存淘汰算法,在后期推出的操作系统系列博文中,你将对其有深入的了解。这里我们只讨论,如何使用链表实现LRU缓存淘汰算法,对此,王争老师的思路这样的——

我们维护一个有序的单链表,越靠近链表尾部的节点是越早之前被插入的。当有一个新的数据被访问时,我们从链表头部开始顺序遍历单链表。

假如这个元素之前被缓存在链表中,则将该元素从原来的位置删除,然后插入到链表的头部

如果该数据没有在缓存链表中,又可以分为两种情况:

  1. 如果缓存未满,则将此节点直接插入到链表的头部
  2. 如果缓存已满,则将链表尾部的元素删除,再将该数据插入到链表的头部。

此外,还可以通过散列表降低访问元素的时间复杂度优化这个方案。

2.2 五花八门的链表结构

首先对链表下一个定义:

区别于数组使用连续的内存空间,链表通过指针将离散的内存块串联在一起。

定义中的内存块即是链表的节点,为了将所有节点串起来,每个节点存储数据以外,还需要记录下一个节点的地址,也就是后继指针next。

常用的链表有三种,其中最简单常用的是单链表——

2.2.1 单链表

image-20210204205710479

我们习惯将第一个节点称为头结点,将最后一个节点称为尾结点,值得注意的是,尾结点不再指向一个地址,而指向null。

与数组一样,链表也支持数据的查找,插入和删除。

在插入,删除新元素时,只需要考虑相邻节点的指针改变——

image-20210204210209271

在链表中查找元素是不方便的,必须顺序遍历链表,它的时间复杂度为O(n).

2.2.2 循环链表

循环链表是特殊的单链表,它与单链表的区别就在于尾结点。循环链表的尾结点指针指向的是头结点。那么这么结构适用于什么场景呢?

它可以用来高效的解决环结构的链表,比如著名的约瑟夫环问题。

在实际应用中,被更多使用的要属双向链表,我们来看下它特定的结构得以应用于什么场景呢。。。

2.2.3 双向链表

双向链表在单链表的基础上增加了前驱节点prev,这样双向链表就可以实现反向遍历,对于找特定节点的前驱节点的时间复杂为O(1)。双向链表降低了遍历链表时的复杂度,但同时也带来了内存的损耗。是一种使用空间换时间的策略

2.2.4 双向链表高效在哪呢

我们知道,删除链表元素的场景分为两类:

  1. 删除值等于给定值的节点
  2. 删除给定指针指向的节点

对于第一种情况,没有捷径可走,只能去逐个遍历节点元素。而对于第二个节点,双向链表增加了通过前驱指针找给定节点的遍历,因此高效的多。同样的,插入一个节点时,使用双向链表也提供了更多的可能。

既然双向链表这么牛,那实际应用场景中有没有双向链表的大展身手呢,那就是LinkedHashMap,这种高效的集合底层即是使用了这种数据结构。

看到这,很多朋友会问,既然双向链表的效率这么高,那再给他加上循环链表的特点岂不是如虎添翼,的确如此,我们来看究极版——
image-20210204212458009

2.3 链表VS数组性能大比拼

这里不再俗套的给出很多理论,而是通过具体的应用场景来考虑数组与链表性能——

CPU缓存应该哪种数据结构呢?CPU管理的是连续的内存空间,使用链表简直天然地不友好,所以使用数组。而对于一个插入删除更频繁的系统来说,使用数组则更众望所归。其实抛开场景说数据结构就是耍流氓,在之前的定义中,我们就提及,数据结构是用于特定场景的。

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

链表的代码因为涉及到指针的操作,往往空想起来比较绕,要想高效地写出链表代码,我们还需要一些技巧——

  1. 理解指针或者引用的含义

    将某个变量赋给指针,实际上就是将这个变量的地址赋值给指针,引用同样如是。

  2. 警惕指针丢失和内存泄漏

    链表的操作往往是有序的,此外,在删除元素时,一定要手动释放内存空间(C语言).

  3. 利用哨兵简化实现难度

    对原生的单链表删除尾结点和插入第一个节点时,需要做特殊处理。这种边界条件能不能简化成通用的情况呢?

    当然可以,我们使用哨兵机制来实现,即在第一个节点之前加入head节点,head节点不存储数据,它指向链表的第一个节点。这里需要注意区分head节点和第一个节点,他们的定义是不同的!

  4. 重点留意边界条件

    常见的边界处理条件有那么几个

    1. 如果链表为空,代码是否能正常运行
    2. 如果链表只包含一个节点时,代码是否能正常工作
    3. 如果链表只包含两个节点时,代码是否能正常工作
    4. 代码逻辑在处理头结点和尾结点时,代码是否能正常工作。
  5. 举例画图,辅助思考

    非常有必要,脑子不是那么靠谱的时候,就需要动手啦。

  6. 多写多练,没有捷径

2.5 面试常考的练习题集锦

https://blog.csdn.net/qq_48573752/article/details/113667050


三.更进一步——关于栈

3.1 从一个问题开始

我们在浏览网站时都会用到前进和后退的功能,那这种功能是如何实现的你知道吗?

没错,就是使用这节我们会讲到的栈,针对这个需求,假设将我们浏览的网页按照先后顺序存储在一个数组中,我们根据下标,即可实现前进和后退,但这里有一个致命的BUG,那就是它无法按我们的需求去删减一些存储在数组中的网页,也正因此我们需要引入栈。

假设我们依次访问了三个网页A,B,C,那栈的情况是这样的——

image-20210209130024426

这时,我们想回退到a页面了,就需要出栈c,b到栈Y中——
image-20210209130206337

然后,我们再去访问页面d:就会入栈X,如果我们再想访问页面b,c就不行了,栈Y会清空栈中的内容

3.2 为什么要学习栈

栈是一种操作受限的线性表,从功能上来说,数组异或链表都可以实现栈。但是数组或是链表都暴露了太多的接口,这就需要我们非常注意邻界条件。

而特定的数据结构是对特定情境的抽象,对于某些场景来说,用栈就很合适了。

当某个数据集合只涉及在一端插入和删除数据,并且满足后进先出,先进后出的特点时,我们就应该首选使用栈。

3.3 怎样学习栈?

实现一个栈

可以查看我的另一篇关于队列和栈基础应用的博文

支持动态扩容的顺序栈

我们先来分析下入栈和出栈的时间复杂度,出栈时,时间复杂度为O(1)没有悬念,而入栈时假如还有空间,那时间复杂度自然也是O(1),但当栈满的时候入栈,就需要扩容,这里的时间复杂度用均摊时间复杂度理解仍为O(1).

既然,我们需要扩容这种机制,而栈本身又可以使用数组实现。不妨用栈来继承一个可以实现动态扩容的集合。

实际上,java就是这样做的——

image-20210209131756275

当需要扩容时,会将数组大小扩展为原来的2倍:

image-20210209131942110

栈的应用

栈在函数调用,括号匹配,表达式运算方面有广泛的应用,我们一一来看——

函数调用

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

表达式运算

编译器实则就是使用两个栈来实现的。其中一个是保存操作数的栈,另一个是保存运算符的栈。当我们从左到右遍历表达式,当遇到数据,我们直接压入操作数栈。

当遇到运算符时,首先与运算符栈的栈顶运算符进行比较,如果比栈顶运算符的优先级低或者相等,则从运算符栈取出栈顶元素,然后从操作数栈取两个元素进行运算,并将运算结果压入操作数栈,继续比较。来看下王争老师的举例——

image-20210209133217261

括号匹配

这里也可以用栈来解决。我们用栈来保存未匹配的左括号,从左到右依次扫描字符串。当扫描到左括号时,则将其压入栈中;当扫描到右括号时,从栈顶取出一个左括号。如果能够匹配,比如“(”跟“)”匹配,“[”跟“]”匹配,“{”跟“}”匹配,则继续扫描剩下的字符串。如果扫描的过程中,遇到不能配对的右括号,或者栈中没有数据,则说明为非法格式。
当所有的括号都扫描完成之后,如果栈为空,则说明字符串为合法格式;否则,说明有未匹配的左括号,为非法格式。

面试常考的练习题集锦

  1. 有效的括号

    待更新

  2. 最小栈

    待更新

  3. 用栈实现队列

    待更新

  4. 比较含退格的栈

    待更新

  5. 基本计算器

    待更新


四. 队列

4.1 从一个问题开始

当我们向固定大小的线程池中请求一个线程时,如果线程池中没有空闲资源了,这个时候线程池会如何处理这个请求,是拒绝请求还是排队请求?各种处理策略又是怎样实现的呢?

一般有两种解决策略,一种是非阻塞的处理方式,直接拒绝任务请求;另一种是阻塞的处理方式,将请求排队。那排队的队列是如何实现的呢——

我们知道,队列有基于数组和链表的实现方式。基于链表的实现方式,可以实现一个支持无限排队的无界队列,但对用户体验来说,着实不怎么友好。

基于数组实现的有界队列,队列的大小有限,当超过队列大小时,请求会被拒绝,这种方式对响应时间敏感的系统来说,相对比较合理。但设置一个合理的队列大小也非常有讲究,需要按照具体应用场景设计。

4.2 概述

队列是一种操作受限的线性表,对比栈,它同样支持入队和出队。操作时,队头指针指向队头元素,队尾指针指向队尾元素的下一个内存空间。

4.3 五花八门的队列

顺序队列的实现

参考博文:https://blog.csdn.net/qq_48573752/article/details/113776915

通过小例子来理解顺序队列的基础操作——

image-20210209225055615

但是我们发现,当tail==n的时候,队列中还有空闲的空间,但却不能执行入队操作,这时要怎么办呢?

数据搬移,也就是当tail==n时触发一次整体的数据搬移,这样的话,入队的时间复杂度就不再是O(1),因为会涉及到数据的搬移,我们来看下新的入队操作:

public boolean enqueue(int item){
    //无法执行入队操作
    if(tail == n){
        //空间已满
        if(head == 0)
            return false;
        for(int i=0;i<tail-head;i++){
		   items[i] = items[head+i];
        }
        tail -= head;
        head = 0;  
    }
    item[n++] = item;
    return true;
}

这样的入队操作无疑更严谨了一些。但这样还是太麻烦了些,有没有什么办法可以不进行数据搬移就可以入队呢,那就要看循环队列了。

循环队列

在循环队列中,应该注意的核心问题就是队列满的判别条件,我们画张图理解下——

image-20210209225033318

再结合代码看下:

参见博文:https://blog.csdn.net/qq_48573752/article/details/113776915

并发队列与阻塞队列

上面的队列都是我们耳闻能详的知识,而实际工程中应用更多的实则是并发队列与阻塞队列。

阻塞队列其实就是在队列基础上增加了阻塞操作。简单来说,就是在队列为空的时候,从队头取数据会被阻塞。因为此时还没有数据可取,直到队列中有了数据才能返回;如果队列已经满了,那么插入数据的操作就会被阻塞,直到队列中有空闲位置后再插入数据,然后再返回。

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

4.4 一点思考

Q:如何实现无锁的并发队列?

A:可以使用数组+CAS机制。在入队之前,获取tail位置,入队时比较tail是否发生了变化。如果否,允许入队。反之,入队失败。相应的,出队则是参照head的位置。

4.5 面试常考的队列面试题

  • 设计循环双端队列

    待更新

  • 滑动窗口最大值

    待更新


日拱一卒,功不唐捐

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值