普通视图

发现新文章,点击刷新页面。
昨天以前首页

LeetCode 530. 二叉搜索树的最小绝对差:两种解法详解(迭代+递归)

作者 Wect
2026年2月26日 16:50

LeetCode 上一道经典的二叉搜索树(BST)题目——530. 二叉搜索树的最小绝对差,这道题看似简单,却能很好地考察我们对 BST 特性的理解,以及二叉树遍历方式的灵活运用。下面我会从题目分析、核心思路、两种解法拆解,到代码细节注释,一步步帮大家搞懂这道题,新手也能轻松跟上。

一、题目解读

题目很直白:给一个二叉搜索树的根节点 root,返回树中任意两个不同节点值之间的最小差值,差值是正数(即两值之差的绝对值)。

这里有个关键前提——二叉搜索树的特性:中序遍历二叉搜索树,得到的序列是严格递增的(假设树中没有重复值,题目未明确说明,但测试用例均满足此条件)。

这个特性是解题的核心!因为递增序列中,任意两个元素的最小差值,一定出现在相邻的两个元素之间。比如序列 [1,3,6,8],最小差值是 3-1=2,而不是 8-1=7 或 6-3=3。所以我们不需要暴力枚举所有两两组合,只需要在中序遍历的过程中,记录前一个节点的值,与当前节点值计算差值,不断更新最小差值即可。

二、核心解题思路

  1. 利用 BST 中序遍历为递增序列的特性,将“任意两节点的最小差值”转化为“中序序列中相邻节点的最小差值”;

  2. 遍历过程中,维护两个变量:min(记录当前最小差值,初始值设为无穷大)、pre(记录前一个节点的值,初始值设为负无穷大,避免初始值影响第一次差值计算);

  3. 遍历每个节点时,用当前节点值与pre 计算绝对值差值,更新 min,再将 pre 更新为当前节点值;

  4. 遍历结束后,min 即为答案。

接下来,我们用两种最常用的遍历方式实现这个思路:迭代中序遍历(解法1)和递归中序遍历(解法2)。

三、解法一:迭代中序遍历(非递归)

迭代遍历的核心是用“栈”模拟递归的调用过程,避免递归深度过深导致的栈溢出(虽然这道题的测试用例大概率不会出现,但迭代写法更通用,适合处理大型树)。

3.1 代码实现(带详细注释)

// 先定义 TreeNode 类(题目已给出,此处复用)
class TreeNode {
  val: number
  left: TreeNode | null
  right: TreeNode | null
  constructor(val?: number, left?: TreeNode | null, right?: TreeNode | null) {
    this.val = (val === undefined ? 0 : val)
    this.left = (left === undefined ? null : left)
    this.right = (right === undefined ? null : right)
  }
}

// 解法1:迭代中序遍历
function getMinimumDifference_1(root: TreeNode | null): number {
  // 边界处理:空树返回0(题目中树至少有一个节点?但严谨起见还是判断)
  if (!root) return 0;
  let min = Infinity; // 最小差值,初始为无穷大
  let pre = -Infinity; // 前一个节点的值,初始为负无穷大
  const stack: TreeNode[] = []; // 用于模拟中序遍历的栈
  let curr: TreeNode | null = root; // 当前遍历的节点

  // 第一步:将左子树所有节点压入栈(中序遍历:左 -> 根 -> 右)
  while (curr) {
    stack.push(curr);
    curr = curr.left; // 一直向左走,直到最左节点
  }

  // 第二步:弹出栈顶节点,处理根节点,再遍历右子树
  while (stack.length) {
    const node = stack.pop(); // 弹出栈顶(当前要处理的根节点)
    if (!node) continue; // 防止空节点(理论上不会出现)

    // 处理右子树:将右子树的所有左节点压入栈
    if (node.right) {
      let right: TreeNode | null = node.right;
      while (right) {
        stack.push(right);
        right = right.left;
      }
    }

    // 计算当前节点与前一个节点的差值,更新最小差值
    min = Math.min(min, Math.abs(pre - node.val));
    // 更新pre为当前节点值,为下一个节点做准备
    pre = node.val;
  }

  return min;
};

3.2 思路拆解

  1. 初始化:栈用于存储待处理的节点,curr指向根节点,先将根节点的所有左子节点压入栈(因为中序遍历要先访问左子树);

  2. 弹出栈顶节点(此时该节点的左子树已处理完毕),先处理其右子树(将右子树的所有左节点压入栈,保证下一次弹出的是右子树的最左节点);

  3. 计算当前节点与pre的差值,更新min,再将pre更新为当前节点值;

  4. 重复上述过程,直到栈为空,遍历结束。

优势:不依赖递归栈,避免递归深度过大导致的栈溢出,空间复杂度由递归的 O(h)(h为树高)优化为 O(h)(栈的最大深度也是树高),实际运行更稳定。

四、解法二:递归中序遍历

递归写法更简洁,代码量少,思路也更直观,适合树的深度不大的场景。核心是用递归函数实现中序遍历的“左 -> 根 -> 右”顺序。

4.1 代码实现(带详细注释)

// 解法2:递归中序遍历
function getMinimumDifference_2(root: TreeNode | null): number {
  if (!root) return 0;
  let min = Infinity; // 最小差值
  let pre = -Infinity; // 前一个节点的值

  // 递归函数:实现中序遍历
  const dfs = (node: TreeNode) => {
    if (!node) return; // 递归终止条件:节点为空

    // 1. 遍历左子树(左)
    if (node.left) dfs(node.left);

    // 2. 处理当前节点(根):计算差值,更新min和pre
    min = Math.min(min, Math.abs(pre - node.val));
    pre = node.val;

    // 3. 遍历右子树(右)
    if (node.right) dfs(node.right);
  }

  // 从根节点开始递归
  dfs(root);
  return min;
};

4.2 思路拆解

  1. 定义递归函数 dfs,参数为当前节点,负责遍历以该节点为根的子树;

  2. 递归终止条件:当前节点为空,直接返回;

  3. 先递归遍历左子树(保证左子树先被处理);

  4. 处理当前节点:计算当前节点与pre的差值,更新min,再将pre更新为当前节点值;

  5. 最后递归遍历右子树;

  6. 从根节点调用dfs,完成整个树的遍历,返回min。

优势:代码简洁,思路直观,容易理解和编写;劣势:当树的深度很大时(如链式树),会出现递归栈溢出,此时迭代写法更合适。

五、两种解法对比与总结

解法 遍历方式 时间复杂度 空间复杂度 优势 劣势
解法1 迭代中序 O(n)(每个节点遍历一次) O(h)(h为树高,栈的最大深度) 稳定,无栈溢出风险,通用 代码稍长,需要手动维护栈
解法2 递归中序 O(n)(每个节点遍历一次) O(h)(递归栈深度) 代码简洁,思路直观,易编写 深度过大时会栈溢出

关键总结

  1. 这道题的核心是利用 BST 中序遍历为递增序列,将“任意两节点最小差值”转化为“相邻节点最小差值”,避免暴力枚举;

  2. 两种解法的核心逻辑一致,只是遍历方式不同,可根据树的深度选择:树深较小时用递归,树深较大时用迭代;

  3. 注意初始值的设置:min设为无穷大(保证第一次差值能更新min),pre设为负无穷大(避免初始值与第一个节点值计算出不合理的差值);

  4. 边界处理:空树返回0(题目中树至少有一个节点,但严谨起见必须判断)。

六、拓展思考

如果这道题不是 BST,而是普通二叉树,该怎么解?

答案:先遍历所有节点,将节点值存入数组,再对数组排序,计算相邻元素的最小差值。时间复杂度 O(n log n)(排序耗时),空间复杂度 O(n)(存储所有节点值),效率低于本题的解法,由此可见利用数据结构特性解题的重要性。

好了,这道题的两种解法就讲解完毕了。希望大家能通过这道题,加深对 BST 特性和二叉树中序遍历的理解,下次遇到类似题目能快速想到解题思路。

程序员都该掌握的“质因数分解”

作者 JYeontu
2026年2月26日 11:55

说在前面

还记得小学数学课上的“质因数分解”吗?这个看似基础的概念,实际上是现代数论的基石。在草稿纸上进行 质因数分解 大家应该都会,那怎么通过代码来实现呢?它又能解决什么问题?

什么是质因数分解?

概念

质因数分解 = 把一个合数,拆成「若干个质数相乘」的形式

例子

12 = 2 × 2 × 3
  • 2、3 都是质数(只能被 1 和自己整除)
  • 12 是合数(能继续拆)
  • 拆到不能再拆,只剩质数,就叫质因数分解

定理

数学里有一条超级重要的定理:

任何一个大于 1 的整数,只有唯一一种质因数分解方式

怎么做质因数分解?

最实用、最好用的方法:短除法

步骤

  • 1.从最小的质数 2 开始试
  • 2.能除就除,除到不能除为止
  • 3.再换下一个质数 3、5、7、11…
  • 4.直到最后结果是 1

例子

180 进行质因数分解

180 ÷ 2 = 90
90  ÷ 2 = 45
45  ÷ 3 = 15
15  ÷ 3 = 5
5   ÷ 5 = 1

所以: 180 = 2² × 3² × 5¹

质因数分解有什么用?

1. 将“乘除”降维成“加减”

在编程中进行算数乘除运算很容易会遇到两个问题:

  • 数字溢出:几个数一相乘,结果可能超出计算机能表示的最大整数范围

  • 精度丢失:一旦引入除法,就可能出现小数,而浮点数的存储和比较天生存在精度误差
1 / 6 * 5 * 5 * 2 * 3

上面这个式子我们快速过一遍不难看出最后的结果应该是 25,但是电脑算出来的结果却是 24.999999999999996

质因子分解 便可以比较优雅的避免这两个问题

例子

我们可以把每个数字“升维”,用一个指数向量来表示它:

12 = 2² × 3¹ × 5⁰ => 向量 [2, 1, 0]
10 = 2¹ × 3⁰ × 5¹ => 向量 [1, 0, 1]
  • 乘法 → 向量加法 12 × 10 = 120 对应的向量运算是:[2, 1, 0] + [1, 0, 1] = [3, 1, 1]

    验证一下:120 = 8 × 3 × 5 = 2³ × 3¹ × 5¹。向量正是 [3, 1, 1]

  • 除法 → 向量减法 120 / 10 = 12 对应的向量运算是:[3, 1, 1] - [1, 0, 1] = [2, 1, 0]

    结果 [2, 1, 0] 正是 12 的向量表示

通过质因数分解,我们可以将复杂的、易出错的乘除法,转换成了简单、精确的整数加减法。

2.最大公因数、最小公倍数

辗转相除法 求最大公因数大家都知道吧,那质因数分解 也能求最大公因数你们知道吗?

比如求 1830GCDLCM

分解

18 = 2¹ × 3²
30 = 2¹ × 3¹ × 5¹

求最大公因数 (GCD)

取每个公共质因子的最低次幂,然后相乘

  • 公共质因子是 23
  • 2 的最低次幂是 min(1, 1) = 1
  • 3 的最低次幂是 min(2, 1) = 1
  • GCD = 2¹ × 3¹ = 6

