链表与数组

73 阅读11分钟

初识

广义上说:数据结构:一组存储数据的结构。算法:操作数据结构的一组方法

10种数据结构:数组、链表、栈、队列、散列表、二叉树、堆、跳表、图、Trie树

10个算法:递归、排序、二分查找、搜索、哈希算法、贪心算法、分治算法、回溯算法、动态规划、字符串匹配算法

Q: 为什么大多数编程语言中,数组要从0开始编号,而不是1开始呢?

A: 从数组存储的内存模型来看,“下标”最确切的定义应该是“偏移(offset)”。如果用a来表示数组的首地址,a[0]就是偏移为0的位置,也就是首地址,a[k]就表示偏移k个type_size的位置,所以计算a[k]的内存地址只需要用这个公式a[k]_address = base_address + k * type_size;如果要是从1开始计算,那么该公式会变为:a[k]_address = base_address + (k - 1) * type_size;从1号开始编号,每次随机访问数组元素都多了一次减法运算,对于cpu来说,就是多了一次减法指令。

链表Linked list(上):如何实现LRU缓存淘汰算法

常见的缓存淘汰策略有三种:先进先出策略FIFO(First In,First Out)、最少使用策略LFU(Least Frequently Used)、最近最少使用策略LRU(Least Recently Used)。 常见链表结构:单链表、双向链表和循环链表。

单链表

链表通过指针将一组零散的内存块串联在一起,其中,我们把内存块称为链表的“结点”,为了将所有的结点串联起来,每个链表的结点除了存储数据之外,还需要记录链上的下一个结点的地址,我们把这个记录下一个结点地址的指针叫作后继指针next .....--[data][next]-->[data][next]-->......,我们习惯的把第一个结点叫作头结点,把最后一个结点叫作尾结点,尾结点特殊的地方是:指针不是指向下一个节点,而是指向一个空地址NULL,表示这是链表上最后一个结点。

循环链接

循环链表是一种特殊的单链表。它和单向链表唯一的区别就是尾结点,单链表的尾结点指针指向空地址,表示这就是最后的结点了。而循环链表的尾结点指针是指向链表的头结点,它像一个环一样首尾相连。循环链表的优点是从链尾到链头比较方便。当要处理的数据具有环型结构特点时,就特别采用循环链表。比如著名的约瑟夫问题

双向链表

单向链表只有一个方向,结点只有一个后继指针next,而双向链表支持两个方向,每个结点不止有一个后继指针next指向后面的结点,还有一个前驱指针prev指向前面的结点。双向链表需要额外的两个空间来存储后继结点前驱结点的地址,所以存储同样多的数据,双向链表要比单向链表占用更多的内存空间。虽然两个指针比较浪费存储空间,但可以支持双向遍历,这样子也带来了双向链表操作的灵活性

Q: 双向链表适合解决哪种问题呢?

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


下面我们讲一下链表的删除操作

删除操作

从链表中删除一个数据无外乎这两种情况:

  • 删除结点中“值等于某个给定值”的结点
  • 删除给定指针指向的结点

对于第一种情况,不管是单链表还是双向链表,为了查找到值等于给定值的结点,都需要从头结点开始一个一个依次遍历对比,直到找到值等于给定值的结点,然后再通过指针操作将其删除。尽管单纯的删除操作时间复杂度是O(1),但遍历查找的时间是主要的耗时点,对应的时间复杂度为O(n)。根据时间复杂度分析的加法法则,删除值等于给定值的结点对应的链表操作的总的时间复杂度为O(n)。

对于第二种情况,我们已经找到了要删除的结点,但是删除某个结点q需要找到其前驱结点,而单链表并不支持直接获取前驱结点,所以,为了找到前驱结点,我们还是要从头结点开始遍历链表,直到p->next=q,说明p是q的前驱结点;但是对于双向链表,因为双向链表已经保存了前驱结点的指针,不需要像单链表那样子遍历,所以针对第二种情况,单链表删除O(n)的时间复杂度,而双向链表只需要O(1)的时间复杂度。

同理,在链表的某个指定结点前面插入一个结点,双向链表比单链表有很大的优势。双向链表可以在O(1)时间复杂度搞定,而单链表需要O(n)时间复杂度。

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


