JS 数组入门到精通:从内存模型到二维陷阱

0 阅读9分钟

数组是数据结构的起点,也是面试 hot 100 的第一站。学好数组不是会背 API 就行,核心要理解 ADT(抽象数据类型) = 连续的存储空间 + 特定的操作。


一、需要掌握的数据结构总览

列表类(线性结构)
├── 数组    ← 很多语言内置,开箱即用,本文重点
├── 链表
├── 栈
└── 队列

树类(非线性结构)
└── 二叉树

学习思路

  • 面向 JavaScript:不要死记 C++ 指针那套,要结合 JS 的特性(动态长度、混合类型、方法链)
  • 面向面试:刷 hot 100,每道题理解"这道题考数组的什么特性"
  • 不要拘于做题,要迁移语言的相关性:同一个特性(比如索引访问 O(1)),换语言、换场景都一样

二、数组是什么

1. 直观认识

JS 数组开箱即用,比其他语言更灵活

  • ❌ 不要求每一项类型一样([1, 'a', true, {}] 都行)
  • ❌ 不需要限制 length(push 会自动变长)
const arr = [1, 'hello', true, { name: 'xhj' }];  // ✅ 合法
arr.push(999);   // ✅ 自动变长,不用扩容

2. 内存模型(为什么按索引访问 O(1))

数组在内存中是连续的存储空间,访问时通过:

目标地址 = 起始地址 + 索引 × 每个元素的大小
内存地址:  0x100   0x104   0x108   0x10C   0x110
           ┌─────┬─────┬─────┬─────┬─────┐
数组元素:  │  12345  │
           └─────┴─────┴─────┴─────┴─────┘
索引:        [0]    [1]    [2]    [3]    [4]

所以 arr[2] 不需要遍历,直接 起始地址 + 2×大小 一步找到 → O(1) 时间复杂度

3. ADT(抽象数据类型)的认识

数据结构 = 数据怎么存(存储空间)+ 能对它做什么(操作)

数组的 ADT:

  • 存储:连续的内存空间
  • 操作:增(push/unshift/splice)、删(pop/shift/splice)、改(arr[i]=x)、查(arr[i] / indexOf / find)

三、数组的创建

1. 字面量(最常用)

const arr1 = [];           // 空数组
const arr2 = [1, 2, 3];    // 初始化带值

2. 构造函数

new Array(7)
// 结果:[empty × 7]
// length = 7,但 7 个位置都是 empty(空洞),不是 undefined

empty 是什么?

  • empty = 这个位置"还没被占据",不属于任何类型
  • 直接读 arr[0] 返回 undefined(JS 把空位置统一当 undefined 返回)
  • mapforEach跳过 empty,这就是大坑:
const arr = new Array(7);
arr.map(() => 1);          // ❌ 返回还是 [empty × 7],map 根本不执行
arr[0] === undefined;      // true(读出来是 undefined,但本质是 empty)

3. fill() —— 填充实值

(new Array(7)).fill(1)
// [1, 1, 1, 1, 1, 1, 1]

用途:创建一个长度确定、每一个元素也确定的数组。

fill(简单类型) 很安全,但 fill(引用类型) 藏着经典坑,见后面的二维数组章节。

4. 安全的空数组创建(推荐)

// 方式一:Array.from
Array.from({ length: 7 });     // [undefined, undefined, ...](真正的 undefined,不是 empty)
Array.from({ length: 7 }, (_, i) => i);  // [0, 1, 2, 3, 4, 5, 6]

// 方式二:展开
[...new Array(7)];             // [undefined, undefined, ...]

这两种方式创建的数组,map / forEach 能正常遍历每一项。


四、四组核心操作方法

1. push / pop / shift / unshift 对照表

把数组想象成一根管子,左边是头(index 0),右边是尾(最后一个)

左(头)              右(尾)
 [ a,   b,   c,   d,   e ]
  ↑                     ↑
shift / unshift    push / pop
(在头部操作)      (在尾部操作)
方法操作位置操作返回值是否改原数组时间复杂度
push尾部添加新长度✅ 是(非纯函数)O(1) 快
pop尾部删除被删元素✅ 是(非纯函数)O(1) 快
unshift头部添加新长度✅ 是(非纯函数)O(n) 慢
shift头部删除被删元素✅ 是(非纯函数)O(n) 慢