求最小公倍数 (LCM)

取所有出现过的质因子的最高次幂,然后相乘

  • 所有质因子是 2, 3, 5
  • 2 的最高次幂是 max(1, 1) = 1
  • 3 的最高次幂是 max(2, 1) = 2
  • 5 的最高次幂是 max(0, 1) = 1
  • LCM = 2¹ × 3² × 5¹ = 90

3.现代密码学的基石

我们每天都在使用的 HTTPS、网上银行、数字签名,其安全性的根基,都与质因数分解的“不对称性”有关。

RSA 加密算法。其核心思想可以通俗地理解为:

给你两个巨大的质数 pq,让你把它们乘起来得到 N,这在计算上非常容易。 但是,反过来,只告诉你乘积 N,让你找出原始的 pq 是什么,这在计算上极其困难。

代码实现

说了这么多,那我们如何用代码来实现质因数分解呢?其实非常简单:

/**
 * 对一个正整数进行质因数分解
 * @param {number} n - 需要分解的正整数
 * @returns {Map<number, number>} - 返回一个 Map,键是质因子,值是其指数
 */
function primeFactorize(n) {
  if (n <= 1) {
    return new Map();
  }
  const factors = new Map();
  // 不断除以2,处理所有偶数因子
  while (n % 2 === 0) {
    factors.set(2, (factors.get(2) || 0) + 1);
    n /= 2;
  }
  // 从3开始遍历奇数,直到 n 的平方根
  // 如果 n 有一个大于其平方根的因子,必然会有一个小于其平方根的因子
  for (let i = 3; i * i <= n; i += 2) {
    while (n % i === 0) {
      factors.set(i, (factors.get(i) || 0) + 1);
      n /= i;
    }
  }
  // 如果最后 n 还大于1,那么 n 本身也是一个质数
  if (n > 1) {
    factors.set(n, (factors.get(n) || 0) + 1);
  }
  return factors;
}
console.log(primeFactorize(120)); 
// 输出: Map { 2 => 3, 3 => 1, 5 => 1 }
console.log(primeFactorize(999));
// 输出: Map {3 => 3, 37 => 1}

公众号

关注公众号『 前端也能这么有趣 』,获取更多有趣内容~

发送 加群 还能加入前端交流群,和大家一起讨论技术、分享经验,偶尔也能摸鱼聊天~

说在后面

🎉 这里是 JYeontu,现在是一名前端工程师,有空会刷刷算法题,平时喜欢打羽毛球 🏸 ,平时也喜欢写些东西,既为自己记录 📋,也希望可以对大家有那么一丢丢的帮助,写的不好望多多谅解 🙇,写错的地方望指出,定会认真改进 😊,偶尔也会在自己的公众号『前端也能这么有趣』发一些比较有趣的文章,有兴趣的也可以关注下。在此谢谢大家的支持,我们下文再见 🙌。

LeetCode 102. 二叉树的层序遍历:图文拆解+代码详解

作者 Wect
2026年2月25日 16:49

LeetCode经典二叉树题目——102. 二叉树的层序遍历,这道题是二叉树广度优先搜索(BFS)的入门必刷题,也是面试中高频出现的基础题,不管是新手还是复盘,都值得好好吃透。

话不多说,先看题目本身,帮大家理清题意、拆解思路,再逐行解析代码,最后总结易错点,确保看完就能上手写对。

一、题目解读:什么是层序遍历?

题目给出二叉树的根节点root,要求返回其节点值的层序遍历,核心要求是「逐层地,从左到右访问所有节点」。

举个简单例子帮助理解:

如果二叉树是:

    3
   / \
  9  20
    /  \
   15   7

那么层序遍历的结果就是 [[3], [9,20], [15,7]] —— 第一层只有根节点3,第二层从左到右是9和20,第三层是15和7,每一层的节点值单独作为一个数组,最终组成二维数组返回。

这里要注意和「前中后序遍历」区分开:前中后序是深度优先(DFS),沿着一条路径走到底再回溯;而层序遍历是广度优先(BFS),先遍历完当前层的所有节点,再进入下一层,像“剥洋葱”一样逐层推进。

二、核心思路:用队列实现BFS

层序遍历的核心难点是「区分每一层的节点」—— 如何知道当前遍历的是哪一层,什么时候结束当前层、进入下一层?

解决这个问题的关键工具是「队列」(先进先出,FIFO),队列的特性刚好契合层序遍历“先遍历当前层所有节点,再处理下一层”的逻辑,具体思路分4步:

  1. 边界处理:如果根节点root为null,直接返回空数组(空树没有节点可遍历);

  2. 初始化:创建一个队列,将根节点入队(队列存储当前待处理的节点);创建一个结果数组,用于存储每一层的节点值;

  3. 循环处理(直到队列为空):

    • 记录当前队列的长度(也就是当前层的节点个数,记为levelSize),这个长度是区分层的关键;

    • 创建一个临时数组level,用于存储当前层的所有节点值;

    • 循环levelSize次,每次从队列头部取出一个节点:

      • 将该节点的值加入临时数组level;

      • 如果该节点有左孩子,将左孩子入队(为下一层遍历做准备);

      • 如果该节点有右孩子,将右孩子入队(同样为下一层遍历做准备);

    • 当前层遍历完成,将临时数组level加入结果数组;

  4. 循环结束,返回结果数组。

这里的核心技巧是「记录当前层的节点个数」—— 因为队列中会不断加入下一层的节点,只有通过levelSize限制循环次数,才能确保每次循环只处理当前层的节点,不会混入下一层的节点,从而正确区分每一层。

三、完整代码+逐行解析

先给出完整可运行的TypeScript代码(题目已提供TreeNode类,直接复用即可),再逐行拆解关键逻辑,新手也能看懂每一步的作用。

完整代码

/**
 * Definition for a binary tree node.
 * class TreeNode {
 *     val: number
 *     left: TreeNode | null
 *     right: TreeNode | null
 *     constructor(val?: number, left?: TreeNode | null, right?: TreeNode | null) {
 *         this.val = (val===undefined ? 0 : val)
 *         this.left = (left===undefined ? null : left)
 *         this.right = (right===undefined ? null : right)
 *     }
 * }
 */

class TreeNode {
  val: number
  left: TreeNode | null
  right: TreeNode | null
  constructor(val?: number, left?: TreeNode | null, right?: TreeNode | null) {
    this.val = (val === undefined ? 0 : val)
    this.left = (left === undefined ? null : left)
    this.right = (right === undefined ? null : right)
  }
}

function levelOrder(root: TreeNode | null): number[][] {
  // 边界处理:空树返回空数组
  if (!root) {
    return []
  }
  // 队列:存储当前待处理的节点,初始入队根节点
  const queue: TreeNode[] = [root]
  // 结果数组:存储每一层的节点值
  const result: number[][] = []
  // 队列不为空,说明还有节点未处理
  while (queue.length) {
    // 记录当前层的节点个数(关键:区分每一层)
    const levelSize = queue.length
    // 临时数组:存储当前层的节点值
    const level: number[] = []
    // 循环处理当前层的所有节点(循环次数 =  当前层节点数)
    for (let i = 0; i < levelSize; i++) {
      // 从队列头部取出节点(先进先出)
      const node = queue.shift()
      // 防止node为null(理论上不会出现,严谨性处理)
      if (!node) continue;
      // 将当前节点值加入当前层数组
      level.push(node.val)
      // 左孩子存在,入队(下一层节点)
      if (node.left) {
        queue.push(node.left)
      }
      // 右孩子存在,入队(下一层节点)
      if (node.right) {
        queue.push(node.right)
      }
    }
    // 当前层处理完毕,加入结果数组
    result.push(level)
  }
  // 返回最终结果
  return result;
};

逐行关键解析

  1. 边界处理 if (!root) return []: 如果根节点为null,说明是一棵空树,没有任何节点,直接返回空数组,避免后续队列操作报错。

  2. 队列初始化 const queue: TreeNode[] = [root]: 队列是核心数据结构,初始时只有根节点,因为层序遍历从根节点开始。

  3. 循环条件 while (queue.length): 只要队列不为空,就说明还有节点没处理(可能是当前层剩余节点,也可能是下一层节点),循环继续。

  4. 记录当前层节点数 const levelSize = queue.length: 这是最关键的一步!假设当前队列中有3个节点,说明当前层有3个节点,后续循环3次,刚好把这3个节点处理完,不会混入下一层的节点。

  5. 临时数组 const level: number[] = []: 每一层都需要一个临时数组存储节点值,遍历完当前层后,将这个数组加入结果数组,保证结果的二维结构。

  6. 取出节点 const node = queue.shift(): shift()方法会删除并返回队列的第一个元素,契合队列“先进先出”的特性,确保先处理当前层的第一个节点(从左到右)。

  7. 左、右孩子入队: 处理完当前节点后,将其左、右孩子(如果存在)入队,这些孩子是下一层的节点,会在后续循环中被处理,从而实现“逐层遍历”。

  8. 加入结果数组 result.push(level): 当前层的所有节点都处理完毕后,将临时数组level加入result,完成一层的遍历。

四、易错点提醒(避坑必看)

这道题看似简单,但新手很容易踩坑,总结3个最常见的错误,帮大家避开:

  1. 忘记记录levelSize,直接循环queue.length: 错误原因:队列在循环中会不断加入下一层的节点,queue.length会动态变化,导致循环次数不对,无法区分层。正确做法:必须在每次while循环开始时,记录当前queue的长度(即levelSize),循环次数固定为levelSize。

  2. 忽略node为null的情况: 错误原因:虽然题目中root是TreeNode|null,但queue中理论上不会有null,但shift()方法在队列空时会返回undefined,加上if (!node) continue是严谨性处理,避免报错。

  3. 左、右孩子入队顺序错误: 错误原因:题目要求“从左到右”访问,若先入队右孩子、再入队左孩子,会导致当前层节点顺序颠倒。正确做法:先入队左孩子,再入队右孩子,确保左在前、右在后。

五、题目延伸与思考

这道题是层序遍历的基础版本,掌握后可以尝试它的变形题,巩固BFS思路:

  • LeetCode 107. 二叉树的层序遍历II:要求自底向上层序遍历(从最下层到最上层),只需将result数组反转即可;

  • LeetCode 199. 二叉树的右视图:层序遍历中,每次只保留当前层的最后一个节点值;

  • LeetCode 637. 二叉树的层平均值:层序遍历中,计算每一层的节点值平均值。

其实这些变形题的核心思路和本题一致,都是用队列实现BFS,只是在处理“每一层结果”时做了不同的操作,掌握了基础,变形题就能迎刃而解。

六、总结

LeetCode 102题的核心是「用队列实现BFS,通过记录当前层节点数区分每一层」,整体难度中等,是二叉树BFS的入门题。

关键记住3点:队列存节点、levelSize分层次、左孩子先入队,再结合边界处理和严谨性判断,就能轻松AC。

如果是新手,建议自己手动模拟一遍队列的操作过程(比如用上面举的例子,一步步推进队列、处理节点),能更直观地理解层序遍历的逻辑。

