数组扁平化从入门到对比

168 阅读12分钟

1. 数组扁平化概念

数组扁平化是指将多维数组转换为一维数组的过程。在 JavaScript 中,我们经常需要处理嵌套的数组结构,将其转换为更易于操作的一维形式。

示例:

// 扁平化前
const nestedArray = [1, [2, [3, [4]], 5]];

// 扁平化后
const flattenedArray = [1, 2, 3, 4, 5];

本文将详细介绍数组扁平化的实现方法。

2. 实现方式

2.1 ES5 实现方式

ES5 中实现数组扁平化主要依赖于递归、循环和数组原生方法。下面介绍两种常见的实现方式:

2.1.1 递归实现

实现思路:递归是实现数组扁平化最直观的方法,核心思想是遍历数组中的每个元素,对数组类型的元素递归调用扁平化函数,对非数组元素直接添加到结果数组中。

关键要点:使用 Array.isArray 判断元素类型,通过 depth 参数控制扁平化深度,使用 Array.prototype.push.apply 方法合并递归结果,保持元素顺序不变。

/**
 * 递归实现数组扁平化
 * @param {Array} arr - 要扁平化的数组
 * @param {number} [depth=Infinity] - 扁平化深度,默认为Infinity(完全扁平化)
 * @returns {Array} 扁平化后的新数组
 */
function flattenArray(arr, depth) {
  // 设置默认深度为Infinity
  depth = typeof depth === "undefined" ? Infinity : depth;

  // 如果深度为0,直接返回数组副本
  if (depth === 0) {
    return arr.slice();
  }

  var result = [];
  // 使用for循环遍历数组元素
  for (var i = 0; i < arr.length; i++) {
    // 使用Array.isArray检查是否为数组
    if (Array.isArray(arr[i]) && depth > 0) {
      // 递归处理嵌套数组,深度减1
      var flattenedSubArray = flattenArray(arr[i], depth - 1);
      // apply 方法允许我们指定函数的 this 值和参数列表
      Array.prototype.push.apply(result, flattenedSubArray);
    } else {
      // 非数组元素直接添加到结果中
      result.push(arr[i]);
    }
  }

  return result;
}

优点

  • 实现简单直观,易于理解
  • 支持自定义扁平化深度
  • 保持原始数组元素的顺序

缺点

  • 对于深层嵌套数组可能导致调用栈溢出
  • 每次递归都创建新的函数调用栈,内存消耗较大
  • 对于超大型数组可能仍有性能瓶颈

2.1.2 迭代实现

实现思路:为了避免递归可能导致的调用栈溢出问题,使用栈结构实现非递归的扁平化。将原数组元素依次入栈,遇到数组类型的元素时将其展开并推入栈中,非数组元素则添加到结果数组。

关键要点:使用 while 循环代替递归,通过栈的后进先出特性处理嵌套数组,使用 unshift 保持原始元素顺序。

/**
 * 使用栈实现非递归扁平化
 * @param {Array} arr - 要扁平化的数组
 * @returns {Array} 扁平化后的新数组
 */
function flattenWithStack(arr) {
  var stack = arr.slice();
  var result = [];

  // 当栈不为空时循环处理
  while (stack.length) {
    var item = stack.pop();

    if (Array.isArray(item)) {
      // 将数组元素展开并推入栈中
      stack.push.apply(stack, item);
    } else {
      // 非数组元素添加到结果中
      result.unshift(item); // 使用unshift保持原始顺序
    }
  }

  return result;
}

优点

  • 避免了递归调用栈溢出的风险
  • 对于深层嵌套数组性能更好
  • 内存占用相对较小

缺点

  • 实现相对复杂,不如递归直观
  • 不支持自定义扁平化深度
  • unshift 可能影响性能

2.2 ES6 实现方式

ES6 引入了许多新特性,使得数组扁平化的实现更加简洁和高效。以下介绍三种常见的 ES6 实现方式:

2.2.1 使用 reduce 方法

