题目:嵌套数组反转 (Nested Array Reversal)
题目描述:
请实现一个函数,该函数接收一个嵌套的数组作为输入。这个数组中可能包含数字以及其他数组。
函数的任务是,在保持原始数组的嵌套结构完全不变的情况下,将其中所有的数字进行反转。具体规则是:将所有数字按其在原数组中的“深度优先遍历”顺序提取出来,组成一个一维序列,然后将这个序列反转,最后将反转后的数字序列依次填充回原数组的结构中相应的位置。
示例:
给定以下输入数组:
输入 (Input):
[1, [2, [3, [4, 5]]]]
该数组中的数字按出现顺序组成的序列为 [1, 2, 3, 4, 5]。
将这个序列反转后得到 [5, 4, 3, 2, 1]。
将反转后的序列填充回原有的结构中,应得到以下输出。
期望输出 (Expected Output):
[5, [4, [3, [2, 1]]]]
解法
这个问题非常经典,它考察的是对数组、递归以及数据结构遍历和重构的理解。从表面看是“反转”,但问题的核心并非简单地颠倒数组,而是在保持原有嵌套结构不变的前提下,将所有数值内容按顺序提取出来,反转这个数值序列,再按原结构顺序填充回去。
我们来一步步拆解这个问题,并给出最优的 JavaScript 实现。
核心思路:两步走的策略
解决这个问题的最直观、最清晰的思路是“两步走”:
-
“拍平”与“提取”:首先,我们需要遍历整个嵌套数组,无论它有多深,把所有的数字(Number类型)按它们出现的顺序提取出来,放进一个一维的普通数组里。
-
“反转”与“填充”:然后,我们将这个只包含数字的一维数组进行反转。最后,我们再次遍历原始的嵌套数组结构,用反转后的数字依次替换掉原来的数字。
这个过程就像是把一座由不同颜色积木搭成的复杂城堡,小心地把所有积木块按顺序拆下来排成一队,然后把这一队积木的颜色顺序颠倒,最后再按原来的图纸把颜色颠倒后的积木块一个个放回原位。
JavaScript 代码实现
基于上述思路,我们可以编写以下代码。我们将整个逻辑封装在一个主函数 reverseNestedArray 中,内部由两个辅助的递归函数分工完成“提取”和“填充”的工作。
JavaScript
/**
* 解决嵌套数组反转问题的核心函数
* @param {Array} arr 原始的嵌套数组
* @returns {Array} 结构相同,但数值内容反转的新数组
*/
function reverseNestedArray(arr) {
// 步骤 1: 提取所有数值到一个扁平数组中
const numbers = [];
function extractNumbers(currentArr) {
for (const item of currentArr) {
if (Array.isArray(item)) {
// 如果是数组,则递归进入下一层
extractNumbers(item);
} else if (typeof item === 'number') {
// 如果是数字,则加入到 numbers 数组中
numbers.push(item);
}
}
}
extractNumbers(arr);
// 此时,numbers = [1, 2, 3, 4, 5]
// 将提取出的数字数组进行反转
numbers.reverse();
// 此时,numbers = [5, 4, 3, 2, 1]
// 步骤 2: 填充反转后的数值回到原始结构中
let index = 0; // 使用一个外部索引来追踪当前要填充的数字
function fillNumbers(currentArr) {
// 使用 map 创建一个新数组,以避免修改正在遍历的原始数组
return currentArr.map(item => {
if (Array.isArray(item)) {
// 如果是数组,递归填充,并返回填充后的新子数组
return fillNumbers(item);
} else if (typeof item === 'number') {
// 如果是数字,从反转后的 numbers 数组中取值并替换
// 使用 index++ 来确保每次都取下一个数字
return numbers[index++];
}
// 如果有非数字、非数组的元素,保持原样(根据题目可以省略)
return item;
});
}
// 返回最终填充完成的新数组
return fillNumbers(arr);
}
// ---- 测试 ----
const input = [1, [2, [3, [4, 5]]]];
const output = reverseNestedArray(input);
console.log(JSON.stringify(output));
// 输出: "[5,[4,[3,[2,1]]]]"
深入讲解
1. 为何选择递归?
对于处理像树或嵌套数组这样具有不确定深度的层级结构,递归是最自然、最优雅的解决方案。
-
extractNumbers函数:它的任务很简单,就是“深度优先遍历”。遇到数组就往里走,遇到数字就收集起来。代码if (Array.isArray(item)) { extractNumbers(item); }正是递归的精髓,它将对当前数组的处理逻辑应用到了所有的子数组上。 -
fillNumbers函数:它同样采用递归来重建结构。map方法非常适合这个场景,因为它会遍历数组的每一项并返回一个新的数组,这保证了我们不会在修改原始数组的同时去遍历它(即“非侵入式”或“不可变性”原则)。当map遇到一个子数组时,我们不能直接复制它,而是需要递归调用fillNumbers来处理这个子数组,以确保深层的数字也被替换掉。
2. index 计数器的重要性
在 fillNumbers 函数中,我们使用了一个外部变量 let index = 0;。这一点至关重要。
我们不能在递归函数内部每次都从反转后的 numbers 数组中取第一个元素(例如使用 numbers.shift())。为什么?
-
性能陷阱:
Array.prototype.shift()操作的时间复杂度是 O(N),其中 N 是数组的剩余长度。因为它需要将删除第一个元素后的所有元素向前移动一位。如果在每次替换数字时都调用shift(),整个填充过程的复杂度会飙升到 O(M2)(M为数字总数),这是非常低效的。 -
逻辑正确性:使用一个共享的、在递归调用之间持续存在的
index计数器,可以确保我们始终按顺序从numbers数组([5, 4, 3, 2, 1])中取值,无论递归进入了多深。它的时间复杂度仅为 O(1),保证了整个填充过程的效率。
3. 算法复杂度分析
-
时间复杂度: O(N)。
-
其中 N 是输入数组中所有元素(包括数字和子数组)的总数。
-
extractNumbers访问每个元素一次,所以是 O(N)。 -
numbers.reverse()的时间复杂度是 O(M),其中 M 是数字的总数 (MleN)。 -
fillNumbers也访问每个元素一次来构建新数组,所以是 O(N)。 -
总时间复杂度为 O(N)+O(M)+O(N)=O(N)。算法效率是线性的,非常高效。
-
-
空间复杂度: O(N)。
-
numbers数组需要 O(M) 的空间。 -
递归调用栈的深度取决于数组的最大嵌套层级,设为 D,需要 O(D) 的空间。
-
fillNumbers函数创建了一个全新的数组作为结果,这需要 O(N) 的空间。 -
综合来看,空间复杂度主要由新创建的数组决定,因此是 O(N)。
-
总结
这个问题看似简单,但它很好地区分了候选人对基础算法的掌握程度。一个优秀的解决方案不仅能正确运行,还应该体现出对递归思想的熟练运用、对数据结构特性的理解(如 map 的不可变性)以及对性能的考量(避免使用 shift())。
我提供的这个两步走、利用递归和外部索引的方案,在可读性、正确性和性能上都达到了很好的平衡,是解决此类问题的理想范式。