LeetCode 105. 从前序与中序遍历序列构造二叉树:题解与思路解析

作者 Wect
2026年2月19日 19:53

在二叉树的算法题型中,“根据遍历序列构造二叉树”是经典考点,而 LeetCode 105 题——从前序与中序遍历序列构造二叉树,更是这一考点的核心代表。这道题不仅能考察我们对二叉树遍历规则的理解,还能检验递归思维和哈希表优化的应用,今天就来一步步拆解这道题,从思路到代码,吃透每一个细节。

一、题目回顾

题目给出两个整数数组preorderinorder,其中 preorder 是二叉树的先序遍历序列,inorder 是同一棵二叉树的中序遍历序列,要求我们构造这棵二叉树并返回其根节点。

补充基础:二叉树遍历规则

  • 先序遍历(preorder):根节点 → 左子树 → 右子树(根在前,左右在后);

  • 中序遍历(inorder):左子树 → 根节点 → 右子树(根在中间,左右分居两侧)。

核心关键:先序遍历的第一个元素一定是二叉树的根节点;而中序遍历中,根节点左侧的所有元素是左子树的中序序列,右侧的所有元素是右子树的中序序列。利用这两个特性,我们就能递归地构造出整个二叉树。

二、解题思路(核心逻辑)

这道题的解题核心是「递归分治」,配合哈希表优化查找效率,具体思路可以分为 4 步,我们结合例子来理解(假设 preorder = [3,9,20,15,7],inorder = [9,3,15,20,7]):

步骤1:确定根节点

根据先序遍历规则,preorder 的第一个元素 preorder[0] 就是整个二叉树的根节点(例子中根节点是 3)。

步骤2:划分左右子树的中序序列

在中序序列 inorder 中找到根节点的索引(例子中 3 的索引是 1):

  • 根节点左侧的元素([9]):左子树的中序序列;

  • 根节点右侧的元素([15,20,7]):右子树的中序序列。

步骤3:划分左右子树的先序序列

左右子树的节点数量,在中序序列和先序序列中是一致的:

  • 左子树的节点数 = 根节点在中序的索引 - 中序序列的起始索引(例子中 1 - 0 = 1,左子树有 1 个节点);

  • 因此,先序序列中,根节点之后的「左子树节点数」个元素([9])是左子树的先序序列;

  • 剩下的元素([20,15,7])是右子树的先序序列。

步骤4:递归构造左右子树

对左子树和右子树,重复上述 3 个步骤:找到子树的根节点、划分左右子序列,直到序列为空(递归终止条件),最终拼接出整个二叉树。

优化点:哈希表加速查找

如果每次在中序序列中查找根节点都用遍历的方式,时间复杂度会达到 O(n²)(n 是节点总数)。我们可以提前用哈希表(Map)存储中序序列中「元素 → 索引」的映射,这样每次查找根节点的索引只需 O(1) 时间,整体时间复杂度优化到 O(n)。

三、完整代码实现(TypeScript)

先给出 TreeNode 类的定义(题目已提供,此处复用并补充注释),再实现核心的 buildTree 函数,每一步代码都附上详细注释,方便理解:

// 二叉树节点类定义
class TreeNode {
  val: number
  left: TreeNode | null  // 左子节点,默认为null
  right: TreeNode | null // 右子节点,默认为null
  constructor(val?: number, left?: TreeNode | null, right?: TreeNode | null) {
    this.val = (val === undefined ? 0 : val) // 节点值,默认0
    this.left = (left === undefined ? null : left)
    this.right = (right === undefined ? null : right)
  }
}

function buildTree(preorder: number[], inorder: number[]): TreeNode | null {
  // 1. 构建中序序列的哈希映射,key是节点值,value是对应索引
  const map = new Map<number, number>();
  inorder.forEach((val, index) => {
    map.set(val, index);
  });

  /**
   * 递归辅助函数:构造当前范围内的二叉树
   * @param preorderStart 先序序列当前范围的起始索引
   * @param preorderEnd 先序序列当前范围的结束索引
   * @param inorderStart 中序序列当前范围的起始索引
   * @param inorderEnd 中序序列当前范围的结束索引
   * @returns 当前范围的二叉树根节点
   */
  const helper = (
    preorderStart: number, 
    preorderEnd: number, 
    inorderStart: number, 
    inorderEnd: number
  ): TreeNode | null => {

    // 递归终止条件:当前范围无节点(起始索引>结束索引),返回null
    if (preorderStart > preorderEnd || inorderStart > inorderEnd) {
      return null;
    }

    // 2. 确定当前范围的根节点(先序序列的第一个元素)
    const rootVal = preorder[preorderStart];
    const root = new TreeNode(rootVal); // 创建根节点

    // 3. 找到根节点在中序序列中的索引,用于划分左右子树
    const inorderIndex = map.get(rootVal)!; // !表示非null断言(确保能找到索引)
    const leftSize = inorderIndex - inorderStart; // 左子树的节点数

    // 4. 递归构造左子树
    // 左子树先序范围:preorderStart+1 ~ preorderStart+leftSize(根节点后leftSize个元素)
    // 左子树中序范围:inorderStart ~ inorderIndex-1(根节点左侧元素)
    root.left = helper(
      preorderStart + 1, 
      preorderStart + leftSize, 
      inorderStart, 
      inorderIndex - 1
    );

    // 5. 递归构造右子树
    // 右子树先序范围:preorderStart+leftSize+1 ~ preorderEnd(左子树之后的剩余元素)
    // 右子树中序范围:inorderIndex+1 ~ inorderEnd(根节点右侧元素)
    root.right = helper(
      preorderStart + leftSize + 1, 
      preorderEnd, 
      inorderIndex + 1, 
      inorderEnd
    );

    return root; // 返回当前范围的根节点
  }

  // 初始调用递归函数,范围是整个先序和中序序列
  return helper(0, preorder.length - 1, 0, inorder.length - 1);
};

四、代码关键细节解析

1. 递归终止条件

preorderStart > preorderEndinorderStart > inorderEnd 时,说明当前范围内没有节点,返回 null(比如叶子节点的左右子节点,就会触发这个条件)。

2. 左子树节点数的计算

leftSize = inorderIndex - inorderStart,这个计算是划分先序序列的关键——因为先序序列中,根节点之后的 leftSize 个元素,必然是左子树的先序序列,剩下的就是右子树的先序序列。

3. 哈希表的非null断言

代码中 map.get(rootVal)! 用到了 TypeScript 的非null断言(!),原因是题目保证了 preorder 和 inorder 是同一棵二叉树的遍历序列,因此根节点的值一定能在中序序列中找到,不会返回 null。

4. 时间和空间复杂度

  • 时间复杂度:O(n),n 是节点总数。哈希表构建需要 O(n) 时间,递归过程中每个节点被处理一次,每次查找根节点索引是 O(1);

  • 空间复杂度:O(n),哈希表存储 n 个元素,递归调用栈的深度最坏情况下是 O(n)(比如斜树),最好情况下是 O(log n)(平衡二叉树)。

五、常见易错点提醒

  1. 先序序列的划分错误:容易把右子树的先序起始索引算错,记住是 preorderStart + leftSize + 1(跳过根节点和左子树);

  2. 中序序列的边界错误:左子树的中序结束索引是 inorderIndex - 1,右子树的中序起始索引是 inorderIndex + 1,容易漏写 ±1 导致死循环;

  3. 忽略空数组情况:当 preorder 和 inorder 为空时,直接返回 null(递归终止条件会处理,但需注意初始调用时的边界);

  4. 不用哈希表优化:直接遍历中序序列找根节点,会导致时间复杂度飙升,在 n 较大时(比如 10^4 级别)会超时。

六、总结

LeetCode 105 题的核心是「利用遍历序列的特性 + 递归分治 + 哈希表优化」,解题的关键在于抓住“先序定根、中序分左右”的规律,再通过递归逐步构造子树。

这道题不仅能帮我们巩固二叉树的遍历知识,还能锻炼递归思维——递归的本质就是“把大问题拆成小问题,解决小问题后拼接结果”,这里的大问题是“构造整个二叉树”,小问题是“构造左子树”和“构造右子树”。

如果能吃透这道题,再遇到“从中序与后序遍历构造二叉树”(LeetCode 106 题)就会事半功倍,因为思路完全相通,只是根节点的位置和序列划分方式略有不同。

LeetCode 106. 从中序与后序遍历序列构造二叉树:题解+思路拆解

作者 Wect
2026年2月19日 20:17

在二叉树的算法题中,“根据遍历序列构造二叉树”是高频考点,而 LeetCode 106 题(从中序与后序遍历序列构造二叉树)更是经典中的经典。它不仅考察对二叉树遍历规则的理解,还需要运用分治思想拆解问题,新手容易在“区间划分”上栽坑。今天就带大家一步步拆解这道题,从思路分析到代码实现,再到细节避坑,彻底搞懂如何通过两个遍历序列还原二叉树。

一、题目核心认知

先明确题目要求,避免理解偏差:

  • 给定两个整数数组 inorder(中序遍历)和 postorder(后序遍历),二者对应同一棵二叉树;

  • 构造并返回这棵二叉树的根节点;

  • 默认输入有效(无重复元素,且两个数组长度一致,能构成合法二叉树)。

关键前提:二叉树遍历规则回顾

要解决这道题,必须牢记中序和后序遍历的核心特点(这是解题的灵魂):

  1. 中序遍历(左-根-右):先遍历左子树,再访问根节点,最后遍历右子树;

  2. 后序遍历(左-右-根):先遍历左子树,再遍历右子树,最后访问根节点。

核心突破口:后序遍历的最后一个元素,一定是当前二叉树的根节点;而中序遍历中,根节点左侧的所有元素都是左子树的节点,右侧的所有元素都是右子树的节点。

二、解题思路拆解(分治思想)

既然能通过后序找到根节点,通过中序划分左右子树,那我们就可以用「分治」的思路,把大问题拆成小问题,递归求解:

步骤1:找到根节点

postorder 的最后一个元素,作为当前二叉树的根节点(root)。

步骤2:划分中序遍历的左右子树区间

inorder 中找到根节点的索引(记为 rootIndex),则:

  • 左子树的中序区间:[inorderStart, rootIndex - 1](根节点左侧所有元素);

  • 右子树的中序区间:[rootIndex + 1, inorderEnd](根节点右侧所有元素)。

步骤3:划分后序遍历的左右子树区间

后序遍历的区间长度和中序遍历一致(因为对应同一棵子树),设左子树的节点个数为 leftSize = rootIndex - inorderStart,则:

  • 左子树的后序区间:[postorderStart, postorderStart + leftSize - 1](左子树节点个数为 leftSize);

  • 右子树的后序区间:[postorderStart + leftSize, postorderEnd - 1](去掉最后一个根节点,剩余部分前半为左子树,后半为右子树)。

步骤4:递归构造左右子树

用同样的方法,递归构造左子树和右子树,分别赋值给根节点的 leftright 指针。

步骤5:优化索引查询(避免重复遍历)

