深入理解数组:方法、底层实现与面试必备技巧

96 阅读10分钟

前端开发中,数组几乎是每个开发者都必须掌握的基本数据结构之一。从遍历、查找、排序到筛选、映射,数组方法无处不在,几乎贯穿了每一个项目。今天,我们将深入解析 JavaScript 数组的常用方法,探讨它们背后的底层实现,同时提供一些实际的代码示例,帮助你在面试中脱颖而出。


一、数组基础回顾:数组是如何工作的?

在我们进入数组的各种方法之前,先来简单回顾一下数组的基础知识。

1. 什么是数组?

数组(Array)是一种用于存储多个值的有序数据结构。在 JavaScript 中,数组是 对象,但它有额外的特性:每个元素都有一个 索引,从 0 开始,表示元素的位置。

const arr = [10, 20, 30, 40];
console.log(arr[0]);  // 输出 10

密集数组与稀疏数组:详细解析

在 JavaScript 中,数组是一个非常灵活的数据结构,它不仅可以存储多个元素,而且能够动态地改变大小和内容。然而,数组在实现时存在着一些特性,比如 密集数组稀疏数组,这两个概念对于数组的性能、遍历等操作有着重要影响。

什么是密集数组?

密集数组(Dense Array)是指所有的数组索引位置都有值,即数组中每一个位置都被赋予了有效的元素。

  • 特点:数组的每一个位置都有元素,没有“空”或“未定义”的位置。
  • 举例:比如 [1, 2, 3, 4],这个数组就是一个密集数组,因为每个索引位置都有值。
const denseArr = [10, 20, 30, 40];
console.log(denseArr[0]);  // 输出 10
console.log(denseArr[3]);  // 输出 40

底层实现

  • 内存分配:密集数组的实现通常会为每个数组元素分配连续的内存空间。在 JavaScript 的 V8 引擎中,这种数组通常被称为“优化数组”。
  • 性能:由于数组元素存储在连续的内存块中,访问和操作密集数组的效率非常高。这也是为什么遍历密集数组时,操作通常比稀疏数组更高效的原因。

什么是稀疏数组?

稀疏数组(Sparse Array)是指数组中某些索引位置没有元素,或者说数组中有索引位置的值是 undefined 或者根本没有值。

  • 特点:数组中的某些位置没有值,这些位置称为“空”或“稀疏”位置。
  • 举例:例如 new Array(5),它会创建一个长度为 5 的数组,但没有任何元素,数组中的位置是“空”的。
const sparseArr = new Array(5);
console.log(sparseArr);  // [ <5 empty items> ]
console.log(sparseArr[0]);  // 输出 undefined

底层实现

  • 内存分配:稀疏数组的内存分配更加灵活,空的索引位置不一定占用内存空间。JavaScript 引擎(如 V8)会在底层为这些空槽分配内存空间,但这些空槽不会占用实际的数组元素存储空间。
  • 性能问题:由于数组的元素不是存储在连续的内存块中,因此访问稀疏数组的元素时,可能会出现额外的查找和计算开销,性能相对较差。

稀疏数组与密集数组的区别:

特性密集数组稀疏数组
元素位置所有元素的位置都有值某些索引位置为空
内存分配元素占用连续的内存空间空槽(未定义位置)不占用实际的内存空间
性能由于连续内存,操作较高效由于不连续的内存,访问操作性能较差
典型例子[1, 2, 3, 4]new Array(5)[1, , 3]

稀疏数组的示例和影响

  1. 创建稀疏数组
const sparseArr = new Array(5);  // 长度为 5,但没有初始化任何元素
console.log(sparseArr);  // [ <5 empty items> ]

这里,new Array(5) 创建了一个具有 5 个“空”位置的数组。这些“空”位置并不等同于 undefined,而是特定的 空槽,并没有实际的值。这就是稀疏数组。

  1. 稀疏数组的遍历

当我们使用诸如 forEach()map()filter() 等方法遍历稀疏数组时,这些空槽会被跳过,不会被执行任何操作:

const sparseArr = new Array(5);
sparseArr[2] = 3;

sparseArr.forEach((item, index) => {
  console.log(`Index: ${index}, Value: ${item}`);
});

输出

Index: 2, Value: 3

在这个例子中,数组的其他位置是“空”的,因此 forEach() 会跳过这些位置。


为什么稀疏数组影响性能?

  1. 非连续内存

    • 稀疏数组的元素不是连续存储的,因此它的内存布局通常比较复杂。虽然空槽没有占用内存,但在遍历数组时,JavaScript 引擎需要额外的处理来跳过空槽,这会增加计算成本。
  2. 访问元素效率低

    • 访问稀疏数组的某个元素时,可能会经历更多的查找步骤。对于每个索引,JavaScript 引擎会进行判断,确认该位置是否有值,进而决定如何处理。
  3. 迭代方法的开销

    • 方法如 forEach()map()filter() 会对数组的所有元素执行操作,即使是空槽,它们也会执行额外的查找和跳过操作,影响性能。

如何避免稀疏数组?

  1. 避免使用 new Array(length) :创建稀疏数组时,避免使用 new Array(length),尤其是大数组。如果你需要初始化一个数组,可以使用 Array.of() 或者 Array.fill()
// 使用 fill 来初始化一个密集数组
const arr = new Array(5).fill(0);  // [0, 0, 0, 0, 0]
  1. 使用对象替代稀疏数组:如果你的需求是稀疏存储数据,而非顺序的数组访问,考虑使用 Object 来存储键值对,这样可以避免稀疏数组的性能问题。
const obj = { 0: 'a', 2: 'b' };  // 而不是 [ 'a', , 'b' ]

总结:

  • 密集数组:数组中的每个位置都被赋予了有效的值。内存布局连续,操作高效,通常性能更好。
  • 稀疏数组:某些数组位置没有值,空槽可能导致性能问题,特别是在遍历和元素访问时。JavaScript 引擎需要处理额外的复杂性,影响性能。

了解这两个概念的差异,可以帮助你在实际开发中做出性能优化的决策,避免使用稀疏数组带来的性能问题。如果你需要创建一个固定长度的数组,最好使用 fill()Array.of() 来避免创建稀疏数组。


二、常见数组方法与实现原理

1. forEach() — 遍历数组,做副作用操作

forEach() 是用来遍历数组的,它执行指定的回调函数,针对每个数组元素进行操作,但它 不会返回新数组

const arr = [1, 2, 3];
arr.forEach((item, index) => {
  console.log(`Index: ${index}, Value: ${item}`);
});

底层实现forEach() 遍历数组时,直接执行回调函数,对每个元素执行操作。由于它 没有返回值,所以它通常用于副作用操作,例如打印日志、修改 DOM、更新外部变量等。

2. map() — 数组映射,返回新数组

map() 用来对数组元素进行转换,并返回 新数组

const arr = [1, 2, 3];
const newArr = arr.map(item => item * 2);
console.log(newArr);  // [2, 4, 6]
  • 原理map() 会对每个元素应用回调函数,并将每次回调的结果加入到新数组中。
  • 特点:返回一个新数组,原数组不受影响。

3. filter() — 数组筛选,返回符合条件的元素

filter() 用来筛选出符合条件的元素,返回一个新的数组,包含所有符合条件的元素。

const arr = [1, 2, 3, 4, 5];
const evenArr = arr.filter(item => item % 2 === 0);
console.log(evenArr);  // [2, 4]

原理filter() 遍历数组,执行回调函数,只保留回调函数返回 true 的元素,最终返回一个新的数组。

4. reduce() — 数组归约,返回累加值

reduce() 用来对数组中的元素进行累加或其他操作,最终返回一个单一的值。

const arr = [1, 2, 3, 4];
const sum = arr.reduce((acc, item) => acc + item, 0);
console.log(sum);  // 10
  • acc:累加器,保存前一次回调的结果。
  • item:当前元素。
  • 初始值:如果不提供初始值,reduce() 会把第一个元素作为初始值。

