《数据结构与算法之美》学习笔记三之链表

前言:先回顾一下本系列上一篇文章的大致内容
  • 二叉树
    1、二叉树的分类:满二叉树(所有节点都是满满的)、完全二叉树(只有最后一层不满,且最后一层节点都在左边)、二叉搜索树(有顺序)、平衡二叉树(左右子树高度差不超过1或者一棵空树)
    2、二叉树的遍历:深度(前中后序)遍历与层序遍历
  • 数组
    1、数组的定义:内存空间中连续地址存储的一系列相同类型的数据
    2、特性:随机访问快、增删慢
    3、寻址公式:a[i]_address = base_address + i * data_type_size
    4、线性表(数组、队列、栈、链表)与非线性表(图、树)

这一篇文章首先介绍一下数组的一个孪生兄弟–链表,以及另一对双胞胎:栈与队列

一、链表

(一)三种链表

1、链表的存储结构

链表也是一种线性表,在内存中并不需要连续的内存空间,结点之间通过指针来实现线性结构。
和数组相比,这种内存存储方式有一个很大的优势,存储相同多的数据时,如果内存中没有一块足够大的连续的空间,但是有足够大的分散的空间,那么用数组就存不下了,用链表就可以充分利用内存中剩余的分散空间。
在这里插入图片描述

2、单链表

单链表就是最最最简单的链表,结点上除了保存当前数据,还需要通过指针串联起来,这个指针我们称为后继指针 next
链表中的最开始的结点,通常称之为 头结点,最后一个结点,通常称之为 尾结点。头结点记录了链表的基地址,有了它,我们就可以通过遍历获取整个链表。尾结点的 next 指向 null。
在这里插入图片描述

3、循环链表

循环链表和单链表只有一丢丢不一样的地方,单链表的尾结点的 next 指针指向的是 null,而循环链表的尾结点的 next 指针指向的是头结点
在这里插入图片描述

循环链表的优势就是从尾结点到头结点很方便,当数据具有环形结构的特性时,比较适合使用循环链表。

4、双向链表

单链表只有一个方向,只能往后走,而双向链表,拥有两个指针,能前进还能后退。其中 prev 指针指向上一个结点,next 指针指向下一个结点。
在这里插入图片描述
从图中可以看出,比起单链表,双向链表的结点还需要多一个空间来存放 prev 指针,这会占用更多的内存,但可以支持双向遍历,可以在 O(1) 时间复杂度下找到前驱结点。

(二)增删查性能分析

1、增和删

我们知道链表的前后顺序,就是通过结点的 next 指针规定的,如果要在 ab 结点直接增加一个结点 c,只需要将 a.next 指向 c 结点,并且将 c.next 指向 b 结点即可,因此只需要进行常量级别的操作即可实现增加结点的操作,时间复杂度为 O(1)
删除操作类似,如果要删除 abc 中的 b 节点,只需要将 a.next 指向 c 结点即可,时间复杂度也是 O(1)
在这里插入图片描述

2、查

数组具有连续存储的特点,通过寻址公式,可以在 O(1) 时间复杂度下实现数据的随机访问,但是链表的随机访问就没有这么迅速了,必须从头节点开始遍历,依次找 .next 直到找到目标,所以链表查找元素的时间复杂度为 O(n)

3、双向链表的性能优势

前面我们提到,双向链表在存储上是要占用更多的内存,但是在某些操作场景下,它却拥有着很大的性能优势哦!
例如,我们考虑一下从链表中删除元素的场景。删除元素一般有两种情况
(1)删除结点值等于目标值的结点
(2)删除给定指针指向的结点
针对第一种情况,不管是单链表还是双向链表,都需要从头结点开始往后遍历,直到找到目标结点,所以时间复杂度都是 O(n)
但是对于第二种情况,双向链表就比较有优势了,因为我们已经知道了目标结点,删除一个结点,我们是需要知道这个结点的前驱结点的
在这里插入图片描述
单链表需要从头结点开始遍历,直到找到 a.next == b 时,前驱结点为 a。但是,双向链表就不需要查找,直接就能获取前驱结点。因此这种情况下,使用单链表的时间复杂度为 O(n),而使用双向链表的时间复杂度为 O(1)
同理,如果我们想在一个结点前面插入一个结点,
在这里插入图片描述
想在 c 前面插入 x,直接通过

c.prev.next = x;
x.next = c;
c.prev=x

