链表相关

        首先要和数组有一个明显的区别,数组需要一块连续的内存空间来存储,对内存要求比较高;链表相反,它并不需要一块连续的空间,它通过“指针”将一组零散的内存块串联起来使用。但它与数组一样都是线性表。  

        链表常见的有三种:单链表、双向链表、循环链表 。

单链表

1)每个结点只包含一个指针,即后继指针。

2)单链表有两个特殊结点,首结点和结点,首结点地址表示整条链表基地址,尾结点的后继指针指向空地址null。

3)删除和插入操作时间复杂度为O(1),查询时间复杂度O(n)。

循环链表
1)除了尾结点的后继指针指向首节点的地址外均与单链表一致。
2)适用于存储有循环特点的数据,比如约瑟夫问题。

双向链表

1)顾名思义,它支持两个方向,每个结点不止有一个后继指针next指向后面的结点,还有一个前驱指针prev指向前面的结点。

2)首结点的前驱指针prev和尾结点的后继指针next均指向null;

3)性能特点:与单链表相比,存储相同的数据,需要消耗更多的存储空间。

插入和删除操作在某些情况下比单链表简单、高效。在开发中,删除链表中一个数据基本两种情况:删除结点中“值等于某个给定的值”的结点删除给定指针指向的结点

1> 第一种情况不管是哪种链表,都需要从首结点开始遍历查找,知道找到对应值相等的结点,然后再删除,尽管删除操作时间复杂度是O(1),但查找时间复杂度都是O(n),所以这个操作整体的时间复杂度为O(n)。

2> 第二种情况,删除指定结点,已经找到对应的结点,但删除该结点需要知道其前驱结点,所以单链表就很难受需要重新遍历找到该结点,执行删除(时间复杂度为O(n)),而双向链表根据其具有前驱指针的特性可直接找到前驱结点,执行删除(时间复杂度为O(1))。同理插入操作与之类似。

除插入与删除之外,对于有序链表,双向链表的按值查询效率也要高于单链表。因为可以根据上次查找的位置p,每次查询时,根据要找的值与p的大小关系,决定是向前还是向后查找,所以平均只需查找一半的数据。(java中LinkedList底层也是双向链表实现)

此外,还有双向循环链表,就是双向链表与循环链表的结合。如下图


对比数组与链表,它们的时间复杂度正好相反。

数组缺点:

1)若申请内存空间较大,比如100M,但内存中若没有100M连续空间,则会申请失败,即使内存中有100M的空间。

2)大小固定,若存储空间不足则需要扩容,一旦扩容需要重新开辟连续空间并进行数据复制,非常耗时。

链表缺点:

1)内存空间消耗更大,因为还需要额外存储指针信息。

2)对链表进行频繁的插入和删除操作,会导致频繁的内存申请和释放,容易造成内存碎片,在java中,可能会造成频繁的GC 。

如何选择

数组简单易用,在实现上使用连续的内存空间,可以借助cpu的缓存机制,预读数组中的数据,所以访问效率更高。而链表在内存中不是连续存储,所以对cpu缓存不友好,没办法有效预读。个人认为代码中对内存使用不苛刻追求访问效率,数组更合适。


LRU缓存淘汰算法

链表的一种经典应用场景,就是LRU缓存淘汰算法。

缓存是一种提高数据读取性能的技术,常见的有 CPU 缓存、数据库缓存、浏览器缓存等等。缓存的大小有限,当缓存被用满时,哪些数据应该被清理出去,哪些数据应该被保留?这就需要缓存淘汰策略来决定。

常见的策略有三种:先进先出策略 FIFO(First In,First Out)、最少使用策略 LFU(Least Frequently Used)、最近最少使用策略 LRU(Least Recently Used)。

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

1、如果此数据之前已经缓存在链表中了,我们遍历得到这个数据对应的结点,并将其原来的位置删除,然后插入到链表的头部。

2、若此数据没有在缓存链表中,则分为两种情况:

  • 如果此缓存链表尚未存满,则将此结点直接插入链表头部;
  • 若已经存满,则将尾结点删除,再将此新结点插入链表头部;

如何优雅的写出链表代码?6大学习技巧

一、理解指针或引用的含义
1.含义:将某个变量(对象)赋值给指针(引用),实际上就是就是将这个变量(对象)的地址赋值给指针(引用)。
2.示例:
p—>next = q; 表示p结点的后继指针存储了q结点的内存地址。
p—>next = p—>next—>next; 表示p结点的后继指针存储了p结点的下下个结点的内存地址。

二、警惕指针丢失和内存泄漏(单链表)
1.插入结点
在结点a和结点b之间插入结点x,b是a的下一结点,,p指针指向结点a,则造成指针丢失和内存泄漏的代码:p—>next = x;x—>next = p—>next; 显然这会导致x结点的后继指针指向自身。
正确的写法是2句代码交换顺序,即:x—>next = p—>next; p—>next = x;
2.删除结点
在节点a和节点b之间删除结点b,b是a的下一结点,p指针指向结点a:p—>next = p—>next—>next;

三、利用“哨兵”简化实现难度
1.什么是“哨兵”?
链表中的“哨兵”结点是解决边界问题的,不参与业务逻辑。如果我们引入“哨兵”结点,则不管链表是否为空,head指针都会指向这个“哨兵”结点。我们把这种有“哨兵”结点的链表称为带头链表,相反,没有“哨兵”结点的链表就称为不带头链表。
2.未引入“哨兵”的情况
如果在p结点后插入一个结点,只需2行代码即可搞定:
new_node—>next = p—>next;
p—>next = new_node;
但,若向空链表中插入一个结点,则代码如下:
if(head == null){
head = new_node;
}
如果要删除结点p的后继结点,只需1行代码即可搞定:
p—>next = p—>next—>next;
但,若是删除链表的最有一个结点(链表中只剩下这个结点),则代码如下:
if(head—>next == null){
head = null;
}
从上面的情况可以看出,针对链表的插入、删除操作,需要对插入第一个结点和删除最后一个结点的情况进行特殊处理。这样代码就会显得很繁琐,所以引入“哨兵”结点vv来解决这个问题。
3.引入“哨兵”的情况
“哨兵”结点不存储数据,无论链表是否为空,head指针都会指向它,作为链表的头结点始终存在。这样,插入第一个结点和插入其他结点,删除最后一个结点和删除其他结点都可以统一为相同的代码实现逻辑了。
4.“哨兵”还有哪些应用场景?
这个知识有限,暂时想不出来呀!但总结起来,哨兵最大的作用就是简化边界条件的处理。

四、重点留意边界条件处理
经常用来检查链表是否正确的边界4个边界条件:
1.如果链表为空时,代码是否能正常工作?
2.如果链表只包含一个结点时,代码是否能正常工作?
3.如果链表只包含两个结点时,代码是否能正常工作?
4.代码逻辑在处理头尾结点时是否能正常工作?

五、举例画图,辅助思考
核心思想:释放脑容量,留更多的给逻辑思考,这样就会感觉到思路清晰很多。

六、多写多练,没有捷径

 

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值