说起数组,我想你肯定不会陌生,甚至还会自信的说,它很简单。

的确,在每一种编程语言中,都会有数组这种数据类型。不过,它不仅仅是一种编程语言的类型,还是一种最基础的数据结构。

尽管数组看起来非常基础、简单,但是绝大多人并没有理解这个基础结构的精髓。

在大部分编程语言中,数组都是从 0 开始编写的,但你是否下意识地想过,为什么数组要从 0 开始,而不是 1 开始?从 1 开始不是更符合人类的思维习惯?你可以带着这个问题学习接下来的内容。

如何实现随机访问

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

ECMAScript 中定义的数组可以存储任意类型数据。

这里有几个关键词,理解这几个关键词,就可以彻底掌握数组的概念。

线性表(Linear List)

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

其实除了数组,链表、队列、栈等也是线性表结构。

与它相对立的概念是非线性表,比如二叉树、堆、图等。之所以叫非线性,是因为在非线性表中数据之间并不是简单的前后关系。

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

正因为这两个限制,它才有了随机访问的特性。但有利也有弊,这两个限制也让数组的很多操作变得非常低效,比如要想在数组中删除、插入一个数据,为了保持连续性,就需要做大量的数据搬移的操作。

那么,数组是如何根据下标随机访问数组元素的?

我们拿一个长度为 10 的数组 const arr = new Array(10); 来举例。
当你创建数组时,计算机给数组分配了一块连续内存空间 1000~1039,其中,内存的首地址为 base_address = 1000。

array.png

我们知道,计算机会给每个内存单元分配一个地址,计算机通过地址来访问来访问内存中的数据。

当计算机需要随机访问数据中的某个元素是时,它会首先通过下面的寻址公式,计算出该元素的内存地址:

a[i]_address = base_address + i * data_type_size

其中 data_type_size 表示数组中每个元素的大小。

数组和链表的区别

关于数据和链表区别,经常有人说 ”链表适合插入、删除、时间复杂度 O(1)“,数组适合查找,查找时间复杂度为 O(1)。

实际上这种表述是不准确的。数组的确适合查找操作,但是查找的时间复杂度并不为 O(1)。

即便是排好序的数组,用二分查找,时间复杂也是 $O(log^n)$ 。所以正确的表述应该是,数据支持随机访问,根据下标随机访问的时间复杂度为 O(1)。

低效的 ”插入“ 和 ”删除“

前面提到过,数组为了保持内存数据的连续性,会导致插入、删除这两个操作比较低效。

现在就来详细说一下,究竟为什么会导致低效?有哪些改进方法?

插入操作

假如数组的长度为 n,现在,如果我们需要将一个数据插入到数组中的第 k 个位置。
这时,我们需要将 k~n 这部分的元素顺序地向后以移动一位。那插入的时间复杂度是多少?

如果是在数组的末尾插入元素,那就不需要移动数据,这时的时间复杂度是 O(1)。
如果在数组中的开头插入数据,那后面所有的数据都要移动,所以最坏时间复杂度是 O(n)。
因为我们在每个位置插入元素的概率是一样的,所以平均情况时间复杂度为 (1 + 2+ 3 + … n) / n = O(n)。

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

为了更好理解,下面来举个例子。

const arr = new Array(10);

arr.push('a');
arr.push('b');
arr.push('c');
arr.push('d');
arr.push('e');

我们需要将元素插入到第 3 个位置。这时只需要将 c 放入 a[5],将 a[2] 赋值给 x 即可。

最后数组中元素如下:a, b, x, d, e, c。

array02.png

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

删除操作

跟插入数据类似,如果我们要删除第 k 个位置的数据,为了内存的连续性,也需要搬移数据,不然中间就会出现空洞,内存就不连续。

如果删除数组末尾的数据,最好情况时间复杂度为 O(1)。如果删除开头的数据,则最坏情况时间复杂度为 O(n)。平均情况时间复杂度也为 O(n)。

实际上,在某些特殊场景下,我们并不一定非要追求数组中数据的连续性。如果我们将多次删除操作集中在一起执行,删除的效率也会得到提升。

继续来看例子。比如数组中存储了 8 个元素:a,b,c,d,e,f,g,h。现在,我们需要依次删除 a,b,c 三个元素。

array03.png

为了避免 d,e,f,g,h 这几个数据被搬移三次,我们可以先记录下已经删除的数据。每次的删除并不是真正地搬移数据,只是记录数据已经删除。当数组中没有更多空间存储数据时,我们再触发一次真正的删除操作,这样就可以大大减少删除操作导致的数据搬移。

如果你了解过 JVM,你会发现,这其实就是 JVM 标记清除垃圾回收算法的核心思想。

数据结构和算法的魅力就在于此,很多时候我们并不是要死记硬背某个数据结构或算法,而是学习它背后的思想和处理技巧,这些东西才是最有价值的。如果你足够细心,应该会发现不管是在软件开发还是架构设计中,总能找到某些算法和数据结构的影子。

数组的越界问题

了解了数组的基本操作之后,可以说下数据访问越界的问题。

数组越界问题在ECMAScript 中定义的数组中体现并不明显。因为其存在静默失败的特性。

越界访问只会返回 undefiend,但是数组也存在存储范围,其范围是0 到 2^32 - 1。

let arr = [];

arr[Math.pow(2, 32) - 2] = 10;

arr.push(12);

console.log(arr); // Invalid array length

但其实,在其他编程语言中,数组访问越界往往会产生严重的问题。比如下面这个 C 语言代码案例。