即可实现,时间复杂度为 O(1)
这里有一个很重要的设计思维,就是 空间换时间。使用双向链表虽然占用更多的空间,但是操作更高效。如果内存空间充足,并且我们追求操作的性能,就可以降低对空间复杂度的要求,以追求更低的时间复杂度;但如果程序运行在单片机或者手机上,可能就需要节约内存空间,可能会造成一些操作性能的降低。

4、链表vs数组性能

由于内存存储方式的区别,链表和数组在插入、删除、随机访问操作中的时间复杂度正好相反
在这里插入图片描述
除了这几个操作的时间复杂度分析,还有一些其他的不同
数组在 CPU 中是连续存储的,可以利用 CPU 的缓存机制预读数据,所以访问效率更高;链表在 CPU 中不是连续存储的,所以对 CPU 的缓存机制不友好,不能预读数据
数组的缺点是固定大小,一经声明就需要占用一整块连续的内存空间,如果申请的内存过大,内存中可能没有那么大的连续空间,就会导致内存不足;如果申请的内存过小,装不下数据,后续增加数据时就需要开辟一个更大的内存空间,并且把数据拷贝过去,数组的拷贝是非常耗时的。而链表没有固定大小,天然支持动态扩容。
但是如果你的程序对内存大小的要求很苛刻,那么数组可能更适合,因为链表结点占用的内存空间更大。
所以在实际开发中,还是需要根据项目的要求,去选择使用数组还是链表。

(三)书写链表的技巧

1、理解指针和引用

有些语言中有指针的概念,有些语言中没有指针的概念,相对应的是引用的概念,不管是指针还是引用,它们的含义都是存储目标对象的内存地址
对于指针的理解,只需要记住这句话:
将一个变量赋值给指针,实际上就是将这个变量的地址赋值给指针。或者反过来说,指针存储了这个变量的内存地址,指向了这个变量,通过指针就能找到这个变量
例如我们在写链表的过程中,总是会看到这样的代码:p.next = q,这句代码的含义就是 p 结点的 next 指针存储了 q 结点的内存地址
还有一个经常会用到的运算:p.next = p.next.next ,即 p 结点的 next 指针,存储 p 结点的下下一个结点的内存地址

2、使用哨兵简化实现难度

我们先来回顾一下在链表中插入一个结点以及删除一个结点的操作
如果我们要在 p 节点后面插入一个结点 newNode,那么代码很简单就可以实现

newNode.next = p.next;
p.next = newNode;

但是如果我们要向空链表中插入第一个节点,就会额外需要一些判断语句

if(head == null) head = newNode;

以及要删除 p 结点后的一个结点时,如果 p 结点不在最后,实现起来也很简单

p.next = p.next.next

但是如果要删除链表中的最后一个结点,也需要进行判断,如果next指针的指向为空,说明链表中仅剩一个结点,此时直接把链表清空即可

if(head.next == null) head = null;

由上述分析得知,当给空链表增加第一个节点和删除链表中的最后一个节点的时候,是需要特殊处理的,这样就可能给我们的运算过程带来些许麻烦。
此时我们就可以引入哨兵结点,放在头结点的前面,哨兵结点起到一个占位的作用,自身并不存储数据,这样就可以避免链表为空的情况,插入头结点和删除最后一个结点的操作就没有什么特殊性了。
在这样的链表中,head 始终指向哨兵结点,这样的链表叫做带头链表
在这里插入图片描述
其实这种做法,刷力扣的话就会发现非常非常的常用,基本上设计链表的都会使用哨兵

3、重点留意边界条件处理

重点留意下面这几个边界条件:

  • 如果链表为空时,代码是否能正常工作?
  • 如果链表只包含一个结点时,代码是否能正常工作?
  • 如果链表只包含两个结点时,代码是否能正常工作?
  • 代码逻辑在处理头结点和尾结点的时候,是否能正常工作?
    不仅仅是处理链表问题,处理一切的编程问题,边界条件都需要认真仔细地检查。
4、举例画图,辅助思考

对于稍微复杂一些的链表操作,可以通过画图来更清晰更直观地分析,例如下面就是一个很好的图例,画出了插入结点 x 的各种情况,插入前和插入后的情况对比
在这里插入图片描述
看着图写代码就会简单多啦!而且我们在写完代码之后,也可以举几个例子,画出来,照着代码走一遍,很容易发现代码中的bug。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值