如果每次在中序数组中找根节点索引都用遍历的方式,时间复杂度会很高(O(n²))。我们可以提前用一个哈希表(Map),存储中序数组中「元素-索引」的映射,这样每次查询根节点索引只需 O(1) 时间,整体时间复杂度优化到 O(n)。

三、完整代码实现(TypeScript)

结合上面的思路,我们来实现代码(题目已给出 TreeNode 类,直接复用即可):

/**
 * Definition for a binary tree node.
 * class TreeNode {
 *     val: number
 *     left: TreeNode | null
 *     right: TreeNode | null
 *     constructor(val?: number, left?: TreeNode | null, right?: TreeNode | null) {
 *         this.val = (val===undefined ? 0 : val)
 *         this.left = (left===undefined ? null : left)
 *         this.right = (right===undefined ? null : right)
 *     }
 * }
 */

class TreeNode {
  val: number
  left: TreeNode | null
  right: TreeNode | null
  constructor(val?: number, left?: TreeNode | null, right?: TreeNode | null) {
    this.val = (val === undefined ? 0 : val)
    this.left = (left === undefined ? null : left)
    this.right = (right === undefined ? null : right)
  }
}

function buildTree(inorder: number[], postorder: number[]): TreeNode | null {
  // 提前存储中序遍历的「元素-索引」映射,优化查询效率
  const map = new Map<number, number>();
  inorder.forEach((val, index) => {
    map.set(val, index);
  });

  // 递归辅助函数:根据区间构造子树
  // inorderStart/inorderEnd:当前子树在中序数组中的区间
  // postorderStart/postorderEnd:当前子树在后序数组中的区间
  const helper = (inorderStart: number, inorderEnd: number, postorderStart: number, postorderEnd: number): TreeNode | null => {
    // 递归终止条件:区间不合法(没有节点),返回null
    if (inorderStart > inorderEnd || postorderStart > postorderEnd) {
      return null;
    }

    // 1. 找到当前子树的根节点(后序数组的最后一个元素)
    const rootVal = postorder[postorderEnd];
    const root = new TreeNode(rootVal);

    // 2. 找到根节点在中序数组中的索引,划分左右子树区间
    const rootIndex = map.get(rootVal)!; // 题目保证输入有效,非null

    // 3. 计算左子树的节点个数,用于划分后序数组区间
    const leftSize = rootIndex - inorderStart;

    // 4. 递归构造左子树和右子树,赋值给根节点
    // 左子树:中序[start, rootIndex-1],后序[start, start+leftSize-1]
    root.left = helper(inorderStart, rootIndex - 1, postorderStart, postorderStart + leftSize - 1);
    // 右子树:中序[rootIndex+1, end],后序[start+leftSize, end-1]
    root.right = helper(rootIndex + 1, inorderEnd, postorderStart + leftSize, postorderEnd - 1);

    // 返回当前子树的根节点
    return root;
  }

  // 初始调用:区间为两个数组的完整区间
  return helper(0, inorder.length - 1, 0, postorder.length - 1);
};

四、代码逐行解析(重点+避坑)

很多新手能看懂思路,但写代码时容易在「区间边界」上出错,这里逐行拆解关键部分,帮大家避坑:

1. 哈希表初始化

const map = new Map<number, number>();
inorder.forEach((val, index) => {
  map.set(val, index);
});

作用:提前缓存中序数组的元素和对应索引,后续每次找根节点索引都能直接通过 map.get(rootVal) 获取,避免重复遍历中序数组,降低时间复杂度。

2. 递归辅助函数(helper)

为什么需要辅助函数?因为我们需要通过「区间边界」来划分左右子树,而主函数的参数只有两个数组,无法直接传递区间信息,所以用 helper 函数封装区间参数,实现递归。

3. 递归终止条件

if (inorderStart > inorderEnd || postorderStart > postorderEnd) {
  return null;
}

避坑点:当区间的起始索引大于结束索引时,说明当前区间没有节点,直接返回 null(比如左子树为空或右子树为空的情况)。比如,根节点的左子树如果没有节点,那么 inorderStart 会等于 rootIndex,此时 inorderStart > rootIndex - 1,触发终止条件,返回 null,正好对应 root.left = null。

4. 根节点创建

const rootVal = postorder[postorderEnd];
const root = new TreeNode(rootVal);

核心:后序遍历的最后一个元素就是当前子树的根节点,这是整个解题的突破口,必须牢记。

5. 区间划分(最容易出错的地方)

const leftSize = rootIndex - inorderStart;
// 左子树递归调用
root.left = helper(inorderStart, rootIndex - 1, postorderStart, postorderStart + leftSize - 1);
// 右子树递归调用
root.right = helper(rootIndex + 1, inorderEnd, postorderStart + leftSize, postorderEnd - 1);

避坑解析:

  • leftSize 是左子树的节点个数,由「根节点索引 - 中序起始索引」得到,因为中序左子树区间是 [inorderStart, rootIndex - 1],长度为 rootIndex - inorderStart;

  • 后序左子树区间的结束索引 = 起始索引 + 左子树节点个数 - 1(因为区间是闭区间,比如起始索引0,个数2,区间是[0,1]);

  • 后序右子树的起始索引 = 左子树结束索引 + 1,结束索引 = 原后序结束索引 - 1(去掉根节点);

  • 中序右子树区间直接从 rootIndex + 1 开始,到 inorderEnd 结束即可。

五、复杂度分析

  • 时间复杂度:O(n),n 是二叉树的节点个数。哈希表初始化遍历一次中序数组(O(n)),每个节点被递归处理一次(O(n)),每次索引查询 O(1);

  • 空间复杂度:O(n),哈希表存储 n 个元素(O(n)),递归调用栈的深度最坏情况下为 n(比如二叉树退化为链表),整体空间复杂度 O(n)。

六、总结与拓展

核心总结

这道题的本质是「利用遍历序列的特点找根节点 + 分治思想拆分左右子树」,关键在于两点:

  1. 牢记后序最后一个元素是根,中序根节点划分左右子树;

  2. 精准划分两个数组的左右子树区间,避免边界出错(建议多动手画示例,标注区间)。

拓展思考

这道题和 LeetCode 105 题(从前序与中序遍历序列构造二叉树)思路高度一致,只是根节点的位置和区间划分略有不同:

  • 105题(前序+中序):前序的第一个元素是根节点;

  • 106题(后序+中序):后序的最后一个元素是根节点。

掌握这两道题,就能轻松应对「遍历序列构造二叉树」的所有同类题型。

LeetCode 100. 相同的树:两种解法(递归+迭代)详解

作者 Wect
2026年2月17日 20:50

LeetCode简单难度的经典二叉树题目——100. 相同的树,这道题虽然难度不高,但非常适合入门二叉树的遍历思想,尤其是递归和迭代两种核心思路的对比练习,新手朋友可以重点看看,老手也可以快速回顾巩固一下。

先简单梳理一下题目要求,避免踩坑:给两棵二叉树的根节点p和q,判断这两棵树是否“相同”。这里的相同有两个核心条件,缺一不可:结构上完全一致,并且对应位置的节点值完全相等

举个直观的例子:如果p是一棵只有根节点(值为1)的树,q也是只有根节点(值为1),那它们是相同的;但如果p的根节点是1、左孩子是2,q的根节点是1、右孩子是2,哪怕节点值都一样,结构不同,也不算相同。

一、题目前置准备

题目已经给出了二叉树节点的定义,用TypeScript实现的,这里再贴一遍,方便大家对照代码理解(注释已补充,新手可重点看构造函数的逻辑):

class TreeNode {
  val: number; // 节点值
  left: TreeNode | null; // 左孩子,可能为null(没有左孩子)
  right: TreeNode | null; // 右孩子,可能为null(没有右孩子)
  // 构造函数:初始化节点,val默认0,左右孩子默认null
  constructor(val?: number, left?: TreeNode | null, right?: TreeNode | null) {
    this.val = (val === undefined ? 0 : val); // 节点值为空时,默认设为0
    this.left = (left === undefined ? null : left); // 左孩子为空时,默认设为null
    this.right = (right === undefined ? null : right); // 右孩子为空时,默认设为null
  }
}

二、解法一:递归解法

1. 递归思路分析

递归的核心思想是“分而治之”,把判断两棵大树是否相同,拆解成判断无数个“小问题”——判断当前两个节点是否相同,以及它们的左孩子、右孩子是否分别相同。

递归的终止条件(也是边界情况)很关键,分三步判断,逻辑层层递进:

  • 如果p和q都为null(两个节点都不存在):说明这两个位置的节点是相同的,返回true;

  • 如果p和q中一个为null、另一个不为null(一个节点存在,一个不存在):说明结构不同,返回false;

  • 如果p和q都不为null,但它们的val不相等(节点值不同):说明节点不相同,返回false;

如果以上三种情况都不满足,说明当前两个节点是相同的,接下来就递归判断它们的左孩子(p.left和q.left)和右孩子(p.right和q.right),只有左右孩子都相同,整棵树才相同(用&&连接两个递归结果)。

2. 递归代码实现

// 递归解法:isSameTree_1
function isSameTree_1(p: TreeNode | null, q: TreeNode | null): boolean {
  // 边界情况1:两个节点都为空,相同
  if (p === null && q === null) {
    return true;
  }
  // 边界情况2:一个为空,一个不为空,结构不同,不相同
  if (p === null || q === null) {
    return false;
  }
  // 边界情况3:两个节点都不为空,但值不同,不相同
  if (p.val !== q.val) {
    return false;
  }
  // 递归:当前节点相同,判断左孩子和右孩子是否都相同
  return isSameTree_1(p.left, q.left) && isSameTree_1(p.right, q.right);
};

3. 递归解法总结

优点:代码极度简洁,逻辑清晰,完全贴合二叉树的递归特性,容易理解和编写,新手友好;

缺点:递归依赖调用栈,如果二叉树深度极深(比如链式二叉树),可能会出现栈溢出的情况(但LeetCode的测试用例一般不会卡这种极端情况,日常刷题完全够用);

时间复杂度:O(n),n是两棵树中节点数较少的那一个,每个节点只会被访问一次;

空间复杂度:O(h),h是树的高度,最坏情况下(链式树)h=n,最好情况下(平衡树)h=logn。

三、解法二:迭代解法(用栈模拟递归,避免栈溢出)

1. 迭代思路分析

迭代解法的核心是“用栈模拟递归的调用过程”,通过手动维护一个栈,把需要判断的节点对(p的节点和q的对应节点)压入栈中,然后循环弹出节点对进行判断,本质上和递归的逻辑是一致的,只是实现方式不同。

具体步骤:

  1. 先判断两棵树的根节点是否都为空(和递归边界1一致),如果是,直接返回true;

  2. 如果根节点一个为空、一个不为空(和递归边界2一致),直接返回false;

  3. 初始化一个栈,把根节点对(p和q)压入栈中(注意压入顺序,后续弹出时要对应);

  4. 循环:只要栈不为空,就弹出两个节点(pNode和qNode),进行判断;

  5. 判断弹出的两个节点:如果都为空,跳过(继续判断下一组节点);如果一个为空一个不为空,返回false;如果值不相等,返回false;

  6. 如果当前节点对相同,就把它们的左孩子对、右孩子对依次压入栈中(注意压入顺序,先压右孩子,再压左孩子,因为栈是“后进先出”,和递归的顺序保持一致);

  7. 循环结束后,说明所有节点对都判断完毕,没有发现不相同的情况,返回true。

