继续开始关于 极客时间《数据结构与算法之美》课程的总结,本次是来自基础篇的数组与链表部分,对应的课程 5-7 部分。
1. 数组
数组(Array)是一种线性表数据结构,它用一组连续的内存空间,来存储一组具有相同类型的数据,关于数组的相关内容如下图:
这个图基本把数组的特性介绍得非常清楚了,这里来看几个其他问题。
1.1. 容器能否完全代替数组?
是否可以用容器完全代替数组,比如:Java 的 ArrayList,容器的优点是可以将很多数组操作的细节封装起来+支持动态扩容。
答案是肯定的,但数组并不是完全无用武之地(在特别在意性能的场景下,多数情况下用数组反而更合适),比如:
- Java ArrayList 无法存储基本类型,比如:int、long 等,使用时需要封装成 Integer、Long 等类,而自动拆箱/装箱是有一些性能消耗的;
- 如果数据大小已知,并且操作比较简单,用不到 ArrayList 提供的大部分方法,是可以直接使用数组的;
- 对于多维数组,用数组反而更加直观,比如:Object[][] array,而容器的定义则是:ArrayList<ArrayList> array;
- 使用 ArrayList 时,如果知道数据初始大小,最好创建的时候事先指定数据大小,需要扩容操作涉及的内存申请和数据迁移是比较耗时的。
数组因为使用的是连续的内存空间,因此,可以借助 CPU 的缓存机制,预读数组中的数据,所以其访问效率比想象中得要高一些。
1.2. 数组为什么要从 0 开始编号
这个也是有一定历史背景的,比如像 MATLAB 中的数组就是从 1 开始计数的,但是也可以从另一个角度来分析一下:
从数据存储的内存模型上看,【下标】最确切的定义应该是 “偏移(offset)”,比如 a[0]
就是偏移为 0 的位置,也就是首地址,a[k]
就表示偏移 k 个 type_size 的位置,计算公式为:
a[k] = base_address + k * type_size
但是,如果数组从 1 开始计数,那么上面的结果则会变成这样:
a[k] = base_address + (k-1) * type_size
对比一下,虽然只是多了一次减法操作,对于 CPU 而言,就是多了一次减法指令,对于这种基础的数据结构,优化是需要做到极致的,因此会从 0 开始编号。
1.3. 数组越界问题
数组越界问题,如果出现在 Java 中,会返回数组越界的错误,但是在 C 语言中就不一样了,相当于去访问了另外一段连续内存,只要该内存是可用的,就是可访问,这时候会导致什么问题就不好说了,所以,在 coding 的时候数据越界问题是需要极大关注的。
2. 链表
接下来看下链表相关的内容,主要内容整理如下图:
2.1. 从底层存储上看链表结构
与数组相反,链表并不需要一块连续的内存空间,通过指针(或引用)将一组零散的内存块串联起来。对于三种不同类型的链表,如下图所示:
链表的特点:
- 除了存储数据之外,还记录了链上的下一个结点位置(双向链表,记录了前后两个结点的位置);
- 头结点和尾结点比较特殊,在插入/删除/查找时需要额外考虑。
2.2. 链表相关的操作
看下其各种操作下的复杂度。
2.2.1. 插入
在链表中插入一个数据,并不需要像数组那样为了保持内存的连续性而搬移结点,所以,插入一个数据是非常快的,只需要考虑相邻结点的指针或引用改变,因此时间复杂度是 。
2.2.2. 查找
链表想要访问第 个元素,就没有数组那么高效了,需要根据指针或引用,一个结点一个结点地遍历,直到找到相应的结点,时间复杂度为:。
2.2.3. 删除
删除操作跟插入操作一样,其时间复杂度都是 ,但这只是单纯的删除操作。
实际操作中的删除分为两种情况:
- 删除结点中【值等于某个给定值】的结点;
- 删除给定指针或引用指向的结点;
对于第一种情况,无论是什么类型的链表,其时间复杂度都是 。
对于第二种情况,双向链表由于记录了上个结点的指针或引用,就不要再遍历查找一边,相当于在这种情况下,做了一部分的优化,时间复杂度变为了 。
2.3. 链表总结
主要还是跟数组的一些对比:
- 链表因为不是连续的内存空间,无法利用操作系统的预读机制;
- 数组的缺点是大小固定,一经声明就要占用整块连续内存空间,而链表本身没有大小的限制,天然地支持动态扩容;
- 链表中每个结点都需要额外的存储空间存储指向下一个结点的指针或引用。
缓存淘汰算法中的 LRU(Least Recently Used,最近最少使用)算法就是使用了链表的数据结构实现。