实现思路:利用 ES6 的 reduce 方法和箭头函数,通过递归方式实现数组扁平化。reduce 方法对数组中的每个元素执行回调函数,将其结果汇总为单个返回值。

关键要点:使用 reduce 初始值为空数组,通过 concat 方法合并结果,对数组元素递归调用扁平化函数,支持自定义扁平化深度。

/**
 * 使用reduce方法递归地扁平化数组
 * @param {Array} arr - 需要扁平化的多维数组
 * @param {number} [depth=1] - 扁平化的深度层级,默认为1(浅扁平化)
 * @returns {Array} - 返回扁平化后的新数组,原数组不会被修改
 */
const flattenWithReduce = (arr, depth = 1) => {
  if (depth <= 0) return arr.slice();
  return arr.reduce(
    (acc, cur) =>
      acc.concat(Array.isArray(cur) ? flattenWithReduce(cur, depth - 1) : cur),
    []
  );
};

优点

  • 代码简洁
  • 支持自定义扁平化深度
  • 保持原始数组元素的顺序
  • 不修改原始数组,返回新数组

缺点

  • 对于深层嵌套数组可能导致调用栈溢出
  • 性能较差,特别是对于大型嵌套数组
  • 每次递归都创建新的函数调用栈,内存消耗较大

2.2.2 使用扩展运算符

实现思路:利用 ES6 的扩展运算符(...)和 some 方法,通过循环迭代方式实现数组扁平化。扩展运算符可以将数组展开为单独的元素,结合 concat 方法可以实现一层扁平化。

关键要点:使用 some 方法检测数组中是否还有嵌套数组,使用 while 循环和扩展运算符逐层展开数组,直到完全扁平化。

/**
 * 递归地扁平化嵌套数组(使用扩展运算符实现)
 *
 * @param {Array} arr - 需要扁平化的嵌套数组(可以包含任意深度的子数组)
 * @returns {Array} - 扁平化后的一维数组
 */
const flattenWithSpread = (arr) => {
  while (arr.some((item) => Array.isArray(item))) {
    arr = [].concat(...arr);
  }
  return arr;
};

优点

  • 代码简洁
  • 不使用递归,避免了调用栈溢出的风险
  • 实现直观易懂,易于维护和理解

缺点

  • 不支持自定义扁平化深度,只能完全扁平化数组
  • 每次循环都创建新数组,对于非常大的数组可能导致内存消耗较大
  • 使用some方法进行数组检测,在每次迭代中都需要遍历整个数组,可能影响性能

2.2.3 迭代实现

实现思路:和 ES5 的迭代实现相似,ES6 版本同样使用迭代方式避免递归可能导致的调用栈溢出问题,不同的地方在于 ES6 利用了新特性使。

/**
 * 将嵌套数组扁平化为一维数组(ES6迭代实现)
 * @param {Array} arr - 需要扁平化的嵌套数组
 * @returns {Array} - 扁平化后的一维数组
 */
const flattenIteratively = (arr) => {
  const queue = [...arr]; // 使用ES6扩展运算符创建数组副本
  const result = [];
  while (queue.length) {
    const first = queue.shift();
    if (Array.isArray(first)) {
      queue.unshift(...first); // 使用扩展运算符展开数组
    } else {
      result.push(first);
    }
  }
  return result;
};

优点

  • 避免了递归调用栈溢出的风险
  • 对于深层嵌套数组性能更好
  • 内存占用相对较小

缺点

  • 实现相对复杂,不如递归直观
  • 不支持自定义扁平化深度
  • unshift 可能影响性能

2.3 原生 flat 方法

ES2019 引入了数组的原生 flat 方法,提供了最简单和最高效的数组扁平化解决方案。

/**
 * 使用原生flat方法扁平化数组
 * @param {Array} arr - 需要扁平化的数组
 * @param {number} [depth=Infinity] - 扁平化深度,默认为Infinity(完全扁平化)
 * @returns {Array} - 扁平化后的新数组
 */
