从内存模型到遍历方法选型,一文吃透 JS 中最重要的数据结构
前言
刷 LeetCode 的时候,你有没有遇到过这样的困惑:
- 为什么大家都说前端面试考算法,用 JavaScript 写就够了?
- JS 的数组和 C/Java 的数组有什么不同?
forEach和for循环到底怎么选?new Array(7).fill([])为什么会踩坑?
这些问题看似零散,实则都指向一个核心——数组是前端工程师手中最频繁使用的数据结构,但很多人只停留在"会用"的层面,对其底层特性和设计哲学缺乏系统性理解。
本文将从零开始,结合 JavaScript 语言特性,用最接地气的方式帮你打通数组的任督二脉。
一、学习数据结构之前,先摆正姿势
学数据结构不是为了应付面试(但在当前环境下,它确实是进大厂的敲门砖)。几点建议:
1.1 面向 JavaScript,不要陷入语言切换的泥潭
很多经典的数据结构教材用的是 C 语言或 Java。如果你用 JS 刷题,不要强迫自己先用 C 写一遍再翻译过来。直接面向 JavaScript 学习,理解 JS 数组与其他语言数组的差异,反而能加深你对语言本身的理解。
1.2 面向面试,精准打击
LeetCode Hot 100 是性价比最高的题单。先把这 100 道题吃透,比你盲目刷 300 道散题效果好得多。Hot 100 覆盖了数组、链表、栈、队列、树(尤其是二叉树)这几类最高频的数据结构。
1.3 不要急于做题,先理解 ADT
很多初学者一上来就打开 LeetCode 刷 Two Sum,刷完看题解,看完就忘。根本原因是没有建立起**抽象数据类型(ADT)**的认知。
数组的 ADT 是什么?一段连续的存储空间 + 一组特定的操作方法。 理解了这个,再去看数组上的各种算法题,你才能抓住不变的核心。
二、JS 数组的灵活性:是福也是祸
JavaScript 的数组和 C/Java 完全不同,它有三大"特权":
| 特性 | C/Java 数组 | JavaScript 数组 |
|---|---|---|
| 类型约束 | 所有元素必须是同一类型 | [1, 'hello', {a:1}, [2,3]] 完全合法 |
| 长度限制 | 创建时必须指定,不可变 | 不需要预分配,arr[999] = 1 直接赋值 |
| 动态伸缩 | 不支持 | push/pop 自动调整大小 |
这种灵活性带来的是极高的开发效率,但代价是什么?底层的内存模型变得更复杂了。
2.1 内存模型:起始地址 + 偏移量
无论是哪种语言,数组在内存中的本质是一样的——一段连续的存储空间。
内存地址: 0x1000 0x1008 0x1010 0x1018
┌──────┬──────┬──────┬──────┐
│ arr[0]│ arr[1]│ arr[2]│ arr[3]│
└──────┴──────┴──────┴──────┘
你访问 arr[3],本质上是在 起始内存地址 + 3 × 元素大小 这个位置上取值。
JS 引擎(V8)会对不同类型的数组做不同优化:
- 如果数组中全是整数(SMI),V8 使用紧凑的内存布局
- 如果数组中类型混杂,V8 会退化为哈希表存储
💡 面试重点:尽量保持数组中元素类型一致,能写出更高效的代码。
三、数组的创建:不止是方括号
3.1 字面量创建(最常用)
const arr = [1, 2, 3];
3.2 构造函数:new Array()
const arr1 = new Array(); // [] —— 等价于字面量
const arr2 = new Array(7); // [empty × 7] —— 指定长度
这里有一个很容易被忽视的细节:new Array(7) 创建的是一个**长度为 7 但全是空槽(empty slot)**的数组。
⚠️ 注意:empty 不是 undefined!
const arr = new Array(7);
console.log(arr[0]); // undefined
console.log(arr); // [ <7 empty items> ]
console.log(0 in arr); // false —— 这个位置根本不存在!
这个区别在 map、forEach 等遍历方法中会有影响:
const arr1 = new Array(3); // [empty × 3]
const arr2 = [undefined, undefined, undefined];
arr1.map((_, i) => i); // [empty × 3] —— map 会跳过空槽!
arr2.map((_, i) => i); // [0, 1, 2]
3.3 fill():创建带初始值的数组
const arr = new Array(7).fill(1); // [1, 1, 1, 1, 1, 1, 1]
fill() 创建长度确定、每个元素都有确定值的数组,在初始化 DP 表格时非常有用。但注意:fill() 的陷阱请直接跳到第六章。
四、数组的增删操作:纯函数 vs 副作用
4.1 四个会修改原数组的方法
| 方法 | 操作 | 返回值 | 副作用 |
|---|---|---|---|
push(item) | 尾部插入 | 新数组长度 | ⚠️ 修改原数组 |
pop() | 尾部删除 | 被删除的元素 | ⚠️ 修改原数组 |
unshift(item) | 头部插入 | 新数组长度 | ⚠️ 修改原数组 |
shift() | 头部删除 | 被删除的元素 | ⚠️ 修改原数组 |
这四个方法有一个共同点:它们都不是纯函数。直接修改原数组,在 React 这类框架中是大忌——状态不可变性要求我们尽量使用纯函数。
纯函数 vs 非纯函数:
// ❌ 非纯函数:依赖外部变量,结果不可预测
let num = 0;
function add(b) {
num += b;
return num;
}
// ✅ 纯函数:同样的输入永远得到同样的输出,无副作用
function pureAdd(a, b) {
return a + b;
}
回到数组操作——在需要保持原数组不变的场景下,应该用 concat、slice 或者展开运算符 [...arr] 来替代。
4.2 时间复杂度分析(面试高频 🔥)
| 方法 | 时间复杂度 | 原因 |
|---|---|---|
push / pop | O(1) | 尾部操作,不需要移动其他元素 |
shift / unshift | O(n) | 头部操作,需要移动后面所有元素 |
这就是为什么用数组模拟队列时,直接用 shift 会很慢——应该用双指针或循环队列来优化。
五、数组遍历:六种方法,各有千秋
JS 提供了丰富的遍历手段,但选错了会影响代码的可读性和性能。
5.1 for 计数循环
for (let i = 0; i < arr.length; i++) {
console.log(arr[i]);
}
- ✅ 性能最好:纯机器指令级别的操作
- ❌ 可读性一般:命令式风格,变量
i没有语义
5.2 for...of
for (const item of arr) {
console.log(item);
}
- ✅ 语义极好:声明式,读起来很自然
- ✅ 可以
break - ❌ 拿不到索引(除非配合
entries())
5.3 forEach
arr.forEach((item, index, self) => {
console.log(item, index, self);
});
- ✅ 功能强大:同时拿到 item、index 和数组本身
- ❌ 不能中途
break:一旦开始就必须遍历完 - ❌ 有函数调用开销
💡 为什么 forEach 不能 break? 因为它本质上是把一个回调函数传给引擎,引擎在内部循环调用这个函数。你在回调里
return只是从当前回调返回,下一次调用照常进行。
5.4 map、filter、every、some
这四个方法都是纯函数——不修改原数组,返回新数组(或布尔值):
const doubled = arr.map(item => item * 2); // 映射:每个元素 ×2
const evens = arr.filter(item => item % 2 === 0); // 筛选:保留偶数
const allEven = arr.every(item => item % 2 === 0); // 全量判断
const hasEven = arr.some(item => item % 2 === 0); // 存在判断
5.5 reduce:万能的聚合器
const sum = arr.reduce((prev, current, index) => {
return prev + current;
}, 0); // 0 是初始值
reduce 是最强大的遍历方法,map、filter 理论上都可以用它来实现。但不要滥用——如果 map 或 filter 就能表达清楚,就优先用它们,代码意图更明确。
5.6 怎么选择?一张表总结
| 场景 | 推荐方法 | 原因 |
|---|---|---|
| 极致性能(算法竞赛) | for 循环 | 无函数调用开销 |
需要 break/continue | for...of | 语义好且支持中断 |
| 需要索引,不中断 | forEach | 参数完整 |
| 转换数据 | map | 纯函数,意图明确 |
| 筛选数据 | filter | 纯函数,意图明确 |
| 聚合计算 | reduce | 万能,但要克制使用 |
| 提前判断 | every / some | 找到答案即停止 |
六、二维数组和 fill 的大坑 🕳️
矩阵是二维数组最常见的应用场景,尤其在动态规划(DP)和大模型的向量矩阵中。
6.1 "教科书级"错误
const matrix = new Array(7).fill([]);
// 看起来创建了 7 行空数组,完美! ✨
但当你这样操作时:
matrix[0][0] = 1;
console.log(matrix);
// [
// [1], ← 只改了第 0 行...
// [1], ← 怎么第 1 行也变了?
// [1], ← 第 2 行也...
// [1],
// [1],
// [1],
// [1]
// ]
所有行都变成了 [1]! 为什么?
因为 fill([]) 中传入的 [] 是一个引用类型。fill 方法会把这个引用的地址复制给数组的每一个槽位——也就是说,7 个位置指向的是内存中的同一个数组!
matrix[0] ──┐
matrix[1] ──┤
matrix[2] ──┼──→ [ ] ← 同一个数组对象!
matrix[3] ──┤
matrix[4] ──┤
matrix[5] ──┤
matrix[6] ──┘
6.2 正确的写法
// 方式一:传统 for 循环
const arr = new Array(7);
for (let i = 0; i < arr.length; i++) {
arr[i] = []; // 每次循环都创建一个全新的数组
}
// 方式二:Array.from(推荐 👍)
const matrix = Array.from({ length: 7 }, () => []);
这样每一行都是独立的新数组,互不影响。
6.3 遍历二维数组的性能优化
const outerLen = arr.length;
for (let i = 0; i < outerLen; i++) {
const innerLen = arr[i].length; // 把长度缓存到外面
for (let j = 0; j < innerLen; j++) {
console.log(arr[i][j]);
}
}
两个优化点:
- 缓存
length:避免每次循环都去读取.length属性 - 缓存内层长度:对于锯齿状数组(每行长度不一),提前计算避免越界
七、从数组出发,构建数据结构知识体系
数组不是孤立的。在面试中,它和以下数据结构紧密关联:
7.1 链表
数组的问题是插入和删除 O(n)。链表的出现就是为了解决这个问题——插入删除 O(1),但代价是随机访问 O(n)(数组是 O(1))。理解这个 trade-off,你就理解了为什么不同场景需要不同数据结构。
7.2 栈(Stack)
栈就是只能在一端操作的受限数组。LIFO(后进先出)的特性让它成为函数调用栈、括号匹配、表达式求值等场景的不二之选。
JS 中直接用 push + pop 就能模拟栈:
const stack = [];
stack.push(1); // 入栈
stack.push(2);
stack.pop(); // 出栈 → 2
7.3 队列(Queue)
队列是只能在一端插入、另一端删除的受限数组。FIFO(先进先出)的特性适合 BFS、消息队列等场景。
const queue = [];
queue.push(1); // 入队
queue.push(2);
queue.shift(); // 出队 → 1
⚠️ 注意:直接用
shift出队是 O(n),在 BFS 密集的场景下可以用数组 + 双指针来模拟队列,把时间复杂度降到 O(1)。详见栈与队列章节。
7.4 树(二叉树)
树是链表的二维扩展。二叉树的数组表示法:
0
/ \
1 2
/ \ / \
3 4 5 6
存储为数组:[0, 1, 2, 3, 4, 5, 6]
左孩子索引:2i + 1
右孩子索引:2i + 2
这是大根堆、小根堆、优先队列的基础。理解树的数组存储,你就打通了数组和树之间的联系。
总结
本文从 JavaScript 的角度,系统地梳理了数组数据结构:
| 知识点 | 核心内容 |
|---|---|
| ADT 思维 | 数组 = 连续存储 + 特定操作,万变不离其宗 |
| JS 数组特性 | 灵活但也带来了 fill 引用陷阱、empty slot 等易错点 |
| 纯函数 vs 副作用 | push/pop/shift/unshift 修改原数组,函数式编程中需谨慎 |
| 遍历方法选型 | 性能优先用 for,语义优先用 for...of,数据处理用 map/filter/reduce |
| 二维数组 | fill([]) 是经典的引用陷阱,用 Array.from 或循环创建才是正解 |
| 知识串联 | 数组 → 链表 → 栈/队列 → 树,形成完整的数据结构网络 |
数组是前端工程师的"母语"数据结构。 深入理解它,不仅能帮你通过面试,更能让你在写业务代码时游刃有余——选择合适的方法、避开常见的坑,写出更优雅的 JavaScript。
📚 下一篇:如何用栈模拟队列 —— JS 原型式面向对象详解