int main(int argc, char* argv[]){
  int i = 0;
  int arr[3] = {0};
  for(; i<=3; i++){
    arr[i] = 0;
    printf("hello world\n");
  }
  return 0;
}

这段代码的执行结果并不是打印三行 ”hello world“,而是会无限打印 ”hello world“。

原因数组大小为 3,而上述代码因为书写错误,当 i = 3 时,数组 a[3] 访问越界。

在 C 语言中,只要不是访问受限的内存,所有的内存空间都是可以自由访问的。根据前面说的寻址公式,a[3] 也会被定位到某块不属于数组的内存地址上,而这个地址正好是存储变量 i 的内存地址,那么 a[3] = 0 就相当于 i = 0,所以就会导致代码无限循环。

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

这种情况下,一般都会出现莫名其妙的逻辑错误,就像上面的例子,调试的难度很大。而且,很多计算器病毒也正是利用到了代码中数组越界可以访问非法地址的漏洞,来攻击系统,所以写代码的时候一定要警惕数组越界。

不过并非所有的语言都像 C 一样,把数组越界检查的工作丢给程序员来做,向 java 本身存在越界检查,js 则会静默失败处理。

int[] a = new int[3];
a[3] = 10;

运行上述 java 代码,会抛出 java.lang.ArrayIndexOutOfBoundsException。

容器能否替代数组?

针对数组类型,很多语言都提供了容器类。比如 Java 中的 ArrayList 、C++ STL 中的 vector。

这里拿 Java 语言来举例。如果你是 java 工程师,几乎天天用 ArrayList ,对它应该非常熟悉。那它与数组相比,到底存在哪些优势?

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

数组本身在定义的时候需要预先指定大小,因为需要分配连续的内存空间。如果我们申请了大小为 10 的数组,当第 11 个数据需要存储到数组中时,就需要重新分配一个更大的空间,将原来的数据复制过去,然后将新的数据插入。

如果使用 ArrayList,我们就完全不需要关心底层的扩容逻辑,ArrayList 已经帮我们实现好了。每次存储空间不够的时候,它都会将空间自动扩容为 1.5 倍左右。不过,扩容操作涉及内存申请和数据搬移,是比较耗时的。所以,如果事先能确定需要存储的数据大小,最好在创建 ArrayList 的时候事先指定数据大小。

比如我们要从数据库中取出 10000 条数据放入 ArrayList。我们看下面这段代码,相比不指定大小,事先指定数据大小可以省掉很多内存申请和数据搬移操作。

ArrayList<User> users = new ArrayList(10000);

for (int i = 0; i < 10000; ++i) {
  users.add(xxx);
}

作为 java 这种高级语言来说,是不是数组就没有价值了?相信你也知道答案,当然不是,任何数据结构都有其存在的意义。

  • Java ArrayList 无法存储基本类型,比如 int、long,需要封装为 Integer、Long 类,而 Autoboxing、Unboxing 则有一定的性能损耗,所以如果特别关注性能,或者希望使用基本类型,就可以选用数组;
  • 如果数据大小事先已知,并且对数据的操作非常简单,不适用 ArrayList 提供的大部分方法,也可以直接使用数组;
  • 当表示多维数组时,用数组表示会比较直观。比如 Object [][] array; 。使用容器则需要这样定义 ArrayList<ArrayList<object>>

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

总结

今天主要学习了数组。数组是最基础、最简单的数据结构了。数组用一块连续的内存空间,来存储相同类型的一组数据,最大的特点就是支持随机访问,但插入、删除操作也因此变得比较低效,平均情况复杂度为 O(n)。

在平时的业务中,我们可以直接使用编程语言提供的容器类,但是如果特别底层的开发,直接使用数组比较合适。

技术拓展

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

从数组的内存模型上来看,”下标“ 最确切的定义应该是 ”偏移(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 开始。

不过,上面说的都算不上压倒性的证明,最主要的原因可能还是历史原因。

C 语言设计者用 0 开始计数数组下标,之后的 Java、JavaScript 等高级语言都效仿了 C 语言,或者说,为了在一定程度上减少 C 语言程序员学习 Java 的成本,因此继续沿用了从 0 开始计数的习惯。实际上,很多语言中数组的下标并不是从 0 开始的,比如 MatLab。甚至还有一些语言支持负数下标,比如 Python。

V8 中的垃圾回收算法

v8 引擎对于栈和堆中的垃圾回收有不同的处理措施。

对于栈来说,使用 ESP 的指针向下移动来销毁该函数保存在栈中的执行上下文。

对于堆来说,就需要使用 JavaScript 的垃圾回收器。

V8 中会把堆分为老生代和新生代两个区域,新生代存放的是生存时间短的对象,老生代存放的是生存时间比较长的对象。

新生区通常只支持 1~8M 的容量,老生区支持的容量大很多。

对于新生区和老生区分别使用两个不同的垃圾回收器:

  • 副垃圾回收器,主要负责新生代的垃圾回收;
  • 主垃圾回收器,主要负责老生代的垃圾回收;

副垃圾回收器,使用 Scavenge 算法进行处理。

主垃圾回收器,采用标记-清除(Mark-Sweep)的算法进行垃圾回收。还有另一种算法标记-整理(Mark-Compact)算法。

二维数组的内存寻址公式

对于 m * n 的数组,a[i][j](i < m, j < n) 的寻址公式:

address = base_address + (i * n + j) * type_size

另外,对于数组访问越界造成无限循环,应该和编译器有关,对于不同的编译器,在内存分配时,会按照内存地址递增或者递减的方式进行分配。如果是内存地址递减的方式,就会造成无限循环。