const nativeFlatten = (arr, depth = Infinity) => arr.flat(depth);

优点

  • 语法简洁明了,使用最方便
  • 原生实现,性能最优
  • 支持自定义扁平化深度
  • 不修改原始数组

缺点

  • 浏览器兼容性问题(需要 ES2019 支持)
  • 在旧版浏览器中需要使用 polyfill

3. 性能对比

不同的数组扁平化方法在性能上存在显著差异,特别是在处理不同深度和大小的嵌套数组时。以下是基于测试的性能比较:

3.1 浅层嵌套数组

对于浅层嵌套数组(2 层嵌套,每层 5 个元素):

方法平均执行时间相对性能
原生 flat 方法约 0.0011ms最快
扩展运算符方法约 0.0012ms很快
迭代方法(ES6)约 0.0014ms很快
迭代方法(ES5)约 0.0018ms较快
递归方法(ES5)约 0.0019ms较快
reduce 方法约 0.0047ms中等

对于浅层嵌套数组,原生 flat 方法表现最佳,扩展运算符方法和迭代方法(ES6)也表现良好。递归方法(ES5)和迭代方法(ES5)表现较快,而 reduce 方法相对较慢。 x

3.2 中等深度嵌套数组

对于中等深度嵌套数组(5 层深度,每层 5 个元素):

方法平均执行时间相对性能
扩展运算符方法约 0.0852ms最快
原生 flat 方法约 0.1050ms很快
递归方法(ES5)约 0.1278ms较快
迭代方法(ES6)约 0.1398ms较快
迭代方法(ES5)约 0.4254ms中等
reduce 方法约 0.5344ms中等

对于中等深度的嵌套数组,扩展运算符方法和原生 flat 方法表现最佳,递归方法(ES5)和迭代方法(ES6)也表现良好,迭代方法(ES5)和 reduce 方法性能相对较慢。

3.3 深层嵌套数组

对于深层嵌套数组(9 层深度,每层 3 个元素):

方法平均执行时间相对性能
原生 flat 方法约 0.892ms最快
扩展运算符方法约 0.942ms很快
递归方法(ES5)约 1.292ms很快
迭代方法(ES6)约 1.432ms较快
reduce 方法约 4.100ms中等
迭代方法(ES5)约 13.864ms较慢

对于深层嵌套数组,原生 flat 方法和扩展运算符方法表现最佳,递归方法(ES5)和迭代方法(ES6)表现良好。reduce 方法性能中等,而迭代方法(ES5)在深层嵌套中性能下降明显。

3.4 结论

基于上述性能测试结果,我们可以得出以下结论:

  1. 最佳选择

    • 对于浅层嵌套数组:原生 flat 方法和扩展运算符方法性能最佳
    • 对于中等深度嵌套数组:扩展运算符方法和原生 flat 方法性能最佳
    • 对于深层嵌套数组:原生 flat 方法和扩展运算符方法性能最佳
  2. 性能考量

    • 原生 flat 方法和扩展运算符方法在各种嵌套深度下表现都很优秀,特别是在深层嵌套中
    • reduce 方法在浅层嵌套中表现一般,在深层嵌套中性能下降
    • 迭代方法(ES5)在深层嵌套数组中性能下降明显
  3. 内存考量

    • 迭代方法内存占用相对较小
    • 扩展运算符和 reduce 方法在处理大型数组时可能导致较高的内存消耗
  4. 综合推荐

    • 一般用途:优先使用原生 flat 方法(如果浏览器支持)或扩展运算符方法
    • 需要兼容性:对于一般用途,使用迭代方法(ES6)或递归方法(ES5)
    • 处理超大型数组:考虑使用迭代方法(ES6)或递归方法(ES5)

4. 扩展思考

4.1 性能差异原因分析

不同数组扁平化方法的性能差异主要源于以下几个因素:

4.1.1 实现机制差异

  1. 原生方法 vs 自定义实现

    • 原生 flat() 方法由浏览器引擎底层实现,经过了高度优化,通常比 JavaScript 层面的实现更高效。
    • 原生方法可以利用浏览器引擎的内部优化,如内存管理和垃圾回收机制。
  2. 递归 vs 迭代

    • 递归方法(如 reduce 和 ES5 递归)需要维护调用栈,每层嵌套都会创建新的函数调用帧,增加内存开销。
    • 迭代方法(如 ES5/ES6 迭代和扩展运算符)使用循环结构,避免了函数调用栈的开销,在深层嵌套时更高效。
  3. 数组操作效率

    • concat() 方法(在递归和 reduce 方法中使用)需要创建新数组并复制元素,开销较大。
    • 扩展运算符 ... 在内部实现上比 concat() 更高效,特别是在 ES6 环境中。
    • push()/unshift() 方法的效率差异:push() 直接在数组末尾添加元素,无需移动现有元素,时间复杂度为 O(1),效率很高;而 unshift() 需要在数组头部插入元素,导致所有现有元素向后移动,时间复杂度为 O(n),效率较低。。

4.1.2 数据结构特性影响

  1. 嵌套深度影响

    • 递归方法在深层嵌套时性能下降明显,因为调用栈深度增加,可能导致栈溢出。
    • 迭代方法和扩展运算符方法在处理深层嵌套时相对稳定,不受调用栈限制。
  2. 数组大小影响

    • 对于大型数组,创建新数组的方法(如 reduce 和扩展运算符)内存消耗较大。
    • 原地修改的方法(如某些迭代实现)在处理大型数组时内存效率更高。

4.2 高效数组处理策略

在 JavaScript 中,数组操作的效率对应用性能有着重要影响。以下是几个提高数组处理效率的策略:

  1. 数组修改方法

    • 操作数组末尾的方法(push/pop)比操作数组开头的方法(shift/unshift)性能高得多,因为后者需要移动所有元素
    • push 的时间复杂度为 O(1),而 unshift 的时间复杂度为 O(n)
    • 在需要频繁从两端操作数组时,考虑使用双端队列数据结构
    const arr = [1, 2, 3, 4, 5];
    // 性能较好:在数组末尾添加元素
    console.time("push");
    for (let i = 0; i < 100000; i++) {
      arr.push(i);
    }
    console.timeEnd("push"); // 通常在几毫秒内完成
    
    const arr2 = [1, 2, 3, 4, 5];
    // 性能较差:在数组开头添加元素
    console.time("unshift");
    for (let i = 0; i < 100000; i++) {
      arr2.unshift(i);
    }
    console.timeEnd("unshift"); // 可能需要几百毫秒甚至更长
    
  2. 数组连接与复制

    • 使用 concat 连接数组时会创建新数组,对于大型数组可能导致性能问题
    • 当需要合并多个数组时,使用 push 配合 apply 或扩展运算符通常更高效
    const arr1 = [1, 2, 3];
    const arr2 = [4, 5, 6];
    
    // 方法1:concat(创建新数组)
    const newArr1 = arr1.concat(arr2);
    
    // 方法2:push with apply(修改原数组,性能更好)
    Array.prototype.push.apply(arr1, arr2);
    
    // 方法3:ES6扩展运算符(语法简洁,但创建新数组)
    const newArr2 = [...arr1, ...arr2];
    
  3. 数组查找与过滤

    • 对于简单查找,indexOfincludes 方法适用于小型数组
    • 对于复杂条件查找,findfilter 提供更灵活的 API,但性能略低
    • 对于频繁查找操作的大型数组,考虑使用 Map 或 Set 数据结构
    const arr = [1, 2, 3, 4, 5];
    
    // 简单查找
    const hasThree = arr.includes(3); // 返回 true
    
    // 复杂条件查找
    const firstEven = arr.find((num) => num % 2 === 0); // 返回 2
    
    // 使用Set进行高效查找
    const set = new Set(arr);
    const hasThreeInSet = set.has(3); // 返回 true,查找复杂度为O(1)