图解数据结构js篇-数组结构

3,421 阅读8分钟

什么是数组

在讨论JS数组之前,我们先回顾一下数据结构中数组的定义:

在计算机科学中,数组数据结构(英语:array data structure),简称数组(英语:Array),是由相同类型的元素(element)的集合所组成的数据结构,分配一块连续的内存来存储。利用元素的索引(index)可以计算出该元素对应的存储地址。引自维基百科

由维基百科给出的数组的定义可知,数组满足:

  1. 数组中所有元素是同种类型的元素(同一类型元素所需存储空间大小一致,所以我们可以很方便的利用元素的索引来计算出元素所在的位置);
  2. 分配一块连续的内存存储(固定长度、连续)。

我们要理解数组的存储方式首先要知道:内存中的数据是由许多晶体管构成的,每一个晶体管只能存储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

插入元素

数组的实际 元素数量有可能小于数组的长度,例如下面的情形:

数组元素数量可能小于数组长度.png

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,直接对当前数组最后一项赋值即可,情况如下图:

数组尾部插入数据.png

arr[6] = 1 // 对第6个元素赋值

console.log(arr) // [ 1, 5, 2, 4, 4, 3, 1, <1 empty item> ]

中间插入

由于数组的每一个元素都有其固定下标,所以不得不首先把插入位置及后面的元素向后移动,腾出地方,再把要插入的元素放到对应的数组位置上;

如果需要在数组第三个位置后插入一个1,那么需要想将第三个位置以及后面的元素全部向后移动一位,留出空闲的位置后再对其进行修改操作。情况如下图:

中间插入元素后面的元素向后移动.png

arr[3]、arr[4]、arr[5] 向后移动,然后修改 arr[3] = 1

中间插入元素.png

代码实现:

// 向数组中下标为 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.png

  • 橙色:数组分配的内存空间
  • 蓝色:正在被其他变量使用的内存空间
  • 灰色:空闲的内存空间

当我们插入元素后,最后一个元素无法往后移,否则就会超出数组的内存空间。此时需要重新开辟一个空间足够的内存空间,然后将原来的空间中的值复制到新的空间中后再进行插入操作。

数组超范围插入2.png

这里新的数组长度只被扩大了一位,实际的扩容可能不止,可能是原数组的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位:

数组删除操作1.png

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所在的位置,然后再删除掉最后一个元素

数组删除操作2.png

这样一来,无须进行大量的元素移动,时间复杂度降低为O(1)。

function arrayRemoveItem(arr, index){
    // 最后一项移动到被删除的位置
    arr[index] = arr[size-1]
    // 移除最后一项
    delete arr[size-1]
    size--
    return arr
}

数组的优势和劣势

优势: 数组拥有非常高效的随机访问能力,只要给出下标,就可以用常量时间找到对应元素。有一种高效查找元素的算法叫作二分查找, 就是利用了数组的这个优势。

劣势: 数组的劣势体现在插入和删除元素方面。由于数组元素连续紧密地存储在内存中,插入、删除元素都会导致大量元素被迫移动,影响效率。

总结: 数组所适合的是读操作多、写操作少的场景!

js中的数组并不完全符合数据结构中的数组定义,js中数组的可以是不同的数据类型,也可以是非线性循序。 详细的内存分配请看我的八股文 深入V8 - js数组的内存是如何分配的