数据结构与算法-----链表

链表

链表经典应用场景

LRU缓存淘汰算法是链表的一个经典应用场景,缓存是一种提高数据读取性能的技术,缓存大小有限,当缓存被用满时,哪些数据应该被清理出去,哪些数据应该被保留?这就需要缓存淘汰策略来决定。常见的策略有三种:先进先出策略 FIFO(First In,First Out)、最少使用策略 LFU(Least Frequently Used)、最近最少使用策略 LRU(Least Recently Used)

开篇问题:如何用链表来实现 LRU 缓存淘汰策略呢?

链表结构

上节课学习了数组,知道它是需要一块连续的内存空间来存储的,但是它有个缺点:如果我们申请一个 100MB 大小的数组,当内存中没有连续的、足够大的存储空间时,即便内存的剩余总可用空间大于 100MB,仍然会申请失败。

链表就解决了这个问题,它并不需要一块连续的内存空间,它通过“指针”将一组零散的内存块串联起来使用,所以如果我们申请的是 100MB 大小的链表,根本不会有问题。
在这里插入图片描述
链表结构有3种:单链表,双向链表和循环链表

单链表:

在这里插入图片描述
我们把内存块称为链表的“结点”,每个链表的结点除了存储数据之外,还需要记录链上的下一个结点的地址。我们把这个记录下个结点地址的指针叫作后继指针 next
第一个结点叫作头结点,最后一个结点叫作尾结点,其中,头结点用来记录链表的基地址,而尾结点特殊的地方是:指针不是指向下一个结点,而是指向一个空地址 NULL,表示这是链表上最后一个结点。

查找、插入与删除
  • 链表插入或删除一个数据,不需要保持内存连续性,因此只需要考虑相邻结点的指针改变,所以对应的时间复杂度是 O(1)
    在这里插入图片描述
  • 但是,链表要想随机访问第 k 个元素,就没有数组那么高效了,因为不是连续存储,无法通过下标寻找,只能根据指针一个结点一个结点地依次遍历,直到找到相应的结点。需要 O(n) 的时间复杂度
  • 形象举例:链表像个队伍,每个人只知道后面的人是谁,要找第k个人,就得一个一个地往下数

循环链表(特殊的单链表)

它跟单链表唯一的区别就在尾结点。循环链表的尾结点指针是指向链表的头结点
在这里插入图片描述
和单链表相比,循环链表的优点是从链尾到链头比较方便。
适合处理环型结构的数据,例如约瑟夫问题

双向链表

双向链表支持两个方向,每个结点不止有一个后继指针 next 指向后面的结点,还有一个前驱指针 prev 指向前面的结点。
在这里插入图片描述
双向链表需要额外的两个空间来存储后继结点和前驱结点的地址,所以双向链表要比单链表占用更多的内存空间,但是提供了操作的灵活性。
双向链表可以支持 O(1) 时间复杂度的情况下找到前驱结点,使双向链表在某些情况下的插入、删除等操作都要比单链表简单、高效。刚讲到单链表的插入、删除操作的时间复杂度已经是 O(1) 了,双向链表还能再怎么高效呢?其实很多数据结构和算法书籍中都会这么讲,但是这种说法实际上是不准确的,或者说是有先决条件的。

  1. 删除操作
  • 删除结点中“值等于某个给定值”的结点

对于第一种情况,不管是单链表还是双向链表,为了查找到值等于给定值的结点,都需要从头结点开始一个一个依次遍历对比,直到找到值等于给定值的结点,然后再通过我前面讲的指针操作将其删除。遍历查找比较耗时,所以复杂度为O(n)

  • 删除给定指针指向的结点
    对于第二种情况,我们已经找到了要删除的结点,但是删除某个结点 q 需要知道其前驱结点,而单链表并不支持直接获取前驱结点,所以,为了找到前驱结点,我们还是要从头结点开始遍历链表,直到 p->next=q,说明 p 是 q 的前驱结点。
    双链表就不需要遍历,所以单链表删除操作需要 O(n),而双向链表只需要O(1)
  1. 插入操作
    如果我们希望在链表的某个指定结点前面插入一个结点,双向链表比单链表有很大的优势。双向链表可以在 O(1) 时间复杂度搞定,而单向链表需要 O(n) 的时间复杂度。

