喜欢该类型文章可以给博主点个关注,博主会持续输出此类型的文章,知识点很全面,再加上LeetCode的真题练习,每一个LeetCode题解我都写了注释,比较适合新手入门数据结构与算法,后续也会更新进阶的文章。1-1 链表(List)及经典问题 链表基础知识
文章图片
链表的典型应用场景
文章图片
现在我们有这样一个需求:现在这个
4GB
的内存是一个连续的存储空间,接下来我们是要在4GB
的内存空间划分出来1GB
的存储空间,如图:文章图片
malloc
函数是c语言里的内容,就好比java
里的new
;作用相同的,就是开辟存储空间。可以想象成我找了一个
1GB
大小的数组,虽然有点夸张,只是举例。经过我这样的申请空间之后,我们把我们的整个数据切分了,原本是好好的一整块数组,变成两块了,一个是
2GB
的碎片,一个是1GB
的碎片,如图:文章图片
那我们接下来在程序里申请空间的话,我们如何去管理这个内存碎片?
操作系统底层里面,把两个内存碎片用链表给他窜起来,我们可以通过
2GB
找到1GB
的碎片,这样就不至于说我们内存里面出现碎片丢失,有一片存储空间找不到的情况;这是简单的一个应用。文章图片
现在只是简单拿出来了解一下,后续会对这个
LRU缓存淘汰算法
进行详细的讲解。什么是
LRU
?你可以理解为冰箱里有好多菜,我周一买了些菜,周二买了些菜,周日也买了些菜,但我们最后发现冰箱里放不下了,这时候我们新买的菜也不能不放进冰箱里,所以这时我们只能在冰箱里挑出一些菜给它扔掉,这时候我们会扔哪些菜呢?答案肯定是放的最久的菜,因为是最容易坏的。文章图片
那我们如何区分哪个最久呢?我们可以用链表给它链起来,这样我们就知道,哪个菜是最开始就放进冰箱里的,我们可以优先将它淘汰掉,并放入我们新的菜,就好比将图片中的
数据1
删掉,存入新的数据5
,再想填入新的数据,删掉数据2
,以此类推。文章图片
假设
数据3
是我们非常想吃的菜,但按照顺序即将被淘汰掉,我们可以把数据3
拿出来,放在最后,更新一下它的优先级,这样的话数据3
的优先级就高于数据4
,我们删除数据2
之后,会删除数据4
;这是链表常用的一个实现方式。文章图片
链表使用的场景还是非常多的。
链表与数组的结构性能对比 【数据结构与算法|数据结构学习笔记 1-1链表概述及LeetCode真题图解(Java)】数组的存储是
连续、聚合
的链表的存储是
离散
的,我们通过抽象的指针域概念连接了起来数组是一段连续的存储空间,支持数据随机访问,我们想找到哪个数据就可以立刻找到;但我们假设有一个
100
内存的数组,我想对这个数组进行扩容的话,我可能需要申请一个200
内存的数组,再把100
内存数组的所有数据原封不动的搬过去,这样我们才能得到一个200
内存的数组。链表是非连续的,扩容非常简单,找一个结点,就可以实现插入或者删除;但我们如果访问数据的话,就只能随机的访问,它不能按顺序进行访问;
这是我们从
数据结构
层面进行对比。那我们从
硬件
层面考虑呢?CPU读取优化
文章图片
读取
数组
时,假设我想读取数组的第100
个数,我可以直接找到数据并将它拿出来,但真正计算机实现里面,我们真的是只把100
这一个数据从内存拿到cpu里面吗?其实不是的,内存的内容导到cpu里,这叫做上下文切换。这种数据之间的读取是非常耗时的,所以说cpu不会大材小用的,你想读一个东西,我就只老老实实的读一个吗?cpu不是这样的,cpu很不老实,在我们当前节点前后,分别多读取一段内存空间,具体数据各个硬件不一样,当我们读取100的时候,cpu很多余的将100
前后的x
个空间都拿出来,它具体读取的内容是x+1+x
,虽然你只想读取一个,但是cpu会读取2x+1
个数。文章图片
cpu为什么会这么做?正是因为数组的连续的,如果我想读取一个数的话,那么我读取前面或者后面这个数的概率会大一点,所以它会进行一个多余的操作,但这个操作也不算是多余,它读取一个数据,和再读取这个数据前后的数据,如果时间消耗几乎是一样的话,cpu为什么不多读一点呢?这就是我们cpu层面上的
缓存优化
。那对于
链表
来说,可以用这种优化吗?答案肯定是不能的。因为链表的存储是非常随机的,在内存里一个在东面,一个在西面,当你cpu想读当前这一个链表结点时,它不知道你前面的结点和后面的结点都在哪里,所以说链表有很大的一个缺点,它没有办法运用在我们的cpu缓存优化;对应到我们的工程实现里面,这两个的差距是非常非常大的。LeetCode真题 经典面试题—链表的访问 LeetCode141.环形链表
难度:
easy
判断链表是否有环
经典的
快慢指针
编程思想 但是我们从初学者的角度来看 是不会想到用快慢指针的哈希表
最朴素的思想肯定是这种先来看无环的情况:哈希
的形式,我们只需要依次遍历整个链表,并创建一个哈希表来存储遍历过的结点。
遍历每一个结点,然后存入哈希表;在存入哈希表之前,先判断哈希表中是否存在该结点。
如果不存在,则存入哈希表;如果已存在,说明遍历到了重复结点,链表有环。
文章图片
有环情况:
文章图片
这个也是大部分人第一次遇到这种题的思路;大家在面试的时候,可以先写出这种最暴力的方式,然后再去优化,不要一直想着怎么去优化然后连最简单的方式都不写,要不然面试官会以为你连最简单的方式都不会。
总结但是这种方式是不是有点复杂,我需要开辟一个额外的存储空间来存储每一个链表结点的地址,我们有更优化的方法吗?
我们只需要遍历这个链表,在遍历过程中记录我们遍历的每个结点。
如果遇到next
结点为null
的结点,说明没有环。
如果遇到我们以前遍历过的结点,说明有环。
题中有一段话:
进阶:你能用快慢指针O(1)
(即,常量)内存解决此问题吗?
也就是说你不要动态的去申请内存空间,那我们如何不借助哈希表来实现这个题呢?那就是我们刚才提到的
快慢指针
。我们定义两个指针,
p
是慢指针(用红色标记),q
是快指针(用黄色标记),每次让p
结点走一步,q
结点走两步。文章图片
无环:
文章图片
当快指针的
next
结点为null
,或者快指针本身结点为null
时,说明该链表没有环,遍历结束。我们再来看一下有环的情况:
如果链表有环,那么快慢指针一定会相遇,指向同一个结点,当指向同一个结点时,遍历结束。
LeetCode题解:两种方法代码实现
LeetCode142.环形链表II
难度:
mid
141是判断是否有环,142是求环的起点。
最简单的方法肯定还是利用哈希表来判断,这里不过多赘述,我们直接上
快慢指针
。文章图片
设p走了a的距离,同时q走了2a的距离,此时p处在环的起点,q在环里,设q距离p的距离为x。
文章图片
由于q与p之间的距离为x,所以当p再走x步时,q走了2x步,此时p,q两点相遇;所以相遇点离我们环起点的位置肯定是x。
链表环的总长为
a+x
,当我们拿到x的长度时,我们也得到了a的长度,此a的长度跟p第一次走到环的起点a的长度是一样的。此时我们可以将p重新指向头结点,p,q同时走a步,即再次相遇,此相遇位置即为环的起点。
文章图片
重新分析一下整体过程:先看浅蓝色的线,p先走了a步,q走了2a步,此时p在环的起点,q在环里,设环的总长为a+x,看红色的线,先看下面,此时q距离p的距离即为x,再看上面,当p再走x步时,q走2x步,p,q两点相遇,此时相遇点距离起点的距离为x,看绿色的线,从而剩下的距离即为环的总长减去x,(a+x)-x=a,所以此时,我们可以将p指向头结点,p和q同时走a步,即p和q再次相遇,此时即为环的起点。
文章图片
LeetCode题解:两种方法代码实现
经典面试题—链表的反转 LeetCode206.反转链表
难度:
easy
文章图片
迭代实现
双指针扫描
这个链表反转其实很像如何交换两个变量的值,我们需要借助另一个变量来实现。
定义指针
pre
,pre
指向null
。定义指针
cur
,cur
指向我们的头结点。定义指针
next
,next
指向cur
所指向结点的下一个结点。文章图片
首先,我们先将
cur
指针所指向的结点指向pre
指针所指向的结点文章图片
然后移动指针
pre
到指针cur
所在的位置文章图片
移动
cur
到next
所在的位置文章图片
将我们的next指针指向cur指针所指向结点的下一个结点
文章图片
此时我们就完成了第一个结点的反转,
1
指向了null
。我们继续刚才的操作,将2反转。
文章图片
将剩下的反转。
文章图片
当
cur
指针指向null
的时候,我们就完成了整个链表的反转。文章图片
递归实现
我们可以借助
系统栈
来实现,入栈的顺序是1->2->3->4,但我们弹出栈的顺序为4->3->2->1。文章图片
递归实现对于新手比较难理解,后续到栈的部分会对这里有着更好的理解。
可以根据这张图看代码。
文章图片
LeetCode题解:两种方法代码实现
递归方法也可以看一下这个人的题解,清晰易懂。
LeetCode92.反转链表II
难度:
mid
反转给定区间的链表。
首先我们定义一个虚拟头结点,起名叫做
hair
,它指向我们的真实头结点,它在链表里有一个专有名词:哨兵结点
。为什么要定义一个这样的结点?其实这个东西很简单,它相当于在头结点上面加了一个边界条件,来防止一些边界特判,
假如我们要反转的是
1->2->3
,那我们反转完之后就是3->2->1->4->5->null
,按照我们之前的编程经验来看,我们首先定义一个int p = head
,然后再对p进行操作,最后返回的就是head,通常不对head进行操作,但对于这种情况,我们还可以直接返回head吗?head指向的是1,我们最终的链表就只剩1->4->5->null
,前面的数被丢掉了;所以这时我们在最前面加入一个哨兵结点
,就可以防止这种边缘特判,否则我们就要反转一次判断一下谁是头结点,把头结点记录一下,然后再反转再记录,会很麻烦,而且还加了很多特判。文章图片
先将哨兵结点
没有什么实际的意义,它不参与我们链表中的活动,你可以把它想象为空,或者里面存的值是-1
但它的任务只有一个,快速找到头结点。
hair
指向头结点定义一个指针
pre
指向hair
定义一个
cur
指向pre
指针所指向结点的下一个结点让我们的pre指针和cur指针同时向后移动,直到我们找到了第
left
个结点,也就是题中的2。文章图片
双指针-头插法
我们可以先将中间的3删除 然后插入到1和2之间 此时的链表是这样的
文章图片
那么同理,
cur
指向的还是2,pre
指向的还是1,所以我们也可以很轻易的将4删除,插入到1和3之间此时链表已经达到了题中的反转要求
文章图片
但是单向链表我们想要删除一个结点,我们必须通过它的上一个结点,在插入的过程中,我们应该注意什么?
一个链表
1->2
,我们想将3插入到1和2之间,我只可以通过1才可以找到2,因为1的下一个结点就是2,我们把1叫做A,把3叫做B,首先让B结点的下一个结点指向A结点的下一个结点,也就是B.next = A.next
,如图文章图片
这么做的目的就是防止1结点和2结点之间断开,而找不到2结点了,这样我们就可以放心大胆的让A的下一个结点指向B,也就是
A.next = B
,如图文章图片
这样3就插入进去了,这就是一个头插法的过程,所以本题我们就可以用这个方法来解决。
我们的核心思路就是将
cur
后面的结点插入到pre
后面。递归
递归还是比较难理解,由于博主也还是初学者,怕我讲的不到位,所以直接给大家看代码吧。
LeetCode题解:两种方法代码实现
经典面试题—链表的结点删除 LeetCode19.删除链表的倒数第N个结点
难度:
mid
我们想删除一个结点,那就一定需要这个结点前面的结点。
假如我们想删除b,我们可以使a不指向b而指向c,这样就完成了删除,之后要将b释放掉,代码就是
a.next = a.next.next
那我们该如何找到a呢?
文章图片
双重遍历 计算链表长度
删除链表
倒数第N
个结点,也就是删除链表第Length - N
结点的下一个结点。文章图片
我们需要先遍历链表,求出
Length
的长度,然后将4删除。文章图片
题中有一段话:
进阶:你能尝试使用一趟扫描实现吗?我们按上述方法是扫描了两遍,才可以删除结点,我们如何只扫描一遍就完成删除操作呢?
双指针
我们还是需要我们的老朋友,
哨兵结点
。同样也是双指针扫描,快慢指针,p指向hair,q指向head,先让q走n步,此时p和q同时走,当q走到null时,p必定指向的是待删除结点的前一个结点。
文章图片
图是力扣程序员吴师兄的。
LeetCode题解:两种方法代码实现
LeetCode83.删除排序链表中的重复元素
难度:
easy
其实还是挺简单的,毕竟是升序排列,也就是说重复元素必定是挨在一起的,我们可以用指针指向的val值跟下一个结点的val值进行对比,如果相同,则直接指向相同结点的下一结点,如果不同,指针往下移。
文章图片
LeetCode题解:代码实现
总结
链表这个数据结构看似简单,没有过多的条件,但它的题都非常有意思,它不涉及一些数据结构算法的思维,但是它每一题的解法都特别巧妙,可以把链表题当成智力测试题,脑筋急转弯,第一次上手那种环形链表,谁能想到用快慢指针呢?这个在面试当中其实是一样的,你不会这个东西你肯定就一直不会,我们如果在面试如果遇到了这种题,在面试的时候也很难再想出来,如果你面试之前做过了这些东西,那么你面试的时候也能大概率的想出来。
推荐阅读
- 程序员|大厂福利内卷,35岁不再是条红线(DBA攻坚指南竟成最佳破冰手段)
- 算法题-字符串
- C语言学习教程|C语言指针进阶-全面分析C指针重难点逐一突破(终篇)
- 竞赛|高级数据结构(树状数组以及逆序对求解)
- 剑指offer|剑指 Offer第 11 天 双指针(简单)
- LeetCode|LeetCode 467. 环绕字符串中唯一的子字符串 -- 动态规划
- C++|蓝桥杯算法提高欧拉函数--复杂度为O(n log n)
- 笔记|二、对象与类、封装、构造方法
- LeetCode编程题解法汇总|力扣解法汇总2044-正则表达式匹配