本次专题文章是对王卓老师的<<数据结构与算法之美>> 05 06 07进行的总结,归纳与思考.
其中本人已经学习过了数据结构相关课程,对于本专栏的笔记记录的更多的是对于耳目一新的说法概念的记录,并不会放许多基础结构的分析.写的文字有的是对于原文的摘抄也有的是本人对于这个点的其它想法,大家注意区分,原文使用斜体表示,本人的看法使用正常字体
当然如果想看比较基础的数组与链表讲解可以看一下这本开源的电子书,关于基础部分讲解还是很详细的.链接
Q:什么是数组呢?
A:我认为数组是一种线性结构,它是使用一段连续的地址空间存储多个类型相同的数据.
重点需要关注的是介绍的三个词语.
一是线性结构,二是连续的,三是类型相同.
Q:数组的优劣
A:数组最大的优点是支持了随机的访问数组的任何一个元素(只要合法)
,它是如何做到的呢?
使用的是起点加上偏移量的方式进行的计算得到需要访问的地址
a[i]_address = base_address + i * data_type_size
相比较之下数组也存在一些缺点,比如最直接的插入与删除操作的时间复杂度通常是O(N)级别的,使用下标访问元素的时间复杂度是O(1),但是其它的访问情况即使是有序+二分查找也是O(logN)的,我们要注意区分下标访问与其它类型访问.
Q:关于数组的一些问题
A:
- 数组的使用是及其容易越界的,一旦边界情况没有控制好就十分容易造成未定义行为,进而造成未知后果.
- 数组的使用虽然麻烦但是在性能上比起后面语言的容器
(指的是封装数组的那种,比如C++的vector)
性能上还是存在优异的,所以在某些对性能要求极为严格的时候可能会选用数组.
!Tip
对于业务开发,直接使用容器就足够了,省时省力。毕竟损耗一丢丢性能,完全不会影响到系统整体的性能。但如果你是做一些非常底层的开发,比如开发网络框架,性能的优化需要做到极致,这个时候数组就会优于容器,成为首选。
- 数组下标为什么是从0开始的呢而不是符合人类的直觉的1呢?
在markdown语法当中*
表示斜体即为下面这段话是摘录过来的.
!answer
*从数组存储的内存模型上来看,"下标"最确切的定义应该是"偏移(offset)"。前面也讲到,如果用a来表示数组的首地址,a[0]就是偏移为0的位置,也就是首地址,a[k]就表示偏移k个type_size的位置,所以计算a[k]的内存地址只需要用这个公式:
a[k]_address = base_address + k * type_size
但是,如果数组从1开始计数,那我们计算数组元素a[k]的内存地址就会变为:
a[k]_address = base_address + (k-1)*type_size
对比两个公式,我们不难发现,从1开始编号,每次随机访问数组元素都多了一次减法运算,对于CPU来说,就是多了一次减法指令。
数组作为非常基础的数据结构,通过下标随机访问数组元素又是其非常基础的编程操作,效率的优化就要尽可能做到极致。所以为了减少一次减法操作,数组选择了从0开始编号,而不是从1开始。*
Q:本节出现的一些值得思考的点.
A:JVM标记清除垃圾回收算法的核心思想,是不是可以用来优化在对数组内存连续性要求不高的场景下对于删除操作的优化呢?直白点,数字删除的优化思想被运用到了GC机制当中去了,而这种结构算法背后的思想才是我们学习的重点.
很多时候我们并不是要去死记硬背某个数据结构或者算法,而是要学习它背后的思想和处理技巧,这些东西才是最有价值的
前面说了数组的随机访问时通过偏移量+起始地址的方式实现的,那么在二维数组当中是如何进行定位的呢?
比如说一个 int A[10][10]
,现在要访问i行j列的元素,那么它对应的地址该如何进行计算?
C
二维数组内存寻址: 对于 m * n 的数组,a [ i ][ j ] (i < m,j < n)的地址为: address = base_address + ( i * n + j) * type_size
这实际揭露了二维数组在内存当中使用行形式进行存储的并且还是一个很长的单行数组.
具体如下
但是值得注意的是,这只是在主流的编译环境上,也有编译器选择按列进行摆放.
Q:链表的经典应用场景
A:实现LRU缓存淘汰算法,
Q:链表又是什么呢?
A:链表和数组一样同样是线性结构但是不同的是数组需要一块连续的内存空间存储数据,二链表是通过指针将一组零散的内存块联系起来,用于存储数据.