对于有序链表,双向链表的按值查询的效率也要比单链表高一些。因为,我们可以记录上次查找的位置 p,每次查询时,根据要查找的值与 p 的大小关系,决定是往前还是往后查找,所以平均只需要查找一半的数据

空间换时间

尽管双向链表比较费内存,不过应用比单链表更广泛,因为用到了用空间换时间的设计思想。当内存空间充足的时候,如果我们更加追求代码的执行速度,我们就可以选择空间复杂度相对较高、但时间复杂度相对很低的算法或者数据结构。相反,如果内存比较紧缺,比如代码跑在手机或者单片机上,这个时候,就要反过来用时间换空间的设计思路。

双向循环链表

在这里插入图片描述

链表 VS 数组 【性能比拼】

在这里插入图片描述
除了时间复杂度,还要从其他方面比较。

  • CPU缓存友好性
    数组可以借助CPU缓存机制,预读数组中的数据,提高访问效率,1
    链表在内存中不是连续存储,对CPU缓存不友好,无法有效预读

  • 内存大小限制
    数组的大小固定了,声明的数组过大过小都会导致内存问题,而且扩容时,需要拷贝原数组,非常费时
    链表没有大小限制,天然支持动态扩容

如若你的代码对内存有很苛刻的要求,推荐使用数组,因为你要消耗额外的存储空间去存储指针,而且,对链表进行频繁的插入、删除操作,还会导致频繁的内存申请和释放,容易造成内存碎片,如果是 Java 语言,就有可能会导致频繁的 GC(Garbage Collection,垃圾回收)。

解答开篇问题

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

  1. 如果此数据之前已经被缓存在链表中了,我们遍历得到这个数据对应的结点,并将其从原来的位置删除,然后再插入到链表的头部。
  2. 如果此数据没有在缓存链表中,又可以分为两种情况:
  • 如果此时缓存未满,则将此结点直接插入到链表的头部;
  • 如果此时缓存已满,则链表尾结点删除,将新的数据结点插入链表的头部

这个方法的时间复杂度时O(n),因为需要遍历一遍链表,其实可以继续优化这个思路,比如使用散列表hash table记录每个数据的位置,将缓存访问的时间复杂度降到 O(1)

课后思考

如何利用数组实现 LRU 缓存淘汰策略呢?

我的想法:
学习上面的做法,因为数组是有序的,所以来了新数据,就直接遍历数组,查找这个数据在哪个位置,如果找到了,就把数组中的数据移动最末尾,如果内存没满又没找到,就直接插入数组末尾,满了,就删除第一个数据,全部前移,然后插入新数据。这几个条件都使用到时间复杂度O(n)

如果字符串是通过单链表来存储的,那该如何来判断是一个回文串呢?

我的想法(硬干法):
一个指针从头取data,另一个指针遍历到底取data,比较二者,要O(n^2)

大神想法:
使用快慢两个指针找到链表中点,慢指针每次前进一步,快指针每次前进两步。在慢指针前进的过程中,同时修改其 next 指针,使得链表前半部分反序。最后比较中点两侧的链表是否相等。 时间复杂度:O(n), 空间复杂度:O(1)

执行思路:

  • 1快慢指针定位中间节点(这里要区分奇偶情况)
    • 1.1 奇数情况,中点位置不需要矫正
    • 1.2 偶数情况,使用偶数定位中点策略,要确定是返回上中位数或下中位数
      • 1.2.1 如果是返回上中位数,后半部分串头取next
      • 1.2.2 如果是返回下中位数,后半部分串头既是当前节点位置,但前半部分串尾要删除掉当前节点
  • 2 从中间节点对后半部分逆序,或者将前半部分逆序
  • 3 一次循环比较,判断是否为回文
  • 4 恢复现场