这里有个小细节:压入栈的顺序是“p.left、q.left、p.right、q.right”,弹出的时候会先弹出p.right和q.right,再弹出p.left和q.left,和递归时“先判断左孩子,再判断右孩子”的顺序是一致的,不影响结果,但要注意保持对应关系,不能压混。

2. 迭代代码实现

// 迭代解法:isSameTree_2(栈模拟递归)
function isSameTree_2(p: TreeNode | null, q: TreeNode | null): boolean {
  // 先处理根节点的边界情况(和递归一致)
  if (p === null && q === null) {
    return true;
  }
  if (p === null || q === null) {
    return false;
  }
  // 初始化栈,压入根节点对(p和q)
  let stack: (TreeNode | null)[] = [];
  stack.push(p);
  stack.push(q);
  
  // 循环:栈不为空时,持续判断节点对
  while (stack.length > 0) {
    // 弹出两个节点,注意栈是后进先出,所以先弹出q,再弹出p(对应压入顺序)
    let qNode: TreeNode | null = stack.pop() ?? null;
    let pNode: TreeNode | null = stack.pop() ?? null;
    
    // 两个节点都为空,跳过(继续判断下一组)
    if (pNode === null && qNode === null) {
      continue;
    }
    // 一个为空一个不为空,结构不同,返回false
    if (pNode === null || qNode === null) {
      return false;
    }
    // 节点值不同,返回false
    if (pNode.val !== qNode.val) {
      return false;
    }
    
    // 当前节点对相同,压入它们的左孩子对和右孩子对(保持对应关系)
    stack.push(pNode.left);
    stack.push(qNode.left);
    stack.push(pNode.right);
    stack.push(qNode.right);
  }
  
  // 所有节点对都判断完毕,没有不相同的情况,返回true
  return true;
}

3. 迭代解法总结

优点:不依赖递归调用栈,避免了极端情况下的栈溢出问题,稳定性更好;

缺点:代码比递归稍长,需要手动维护栈和循环逻辑,对新手来说稍微复杂一点;

时间复杂度:O(n),和递归一致,每个节点只会被压入栈、弹出栈各一次,访问一次;

空间复杂度:O(n),最坏情况下(平衡树),栈中会存储n/2个节点对,空间复杂度为O(n);最好情况下(链式树),栈中最多存储2个节点对,空间复杂度为O(1)。

四、两种解法对比 & 刷题建议

解法类型 优点 缺点 适用场景
递归 代码简洁、逻辑清晰、易编写 极端情况下可能栈溢出 日常刷题、二叉树深度不深的场景
迭代(栈) 无栈溢出问题、稳定性好 代码稍长、需维护栈逻辑 二叉树深度极深、生产环境场景

刷题建议:新手先掌握递归解法,因为它最贴合二叉树的特性,后续做二叉树的遍历、对称树、翻转树等题目时,思路可以无缝迁移;掌握递归后,再理解迭代解法,重点体会“栈模拟递归”的思想,这是二叉树迭代题目的核心套路。

五、常见踩坑点提醒

  • 踩坑点1:忽略“结构不同”的情况,只判断节点值。比如p有左孩子、q没有左孩子,但其他节点值都相同,此时会误判为相同;

  • 踩坑点2:递归时忘记写终止条件,导致无限递归,栈溢出;

  • 踩坑点3:迭代时压入栈的节点对顺序混乱,导致弹出时判断的不是“对应节点”(比如把p.left和q.right压在一起);

  • 踩坑点4:处理节点为null时不严谨,比如用p.val === q.val时,没有先判断p和q是否为null,导致空指针错误(代码中已规避此问题)。

六、总结

LeetCode 100. 相同的树,本质上是二叉树“同步遍历”的入门练习,核心是判断“结构+节点值”是否双匹配。递归解法胜在简洁,迭代解法胜在稳定,两种解法的逻辑完全一致,只是实现方式不同。

刷这道题的重点不在于“写出代码”,而在于理解“如何同步判断两棵树的对应节点”,这个思路后续会用到很多类似题目中(比如101. 对称二叉树,只是判断的是“自身的左孩子和右孩子”,逻辑高度相似)。

面试必考:如何优雅地将列表转换为树形结构?

2026年2月17日 19:41

面试必考:如何优雅地将列表转换为树形结构?

前言

在前端开发中,我们经常会遇到这样一个场景:后端返回的是一个扁平的列表数据,但前端需要渲染成树形结构。比如:

  • 省市区三级联动
  • 组织架构树
  • 权限菜单树
  • 商品分类树

今天我们就来深入探讨这个经典面试题,从递归到优化,一步步掌握列表转树的精髓。

第一章:理解数据结构

1.1 什么是扁平列表?

想象一下,你有一张Excel表格,每一行都是一个独立的数据,它们之间通过某个字段(比如parentId)来表明谁是谁的“爸爸”:

// 这是一个扁平的列表
const list = [
    {id: 1, name: 'A', parentId: 0},  // A是根节点(parentId为0表示没有父节点)
    {id: 2, name: 'B', parentId: 1},  // B的爸爸是A(parentId=1)
    {id: 3, name: 'C', parentId: 1},  // C的爸爸也是A
    {id: 4, name: 'D', parentId: 2}   // D的爸爸是B
]

这种数据的特点:

  • 每条数据都有一个唯一的 id(就像每个人的身份证号)
  • 通过 parentId 来表示父子关系(就像你知道你爸爸的身份证号)
  • parentId: 0 表示根节点(没有爸爸,或者爸爸是“虚拟”的根)

1.2 什么是树形结构?

树形结构就像你家的家族族谱:爷爷下面有爸爸和叔叔,爸爸下面有你和你兄弟姐妹:

// 我们希望转换成的树形结构
[
  {
    id: 1,
    name: 'A',
    parentId: 0,
    children: [  // children表示“孩子”们
      {
        id: 2,
        name: 'B',
        parentId: 1,
        children: [
          { id: 4, name: 'D', parentId: 2 }  // D是B的孩子
        ]
      },
      { id: 3, name: 'C', parentId: 1 }  // C是A的孩子,但没有自己的孩子
    ]
  }
]

1.3 为什么要转换?

后端为什么给扁平列表?因为存数据方便(只需要一张表)。 前端为什么要树结构?因为展示数据方便(树形菜单、级联选择器等)。

第二章:递归法(最直观的思路)

2.1 什么是递归?

递归就像俄罗斯套娃:一个大娃娃里面套着一个小娃娃,小娃娃里面还套着更小的娃娃...用程序的话说,就是函数调用自身

2.2 思路分析

想象你在整理家族族谱:

  1. 先找到所有没有爸爸的人(parentId: 0),他们是第一代人
  2. 然后为每个人找他的孩子:遍历整个列表,找到所有爸爸ID等于这个人ID的人
  3. 对每个孩子重复第2步(递归!)

2.3 基础版代码实现(逐行解释)

function listToTree(list, parentId = 0) {
    // result用来存放最终的结果
    // 比如第一次调用时,它用来存放所有根节点
    const result = []
    
    // 遍历列表中的每一项
    list.forEach(item => {
        // 检查当前项的父亲是不是我们要找的那个父亲
        // 比如parentId=0时,我们就在找所有根节点
        if (item.parentId === parentId) {
            
            // ★ 关键递归:找当前项的孩子
            // 把当前项的id作为新的parentId,去找它的孩子
            const children = listToTree(list, item.id)
            
            // 如果找到了孩子(children数组不为空)
            if (children.length) {
                // 给当前项添加一个children属性,把孩子们放进去
                item.children = children
            }
            
            // 把处理好的当前项放进结果数组
            result.push(item)
        }
    })
    
    // 返回这一层找到的所有人
    return result
}

2.4 代码执行过程演示

假设我们有这样的数据:

const list = [
    {id: 1, name: 'A', parentId: 0},  // 爷爷
    {id: 2, name: 'B', parentId: 1},  // 爸爸
    {id: 3, name: 'C', parentId: 1},  // 叔叔
    {id: 4, name: 'D', parentId: 2}   // 孙子
]

第一次调用listToTree(list, 0)

  • 找爸爸ID为0的人 → 找到A(id=1)
  • 调用listToTree(list, 1)找A的孩子

第二次调用listToTree(list, 1)

  • 找爸爸ID为1的人 → 找到B(id=2)和C(id=3)
  • 先处理B:调用listToTree(list, 2)找B的孩子
  • 再处理C:调用listToTree(list, 3)找C的孩子

第三次调用listToTree(list, 2)

  • 找爸爸ID为2的人 → 找到D(id=4)
  • 调用listToTree(list, 4)找D的孩子(没找到)
  • 返回[D],作为B的children

第四次调用listToTree(list, 3)

  • 找爸爸ID为3的人 → 没找到
  • 返回[],作为C的children(所以C没有children属性)

2.5 进阶版:使用ES6简化(逐行解释)

function listToTree(list, parentId = 0) {
    // 1. 先用filter过滤出当前层的所有节点
    // 比如找所有parentId等于0的根节点
    return list
        .filter(item => item.parentId === parentId)
        
        // 2. 然后用map对每个节点进行处理
        .map(item => ({
            // 这里用了三个点,后面会详细解释
            ...item,
            
            // 3. 递归找当前节点的孩子
            children: listToTree(list, item.id)
        }))
}

这段代码虽然简洁,但做了三件事:

  1. filter:从列表中筛选出符合条件的节点(比如所有根节点)
  2. map:对每个筛选出的节点进行处理
  3. 递归:为每个节点找它的孩子

2.6 递归法的优缺点

优点

  • 逻辑清晰,容易理解
  • 代码简洁优雅
  • 符合人的思维习惯

缺点

  • 时间复杂度 O(n²) - 每个节点都需要遍历整个列表
  • 列表越长,性能越差
  • 可能造成栈溢出(数据量极大时)

第三章:深入理解 ...item 的作用

3.1 如果不使用 ...item 会怎样?

很多初学者可能会这样写:

// 错误示例 ❌
map[item.id] = item
map[item.id].children = []  // 这样会修改原始数据!

3.2 为什么不能直接使用原对象?

让我们用一个生活例子来理解:

假设你有一张原始的家族成员名单

const originalList = [
    {id: 1, name: '爷爷'}
]

情况1:直接使用原对象(坏的做法)

const map = {}
map[1] = originalList[0]  // 把爷爷的原始记录放进map
map[1].children = ['孙子']  // 在原始记录上添加孙子信息

console.log(originalList[0])  
// 输出:{id: 1, name: '爷爷', children: ['孙子']}
// 哎呀!原始名单被修改了!

情况2:使用 ...item 复制(好的做法)

const map = {}
map[1] = { ...originalList[0] }  // 复制一份爷爷的记录
map[1].children = ['孙子']  // 在**副本**上添加孙子信息

