先记住这个答案
写一个 flatten(arr),先建一个空的 result,然后遍历 arr:当前元素是数组,就递归调用 flatten 把它拍平,再把得到的元素依次放进 result;不是数组就直接放进去。递归每次都返回一个一维数组,所以不管嵌套几层,最后都会被一层层剥开。判断数组要用 Array.isArray,函数返回新数组,不修改传进来的数组。
- 用
Array.isArray判断,是数组就递归,否则直接放入 - 返回新数组,不修改原数组
- 加一个
depth参数就能对齐原生flat - 嵌套极深会爆栈,改用显式栈循环
基础版:遇到数组就递归
先写出最短能跑的版本,再用几种输入测一下,包括空数组和混着对象的数组。
function flatten(arr) {
const result = []
for (const item of arr) {
if (Array.isArray(item)) {
result.push(...flatten(item)) // 子数组拍平后逐个放进来
} else {
result.push(item)
}
}
return result
}
console.log(JSON.stringify(flatten([1, [2, [3, [4]]], 5])))
console.log(JSON.stringify(flatten([[], [[]], 1])))
console.log(JSON.stringify(flatten([['a'], { b: [1] }, [['c']]])))查看输出与解释
[1,2,3,4,5]
[1]
["a",{"b":[1]},"c"]第二个用例里全是空数组,递归返回的都是空数组,展开后什么也不加,最后只剩 1。第三个用例里对象 { b: [1] } 不是数组,会原样放进结果,对象里面的数组也不会被拆开。扁平化只处理数组嵌套数组的情况。
result.push(...flatten(item)) 也可以写成 result = result.concat(flatten(item)),结果一样,只是 concat 每次都会再建一个新数组,递归层数多的时候多一些拷贝。
对齐原生 flat:加上 depth 参数
原生 arr.flat() 默认只拍平一层,arr.flat(Infinity) 才是全部拍平。面试官经常让你补上这个参数,改动很小:多传一个 depth,只有 depth 大于 0 时才递归,递归时减 1。
function flatten(arr, depth = 1) {
const result = []
for (const item of arr) {
if (Array.isArray(item) && depth > 0) {
result.push(...flatten(item, depth - 1))
} else {
result.push(item)
}
}
return result
}
const nested = [1, [2, [3, [4]]]]
console.log(JSON.stringify(flatten(nested)))
console.log(JSON.stringify(flatten(nested, Infinity))) // Infinity 减 1 还是 Infinity
console.log(JSON.stringify(flatten([1, , [2]], Infinity))) // 稀疏数组
console.log(JSON.stringify([1, , [2]].flat(Infinity)))查看输出与解释
[1,2,[3,[4]]]
[1,2,3,4]
[1,null,2]
[1,2]Infinity - 1 仍然是 Infinity,所以传 Infinity 时每一层都会继续递归,不用单独处理。
- 稀疏数组的差别
[1, , [2]]中间有一个空位,就是这个下标上根本没有元素。for...of遍历时会把空位读成undefined,所以手写版结果里多了一项(JSON.stringify把undefined数组元素打印成null)。原生flat会直接跳过空位。想完全对齐,可以用下标循环,并用i in arr判断这个位置有没有元素。
嵌套太深时改用显式栈
递归每深入一层就占一个调用栈帧,嵌套层数足够多时会抛 RangeError: Maximum call stack size exceeded。栈的上限和引擎、函数本身都有关,没有固定数字。数据来自用户或者程序生成的时候,可以把递归改成自己维护一个栈。
function flattenIter(arr) {
const stack = [...arr]
const result = []
while (stack.length) {
const item = stack.pop() // 从末尾取,所以结果是倒序的
if (Array.isArray(item)) stack.push(...item)
else result.push(item)
}
return result.reverse()
}
let deep = [0]
for (let i = 0; i < 100000; i++) deep = [deep] // 嵌套十万层
console.log(JSON.stringify(flattenIter([1, [2, [3, [4]]], 5])))
console.log(flattenIter(deep).length)查看输出与解释
[1,2,3,4,5]
1栈放在堆内存里,层数再多也不会爆调用栈。因为每次从末尾 pop,元素是倒着进结果的,最后 reverse 一次恢复顺序。十万层的数组用递归版基本一定爆栈,用栈版能正常得到结果。
容易答错的地方
- 用
typeof item === 'array'判断数组 typeof对数组返回的是object,永远不会等于array,这个判断一直是false,结果就是什么都没拍平。要用Array.isArray。item instanceof Array在 iframe 之间传递数组时会判断错,因为不同窗口有各自的Array构造函数,Array.isArray没有这个问题。- 用
toString或join偷懒 arr.toString().split(',')看起来也能拍平,但所有元素都变成了字符串,数字 1 变成'1',对象变成[object Object],元素里本来就带逗号的字符串还会被切开。面试时可以提一句这个写法,但要说明它只适用于纯数字且不在乎类型的场景。
面试官还会怎么问?
能用 reduce 写一行吗?
可以:const flatten = (arr) => arr.reduce((acc, x) => acc.concat(Array.isArray(x) ? flatten(x) : x), [])。思路和循环版一样,只是用 reduce 做累加。每一步 concat 都会生成新数组,数据量大时比 push 慢一些。
数组里引用了自己会怎样?
比如 const a = [1]; a.push(a),递归版会一直展开同一个数组,最终爆栈。原生 flat(Infinity) 同样处理不了。要支持这种情况,得用一个 Set 记录正在展开的数组,遇到已经在里面的就跳过或报错。
时间和空间复杂度是多少?
设所有层加起来一共 n 个元素(包括子数组本身),每个元素只被访问一次,时间是 O(n)。递归版额外空间是结果数组的 O(n) 加上递归深度 d 占的调用栈 O(d);栈版把 O(d) 换成了堆上的栈数组。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。