学习笔记二十 —— 数据结构互转(数组 -> 树 || 树 -> 数组)

95 阅读16分钟

我们现在来深度剖析数据结构转换类问题的解决逻辑。 这类问题在面试中高频出现(如二叉查找树转双向链表、扁平数组转树结构、树形结构扁平化等),本质上都围绕着 数据关系的重组 展开。 理解以下四个核心层次,能帮你建立起系统性的解题框架:


🔍 一、问题识别:明确转换的本质(关键!)

在动手前,先问自己:题目到底在要求我改变什么? 转换类问题通常围绕以下三个核心维度:

  1. 关系的重组

    • 父子 → 线性(如树 → 链表):本质是通过特定顺序(中序、前序)将树节点“拉直”,调整指针方向(如 left/rightprev/next)。
    • 线性 → 层级(如数组 → 树):通过 idparentId 字段重建父子引用,将松散节点组织为嵌套结构。
    • 多叉 ↔ 二叉:通过 “左子右兄弟” 法调整节点引用关系。
  2. 数据存储的调整

    • 原地转换:不创建新节点,仅调整指针(如 BST 转双向链表)。
    • 生成新结构:创建新对象或复制数据(如扁平数组转树时深拷贝避免引用污染)。
  3. 顺序的保持

    • 是否需维持原数据顺序?(如 BST 转链表需保持中序升序)。
    • 是否需要稳定输出顺序?(如多子树情况下的兄弟节点顺序)。

💡 关键洞察:所有转换问题都是对节点关系 + 数据顺序 + 存储方式的重定义。明确这三点,就抓住了题目内核。


🧩 二、通用四步解题框架

无论题目如何变化,解题都遵循以下结构化步骤:

1. 构建映射关系(Mapping)

快速访问节点是解题的基础:

  • 哈希表(Map):以唯一标识(如 id)为 Key,节点对象为 Value,实现 O(1) 查找。
  • 递归指针传递:在树形问题中,通过递归参数传递父节点或链表尾节点。

示例:数组转树时,先遍历所有节点存入 Map,后续通过 parentId 直接找到父节点挂载子节点。

2. 连接节点(Linking)

根据目标结构确定连接策略:

  • 树 → 链表
    • 中序遍历:按升序访问节点,将当前节点链接到链表尾部,并更新尾指针。
    • 递归分治:先处理左子树返回链表头,链接当前节点,再处理右子树。
  • 数组 → 树
    • 自顶向下:从根节点(parentId=null)开始递归挂载子节点。
    • 自底向上:通过 Map 直接挂载所有子节点到父节点,最后收集根节点。

3. 处理边界与异常(Edge Handling)

忽略边界是面试常见失分点:

  • 根节点/叶子节点:根节点无父节点,叶子节点无子节点。
  • 循环依赖:检查 A→B→A 的死循环(通过临时 Set 记录路径)。
  • 孤立节点:无父节点且非根节点,需明确处理方式(丢弃或视为根)。
  • 重复ID:导致映射冲突,需提前校验。

4. 验证与优化(Validation & Optimization)

完成转换后必须验证:

  • 结构验证:检查链表能否双向遍历;树是否所有节点连通。
  • 顺序验证:链表是否有序;树的层级是否正确。
  • 复杂度优化:如数组转树时,用 Map 方案(O(n))替代递归嵌套循环(O(n²))。

⚙️ 三、递归 vs. 迭代:策略选择原则

两种思路各有适用场景,选错会导致代码复杂或性能低下:

策略适用场景优势典型案例
递归树形结构、问题可分治(子问题独立)代码简洁,符合树形思维惯性BST 转链表;树形扁平化
迭代存在循环依赖、避免栈溢出、追求低复杂度无递归深度限制,性能稳定数组转树(Map法);BFS 树遍历

💡 递归的陷阱:在非独立子问题(如图结构)中使用递归,会导致重复计算或栈溢出。


🧪 四、贯穿案例:BST 转排序双向链表

以经典题为例,看如何应用上述框架:

  1. 问题识别

    • 关系:树节点(left/right)→ 链表节点(prev/next)。
    • 顺序:中序升序。
    • 存储:原地调整指针。
  2. 四步解题

    • 映射:通过中序遍历顺序访问节点。
    • 连接:维护 prev 指针指向上一个节点,将 prev.next = currentcurrent.prev = prev
    • 边界:头节点(无 prev)、尾节点(无 next)。
    • 验证:链表能否从头到尾升序遍历,且能反向遍历。
  3. 递归 vs. 迭代

    • 递归:中序遍历内部更新指针,代码简洁。
    • 迭代:显式栈模拟中序,避免递归开销。