console.log(originalList[0])  
// 输出:{id: 1, name: '爷爷'}
// 太好了!原始名单完好无损!

3.3 ...item 到底在做什么?

... 是JavaScript的扩展运算符,它的作用就像复印机:

const 原件 = { name: '张三', age: 18 }

// 用...复制一份
const 复印件 = { ...原件 }

// 现在原件和复印件是两份独立的数据
复印件.age = 19

console.log(原件.age)    // 18(没变)
console.log(复印件.age)  // 19(变了)

3.4 在列表转树中的应用

在我们的代码中:

map[item.id] = {
    ...item,        // 把item的所有属性复制过来
    children: []    // 再添加一个新的children属性
}

这相当于:

// 如果item是 {id: 1, name: 'A', parentId: 0}
// 那么新对象就是:
{
    id: 1,           // 从item复制来的
    name: 'A',       // 从item复制来的
    parentId: 0,     // 从item复制来的
    children: []     // 新添加的
}

3.5 什么时候必须用 ...item

必须用的场景:当你不想修改原始数据时

// 场景1:后端返回的数据,你不想污染它
const dataFromServer = [...]
const myCopy = { ...dataFromServer[0] }

// 场景2:多次使用同一份数据
const baseConfig = { theme: 'dark' }
const user1Config = { ...baseConfig, name: '张三' }
const user2Config = { ...baseConfig, name: '李四' }
// user1Config和user2Config互不影响

// 场景3:需要添加新属性,又不影响原对象
const original = { x: 1, y: 2 }
const enhanced = { ...original, z: 3 }
// original还是{x:1,y:2},enhanced是{x:1,y:2,z:3}

第四章:Map优化法(空间换时间)

4.1 为什么要优化?

递归法虽然好理解,但有个严重的问题:太慢了

想象一下:

  • 100个节点:递归法要做100×100=10000次操作
  • 1000个节点:要做1000×1000=1000000次操作
  • 10000个节点:...算了,太可怕了!

这就是我们常说的时间复杂度O(n²),数据量越大越慢。

4.2 优化思路

就像你去图书馆找书:

  • 递归法:每次找一本书都要把整个图书馆逛一遍
  • 优化法:先做一个索引表,想看什么书直接查索引

4.3 基础版代码实现(逐行解释)

function listToTree(list) {
    // 1. 第一步:创建"索引表"(map)
    // 这个map就像一个电话簿,通过id能直接找到对应的人
    const map = {}
    
    // 2. 第二步:存放最终结果(根节点们)
    const result = []
    
    // 3. 第一次遍历:把所有人都放进"电话簿"
    list.forEach(item => {
        // 对每个人,都做一份复印件(用...item复制)
        // 并且给复印件加一个空的"孩子名单"(children数组)
        map[item.id] = {
            ...item,        // 复印个人信息
            children: []    // 准备一个空的孩子名单
        }
    })
    
    // 4. 第二次遍历:建立父子关系
    list.forEach(item => {
        // 判断:这个人是不是根节点(没有爸爸)?
        if (item.parentId === 0) {
            // 是根节点:直接放进最终结果
            result.push(map[item.id])
        } else {
            // 不是根节点:找到他爸爸,把自己加入爸爸的孩子名单
            
            // map[item.parentId] 通过爸爸的ID找到爸爸
            // ?. 是可选链操作符,意思是:如果爸爸存在,就执行后面的操作
            // .children.push() 把自己加入爸爸的孩子名单
            map[item.parentId]?.children.push(map[item.id])
        }
    })
    
    // 5. 返回最终结果
    return result
}

4.4 图解Map优化法

假设有这样的数据:

