End

数据结构与算法之美-2 数组和链表

本文地址


目录

基础篇 (38讲)

05 | 数组:为什么很多编程语言中数组都从0开始编号?

思考:为什么数组要从 0 开始编号,而不是从 1 开始呢?

如何实现随机访问?

数组(Array)是一种线性表数据结构。它用一组连续的内存空间,来存储一组具有相同类型的数据。

线性表

顾名思义,线性表就是数据排成像一条线一样的结构。每个线性表上的数据最多只有前和后两个方向。除了数组,链表队列等也是线性表结构。

而与它相对立的概念是非线性表,比如二叉树等。在非线性表中,数据之间并不是简单的前后关系。

连续的内存空间和相同类型的数据

第二个是连续的内存空间和相同类型的数据。

正是因为这两个限制,它才有了一个堪称杀手锏的特性:随机访问

这两个限制也让数组的很多操作变得非常低效,比如要想在数组中删除、插入一个数据,为了保证连续性,就需要做大量的数据搬移工作。

数组是如何实现根据下标随机访问数组元素的

例如数组 int[] a = new int[10],计算机给数组分配了一块连续内存空间:

计算机会给每个内存单元分配一个地址,计算机通过地址来访问内存中的数据。当计算机需要随机访问数组中的某个元素时,它会首先通过下面的寻址公式,计算出该元素存储的内存地址

a[i]_address = base_address + i * data_type_size

这里我要特别纠正一个错误。我在面试的时候,常常会问数组和链表的区别,很多人都回答说,“链表适合插入、删除,时间复杂度 O(1);数组适合查找,查找时间复杂度为 O(1)”。实际上,这种表述是不准确的。数组是适合查找操作,但是查找的时间复杂度并不为 O(1)。即便是排好序的数组,你用二分查找,时间复杂度也是 O(logn)。所以,正确的表述应该是,数组支持随机访问,根据下标随机访问的时间复杂度为 O(1)

低效的插入和删除

插入操作的优化

如果数组中存储的数据并没有任何规律(即:无序),数组只是被当作一个存储数据的集合。在这种情况下,如果要将某个数据插入到第 k 个位置,为了避免大规模的数据搬移,我们还有一个简单的办法就是,直接将第 k 位的数据搬移到数组元素的最后,把新的元素直接放入第 k 个位置。

利用这种处理技巧,在特定场景下,在第 k 个位置插入一个元素的时间复杂度就会降为 O(1)。这个处理思想在快排中也会用到。

正常情况,不会使用这种操作,

删除操作的优化

在某些特殊场景下,我们并不一定非得追求数组中数据的连续性。我们可以先记录下已经删除的数据,每次的删除操作并不是真正地搬移数据,只是记录数据已经被删除。当数组没有更多空间存储数据时,我们再触发执行一次真正的删除操作,这样就大大减少了删除操作导致的数据搬移。

这其实就是 JVM 标记清除垃圾回收算法的核心思想。

警惕数组的访问越界问题

数组越界在 C 语言中是一种未决行为,并没有规定数组访问越界时编译器应该如何处理。因为,访问数组的本质就是访问一段连续内存,只要数组通过偏移计算得到的内存地址是可用的(非受限的内存),那么程序就可能不会报任何错误。

容器能否完全替代数组?

ArrayList 最大的优势就是可以将很多数组操作的细节封装起来。比如前面提到的数组插入、删除数据时需要搬移其他数据等。另外,它还有一个优势,就是支持动态扩容

数组本身在定义的时候需要预先指定大小,因为需要分配连续的内存空间

因为扩容操作涉及内存申请和数据搬移,是比较耗时的。所以,如果事先能确定需要存储的数据大小,最好在创建 ArrayList 的时候事先指定容器大小

对于业务开发,直接使用容器就足够了,省时省力。毕竟损耗一丢丢性能,完全不会影响到系统整体的性能。但如果你是做一些非常底层的开发,比如开发网络框架,性能的优化需要做到极致,这个时候数组就会优于容器,成为首选。

解答开篇

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

从数组存储的内存模型上来看,下标最确切的定义应该是偏移(offset)。如果用 a 来表示数组的首地址,a[0] 就是偏移为 0 的位置,也就是首地址,a[k] 就表示偏移 k 个 type_size 的位置。

如果从 1 开始编号,每次随机访问数组元素都多了一次减法运算,对于 CPU 来说,就是多了一次减法指令。

数组作为非常基础的数据结构,通过下标随机访问数组元素又是其非常基础的编程操作,效率的优化就要尽可能做到极致。所以为了减少一次减法操作,数组选择了从 0 开始编号,而不是从 1 开始。

不过,上面解释得再多其实都算不上压倒性的证明,我觉得最主要的原因可能是历史原因。C 语言设计者用 0 开始计数数组下标,之后的 Java、JavaScript 等高级语言都效仿了 C 语言,或者说,为了在一定程度上减少 C 语言程序员学习 Java 的学习成本,因此继续沿用了从 0 开始计数的习惯。实际上,很多语言中数组也并不是从 0 开始计数的,比如 Matlab。甚至还有一些语言支持负数下标,比如 Python。

06 | 链表(上):如何实现LRU缓存淘汰算法?

缓存是一种提高数据读取性能的技术,在硬件设计、软件开发中都有着非常广泛的应用,比如常见的 CPU 缓存、数据库缓存、浏览器缓存等等。