Q:链表的类型区分
A:链表的区分主要是根据指针指向的特点来进行区分的.
比如链表所有节点都是同一个指向称为单链表.
循环链表是一种特殊的单链表。实际上,循环链表也很简单。它跟单链表唯一的区别就在尾结点。
双向链表,顾名思义,它支持两个方向,每个结点不止有一个后继指针next指向后面的结点,还有一个前驱指针prev指向前面的结点。
当然这几种链表也可以与环结合起来形成一种循环链表这里就不再过多的演示了.
Q:链表各种操作的时间复杂度如何呢?
A:结论插入:O(1),删除:O(1),查找:O(N)
在链表中插入或者删除一个数据,我们并不需要为了保持内存的连续性而搬移结点,因为链表的存储空间本身就不是连续的。
针对链表的插入和删除操作,我们只需要考虑相邻结点的指针改变,所以对应的时间复杂度是O(1),但是这是已知我们想要删除节点的位置的情况,在一般情况下往往是需要不低的时间复杂度去查找节点的。
当然了这只是在单链表情况下进行的分析,实际使用当中也很少使用单链表,而是选择双向链表,这个链表的空间消耗更大,不过时间的开销是要少一点的。
Q:该如何写好链表的代码呢?
A:主要是六个技巧
1.理解指针(引用)
的含义,本人认为的是指针是存储想要指向变量的地址,通过这个地址就可以访问指向的变量。
-
警惕指针丢失与内存泄漏的问题
-
学会理由哨兵简化代码逻辑和难度
-
留意边界情况的处理,建议在编写完待会后对几种常见的情况进行分析
-
空节点的情况
-
一个节点的情况
-
两个节点的情况
-
对于头尾节点处理的情况
-
举例画图,使用简单的图像辅助分析
-
多学多练,不存在捷径
-
这个技巧是本人的体会,熟练掌握链表的基础操作,比如链表逆序,找中间节点,链表的合并,带环链表的检测.许多的链表题背后都是可以拆解成这几个简单操作的.
推荐的算法题链接(用于熟悉上面提到的操作)
:
876. 链表的中间结点
206. 反转链表
141. 环形链表
21. 合并两个有序链表
19. 删除链表的倒数第 N 个结点
LCR 027. 回文链表 )
Q:链表与数组之间的对比
A:数组和链表是两种截然不同的内存组织方式。正是因为内存存储的区别,它们插入、删除、随机访问操作的时间复杂度正好相反。
数组简单易用,在实现上使用的是连续的内存空间,可以借助CPU的缓存机制,预读数组中的数据,所以访问效率更高。而链表在内存中并不是连续存储,所以对CPU缓存不友好,没办法有效预读。
数组的缺点是大小固定,一经声明就要占用整块连续内存空间。如果声明的数组过大,系统可能没有足够的连续内存空间分配给它,导致"内存不足(out of memory)"。如果声明的数组过小,则可能出现不够用的情况。这时只能再申请一个更大的内存空间,把原数组拷贝进去,非常费时。链表本身没有大小的限制,天然地支持动态扩容,我觉得这也是它与数组最大的区别。
Q:本节出现的一些值得思考的点.
A:
-
关于LRU链表缓存部分,该如何进行对应的设计呢?如何在O(1)的情况下找出需要清理出缓存的数据呢?
缓存是一种提高数据读取性能的技术,在硬件设计、软件开发中都有着非常广泛的应用,比如常见的CPU缓存、数据库缓存、浏览器缓存等等。
缓存的大小有限,当缓存被用满时,哪些数据应该被清理出去,哪些数据应该被保留?这就需要缓存淘汰策略来决定。常见的策略有三种:先进先出策略FIFO(First In,First Out)、最少使用策略LFU(Least Frequently Used)、最近最少使用策略LRU(Least Recently Used)。 -
关于链表背后的思想.
实际上,链表有一个更加重要的知识点需要你掌握,那就是用空间换时间的设计思想。当内存空间充足的时候,如果我们更加追求代码的执行速度,我们就可以选择空间复杂度相对较高、但时间复杂度相对很低的算法或者数据结构。相反,如果内存比较紧缺,比如代码跑在手机或者单片机上,这个时候,就要反过来用时间换空间的设计思路。
总结
这两个小节详细介绍了数组与链表链表的核心思想与优缺点,在学习的过程当中也了解了以前忽视的点比如GC机制背后竟然还存在数组删除的影子,数据存储数组上在内存当中的分布,LRU缓存的实现,但是个人认为对于初学者学习这些文章存在问题,学习者至少需要一些基础的计算机知识,如果只是想要借助本专栏入门算法与数据结构,可能会错失很多的精彩部分.