💎 总结:解题心智模型

  1. 先解剖问题:明确关系、顺序、存储三要素。
  2. 再选择武器:递归用于分治问题,迭代用于复杂依赖。
  3. 后处理边界:孤立节点、循环引用、空输入需主动处理。
  4. 终验证正确:通过遍历、打印或断言验证结构。

掌握这一框架后,你会发现:所有转换题都是同一套逻辑的不同包装。接下来可尝试用此框架分析扁平数组转树或树形数据扁平化,体会方法的通用性。


以下是数组转树类面试题的常见考察形式及解题思路分析,结合问题识别、映射策略、连接逻辑和边界处理四步框架进行拆解:

一、常见题型及变种

1. 单链表式嵌套

  • 题目特征:数组元素通过 parent 字段形成单链结构,目标转为嵌套对象(每个节点最多一个子节点)。
  • 示例
    const obj = [
      { id: 1, parent: null },
      { id: 2, parent: 1 },
      { id: 3, parent: 2 }
    ]
    // 目标:{ obj: { id:1, child: { id:2, child: { id:3 }}}}
    
  • 核心考点
    • 关系的重组:线性父子关系 → 嵌套单链结构。
    • 顺序要求:输入无序时需保证父节点先处理(否则子节点找不到父节点)。

2. 多叉树构造(高频题)

  • 题目特征:通过 pidid 构建多子树结构,每个节点可能有多个子节点。
  • 示例
    const data = [
      { id: 1, pid: null, name: '部门1' },
      { id: 2, pid: 1, name: '子部门1' },
      { id: 3, pid: 1, name: '子部门2' }
    ]
    // 目标:[{ id:1, children: [ {id:2}, {id:3} ]}]
    
  • 核心考点
    • 层级关系重建:扁平节点 → 树形层级。
    • 性能优化:避免递归嵌套循环(O(n²)),需用 Map 实现 O(n) 。

