数组是数据结构的起点,也是面试 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
┌─────┬─────┬─────┬─────┬─────┐
数组元素: │ 1 │ 2 │ 3 │ 4 │ 5 │
└─────┴─────┴─────┴─────┴─────┘
索引: [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 返回) - 但
map、forEach会跳过 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) | 每项填 x | x 是引用类型时,共享同一个 |
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. 核心心法
- 数组是连续内存:
arr[i]访问 O(1),头部插入 O(n) - 区分 empty 和 undefined:
new Array(n)的 empty 会被 map 跳过 - 区分纯函数和非纯函数:push 系列改原数组,map 系列返回新数组
- fill 引用类型是共享:创建二维数组用
Array.from({length:n}, () => []) - forEach 不能 break:要跳出用 for 或 for...of