如何轻松写出链表代码?

技巧一:理解指针或引用的含义

我相信很多人初学链表的时候,就觉得指针很难理解,特别是有些语言有“指针”的概念,比如 C 语言;有些语言没有指针,取而代之的是“引用”,比如 Java、Python。你会在编写中很容易弄混,其实不管是“指针”还是“引用”,实际上,它们的意思都是一样的,都是存储所指对象的内存地址

注:C语言的指针,在其他没有指针的语言中,我们就直接理解成“引用”就好了=

指针的理解:

将某个变量赋值给指针,实际上就是将这个变量的地址赋值给指针,或者反过来说,指针中存储了这个变量的内存地址,指向了这个变量,通过指针就能找到这个变量。

在编写链表代码的时候,我们经常会有这样的代码:p->next=q。这行代码是说,p 结点中的 next 指针存储了 q 结点的内存地址

还有一个经常用到的,p->next=p->next->next。这行代码表示,p 结点的 next 指针存储了 p 结点的下下一个结点的内存地址。

技巧二:警惕指针丢失和内存泄漏

写链表代码的时候,指针指来指去,一会儿就不知道指到哪里了。所以,我们在写的时候,一定注意不要弄丢了指针。
在这里插入图片描述
我们希望在a和b之间插入结点x,假设当前指针 p 指向结点 a。如果我们将代码实现变成下面这个样子,就会发生指针丢失和内存泄露

p->next = x;  // 将 p 的 next 指针指向 x 结点;
x->next = p->next;  // 将 x 的结点的 next 指针指向 b 结点;

初学者经常会在这儿犯错。p->next 指针在完成第一步操作之后,已经不再指向结点 b 了,而是指向结点 x。第 2 行代码相当于将 x 赋值给 x->next,自己指向自己。因此,整个链表也就断成了两半,从结点 b 往后的所有结点都无法访问到了。C 语言,内存管理是由程序员负责的,如果没有手动释放结点对应的内存空间,就会产生内存泄露。

所以,在插入和删除节点时,一定要注意操作顺序,手动释放内存空间
正确答案应该是先将结点 x 的 next 指针指向结点 b,再把结点 a 的 next 指针指向结点 x,这样才不会丢失指针,导致内存泄漏。把两个语句顺序颠倒一下即可。
有个点必须要理解到位,就是在一开始,p->next其实就是b的内存地址

技巧三:利用哨兵简化实现难度

没有哨兵的时候,单链表的插入删除操作:

#如果我们在结点 p 后面插入一个新的结点,下面两行代码即可

new_node->next = p->next;
p->next = new_node;
#空链表中插入第一个结点,需要特殊处理,head 表示链表的头结点

if (head == null) {
  head = new_node;
}
#如果要删除结点 p 的后继结点,我们只需要一行代码就可以搞定。

p->next = p->next->next; 
#删除链表中的剩下的最后一个结点(头结点),特殊处理

if (head->next == null) {
   head = null;
}

上面想表达的是,针对链表的插入、删除操作,需要对插入第一个结点和删除最后一个结点的情况进行特殊处理。这样代码实现起来就会很繁琐,不简洁,而且也容易因为考虑不全而出错。如何来解决这个问题呢?
就是添加哨兵,解决边界问题,不参与业务逻辑

当我们表示空链表的时候,是用head=null来表示链表没有结点了,head表示头结点的指针,指向第一个结点。如果我们引入哨兵,不管链表是否为空,head 指针都会一直指向这个哨兵结点。这种叫带头链表,没有哨兵结点叫不带头链表
在这里插入图片描述
实际上,这种利用哨兵简化编程难度的技巧,在很多代码实现中都有用到,比如插入排序、归并排序、动态规划等。下面用C语言举一些比较简单的例子:

 在数组 a 中,查找 key,返回 key 所在的位置
 其中,n 表示数组 a 的长度
