什么是数组
在讨论JS数组之前,我们先回顾一下数据结构中数组的定义:
在计算机科学中,数组数据结构(英语:array data structure),简称数组(英语:Array),是由相同类型的元素(element)的集合所组成的数据结构,分配一块连续的内存来存储。利用元素的索引(index)可以计算出该元素对应的存储地址。引自维基百科
由维基百科给出的数组的定义可知,数组满足:
- 数组中所有元素是同种类型的元素(同一类型元素所需存储空间大小一致,所以我们可以很方便的利用元素的索引来计算出元素所在的位置);
- 分配一块连续的内存存储(固定长度、连续)。
我们要理解数组的存储方式首先要知道:内存中的数据是由许多晶体管构成的,每一个晶体管只能存储0或者1,也就是数据的最小单位(位);以整型数组为例,数组的存储形式如下图所示:
数组中的每一个元素有着自己的下标,只不过这个下标从0开始,一直到数组长度-1;数组的另一个特点,是在内存中顺序存储,因此可以很好地实现逻辑上的顺序表。
数组在内存中的顺序存储:内存是由一个个连续的内存单元组成的,每一个内存单元都有自己的地址。在 这些内存单元中,有些被其他数据占用了,有些是空闲的; 数组中的每一个元素,都存储在小小的内存单元中,并且元素之间紧密排列, 既不能打乱元素的存储顺序,也不能跳过某个存储单元进行存储。
如图
- 蓝色:表示被使用的内存空间
- 橙色:表示当前数组分配的内存空间
- 灰色:表示空闲的内存空间
不同类型的数组,每个元素所占的字节个数也不同。
数组的基本操作
读取元素
JavaScript 中通过 数组[下标]
的方式来访问数组中指定下标的元素值,数组下标从0开始。第i个元素的下标为 i-1
, 如下图。
其中 arr[0]
对应值 1、 arr[1]
对应值 5、 arr[2]
对应值 2、...
当程序需要读取内存中的数据时,都会提供要被读取数据的内存地址;对于一个数组,其变量存储的是内存中数组的第一个元素的内存地址(首地址)。当我们需要访问数组的第 i 个元素时,根据数组存储是连续的和其存储的是同一种类型(即每一个元素占用的内存长度是固定的)。
可以根据公式直接计算出下标为 i 的元素的内存地址来访问其内存中的值
下标为i的元素的内存地址 = 数组首地址 + (单个元素的长度 * i)
以上图为例,数组 arr 分配的内存空间从第 5 个位置开始,每个元素占用一个空间,所以其首地址为 4 (从0开始计算);当需要访问数组下标为 5 的元素,根据公式
4 + (1 * 5) = 9
我们就可以让内存直接去访问地址为 9 的内存空间(第 10 个空间),得到其值为 3
所以 arr[5]
的值就是 3。
let arr = [1,5,2,4,4,3,2,14]
console.log(arr[5]) // 3
更新元素
数组元素值的更新原理与访问原理一致,也是通过先计算出要更新的内存区域,然后再对其进行修改。
JavaScript 中通过 数组[下标] = 值
的方式对数组中指定位置的值进行修改。
let arr = [1,5,2,4,4,3,2,14]
arr[5] = 100
console.log(arr[5]) // 100
插入元素
数组的实际 元素数量有可能小于数组的长度,例如下面的情形:
let arr = new Array(8); //定义长度为8的数组,在内存中会为我们分配8个元素的空间
// 初始化:对数组前6个元素赋值
arr[0] = 1;
arr[1] = 5;
arr[2] = 2;
arr[3] = 4;
arr[4] = 4;
arr[5] = 3;
arr.size = 5 // 代表当前数组的实际使用长度
因此,插入数组元素的操作存在3种情况:
- 尾部插入
- 中间插入
- 超范围插入
尾部插入
尾部插入,是最简单的情况,直接把插入的元素放在数组尾部的空闲位置即可,等同于更新元素的操作
如果需要在数组最后插入一个1,直接对当前数组最后一项赋值即可,情况如下图:
arr[6] = 1 // 对第6个元素赋值
console.log(arr) // [ 1, 5, 2, 4, 4, 3, 1, <1 empty item> ]
中间插入
由于数组的每一个元素都有其固定下标,所以不得不首先把插入位置及后面的元素向后移动,腾出地方,再把要插入的元素放到对应的数组位置上;
如果需要在数组第三个位置后插入一个1,那么需要想将第三个位置以及后面的元素全部向后移动一位,留出空闲的位置后再对其进行修改操作。情况如下图:
arr[3]、arr[4]、arr[5] 向后移动,然后修改 arr[3] = 1
代码实现:
// 向数组中下标为 index 的位置插入一个item值
function arrayAddItem(arr, index, item) {
// 将第item后的元素全部向后移动一位
for(let i = arr.size; i > index; i--){
// 移动
arr[i] = arr[i-1]
}
// 将要插入的值赋值到指定位置
arr[index] = item
arr.size++
return arr
}
arr = arrayAddItem(arr, 3, 1)
console.log(arr); // [ 1, 5, 2, 1, 4, 4, 3, <1 empty item> ]
超范围插入
由于数组的长度是不可变的,所以一个数组分配到的内存空间也是固定的。前面都是在数组中元素数量小于数组长度的情况下插入元素的。那么当数组中的元素数量等于长度长度时,该如何插入数据呢?
例如下图的情况:
- 橙色:数组分配的内存空间
- 蓝色:正在被其他变量使用的内存空间
- 灰色:空闲的内存空间
当我们插入元素后,最后一个元素无法往后移,否则就会超出数组的内存空间。此时需要重新开辟一个空间足够的内存空间,然后将原来的空间中的值复制到新的空间中后再进行插入操作。
这里新的数组长度只被扩大了一位,实际的扩容可能不止,可能是原数组的1.5或者2倍等等。这样可以有效的减少频繁的扩容操作(重新分配内存)
let arr = [1,2,3,4,5]
arr.size = 5 // 当前数组是使用长度
// 向数组中下标为 index 的位置插入一个item值
function arrayAddItem(arr, index, item) {
if(arr[arr.length-1]){
// 数组中的元素数量已经满了,扩容数组
arr = expandArray(arr)
}
// 将第item后的元素全部向后移动一位
for(let i = arr.size; i > index; i--){
// 移动
arr[i] = arr[i-1]
}
// 将要插入的值赋值到指定位置
arr[index] = item
arr.size++
return arr
}
// 扩容数组为原来长度的两倍
function expandArray(arr){
// 创建一个长度为原来长度两倍的数组
let newArray = new Array(arr.size * 2)
// 将原来的数组复制到新的数组中
for (let i = 0; i < arr.size; i++) {
newArray[i] = arr[i]
}
newArray.size = arr.size
return newArray
}
arr = arrayAddItem(arr, 3, 1)
console.log(arr); [ 1, 2, 3, 1, 4, 5, <4 empty items>]
删除元素
数组的删除操作和插入操作的过程相反,如果删除的元素位于数组中间,其后的元素都需要向前挪动1位:
function arrayRemoveItem(arr, index){
for (let i = index; i < arr.size-1; i++) {
// 后面一项往前移动
arr[i] = arr[i+1]
}
// 当前项算法为最后一项
delete arr[arr.size-1]
arr.size--
return arr
}
删除操作,只涉及元素的移动,时间复杂度是O(n)
如果对数组元素没有顺序要求,删除操作还存在一种取巧的方法:
如下图所示,需要删除的是数组中的元素2,可以把最后一个元素复制到元素2所在的位置,然后再删除掉最后一个元素
这样一来,无须进行大量的元素移动,时间复杂度降低为O(1)。
function arrayRemoveItem(arr, index){
// 最后一项移动到被删除的位置
arr[index] = arr[size-1]
// 移除最后一项
delete arr[size-1]
size--
return arr
}
数组的优势和劣势
优势: 数组拥有非常高效的随机访问能力,只要给出下标,就可以用常量时间找到对应元素。有一种高效查找元素的算法叫作二分查找, 就是利用了数组的这个优势。
劣势: 数组的劣势体现在插入和删除元素方面。由于数组元素连续紧密地存储在内存中,插入、删除元素都会导致大量元素被迫移动,影响效率。
总结: 数组所适合的是读操作多、写操作少的场景!
js中的数组并不完全符合数据结构中的数组定义,js中数组的可以是不同的数据类型,也可以是非线性循序。 详细的内存分配请看我的八股文 深入V8 - js数组的内存是如何分配的