缓存实际上就是利用了空间换时间的设计思想。如果我们把数据存储在硬盘上,会比较节省内存,但每次查找数据都要询问一次硬盘,会比较慢。但如果我们通过缓存技术,实现将数据加载在内存中,虽然会比较耗费内存空间,但是每次数据查询的速度就大大提高了。所以对于执行较慢的程序,可以通过消耗更多的内存(空间换时间)来进行优化;而消耗过多内存的程序,可以通过消耗更多的时间(时间换空间)来降低内存的消耗。


与数组一样,链表也支持数据的查找、插入和删除操作。在进行数组的插入、删除操作时,为了保持内存数据的连续性,需要做大量的数据搬移,所以时间复杂度是O(n)。而在链表中插入或者删除一个数据的时候,并不需要为了保持内存的连续性而搬移结点,因为链表的存储本身就不是连续的,所以链表中的插入和删除一个数据是非常快速的。但是有利有弊,链表想要随机访问第k个元素,就没有数组那么高效了。因为链表的数据并非连续存储的,所以不能像数组那样根据首地址和下班。通过寻址公式就能直接计算出对应的内存地址,而是需要根据指针的一个结点一个结点地依次遍历,直到找到对应的结点。链表随机访问的性能没有数组好,需要O(n)的时间复杂度

数组和链表都是非常基础的也是非常常用的数据结构,那么这两个有什么区别呢?

Q:数组和链接区别?

A:从底层的存储结构:数组需要一块连续的内存空间来存储,对内存的要求比较高;链表恰恰相反,它不需要一块连续的内存空间,它通过“指针”将一组零散的内存块串联起来使用

Q:链表 VS 数组性能大比拼

A: 数组和链表是两种截然不同的内存组织方式。正是因为内存存储的区别,它们的插入、删除、随机访问操作的时间复杂度正好相反。数组简单易用,在实现上使用的是连续的内存空间,可以借助CPU的缓存机制,预读数组中的数据,所以访问效率更高。而链表在内存中并不是连续存储,所以对CPU缓存不友好,没办法有效的预读。(CPU在内存中读取数据的时候,会先把读取的数据加载到CPU缓存中。而CPU每次从内存读取数据并不是只读取那个特定要访问的地址,而是读取一个数据块并保存到CPU缓存中,然后下次访问的时候,会先从CPU缓存开始查找,然后找不到就不需要再从内存中读取,这样子就实现了比内存访问更快的机制,也就是CPU缓存存在的意思,为了弥补内存访问速度过慢与CPU执行速度快之间的差异而引入。对于数组来说,存储空间是连续的,所以在加载某个下标的时候可以把以后的几个下标元素也加载到CPU缓存中这样执行速度会快于存储空间不连续的链表存储)

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

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

Q:如何基于链表实现LRU缓存淘汰算法?

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

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

Q:如何基于数组实现LRU缓存淘汰算法?

A:1. 方法一:首位置保存最新访问数据,末尾位置优先清理。当访问的数据未存在于缓存的数组中,直接将数据插入数组第一个元素位置,此时数组所有元素都需要向后移动一个位置,时间复杂度为O(n);当访问的数据存在于缓存的数组中时,查找到数据并将其插入数组的第一个位置,此时也需要移动数组元素,时间复杂度也为O(n)。缓存用满时,则清理末尾的数据,时间复杂度为O(1)。

  1. 方法二:首位置优先清理,末尾位置保存最新访问数据。当访问的数据未存在缓存的数组中,直接将数据添加进数组作为最后一个元素,时间复杂度为O(1);当访问的数据存在于缓存的数组中时,查找到数据并将其插入到当前数组的最后一个位置,此时需要移动数组元素,时间复杂度为O(n);缓存满的时候,需要清理首位,然后数组元素整体前移,时间复杂度为O(n);(优化,清理的时候可以考虑一次性清理一定数量,从而降低清理次数,提高性能)

因为不管缓存满没有满,我们都需要遍历一遍链表,所以这种基于链表的实现思路,缓存访问的时间复杂度为O(n)。

实际上我们可以继续优化这种思路,比如引入散列表(Hash table)来记录每个数据的位置,将缓存访问的时间复杂度降到O(1)。

总结

和数组相比,链表更适合插入、删除操作频繁的场景,查询的时间复杂度较高。具体选择数组还是链表在实际开发中综合选择。

相关问题

如何判断一个字符串是否是回文字符串? leetcode.cn/problems/va…