lusiqi

数据结构-链表,介绍链表的数据结构,及底层实现。


简介

与数组相比,链表不需要一块连续的内存空间,它通过指针将一组零散的内存块串联起来使用。

分类

链表结构五花八门,常见的链表结构有:单链表、双向链表和循环链表。

单链表

链表通过指针将一组零散的内存块串联在一起,其中,我们把内存块称为链表的结点,为了将所有的结点串联起来,每个链表的结点处了存储数据外,还需要记录链上的下一个结点的地址,我们把这个记录下一个结点地址的指针叫做后继指针 next。

我们习惯性把第一个结点叫做头结点,把最后一个结点叫做尾结点,其中头结点用来记录链表的基地址,有了它,我们可以遍历整条链表。而尾结点特殊地方是:指针不是指向下一个结点,而是指向一个空地址NULL,表示这是链表上最后一个结点。

插入删除操作

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

链表中插入或者删除一个数据,我们并不需要为了保持内存的连续性而搬移结点,因为链表的存储空间本身就是不连续的。所以在链表中插入和删除一个数据是非常快速的。

只嗯对链表的插入和删除操作,我们只考虑相邻结点的指针改变,所以对应的时间复杂度是O(1)。

但是有利就有弊,链表想要随机访问第k个元素,就没数据那么高效了。因为链表中的数据并非连续存储的,无法像数组那样根据首地址和下标,通过寻址公式直接计算出对应的内存地址。而是根据指针一个结点一个结点一次遍历,知道找到相应的结点。链表的随机访问性能没有数组好,需要O(n)的时间复杂度。

循环链表

循环链表是一种特殊的单链表。实际上,循环链表与单链表的唯一区别就在尾结点,我们知道,单链表的尾结点指向空地址,表示这就是最后的结点了。而循环链表的尾结点是指向头结点的。

与单链表相比,循环链表的优点是从链尾到链头比较方便,当要处理的数据具有唤醒结构特点时,就特别适合循环链表。

双向链表

单链表只有一个方向,结点只有一个后继指针next指向后面的结点;而双向链表,支持两个方向,每个结点不止有一个后继指针next指向后面的结点,还有一个前驱指针prev指向前面的结点。

双向链表需要额外的两个空间来存储后继结点和前驱结点的地址,所以,如果存储同样多的数据,双向链表比单链表占用更多的内存空间,虽然两个指针比较浪费存储空间,但是可以支持双向遍历,这样也带来了双向链表的灵活性。

从结构上来看,双向链表可以支持O(1)时间复杂度的情况下找到前驱结点,正式这样的特点,双向链表在某些情况下的插入、删除等操作比单链表更简单、高效。

删除插入操作

在实际开发过程中,链表中删除一个数据有两种情况:

  • 删除结点中,值等于value的结点
  • 删除给定指针指向的结点

对于第一种情况,单链表和双向链表,为了查找到值等于给定值的结点,都需要从头结点开始一个个依次遍历,知道找到值等于给定值的结点,然后在通过指针操作将其删除。

尽管单纯的删除操作时间复杂度是O(1),但遍历查找的时间是主要的耗时点,对应的时间复杂度为O(n),根据时间复杂度分析中的加法法则,删除值等于给定值结点对应链表的操作的总时间复杂度为O(n)。

对于第二种情况,我们已经找到了要删除的结点,但是删除某个结点q需要知道其前驱结点,而单链表并不支持直接获取前驱结点,我们还是要从头结点开始遍历链表,知道p->next=q,说明p是q的前驱结点。

但是对于双向链表来说,这种情况就比较有优势了,因为双向链表中的结点已经保存了前驱结点的指针,不需要像单链表那样遍历,所以针对第二种情况,单链表删除操作需要O(n)的时间复杂度,而双向链表只需要O(1)的时间复杂度内就搞定了。

同理。如果我们希望在链表的某个指定结点前面插入一个结点,双向链表就有很大的优势了,双向链表可以在O(1)时间复杂度内就搞定了,而单链表需要O(n)的时间复杂度。Java语言中,LinkedHashMap容器就使用了双向链表的数据结构。

这里有一个更加需要掌握的,就是用空间换时间的设计思想,当内存空间充足时,如果更加追求代码的执行速度,我们可以选择空间复杂度相对较高、但时间复杂度相对较低的算法或者数据结构,相反,内存比较紧张,反过来采用时间换空间的设计思路。

对比数组

时间复杂度对比:

时间复杂度 数组 链表
插入删除 O(n) O(1)
随机访问 O(1) O(n)

数组简单易用,在实现上使用的是连续的内存空间,可以借助CPU的缓存机制,预读数组中的数据,所以访问效率高,而链表在内存中并不是连续存储,所以对CPU缓存不友好。

数组的缺点是大小固定,一经声明就要占用整块内存空间,如果声明的数组过大,系统可能没有足够的连续的内存空间分配给它,导致内存不足;如果声明的数组过小,则可能出现不够用的情况,这时只能在申请一个更大的内存空间,把原数据拷贝进去,非常费时,链表本身没有大小的限制,天然的支持动态扩容,这是链表与数组最大的区别。

如果你的代码对内存使用非常苛刻,数组就更适合你,因为链表中的每个结点都需要消耗额外的空间去存储下一个结点的指针,所以内存消耗会翻倍。而且对链表进行频繁的插入、删除操作,还会导致频繁的内存申请和释放,容易造成内存碎片,就可能导致频繁的GC。

实现LRU缓存淘汰算法

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

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

这样就用链表实现了一个LRU缓存。缓存访问的时间复杂度为O(n)

 评论