缓存的大小有限,当缓存被用满时,哪些数据应该被清理出去,哪些数据应该被保留?这就需要缓存淘汰策略来决定。常见的策略有三种:先进先出策略 FIFO(First In,First Out)、最少使用策略 LFU(Least Frequently Used)、最近最少使用策略 LRU(Least Recently Used)。

五花八门的链表结构

数组需要一块连续的内存空间来存储,对内存的要求比较高。如果我们申请一个 100MB 大小的数组,当内存中没有连续的、足够大的存储空间时,即便内存的剩余总可用空间大于 100MB,仍然会申请失败。

而链表恰恰相反,它并不需要一块连续的内存空间,它通过“指针”将一组零散的内存块串联起来使用,所以如果我们申请的是 100MB 大小的链表,根本不会有问题。

三种最常见的链表结构:

  • 单链表
    • 把第一个结点叫作头结点,把最后一个结点叫作尾结点
    • 头结点用来记录链表的基地址,而尾结点指向一个空地址 NULL
  • 双向链表:
    • 双向链表支持两个方向,后继指针 next 指向后面的结点,前驱指针 prev 指向前面的结点
    • 用空间换时间:虽然两个指针比较浪费存储空间,但可以支持双向遍历,很多操作都比单链表更加高效
    • 找到前驱结点、在某个结点前面插入一个结点的时间复杂度为 O(1)
    • LinkedHashMap 中就用到了双向链表
  • 循环链表:
    • 循环链表的尾结点指针指向链表的头结点
    • 循环链表的优点是从链尾到链头比较方便。适合处理具有环型结构特点时数据,比如著名的约瑟夫问题

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

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

因为链表中的数据并非连续存储的,所以无法像数组那样,根据首地址和下标,通过寻址公式就能直接计算出对应的内存地址,而是需要根据指针一个结点一个结点地依次遍历,直到找到相应的结点。

链表 VS 数组性能大比拼

  • 数组简单易用,在实现上使用的是连续的内存空间,可以借助 CPU 的缓存机制,预读数组中的数据,所以访问效率更高。而链表在内存中并不是连续存储,所以对 CPU 缓存不友好,没办法有效预读。
  • 数组的缺点是大小固定,一经声明就要占用整块连续内存空间。如果声明的数组过大,系统可能没有足够的连续内存空间分配给它,导致内存不足。如果声明的数组过小,则可能出现不够用的情况。这时只能再申请一个更大的内存空间,把原数组拷贝进去,非常费时。链表本身没有大小的限制,天然地支持动态扩容,我觉得这也是它与数组最大的区别。
  • 因为链表中的每个结点都需要消耗额外的存储空间去存储一份指向下一个结点的指针,所以内存消耗会翻倍。而且,对链表进行频繁的插入、删除操作,还会导致频繁的内存申请和释放,容易造成内存碎片,如果是 Java 语言,就有可能会导致频繁的 GC。

解答开篇

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

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

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

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

内容小结

和数组相比,链表更适合插入、删除操作频繁的场景,查询的时间复杂度较高。不过,在具体软件开发中,要对数组和链表的各种性能进行对比,综合来选择使用两者中的哪一个。

07 | 链表(下):如何轻松写出正确的链表代码?

想要写好链表代码并不是容易的事儿,比如链表反转有序链表合并等,写的时候非常容易出错。从我上百场面试的经验来看,能把链表反转这几行代码写对的人不足 10%

几个写链表代码的技巧

理解指针或引用的含义

Java 中可以把它理解成引用

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

警惕指针丢失和内存泄漏

  • 插入结点时,一定要注意操作的顺序
  • 删除链表结点时,也一定要记得手动释放内存空间(Java 中不需要关注)

利用哨兵简化实现难度

  • 针对链表的插入、删除操作,需要对插入第一个结点删除最后一个结点的情况进行特殊处理。这样代码实现起来就会很繁琐,不简洁,而且也容易因为考虑不全而出错。
  • 如果我们引入哨兵结点,在任何时候,不管链表是不是空,head 指针都会一直指向这个哨兵结点。
  • 这种利用哨兵简化编程难度的技巧,在很多代码实现中都有用到,比如插入排序归并排序动态规划等。

哨兵可以理解为它可以【减少特殊情况的判断】,比如判空,比如判越界。

空与越界可以认为是小概率情况,所以代码每一次操作都走一遍判断,在大部分情况下都会是多余的。

哨兵的巧妙就是【提前】将这种情况去除,比如给一个哨兵结点,以及将key赋值给数组末元素,让数组遍历不用判断越界也可以因为相等停下来。

使用哨兵的指导思想应该是【将小概率需要的判断先提前扼杀】,比如提前给他一个值让他不为null,或者提前预设值,或者多态的时候提前给个空实现,然后在每一次操作中不必再判断以增加效率。

重点留意边界条件处理

  • 如果链表为时,代码是否能正常工作?
  • 如果链表只包含一个结点时,代码是否能正常工作?
  • 如果链表只包含两个结点时,代码是否能正常工作?
  • 代码逻辑在处理头结点和尾结点的时候,是否能正常工作?

举例画图,辅助思考

比如往单链表中插入一个数据

多写多练,没有捷径

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

内容小结

写链表代码是最考验逻辑思维能力的

实际上,写链表代码还是主要为了锻炼写代码的能力,倒不是思考解决办法。像环的检测这种解决办法一般都是想不出来,都是看了答案之后才恍然大悟。

2021-07-25

posted @ 2021-07-25 21:15  白乾涛  阅读(265)  评论(0编辑  收藏  举报