5. find() — 查找符合条件的第一个元素

find() 用于查找数组中第一个符合条件的元素。

const arr = [1, 2, 3, 4, 5];
const result = arr.find(item => item > 3);
console.log(result);  // 4

原理find() 会遍历数组,返回 第一个符合条件 的元素,一旦找到符合条件的元素,遍历就会停止。


三、数组的底层实现与性能

理解数组方法背后的底层实现,能帮助你在面试中回答更深层次的问题,甚至在代码中做出性能优化。

1. 稀疏数组与密集数组

  • 稀疏数组:当数组的某些索引没有元素时,它就是稀疏数组。例如:new Array(5) 会创建一个长度为 5 的数组,但里面没有元素,只有空槽。
const arr = new Array(5);
console.log(arr);  // [ <5 empty items> ]
  • 密集数组:每个索引都有值,类似 [1, 2, 3, 4]

2. 稀疏数组性能问题

稀疏数组 会影响遍历的性能,因为:

  • forEachmapfilter 等方法会遍历数组的每个索引(包括空槽),导致性能降低。
  • for 循环 更适合用于遍历稀疏数组。

3. 底层实现:数组的动态扩展与优化

JavaScript 数组并不是固定长度的,数组长度会根据需要动态扩展。内部使用类似 哈希表 的方式来管理数组的元素,同时提供高效的索引访问。

Array.prototype 上的方法(如 map()filter() 等)会通过原型链继承到所有数组实例上,不需要每次都重新定义。


四、面试中常见的数组问题

面试时,除了基本的数组方法使用,面试官还会考察你对数组性能优化和底层原理的理解。以下是一些常见的数组面试题,帮助你巩固知识点。

1. 数组去重

const arr = [1, 2, 2, 3, 4, 4, 5];
const uniqueArr = [...new Set(arr)];
console.log(uniqueArr);  // [1, 2, 3, 4, 5]

2. 实现 map()filter()reduce()

假如面试官要求你手写 map()filter()reduce(),你可以这样实现:

// 手写 map
Array.prototype.myMap = function(callback) {
  const result = [];
  for (let i = 0; i < this.length; i++) {
    result.push(callback(this[i], i, this));
  }
  return result;
};

// 手写 filter
Array.prototype.myFilter = function(callback) {
  const result = [];
  for (let i = 0; i < this.length; i++) {
    if (callback(this[i], i, this)) {
      result.push(this[i]);
    }
  }
  return result;
};

// 手写 reduce
Array.prototype.myReduce = function(callback, initialValue) {
  let accumulator = initialValue;
  for (let i = 0; i < this.length; i++) {
    accumulator = callback(accumulator, this[i], i, this);
  }
  return accumulator;
};

3. 数组扁平化

const arr = [1, [2, 3], [4, [5]]];
const flatArr = arr.flat(2);
console.log(flatArr);  // [1, 2, 3, 4, 5]

如果需要支持老版本的 JS 环境,可以手动实现扁平化:

function flatten(arr) {
  return arr.reduce((acc, val) => acc.concat(Array.isArray(val) ? flatten(val) : val), []);
}

console.log(flatten([1, [2, 3], [4, [5]]]));

4. 深拷贝与浅拷贝

面试时,可能会考察你对浅拷贝和深拷贝的理解。数组的拷贝方法有:

  • 浅拷贝:直接赋值或使用 slice()concat()
  • 深拷贝:使用 JSON.parse(JSON.stringify()),或者手动实现递归深拷贝。
// 深拷贝
const deepCopy = JSON.parse(JSON.stringify(arr));

五、总结:

今天我们深入讨论了数组的常用方法,包括 forEach()map()filter()reduce(),并探讨了它们的底层实现和性能问题。此外,我们还提供了一些实际的代码例子,帮助你在面试中更好地应对数组相关的题目。

我是小阳,我们下期见!!!