3. 有序数组转平衡二叉搜索树(LeetCode 108)

  • 题目特征:升序数组转为高度平衡的二叉搜索树(左右子树高度差 ≤1)。
  • 示例
    输入:[-10, -3, 0, 5, 9]
    输出:二叉树的根节点(如 val=0, 左子树-10,右子树5
  • 核心考点
    • 分治策略:数组二分,中点作根节点,左右子数组递归构建子树。
    • 平衡性保证:严格取中点可自然满足平衡。

4. 复杂变种:多根节点树 & 动态挂载

  • 变种示例
    • 多根节点:多个 pid=null 的节点作为独立树的根。
    • 动态挂载:子节点可能先于父节点被遍历(需预创建空父节点)。

二、通用解题框架应用

无论题型如何变化,均遵循 映射 → 连接 → 边界 → 优化 四步:

1. 映射策略(Mapping)

  • 目标:快速定位节点。
  • 方案
    • 单链表/多叉树:用 Map<id, node> 存储节点,实现 O(1) 查找。
    • 二叉搜索树:无需额外映射,直接通过数组下标二分访问。

2. 连接逻辑(Linking)

  • 单链表式
    • 遍历数组,将子节点挂到父节点的 child 属性下(父节点需先存在于 Map 中)。
  • 多叉树
    • 遍历数组,若当前节点 pid 非空,则从 Map 中找到父节点,将当前节点加入其 children 数组。
  • 二叉搜索树
    • 递归分治:取中点 mid 为根,[left, mid-1] 构建左子树,[mid+1, right] 构建右子树。

3. 边界处理(Edge Handling)

  • 孤立节点:无父节点且非根节点时,需丢弃或标记为根(根据题意)。
  • 循环依赖:用 Set 记录当前路径,发现重复节点立即报错(如 A→B→A)。
  • 空输入:数组为空时返回 []null

4. 复杂度优化

  • 递归陷阱:多叉树避免嵌套循环递归(O(n²)),改用 Map 一次遍历(O(n))。
  • 二叉搜索树:递归栈深度 O(log n),为最优解。

三、题型专项分析

1. 单链表式嵌套

  • 正解:遍历数组两次:
    1. 第一次:将所有节点存入 Map;
    2. 第二次:将非根节点挂到父节点的 child 下。
  • 边界:根节点(parent=null)作为起点。

2. 多叉树构造(Map 最优解)

function listToTree(data) {
  const map = new Map();
  const roots = [];
  // 1. 映射:创建节点并存入 Map
  data.forEach(item => {
    map.set(item.id, { ...item, children: [] });
  });
  // 2. 连接:挂载子节点
  data.forEach(item => {
    if (item.pid === null) {
      roots.push(map.get(item.id)); // 根节点
    } else {
      const parent = map.get(item.pid);
      parent?.children.push(map.get(item.id)); // 子节点挂载
    }
  });
  return roots;
}

3. 有序数组转平衡 BST

function sortedArrayToBST(nums) {
  const build = (left, right) => {
    if (left > right) return null;          // 边界:空区间
    const mid = left + Math.floor((right - left) / 2); // 取中点
    const root = new TreeNode(nums[mid]);  // 根节点
    root.left = build(left, mid - 1);       // 左子树递归
    root.right = build(mid + 1, right);     // 右子树递归
    return root;
  };
  return build(0, nums.length - 1);
}

四、递归 vs 迭代选择策略

题型推荐方法原因
单链表嵌套迭代 + Map避免递归栈溢出,逻辑直接
多叉树构造迭代 + Map时间复杂度 O(n) 最优,避免递归嵌套循环
有序数组转 BST递归分治代码简洁,分治天然匹配树结构
动态挂载(节点乱序)迭代 + Map支持子节点先处理,预创建父节点

💡 避坑指南

  • 递归用于 分治问题(如 BST 构建),而非线性缩减数据集;
  • 迭代 + Map 是多层级重组的最优解,面试优先使用。
    理解问题本质(关系+顺序+存储),再选择武器(递归/迭代),即可系统性攻破所有变种。

以下是针对单链表式嵌套结构的高频面试考题及实战分析。我将从题目描述、核心考点、解题思路、代码实现(JavaScript) 四个维度展开,助你系统掌握此类问题的解法。

1. 链表转嵌套对象

题目描述
将线性链表结构转换为嵌套对象,每个节点的 child 指向下一节点:

// 输入
const list = [
  { id: 1, parent: null },
  { id: 2, parent: 1 },
  { id: 3, parent: 2 }
];

// 输出
{
  id: 1,
  child: {
    id: 2,
    child: {
      id: 3
    }
  }
}

核心考点

  • 关系重组(线性 → 嵌套)
  • 边界处理(尾节点无 child
  • 时间复杂度优化(避免递归嵌套循环)

解题思路

  1. 映射建索引:遍历链表,用 Map 存储节点(key=id)。
  2. 连接父子节点:二次遍历,若节点有 parent,则从 Map 中取父节点并将子节点挂载到 child
  3. 定位根节点parentnull 的节点是嵌套结构的入口。

代码实现

const listToNested = (list) => {
  let root = null
  const map = new Map()

  for (const item of list) {
    map.set(item.id, { id: item.id })
  }

  for (const item of list) {
    if (item.parent === null) {
      root = map.get(item.id)
    } else {
      const pNode = map.get(item.parent)
      pNode.child = map.get(item.id)
    }
  }
  return root
}

2. 链表转嵌套对象(变种)

题目描述
将线性链表结构转换为嵌套对象,每个节点的 child 指向下一节点:

// 输入
const list = [
  { id: 1, next: 2 },
  { id: 2, next: 3 },
  { id: 3, next: null }
];

// 输出
{
  id: 1,
  child: {
    id: 2,
    child: {
      id: 3
    }
  }
}

核心考点

  • 关系重组(线性 → 嵌套)
  • 边界处理(尾节点无 child
  • 时间复杂度优化(避免递归嵌套循环)

解题思路

  1. 映射建索引:遍历链表,用 Map 存储节点(key=id)。
  2. 连接父子节点:二次遍历,若节点有 next,则从 Map 中取子节点挂载到 child
  3. 定位根节点:没有被next引用的的节点是嵌套结构的入口。

代码实现

const listToNested = (list) => {
  if (list.length === 0) return null;

  const map = new Map();
  const childIds = new Set(); // 记录所有被引用的节点ID

  // 第一次遍历:建立节点映射,记录被引用节点
  for (const item of list) {
    // 只保留 id 和 child,丢弃 next
    map.set(item.id, { id: item.id }); 
    if (item.next !== null) {
      childIds.add(item.next); // 记录被引用节点ID
    }
  }

  // 第二次遍历:构建 child 关系
  let root = null;
  for (const item of list) {
    const node = map.get(item.id);
    if (item.next !== null) {
      const childNode = map.get(item.next);
      node.child = childNode; // 建立引用
    }
    // 根节点:未被任何节点引用
    if (!childIds.has(item.id)) {
      if (root) throw new Error("存在多个根节点"); // 多根异常
      root = node;
    }
  }

  return root || null; // 处理无根节点
};

3. 嵌套结构扁平化

题目描述
将嵌套对象还原为线性链表数组:

// 输入
const obj = {
  id: 1,
  child: {
    id: 2,
    child: { id: 3 }
  }
};

// 输出
[
  { id: 1, next: 2 },
  { id: 2, next: 3 },
  { id: 3, next: null }
]

核心考点

  • 遍历策略选择(迭代 vs 递归)
  • 引用断开(避免循环引用导致内存泄漏)

解题思路

  1. 迭代法:用指针 current 遍历链表,依次抽取节点属性并推进数组。
  2. 顺序控制:保持链表的原始顺序(头 → 尾)。

🔁 一、迭代解法

核心思路:使用指针 current 逐层遍历嵌套对象,动态构建链表节点并推入数组。

function nestedToArrayIterative(root) {
    const result = [];
    let current = root;
    
    while (current) {
        // 创建节点:id为当前值,next指向子节点的id(若无子节点则为null)
        const node = { id: current.id };
        node.next = current.child ? current.child.id : null;
        
        result.push(node);
        current = current.child; // 移动到下一层
    }
    return result;
}

关键点

  1. 时间复杂度 O(n):单次遍历嵌套结构,每个节点处理一次。
  2. 空间复杂度 O(1)(额外空间):仅使用常量指针,结果数组为必然输出,不计入额外空间。
  3. 优势:避免递归栈开销,适合深度大的嵌套结构(如超过1000层)。

执行示例

const obj = { id:1, child: { id:2, child: { id:3 }}};
console.log(nestedToArrayIterative(obj)); 
// 输出: [{id:1, next:2}, {id:2, next:3}, {id:3, next:null}]

♻️ 二、递归解法

核心思路:递归到最深层(尾节点),回溯时逐步构建数组。

function nestedToArrayRecursive(node) {
    if (!node) return []; // 基线条件:节点为空
    
    // 创建当前节点,next由子节点决定(递归结果的首项id或null)
    const currentNode = { id: node.id };
    const childArray = nestedToArrayRecursive(node.child);
    
    // 设置next:若存在子节点数组,则指向其首项的id;否则为null
    currentNode.next = childArray.length > 0 ? childArray[0].id : null;
    
    return [currentNode, ...childArray]; // 合并当前节点与子节点数组
}

关键点

  1. 时间复杂度 O(n):每个节点访问一次。
  2. 空间复杂度 O(n):递归栈深度与嵌套层数正比,深度大时可能栈溢出。
  3. 优势:代码简洁,符合递归思维,适合嵌套较浅(<1000层)的场景。

执行示例

const obj = { id:1, child: { id:2, child: { id:3 }}};
console.log(nestedToArrayRecursive(obj)); // 输出同上

⚖️ 三、方法对比与选择建议

维度迭代法递归法
时间复杂度O(n)O(n)
空间复杂度O(1)(额外空间)O(n)(递归栈)
适用嵌套深度任意深度(推荐 >1000层)较浅深度(<1000层)
代码简洁性中等(显式指针控制)高(自然表达层级关系)
潜在风险深度过大时栈溢出
推荐场景生产环境、未知深度的数据嵌套固定且较浅、代码简洁性优先

💎 四、总结

  • 迭代法更稳健:适合生产环境,尤其数据深度未知或较大时,优先选择迭代避免栈溢出风险。
  • 递归法更简洁:在明确嵌套较浅且需代码简洁的场景(如算法题)可使用,但需评估深度限制。

推荐选择

💡 若无深度隐患(如数据层级 < 100),递归法以简洁性胜出;
⚙️ 若需处理大规模数据,迭代法是更安全的选择。


4. 链表逆置

题目描述
反转单链表式嵌套结构:

// 输入
{
  id: 1,
  child: {
    id: 2,
    child: { id: 3 }
  }
}

// 输出
{
  id: 3,
  child: {
    id: 2,
    child: { id: 1 }
  }
}

核心考点

  • 指针操作(双指针反转方向)
  • 递归分治(子问题拆分)

解题思路

  1. 迭代法
    • 维护 prevcurrentnext 三指针。
    • 逐节点反转 child 指向。
  2. 递归法
    • 递归到尾部,回溯时反转指向。

代码实现(迭代)

function reverseNested(root) {
  let prev = null;
  let current = root;

  while (current) {
    const next = current.child; // 暂存下一节点
    current.child = prev;      // 反转指向
    prev = current;             // 移动prev
    current = next;             // 移动current
  }

  return prev; // 返回新头部
}

5. 链表环检测

题目描述
判断单链表式嵌套结构是否存在环:

// 输入(环:1→2→3→2)
const list = [
  { id: 1, next: 2 },
  { id: 2, next: 3 },
  { id: 3, next: 2 } // 指向节点2形成环
];

// 输出:true

核心考点

  • 快慢指针算法(Floyd判圈法)
  • 边界安全(避免死循环)

解题思路

  1. 快慢指针
    • slow 每次走一步,fast 走两步。
    • 若相遇则有环。

代码实现

function hasCycle(root) {
  if (!root) return false;
  
  let slow = root;
  let fast = root.child?.child; // 从第二节点开始

  while (fast && fast.child) {
    if (slow === fast) return true; // 相遇即有环
    slow = slow.child;              // 慢指针走一步
    fast = fast.child?.child;       // 快指针走两步
  }

  return false; // 无环
}

6. 链表排序

题目描述
id 升序对链表排序(要求原地操作):

// 输入
{
  id: 3,
  child: {
    id: 1,
    child: { id: 2 }
  }
}

// 输出
{
  id: 1,
  child: {
    id: 2,
    child: { id: 3 }
  }
}

核心考点

  • 归并排序的应用(分治 + 合并)
  • 指针操作精度(避免断链)

解题思路

  1. 分治策略
    • 快慢指针找到链表中点。
    • 递归排序左右子链表。
  2. 合并有序链表
    • 双指针比较节点值,按序连接。

代码实现

function mergeSortNested(head) {
  if (!head || !head.child) return head;

  // 快慢指针找中点
  let slow = head;
  let fast = head.child;
  while (fast?.child) {
    slow = slow.child;
    fast = fast.child?.child;
  }

  // 分割链表
  const mid = slow.child;
  slow.child = null; // 断开连接

  // 递归排序子链表
  const left = mergeSortNested(head);
  const right = mergeSortNested(mid);

  // 合并有序链表
  return merge(left, right);
}

function merge(left, right) {
  const dummy = { id: -1, child: null }; // 虚拟头节点
  let current = dummy;

  while (left && right) {
    if (left.id < right.id) {
      current.child = left;
      left = left.child;
    } else {
      current.child = right;
      right = right.child;
    }
    current = current.child;
  }

  // 连接剩余节点
  current.child = left || right;
  return dummy.child;
}

题型总结与面试技巧

  1. 高频考点分布

    题型出现频率难度核心算法
    链表转嵌套⭐⭐⭐⭐初级Map映射
    嵌套扁平化⭐⭐⭐初级迭代/递归
    链表逆置⭐⭐⭐⭐中级双指针
    环检测⭐⭐⭐中级快慢指针
    链表排序⭐⭐高级归并排序
  2. 避坑指南

    • 避免递归栈溢出:嵌套过深时优先选迭代(如扁平化、逆置)。
    • 注意引用陷阱:操作链表时先缓存 next 再改指向(如逆置)。
    • 边界检查:头节点为 null、单节点链表、环形链表需特殊处理。
  3. 答题话术模板

    “我打算用 Map建立节点索引 解决链表转嵌套问题,先遍历存储所有节点,再二次遍历连接父子关系,时间复杂度 O(n)。
    若遇到环形链表,我会用 快慢指针 检测环,空间复杂度 O(1) 更优。”