原始列表:
[  {id:1, parentId:0, name:'A'},  // 根节点  {id:2, parentId:1, name:'B'},  // A的孩子  {id:3, parentId:1, name:'C'}   // A的孩子]

第一次遍历后(建立索引表):
map = {
  1: {id:1, name:'A', children:[]},
  2: {id:2, name:'B', children:[]},
  3: {id:3, name:'C', children:[]}
}

第二次遍历(建立关系):
- 处理item1: parentId=0 → result = [map[1]]
- 处理item2: parentId=1 → map[1].children.push(map[2])
- 处理item3: parentId=1 → map[1].children.push(map[3])

最终result:
[{
  id:1, name:'A',
  children: [
    {id:2, name:'B', children:[]},
    {id:3, name:'C', children:[]}
  ]
}]

4.5 使用ES6 Map版本(更专业的写法)

function listToTree(list) {
    // 使用ES6的Map数据结构代替普通对象
    // Map相比普通对象有更多优点:键可以是任何类型,有size属性等
    const nodeMap = new Map()
    const tree = []
    
    // 第一次遍历:初始化所有节点
    list.forEach(item => {
        nodeMap.set(item.id, {
            ...item,
            children: []
        })
    })
    
    // 第二次遍历:构建树结构
    list.forEach(item => {
        if (item.parentId === 0) {
            // 根节点直接加入树
            tree.push(nodeMap.get(item.id))
        } else {
            // 非根节点找爸爸
            const parentNode = nodeMap.get(item.parentId)
            if (parentNode) {
                // 把自己加入爸爸的孩子名单
                parentNode.children.push(nodeMap.get(item.id))
            }
        }
    })
    
    return tree
}

4.6 为什么返回result就是返回所有树的元素?

这是一个非常关键的知识点!很多初学者会疑惑:为什么最后返回result就能得到完整的树?

让我们用一个比喻来理解:

想象你是一个班主任,要整理全校学生的家族关系:

  1. 你有一张全校学生名单(list
  2. 你做了一个索引表(map),通过学号能快速找到每个学生
  3. 你有一个空的花名册(result),用来放每个家族的"族长"(根节点)

关键理解:当你把"族长"放进result时,这个"族长"已经通过索引表关联了所有的子孙后代!

// 实际内存中的关系
map[1] = { id:1, name:'A', children: [] }  // 族长A
map[2] = { id:2, name:'B', children: [] }  // A的儿子B
map[3] = { id:3, name:'C', children: [] }  // A的儿子C

// 建立关系后
map[1].children.push(map[2])  // 现在 map[1].children 里有 map[2] 的引用
map[1].children.push(map[3])  // 现在 map[1].children 里有 map[3] 的引用

// 把map[1]放入result
result.push(map[1])

// 此时的map[1]长这样:
{
    id: 1,
    name: 'A',
    children: [
        { id:2, name:'B', children:[] },  // 注意:这里是完整的B对象
        { id:3, name:'C', children:[] }   // 注意:这里是完整的C对象
    ]
}

重点来了:虽然我们只把A放进了result,但是A的children数组里直接存储了B和C对象的引用。也就是说:

  • result[0] 就是 A
  • result[0].children[0] 就是 B
  • result[0].children[1] 就是 C

所以通过result,我们就能访问到整棵树的所有节点!

如果有多个根节点:

// 假设还有另一个家族:D是族长,E是D的儿子
map[4] = { id:4, name:'D', children: [] }
map[5] = { id:5, name:'E', children: [] }
map[4].children.push(map[5])

// 把map[4]也放入result
result.push(map[4])

// 最终result:
[
    {  // 第一棵树
        id: 1, name:'A',
        children: [ { id:2, name:'B' }, { id:3, name:'C' } ]
    },
    {  // 第二棵树
        id: 4, name:'D',
        children: [ { id:5, name:'E' } ]
    }
]

所以返回result就是返回了所有的树,因为:

  1. 每个根节点都包含了它的所有子孙节点(通过引用)
  2. result数组收集了所有的根节点
  3. 通过这些根节点,我们可以访问到整个森林的所有节点

4.7 为什么说"空间换时间"?

  • 递归法:速度快吗?慢!占内存吗?少!(时间多,空间少)
  • Map优化法:速度快吗?快!占内存吗?多!(时间少,空间多)

就像搬家:

  • 递归法:每次需要什么都临时去买(耗时但省地方)
  • Map优化法:先把所有东西都买好放仓库(费地方但省时间)

第五章:两种方法的详细对比

对比维度 递归法 Map优化法 通俗解释
时间复杂度 O(n²) O(n) 100个数据:递归法要查10000次,Map法只要查200次
空间复杂度 O(1) O(n) 递归法基本不占额外内存,Map法需要建一个索引表
代码长度 短(3-5行) 稍长(10-15行) 递归法更简洁
可读性 ⭐⭐⭐⭐⭐ ⭐⭐⭐ 递归法更容易理解
适用场景 小数据量(<100条) 大数据量(>100条) 根据数据量选择

第六章:实际应用场景(详细版)

6.1 省市区三级联动

// 实际开发中,后端通常只返回扁平列表
const areas = [
    {id: 1, parentId: 0, name: '中国'},
    {id: 2, parentId: 1, name: '北京'},
    {id: 3, parentId: 1, name: '上海'},
    {id: 4, parentId: 2, name: '东城区'},
    {id: 5, parentId: 2, name: '西城区'},
    {id: 6, parentId: 3, name: '黄浦区'}
]

// 转换后,就可以方便地实现三级联动选择器
const areaTree = listToTree(areas)
// 用户选择"中国"后,自动显示"北京"、"上海"
// 选择"北京"后,自动显示"东城区"、"西城区"

6.2 组织架构树

// 公司人员列表
const employees = [
    {id: 1, parentId: 0, name: '张总', position: 'CEO'},
    {id: 2, parentId: 1, name: '李经理', position: '技术总监'},
    {id: 3, parentId: 1, name: '王经理', position: '市场总监'},
    {id: 4, parentId: 2, name: '赵前端', position: '前端工程师'},
    {id: 5, parentId: 2, name: '钱后端', position: '后端工程师'},
    {id: 6, parentId: 3, name: '孙策划', position: '市场专员'}
]

// 转换后可以渲染出组织架构图
const orgTree = listToTree(employees)

6.3 权限菜单树

// 后台管理系统的菜单
const menus = [
    {id: 1, parentId: 0, name: '系统管理', icon: '⚙️'},
    {id: 2, parentId: 1, name: '用户管理', icon: '👤'},
    {id: 3, parentId: 1, name: '角色管理', icon: '🔑'},
    {id: 4, parentId: 2, name: '新增用户', permission: 'user:add'},
    {id: 5, parentId: 2, name: '编辑用户', permission: 'user:edit'}
]

// 转换后可以方便地渲染侧边栏菜单
const menuTree = listToTree(menus)

第七章:常见问题解答(FAQ)

Q1: 如果数据中有多个根节点怎么办?

A: 没问题!result 数组会包含所有根节点,形成一个"森林"(多棵树)。每个根节点都带着自己的子树。

Q2: 如果数据中有循环引用(A的爸爸是B,B的爸爸是A)怎么办?

A: 这会导致无限递归!需要先做数据校验,或者设置最大递归深度。

Q3: 什么情况下用递归法,什么情况下用Map法?

A:

  • 数据量小(<100条):用递归法,简单易懂
  • 数据量大(>100条):用Map法,性能好
  • 面试时:先说递归法展示思路,再说Map法展示优化能力

Q4: 为什么 map[item.parentId]?.children 要加问号?

A: 问号是可选链操作符,意思是:如果 map[item.parentId] 存在,才执行后面的 .children.push。防止出现"找不到爸爸"的错误。

Q5: 为什么返回result就能得到完整的树?

A: 因为每个根节点的children数组里存储的是子节点的引用,而不是复制品。当你通过result访问根节点时,实际上可以通过引用链访问到所有后代节点。

第八章:面试技巧

当面试官问到这个问题时,可以这样回答:

  1. 第一层(基础):"我可以用递归实现,先找根节点,再递归找每个节点的子节点。"

  2. 第二层(优化):"但递归的时间复杂度是O(n²),数据量大时会很慢。我可以用Map优化到O(n)。"

  3. 第三层(细节):"在实现时要注意用...item复制对象,避免修改原始数据。还要处理边界情况,比如找不到父节点的情况。"

  4. 第四层(原理):"Map优化法的核心是空间换时间,通过索引表实现O(1)查找。返回result之所以能得到完整的树,是因为JavaScript的对象引用机制——根节点的children里存储的是子节点的引用。"

  5. 第五层(应用):"这个算法在实际开发中很常用,比如我在做省市区选择器、后台管理系统菜单时都用到了。"

第九章:总结与思考

通过这篇文章,我们学习了:

  1. 什么是列表转树:把扁平数据变成树形结构
  2. 递归法:直观但性能较差
  3. ...item的作用:复制对象,避免修改原始数据
  4. Map优化法:性能好但稍微复杂
  5. 返回结果的原理:通过引用机制,根节点包含所有子孙节点
  6. 实际应用场景:省市区联动、组织架构、权限菜单等

掌握了这个算法,你不仅能应对面试,在实际开发中也能游刃有余地处理各种树形结构数据。


如果这篇文章对你有帮助,欢迎点赞收藏,也欢迎在评论区提出你的问题!

LeetCode 104. 二叉树的最大深度:解题思路+代码解析

作者 Wect
2026年2月17日 18:29

LeetCode基础题第104题「二叉树的最大深度」,这道题是二叉树遍历的经典入门题,核心考察对二叉树层次遍历(BFS)的理解和应用,适合新手入门练手。今天就来详细拆解这道题,从题目理解到代码实现,再到细节优化,一步步讲清楚,看完就能轻松掌握。

一、题目解读

题目描述

给定一个二叉树 root ,返回其最大深度。二叉树的最大深度是指从根节点到最远叶子节点的最长路径上的节点数。

简单来说,就是要找到二叉树“最深”的那一层,统计从根到这一层的所有节点个数。举个例子:

  • 如果二叉树只有根节点,没有左右子节点,最大深度就是1;

  • 如果根节点有左子节点,左子节点又有一个子节点,那么最大深度就是3;

  • 如果二叉树是空树(root为null),最大深度就是0。

核心考点

这道题的核心是「二叉树的遍历」,常见的解法有两种:

  1. 层次遍历(BFS,广度优先搜索):按层遍历二叉树,每遍历完一层,深度加1,最终的深度就是最大深度(本文重点讲解这种解法,对应给出的代码);

  2. 深度优先搜索(DFS):递归遍历左右子树,取左右子树的最大深度,再加上当前根节点的深度1,即为整个树的最大深度(后续会补充备选代码)。

二、代码解析(TypeScript)

先贴出完整代码(已优化,解决原代码潜在问题),再逐行拆解思路,新手也能看懂~

/**
 * Definition for a binary tree node.
 * 二叉树节点的定义(题目已给出,无需修改)
 */
class TreeNode {
  val: number
  left: TreeNode | null
  right: TreeNode | null
  constructor(val?: number, left?: TreeNode | null, right?: TreeNode | null) {
    this.val = (val === undefined ? 0 : val)
    this.left = (left === undefined ? null : left)
    this.right = (right === undefined ? null : right)
  }
}

/**
 * 计算二叉树最大深度(层次遍历/BFS解法)
 * @param root 二叉树根节点
 * @returns 二叉树的最大深度
 */
function maxDepth(root: TreeNode | null): number {
  // 边界处理:空树直接返回深度0(避免后续无效循环)
  if (root === null) {
    return 0;
  }

  let depth = 0; // 记录当前深度,初始化为0
  // 用数组模拟队列,存储当前层的所有节点(初始存入根节点)
  const queue: TreeNode[] = [root];

  // 队列不为空,说明还有节点未遍历,继续循环
  while (queue.length > 0) {
    const levelSize = queue.length; // 当前层的节点个数
    // 遍历当前层的所有节点
    for (let i = 0; i < levelSize; i++) {
      // 取出当前层的节点(优化:用pop+unshift替代shift,提升性能)
      const node = queue.pop()!;
      
      // 若当前节点有右子节点,存入队列(先存右,再存左,保证遍历顺序)
      if (node.right) {
        queue.unshift(node.right);
      }
      // 若当前节点有左子节点,存入队列
      if (node.left) {
        queue.unshift(node.left);
      }
    }
    // 可选:打印队列,观察每一层遍历后的节点变化(调试用)
    
    
    // 当前层遍历完毕,深度加1
    depth++;
  }

  return depth;
}

逐行拆解思路

1. 边界处理(关键!)

if (root === null) { return 0; }

这一步是很多新手容易忽略的点:如果二叉树是空树(root为null),没有任何节点,最大深度自然是0。如果不做这个判断,后续队列会存入null,导致循环多执行一次,最终返回错误的深度1。

2. 初始化变量

  • let depth = 0;:用于记录二叉树的深度,初始值为0(因为还未开始遍历任何一层);

  • const queue: TreeNode[] = [root];:用数组模拟队列(队列是BFS的核心数据结构),初始时将根节点存入队列,代表从根节点开始遍历。

3. 层次遍历核心循环

while (queue.length > 0) { ... }:队列不为空,说明还有节点未遍历,循环继续。每一次循环,都代表遍历完「一层」节点。

4. 遍历当前层节点

const levelSize = queue.length;:获取当前层的节点个数,这个值是固定的(因为后续队列会存入下一层的节点,不能直接用queue.length判断当前层节点数)。

for (let i = 0; i < levelSize; i++) { ... }:循环遍历当前层的每一个节点,把每个节点的左右子节点(如果有的话)存入队列,为下一层遍历做准备。

5. 节点取出与子节点入队(性能优化点)

原代码中如果用queue.shift()取出节点,会有性能问题——因为数组的shift()方法是O(n)时间复杂度(需要将数组中所有元素向前移动一位),当二叉树节点较多时,效率会很低。

优化方案:用queue.pop()(O(1)时间复杂度)取出队列尾部的节点,同时调整子节点入队顺序(先存右子节点,再存左子节点),保证遍历顺序和shift()一致,既提升性能,又不影响结果。

这里的!是非空断言,因为我们已经通过边界处理和循环条件,确保队列中的节点一定是TreeNode类型(不会为null),所以可以安全使用非空断言。

6. 深度递增

depth++;:每遍历完一层,说明二叉树的深度增加了1,所以深度加1。当循环结束时,depth就是二叉树的最大深度。

三、代码优化说明

对比LeetCode题目给出的初始模板,这段代码做了3个关键优化,兼顾性能和正确性:

  1. 新增边界处理:解决空树返回错误深度的问题,让代码更健壮;

  2. 性能优化:用pop() + unshift()替代shift(),将节点取出操作的时间复杂度从O(n)降至O(1),适合处理节点较多的二叉树;

可读性优化:添加详细注释,变量命名语义化(如levelSize代表当前层节点数),方便新手理解每一步的执行过程。

四、备选解法(DFS递归版)

除了上述层次遍历(BFS)解法,这道题还可以用深度优先搜索(DFS)的递归写法,代码更简洁,适合树深度不大的场景(避免递归栈溢出),新手也可以了解一下:

function maxDepthRecursive(root: TreeNode | null): number {
  // 空树返回0
  if (root === null) {
    return 0;
  }
  // 递归计算左右子树的最大深度,当前深度 = 左右子树最大深度 + 1(当前节点)
  return Math.max(maxDepthRecursive(root.left), maxDepthRecursive(root.right)) + 1;
}

递归解法的核心思路:二叉树的最大深度 = 左子树最大深度和右子树最大深度的最大值 + 1(当前根节点),本质是遍历到最底层的叶子节点,再回溯计算深度。

五、总结

LeetCode 104题是二叉树遍历的入门题,难度简单,但能很好地巩固BFS和DFS的基础思路。本文讲解的层次遍历(BFS)解法,适合所有场景(包括极深的二叉树,避免递归栈溢出),优化后的代码兼顾性能和可读性,新手可以重点掌握。

解题关键记住两点:

  1. 边界处理:空树直接返回0,避免错误;

  2. 层次遍历核心:用队列存储每一层的节点,每遍历完一层,深度加1。

社区推荐重排技术:双阶段框架的实践与演进|得物技术

作者 得物技术
2026年2月12日 13:48

一、背景

推荐系统典型pipeline

在推荐系统多阶段Pipeline(召回→粗排→精排→重排)中,重排作为最终决策环节,承担着将精排输出的有限候选集(通常为Top 100–500个Item)转化为最优序列的关键职责。数学定义为在给定候选集C={x1,x2,,xn}C = \lbrace x_1,x_2,……,x_n \rbrace与目标列表长度LL,重排的目标是寻找一个排列πP(C,L)\pi^* \in P(C,L),使得全局收益函数最大化。

在推荐系统、搜索排序等AI领域,Pointwise 建模是精排阶段的核心方法,即对每个 Item 独立打分后排序,pointwise 建模范式面临挑战:

  • 多样性约束:精排按 item 独立打分排序 → 高分 item 往往语义/类目高度同质(如5个相似短视频连续曝光)。
  • 位置偏差:用户注意力随位置显著衰减,且不同item对位置敏感度不同。
  • 上下文建模:用户决策是序列行为,而非独立事件。

二、重排架构演进:生成式模型实践

我们的重排系统采用G-E两阶段协同框架:

  • 生成阶段(Generation):高效生成若干高质量候选排列。
  • 评估阶段(Evaluation):对候选排列进行精细化打分,选出全局最优结果。

不考虑算力和耗时的情况下,通过穷举所有排列P(C,L)P(C,L)

生成阶段主要依赖启发式规则、随机扰动 + beamSearch算法生成候选list,双阶段范式存在显著的痛点:

  • 质量-延迟-多样性的“不可能三角”:在实践中,增加生成候选list数一般可以提升最终list的质量,但边际收益递减;优化过程中,我们通过增加多目标、多样性等策略都取得了消费指标的提升,但在候选list达到百量级时,单纯增加候选集对指标的提升,同时还有:
  1. 增加beam width,系统耗时增加,DCG@K提升逐渐减少。

  2. 增加通道数,通道间重叠度逐渐增加,去重list增加逐渐减少。

  • 阶段间目标不一致:
  1. 分布偏移:启发式生成Beam Search输出的Top排列中,20%被评估模型否定,生成阶段搜索效率浪费。

  2. 梯度断层:Beam Search含argmax操作,双阶段无法端到端优化;生成模型无法感知评估反馈,优化方向偏离全局最优。

生成模型优化

生成分为启发式方法和生成式模型方法, 一般认为生成式模型方法要好于启发式方法。生成式模型逐渐成为重排主流范式,主要分为两类:自回归生成模型、非自回归生成模型。

  • 自回归生成:按位置顺序逐个生成物品,第 t 位的预测依赖前 t-1 位已生成结果。
  1. 优点:

a. 序列依赖建模强,天然捕获物品间的顺序依赖。

b. 训练简单稳定,每步使用真实前序作为输入,收敛快。

c. 生成质量高,逐步细化决策,适合长序列精细优化。

  1. 缺点:

a. 推理延迟高,生成 L 个物品需 L 次前向传播,线上服务难以满足毫秒级要求。

b. 局部最优风险,早期错误决策无法回溯修正,影响整体序列质量。

  • 非自回归生成:一次性预测整个推荐序列的所有位置,各位置预测相互独立。
  1. 优点:

a. 推理速度极快:生成整个序列仅需1次前向传播。

2.缺点:

b.条件独立性假设过强:各位置并行预测,难以显式建模物品间复杂依赖关系。

非自回归模型

为了对齐双阶段一致性,同时考虑线上性能,我们推进了非自回归模型的上线。模型结构如下图:

模型包括Candidates Encoder和Position Encoder,Candidates Encoder是标准的Transformer结构, 用于获取item间的交互信息;Position Encoder额外增加了Cross Attention,期望Position序列同时关注Candidate序列。

  • 模型特征:用户信息、item特征、位置信息、上游精排打分特征。
  • 模型输出:一次性输出 n×L 的位置-物品得分矩阵(n 为候选 item 数,L 为目标列表长度),支持高效并行推理
p^ij=exp(xitj)i=1nexp(xitj)\hat{p}_{ij} = \frac{\exp(\mathbf{x}_i^\top \mathbf{t}_j)}{\sum_{i=1}^n \exp(\mathbf{x}_i^\top \mathbf{t}_j)}
  • 位置感知建模:引入可学习位置嵌入,显式建模“同一 item 在不同位置表现不同”的现象(如首屏效应、位置衰减)。
  • 训练目标:模型使用logloss,让正反馈label序列的生成概率最大, 同时负反馈label序列的生成概率最小:
Llog=i[pijyilog(pij^)+pij(1yi)log(1pij^)]\mathcal{L}_{\log} = -\sum_{i} \big[ p_{ij}y_i \log(\hat{p_{ij}}) + p_{ij}(1-y_i) \log(1-\hat{p_{ij}}) \big]

其中,pijp_{ij}表示位置i上是否展示物品j,yiy_{i}表示位置i上的label。

线上实验及收益:

  • 一期新增了非自回归生成通道,pvctr +0.6%,时长+0.55%。
  • 二期在所有通道排序因子中bagging非自回归模型,pvctr +1.0%,时长+1.13%。

自回归模型

由于条件独立性假设, 非自回归模型对上下文信息建模是不够的,近期我们重点推进了自回归模型的开发。

模型通过Transformer架构建模list整体收益,我们使用单向transformer模拟用户浏览行为的因果性,同时解决自回归生成的暴露偏差问题,保持训练和推理的一致性。结构如下:

  • 模型特征:用户信息、item特征、位置信息、上游精排打分特征。
  • 训练目标:模型使用有序回归loss,在评估多个回合中不同长度的子列表时,能够很好地体现出序列中的增量价值。是用于判断长度为j的子列表是否已经达到i次点击或转化的损失函数。
Li,j(θj)=k=1N([yk<i]log(1pi,j(xk))+[yki]log(pi,j(xk)))L_{i,j}(\theta_j) = -\sum_{k=1}^{N} \left([y_k < i]\log(1-p_{i,j}(x_k)) + [y_k \geq i]\log(p_{i,j}(x_k))\right)

线上模型推理效率优化及实验效果:

自回归生成模型推理延迟高,生成 L 个物品需 L 次前向传播,线上服务难以满足毫秒级要求。因此,我们在传统自回归生成模型的基础上增加MTP(multi token prediction)结构,突破生成式重排模型推理瓶颈。其核心思想是将传统自回归的单步预测扩展为单步多token联合预测,显著减少生成迭代次数。

自回归生成模型在社区推荐已完成了推全,实验中我们新增了自回归生成模型通道,但不是完全体,仅部分位置生成调用了模型:

  • 一期调用两次模型,每次预测4个位置,pvctr +0.69%,有效vv +0.58%。
  • 二期调用两次模型,每次预测5个位置,pvctr +0.54%,有效vv +0.40%。

三、推理性能优化:端到端生成的效率保障

工程架构

为解决CPU推理模型延迟高、制约业务效果的问题,我们对DScatter模型服务进行升级,引入高性能GPU推理能力,具体方案如下:

  • GPU推理框架集成与升级:
  1. 框架升级:将现有依赖的推理框架升级为支持GPU的高性能服务框架。

  2. 硬件资源引入:引入 NVIDIA L20 等专业推理显卡,为当前的listwise评估模型及自回归生成模型提供专用算力,实现模型推理的硬件加速。

  • DScatter模型服务独立部署与容量提升:
  1. 为解决模型部署效率低与资源竞争问题,将DScatter的模型打分逻辑从现有重排服务中完全解耦,构建并部署独立的 DScatter-Model-Server 集群,从根本上消除与重排服务在CPU、内存等关键资源上的竞争。

模型优化

  • 模型格式转换与加速:

导出为 ONNX 格式,使用 TensorRT 进行量化、层融合、动态张量显存等技术加速推理。

  • Item Embedding缓存:

预计算item静态网络,线上直接查询节省计算量。

  • 自回归生成模型核心优化,KV Cache 复用:

缓存已生成token的KV和attention值,仅计算增量token相关值,避免重复计算。

  • 其他LLM推理加速技术应用落地,例如GQA

四、未来规划:迈向端到端序列生成的下一代重排架构

当前“生成-评估”双阶段范式虽在工程落地性上取得平衡,但其本质仍是局部优化:生成阶段依赖启发式规则或浅层模型生成候选,评估阶段虽能识别优质序列,却无法反向指导生成过程,导致系统能力存在理论上限。为突破这一瓶颈,我们规划构建端到端序列生成(End-to-End Sequence Generation) 架构,将重排从“候选筛选”升级为“序列创造”,直接以全局业务目标(如用户停留时长、互动深度、内容生态健康度)为优化目标。

核心架构设计:

  • 统一生成器:以 Transformer 为基础架构,搭建自回归序列建模能力,采用分层混合生成策略:
  1. 粗粒度并行生成:首层预测序列骨架(如类目分布、内容密度)等。

  2. 细粒度自回归精调:在骨架约束下,自回归生成具体 item,确保局部最优。

  • 序列级Reward Modeling:
  1. 构建多目标 reward 函数:xtr、多样性。

  2. Engagement:基于用户滑动轨迹建模序列累积收益(如滑动深度加权CTR)。

  3. Diversity:跨类目/创作者/内容形式的分布熵。

4.Fairness:冷启内容、长尾创作者曝光保障。

训练范式升级:强化学习与对比学习融合

推进自回归生成模型的架构升级与训练体系重构,引入强化学习微调(PPO/DPO)与对比学习机制,提升序列整体效率。

  • 搭建近线系统,生成高质量list候选,提升系统能力上限:

1.基于 DCG 的列表质量打分:

a. 对每个曝光列表L,计算其 DCG@K作为质量分数:

DCG(L)=j=1Kgain(itemj)log2(j+1)\text{DCG}(L) = \sum_{j=1}^{K} \frac{\text{gain}(item_j)}{\log_2(j + 1)}

其中 gain(item)可定义为:

若点击:+1.0

若互动(点赞/收藏):+1.5

若观看 >5s:+0.8

否则:0

2.构造偏好对:

a.对同一用户在同一上下文下的两个列表 LwL_w(win)和 LlL_l(lose)。

b.若 DCG(Lw)>DCG(Ll)+δDCG(L_w) > DCG(L_l) + \delta(δ 为 margin,如 0.1),则构成一个有效偏好对。

  • 引入强化学习微调(PPO/DPO)与对比学习机制,提升序列整体效率:
  1. 模型结构:

a.使用当前自回归生成模型作为策略模型。

b.固定预训练模型作为参考策略 (即 DPO 中的“旧策略”)。

2.DPO损失:

LDPO(θ)=E(x,yw,yl)D[logσ(β(logπθ(ywx)πref(ywx)logπθ(ylx)πref(ylx)))]\mathcal{L}_{\text{DPO}}(\theta) = -\mathbb{E}_{(x, y_w, y_l) \sim \mathcal{D}} \left[ \log \sigma \left( \beta \left( \log \frac{\pi_\theta(y_w \mid x)}{\pi_{\text{ref}}(y_w \mid x)} - \log \frac{\pi_\theta(y_l \mid x)}{\pi_{\text{ref}}(y_l \mid x)} \right) \right) \right]
  • 技术价值:
  1. 突破“质量-延迟-多样性”不可能三角:通过序列级优化,在同等延迟下实现质量与多样性双提升。

  2. 为AIGC与推荐融合铺路:端到端生成器可无缝接入AIGC内容,实现“内容生成-序列编排”一体化。

参考文献:

  1. Gloeckle F, Idrissi B Y, Rozière B, et al. Better & faster large language models via multi-token prediction[J]. arXiv preprint arXiv:2404.19737, 2024.
  2. Ren Y, Yang Q, Wu Y, et al. Non-autoregressive generative models for reranking recommendation[C]//Proceedings of the 30th ACM SIGKDD Conference on Knowledge Discovery and Data Mining. 2024: 5625-5634.
  3. Meng Y, Guo C, Cao Y, et al. A generative re-ranking model for list-level multi-objective optimization at taobao[C]//Proceedings of the 48th International ACM SIGIR Conference on Research and Development in Information Retrieval. 2025: 4213-4218.
  4. Zhao X, Xia L, Zhang L, et al. Deep reinforcement learning for page-wise recommendations[C]//Proceedings of the 12th ACM conference on recommender systems. 2018: 95-103.
  5. Feng Y, Hu B, Gong Y, et al. GRN: Generative Rerank Network for Context-wise Recommendation[J]. arXiv preprint arXiv:2104.00860, 2021.
  6. Pang L, Xu J, Ai Q, et al. Setrank: Learning a permutation-invariant ranking model for information retrieval[C]//Proceedings of the 43rd international ACM SIGIR conference on research and development in information retrieval. 2020: 499-508.

往期回顾

1.Flink ClickHouse Sink:生产级高可用写入方案|得物技术

2.服务拆分之旅:测试过程全揭秘|得物技术

3.大模型网关:大模型时代的智能交通枢纽|得物技术

4.从“人治”到“机治”:得物离线数仓发布流水线质量门禁实践

5.AI编程实践:从Claude Code实践到团队协作的优化思考|得物技术

文 /张卓

关注得物技术,每周一、三更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

❌
❌