2. 代码演示

const arr = ['姚明', '麦迪'];

// push:尾部加
arr.push('穆托姆博');        // 返回 3(新长度),一次可加多个
// arr: ['姚明', '麦迪', '穆托姆博']

// pop:尾部删
arr.pop();                   // 返回 '穆托姆博'
// arr: ['姚明', '麦迪']

// unshift:头部加
arr.unshift('范甘迪');       // 返回 3
// arr: ['范甘迪', '姚明', '麦迪']

// shift:头部删
arr.shift();                 // 返回 '范甘迪'
// arr: ['姚明', '麦迪']

3. 为什么头部操作慢?

shift 删除第一个元素:
[范甘迪, 姚明, 麦迪, 弗朗西斯]
         ↓      ↓       ↓
       姚明挪到0, 麦迪挪到1, 弗朗西斯挪到2
[姚明,   麦迪, 弗朗西斯, _]

每个元素都要重排索引 → O(n)

头部加/删一个元素,后面所有元素都要集体往前/往后挪一位,改索引。数据量大时非常慢。

工程原则:大量增删 → 优先尾部(push/pop),避免头部(shift/unshift)。

4. 纯函数 vs 非纯函数

分类定义例子
非纯函数(破坏性方法)会修改原数组push / pop / shift / unshift / splice / reverse / sort
纯函数(非破坏性方法)不改原数组,返回新结果map / filter / every / some / concat / slice
// 非纯函数:原数组被破坏
const a = [1, 2, 3];
a.push(4);   // a 现在是 [1,2,3,4],原数组变了

// 纯函数:原数组不变
const b = [1, 2, 3];
b.map(x => x * 2);   // 返回 [2,4,6],b 还是 [1,2,3]

面试常问:哪些数组方法会修改原数组?—— 记忆:四组操作(push/pop/shift/unshift)+ splice/reverse/sort,其余基本都是纯函数。


五、数组的访问和遍历

1. 访问:索引 / 下标

const arr = ['a', 'b', 'c'];
arr[0];   // 'a'     ← 第 1 个,下标从 0 开始
arr[2];   // 'c'     ← 第 3 个
arr[99];  // undefined ← 越界不会报错,返回 undefined
arr[-1];  // undefined ← JS 数组不支持负索引(但可以用 arr.at(-1))

2. 四种遍历方法对比

① for 计数循环(最老派,性能最好)

