全栈面试必学:JavaScript 数组从入门到精通

1 阅读10分钟

从内存模型到遍历方法选型,一文吃透 JS 中最重要的数据结构

前言

刷 LeetCode 的时候,你有没有遇到过这样的困惑:

  • 为什么大家都说前端面试考算法,用 JavaScript 写就够了?
  • JS 的数组和 C/Java 的数组有什么不同?
  • forEachfor 循环到底怎么选?
  • 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  —— 这个位置根本不存在!

这个区别在 mapforEach 等遍历方法中会有影响:

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;
}

回到数组操作——在需要保持原数组不变的场景下,应该用 concatslice 或者展开运算符 [...arr] 来替代。

4.2 时间复杂度分析(面试高频 🔥)

方法时间复杂度原因
push / popO(1)尾部操作,不需要移动其他元素
shift / unshiftO(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 mapfiltereverysome

这四个方法都是纯函数——不修改原数组,返回新数组(或布尔值):

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 是最强大的遍历方法,mapfilter 理论上都可以用它来实现。但不要滥用——如果 mapfilter 就能表达清楚,就优先用它们,代码意图更明确。

5.6 怎么选择?一张表总结

场景推荐方法原因
极致性能(算法竞赛)for 循环无函数调用开销
需要 break/continuefor...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]);
  }
}

两个优化点:

  1. 缓存 length:避免每次循环都去读取 .length 属性
  2. 缓存内层长度:对于锯齿状数组(每行长度不一),提前计算避免越界

七、从数组出发,构建数据结构知识体系

数组不是孤立的。在面试中,它和以下数据结构紧密关联:

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 原型式面向对象详解