int find(char* a, int n, char key) {
   //边界条件处理,如果 a 为空,或者 n<=0,说明数组中没有数据,就不用 while 循环比较了
  if(a == null || n <= 0) {
    return -1;
  }
  
  int i = 0;
  // 这里有两个比较操作:i<n 和 a[i]==key.
  while (i < n) {
    if (a[i] == key) {
      return i;
    }
    ++i;
  }
  
  return -1;
}
 在数组 a 中,查找 key,返回 key 所在的位置
 其中,n 表示数组 a 的长度
 我举 2 个例子,你可以拿例子走一下代码
 //a = {4, 2, 3, 5, 9, 6}  n=6 key = 7
// a = {4, 2, 3, 5, 9, 6}  n=6 key = 6
int find(char* a, int n, char key) {
  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 数组中的内容
  char tmp = a[n-1];
  // 把 key 的值放到 a[n-1] 中,此时 a = {4, 2, 3, 5, 9, 7}
  a[n-1] = key;
  
  int i = 0;
  // while 循环比起代码一,少了 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;
  }
}

对比两段代码,在字符串 a 很长的时候,比如几万、几十万,你觉得哪段代码运行得更快点呢?答案是代码二,因为两段代码中执行次数最多就是 while 循环那一部分。第二段代码中,我们通过一个哨兵 a[n-1] = key,成功省掉了一个比较语句 i<n,不要小看这一条语句,当累积执行万次、几十万次时,累积的时间就很明显了。 当然,这只是为了举例说明哨兵的作用,你写代码的时候千万不要写第二段那样的代码,因为可读性太差了。大部分情况下,我们并不需要如此追求极致的性能。

技巧四:重点留意边界条件处理

软件开发中,代码在一些边界或者异常情况下,最容易产生 Bug。链表代码也不例外。要实现没有 Bug 的链表代码,一定要在编写的过程中以及编写完成之后,检查边界条件是否考虑全面,以及代码在边界条件下是否能正确运行。可以从下面4个问题去判断边界条件下是否能正确运行:

  • 如果链表为空时,代码是否能正常工作?
  • 如果链表只包含一个结点时,代码是否能正常工作?
  • 如果链表只包含两个结点时,代码是否能正常工作?
  • 代码逻辑在处理头结点和尾结点的时候,是否能正常工作?
技巧五:举例画图,辅助思考

对于稍微复杂的链表操作,比如前面我们提到的单链表反转,指针一会儿指这,一会儿指那,一会儿就被绕晕了。总感觉脑容量不够,想不清楚。可以尝试一下举例法画图法
比如往单链表中插入一个数据这样一个操作,一般都是把各种情况都举一个例子,画出插入前和插入后的链表变化,如图所示:
在这里插入图片描述

技巧六:多写多练,没有捷径

一开始学,不要着急,肯定会有各种错误,自己多写几遍,一点一点调试,熟能生巧嘛~
练手题目:

  • 单链表反转 leetcode206
  • 链表中环的检测 leetcode141
  • 两个有序的链表合并 leetcode21
  • 删除链表倒数第 n 个结点 leetcode19
  • 求链表的中间结点 leetcode876

  1. CPU在从内存读取数据的时候,会先把读取到的数据加载到CPU的缓存中。而CPU每次从内存读取数据并不是只读取那个特定要访问的地址,而是读取一个数据块(这个大小我不太确定。。)并保存到CPU缓存中,然后下次访问内存数据的时候就会先从CPU缓存开始查找,如果找到就不需要再从内存中取。这样就实现了比内存访问速度更快的机制,也就是CPU缓存存在的意义:为了弥补内存访问速度过慢与CPU执行速度快之间的差异而引入。 对于数组来说,存储空间是连续的,所以在加载某个下标的时候可以把以后的几个下标元素也加载到CPU缓存这样执行速度会快于存储空间不连续的链表存储。 ↩︎

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值