for (let i = 0; i < arr.length; i++) {
    console.log(arr[i]);
}
优点缺点
性能最好(纯内存操作,无额外开销)机器化、命令式,可读性一般,代码长
可以 break 中途跳出容易写错边界(<= vs <
可以拿到索引 i

② for...of(语义最好)

for (const item of arr) {
    console.log(item);
}
优点缺点
代码短,语义清晰:对数组的每一项做点什么拿不到索引(需要额外 arr.entries()
可以 break 跳出性能略低于 for 循环

配合 entries() 可以同时拿索引和值:

for (const [i, item] of arr.entries()) { ... }

③ forEach(函数式风格)

arr.forEach((item, index, array) => {
    console.log(item, index);
});
优点缺点
函数式,可链式,功能强大不能 break(只能用 try-catch 硬抛异常,不推荐)
同时拿 item / index / 原数组有函数调用栈开销,性能一般
不用关心边界

forEach 的坑return 只能跳过当前这一项,等价于 for 里的 continue不等于 break

[1, 2, 3].forEach(x => {
    if (x === 2) return;   // ❌ 不是跳出,只是跳过 2
    console.log(x);        // 打印 1, 3
});

④ map / filter / every / some(基于 forEach 的纯函数)

它们底层都是基于 forEach 实现,但返回全新结果,不改原数组,是纯函数

方法作用返回什么情况用
map一项对一项转换新数组,长度和原数组一样"每个都要加工"
filter筛选,true 的留下新数组,长度 ≤ 原数组"挑出符合条件的"
every每一项都满足?布尔值"是不是所有都..."
some有一项满足就行?布尔值"有没有..."
const nums = [1, 2, 3, 4, 5];

// map:每项 × 2
nums.map(x => x * 2);                  // [2, 4, 6, 8, 10]

// filter:只留偶数
nums.filter(x => x % 2 === 0);         // [2, 4]

// every:是不是都是偶数?
nums.every(x => x % 2 === 0);          // false(1,3,5 不是)

// some:有没有大于 4 的?
nums.some(x => x > 4);                 // true(5 满足)

怎么选择?

  • 加工每项 → map
  • 筛选部分 → filter
  • 要判断全都满足 → every
  • 要判断至少一个满足 → some
  • 提前退出循环 → for 循环或 for...of
  • 拿索引且不怕写长 → for 循环

六、二维数组(矩阵)

1. 什么是二维数组

二维数组 = 数组的元素还是数组 = 两层嵌套 = 矩阵

[
  [1, 2, 3],   ← 第 0 行
  [4, 5, 6],   ← 第 1 行
  [7, 8, 9]    ← 第 2 行
]

实际应用

  • 棋盘、迷宫、地图
  • 表格数据(行 × 列)
  • LLM 把大模型语言变成向量矩阵(神经网络的输入输出本质都是矩阵运算)

2. 访问二维数组

arr[行索引][列索引]

const m = [[1,2,3],[4,5,6]];
m[0];       // [1,2,3]      ← 第 0 行
m[0][1];    // 2            ← 第 0 行第 1 列

3. 经典坑:fill([]) 的引用陷阱

const arr = (new Array(7)).fill([])

表面看[[], [], [], [], [], [], []](7 个空数组)

实际上:7 个位置指向同一个数组!因为 [] 是引用类型,fill 传的是入参的引用(内存地址),不是复制一份。

arr[0].push(1);
console.log(arr);
// 你以为:[[1], [], [], [], [], [], []]
// 实际上:[[1], [1], [1], [1], [1], [1], [1]]  ← 全都变了!

原理

内存里只有一个 []:地址 0x888
fill 把 0x888 复制到了 7 个位置
所以 arr[0] === arr[1] === ... === 同一个东西
改一个就是改全部

4. 正确创建二维数组

// ✅ 方法一:Array.from(推荐)
const arr = Array.from({ length: 7 }, () => []);
// 每次调用一次 () => [],创建 7 个独立数组

arr[0].push(1);
// [[1], [], [], [], [], [], []]  ✅ 互不影响

// ✅ 方法二:展开 + map
const arr2 = [...new Array(7)].map(() => []);

// ✅ 方法三:传统 for 循环
const arr3 = [];
for (let i = 0; i < 7; i++) arr3.push([]);

创建 n×m 矩阵(面试高频)

// 创建 7 行 × 5 列,每格填 0
const matrix = Array.from({ length: 7 }, () => new Array(5).fill(0));

绝对不能写 new Array(7).fill(new Array(5).fill(0)),否则改一个格子整列都变。


七、总结速查表

1. 创建方式

写法结果注意
[] / [1,2,3]字面量数组最常用
new Array(n)[empty × n]empty 不是 undefined,map 会跳过
new Array(n).fill(x)每项填 xx 是引用类型时,共享同一个
Array.from({length:n}, () => [])n 个独立数组✅ 创建二维数组推荐

2. 操作方法

分类方法改原数组?复杂度
尾部增删push / pop✅ 改O(1)
头部增删unshift / shift✅ 改O(n)(重排索引)
纯函数转换map / filter❌ 不改O(n)
布尔判断every / some❌ 不改O(n)(短路)

3. 遍历方法

方法能拿索引?能 break?性能场景
for 循环最好追求性能、需要精确控制
for...of需要 entries()语义化遍历
forEach❌ 不能一般函数式,无中途退出
map/filter/every/some✅ 回调里有❌ 不能一般转换/筛选/判断

4. 核心心法

  1. 数组是连续内存arr[i] 访问 O(1),头部插入 O(n)
  2. 区分 empty 和 undefinednew Array(n) 的 empty 会被 map 跳过
  3. 区分纯函数和非纯函数:push 系列改原数组,map 系列返回新数组
  4. fill 引用类型是共享:创建二维数组用 Array.from({length:n}, () => [])
  5. forEach 不能 break:要跳出用 for 或 for...of