云计算百科
云计算领域专业知识百科平台

DAY12: LeetCode 129|求根节点到叶节点数字之和

LeetCode 129|求根节点到叶节点数字之和

一、题目要做什么?

假设有这样一棵树:

4
/ \\
9 0
/ \\
5 1

这里的叶子节点是:5、1、0

所谓叶子节点,就是:

左孩子和右孩子都不存在的节点。

所以:

  • 只有左孩子为空,不一定是叶子;
  • 只有右孩子为空,也不一定是叶子;
  • 必须左右孩子都为空,才是叶子。

题目要求我们从根节点开始,一直走到叶子节点。

于是这里一共有三条完整路径:

4 → 9 → 5

4 → 9 → 1

4 → 0

把经过的数字连起来,就得到:

495

491

40

最终:495 + 491 + 40 = 1026

所以这道题实际上需要解决两个问题:

  • 怎么在往下走的时候,把经过的数字逐渐组成一个完整数字?
  • 怎么找到树里面所有“根节点 → 叶子节点”的路径?

  • 二、先把问题缩小:如果只有一条路怎么办?

    先假设树根本不会分叉,只有:4 → 9 → 5

    那事情就很简单 :
    初始:current_num = 0

    • 来到 4:4
    • 来到 9:4 × 10 + 9 = 49
    • 来到 5:49 × 10 + 5 = 495

    所以我们会发现一个规律。

    每来到一个新的节点,只需要:之前的数字 × 10 + 当前节点的值

    也就是:

    current_num = current_num * 10 + node.val

    所以,如果我们沿着一条路径往下走,就只需要记住一个东西:

    到目前为止,这条路径已经组成了什么数字?


    三、真正麻烦的地方:树会分叉

    现实中的树不是:4 → 9 → 5 这么简单。

    而是:

    4
    / \\
    9 0
    / \\
    5 1

    • 来到 4 以后,有两个方向:

      • 左边去 9。
      • 右边去 0。
    • 来到 9 以后,又出现两个方向:

      • 左边去 5。
      • 右边去 1。

    所以我们需要不断做这样一件事情:

    来到一个节点

    处理当前节点

    看看下面还有没有节点

    如果还有,就继续处理它的孩子

    🔖 但重点来了:

    • 来到 4,需要做这些事情。
    • 来到 9,也需要做这些事情。
    • 来到 5、1、0,也都是面对同样的问题。

    也就是说:

    “处理当前这棵树”和 “处理它的左子树、右子树” 本质上是同一种问题。

    这时候使用 递归 就很自然了。

    因为递归特别适合处理这种:

    大问题里面包含结构完全相同的小问题。

    当然,这道题并不是只能递归。

    也可以自己维护一个栈,用迭代 DFS 完成。

    但这一篇先只讨论递归,因为递归最容易看清楚树的结构。


    四、构建递归函数

    前面我们已经知道:

    这道题需要不断处理当前节点,然后继续处理它的左子树和右子树。

    所以接下来,我们正式开始构建递归函数。

    写一个递归函数时,可以先想几个最基本的问题:

  • 这个函数需要哪些参数?
  • 每进入一层递归,要先处理什么?
  • 什么时候停止递归?
  • 如果还没有结束,要怎么继续递归?
  • 当前这一层最后要返回什么?
  • 我们就按照这个顺序来。


    1. 递归函数需要哪些参数?

    首先,我们肯定需要知道:

    当前走到了哪个节点?

    所以第一个参数是:node

    但只有 node 还不够。

    比如现在已经走到了:

    4 → 9

    来到节点 9 时,我们需要得到:49

    但是 9 自己只知道:

    node.val = 9

    它还必须知道:

    在来到我之前,这条路径已经组成了数字 4。

    所以递归函数还需要额外保存:

    当前路径已经组成的数字。

    我们把它叫做:current_num

    于是递归函数就可以定义成:

    dfs(node, current_num)

    其中:

    node
    → 当前正在处理的节点

    current_num
    → 来到当前节点之前,这条路径已经组成的数字

    最开始还没有经过任何节点,所以第一次调用:

    dfs(root, 0)


    2. 进入一层递归后,先更新当前路径数字

    假设当前调用:

    dfs(9, 4)

    表示来到节点 9 时,前面的路径已经组成数字 4。

    根据前面总结的规律:

    current_num = current_num * 10 + node.val

    所以这一层更新后:

    4 × 10 + 9 = 49

    此时:

    current_num = 49


    3. 什么时候停止递归?

    接下来要确定:

    递归什么时候不需要再继续往下走?

    这道题里有两种情况:

    叶子节点

    题目要求的是:根节点 → 叶子节点

    所以只有走到 叶子节点,才说明一条完整路径真正形成 ->> 当前这条从根节点出发的路径已经走完了。

    叶子节点指的是: 左孩子和右孩子都不存在的节点。

    因此需要同时满足:

    node.left is None and node.right is None

    注意这里是 and。

    只有左右两边都为空,当前节点才是叶子节点。


    走到叶子以后返回什么?

    比如当前路径是:

    4 → 9 → 5

    来到节点 5 后,我们已经计算出:

    49 × 10 + 5 = 495

    而节点 5 没有左右孩子,所以这条路径到这里正式结束。

    此时:495 就是 这一条路径最终组成的数字。

    因此直接返回当前的 current_num:

    return current_num

    一旦执行 return,当前这一层递归就结束了,不会再继续往下寻找子节点。


    空节点

    另一种停止递归的情况是:

    当前这个方向根本没有节点。

    例如:

    1
    /
    2
    \\
    3

    节点 2 不是叶子,因为它还有右孩子 3。

    所以后面仍然需要继续处理它的左右两边。

    但是往左走时会发现:

    2 的左边是空的

    既然这一边已经没有节点,就没有必要继续递归了。

    因此遇到空节点时,可以直接返回:

    return 0

    为什么返回 0?

    因为这一边没有形成任何完整路径,所以对最终结果没有贡献。

    后面我们会把左右两边得到的结果相加:

    左边结果 + 右边结果

    如果左边不存在,那么:

    0 + 右边真正的结果

    不会影响右边的答案。


    4. 如果未终止,就继续递归左右子树

    假设现在来到节点 9:

    9
    / \\
    5 1

    当前已经得到:

    current_num = 49

    但是节点 9 还有孩子,所以路径还没有结束。

    接下来就需要继续处理它的左子树和右子树。

    左边:

    num_left = dfs(node.left, current_num)

    也就是:

    num_left = dfs(5, 49)

    右边:

    num_right = dfs(node.right, current_num)

    也就是:

    num_right = dfs(1, 49)

    🚩 注意这里的代码执行顺序。

    Python 会先执行:

    dfs(5, 49)

    只有等这个函数调用结束并且得到返回值以后:num_left 才会真正拿到结果。

    然后程序才继续执行:

    dfs(1, 49)


    5. 递归函数到底返回什么?

    这里是这道题最重要的地方之一。

    我们需要先明确:

    dfs(node, current_num) 的返回值到底代表什么?

    可以把它定义成:

    从当前节点继续往下走,所有完整路径最终组成的数字之和。

    比如 节点 5:

    4 → 9 → 5

    只有这一条路径。

    所以:

    dfs(5, 49)

    返回:495

    节点 1:

    4 → 9 → 1

    也只有这一条路径。

    所以:

    dfs(1, 49)

    返回:491

    于是回到节点 9 以后:

    num_left = 495
    num_right = 491

    那么节点 9 这一整棵子树下面,所有路径的总和 就是:

    495 + 491 = 986

    所以节点 9 最后返回:

    return num_left + num_right

    也就是:

    return 986


    return 495 为什么会回到节点 9?

    因为节点 9 之前执行的是:

    num_left = dfs(5, 49)

    执行到这里时,节点 9 这一层的函数调用会暂时停下来。

    程序进入:

    dfs(5, 49)

    节点 5 最后:

    return 495

    于是这个返回值会交回给调用它的位置:

    num_left = dfs(5, 49)

    因此:

    num_left = 495

    然后节点 9 这一层继续执行下一行:

    num_right = dfs(1, 49)

    这就是递归中的调用栈:

    上一层函数调用下一层

    上一层暂停

    下一层计算完成

    return 返回结果

    上一层从刚才暂停的位置继续执行


    五、把完整递归过程走一遍

    现在再把整棵树跑一次:

    4
    / \\
    9 0
    / \\
    5 1

    🔵 第一次:

    dfs(4, 0)

    先处理节点 4:

    current_num = 0 × 10 + 4
    = 4

    节点 4 不是叶子,所以继续处理左子树:

    dfs(9, 4)

    🔵 来到节点 9:

    current_num = 4 × 10 + 9
    = 49

    节点 9 也不是叶子。

    🟢 先处理左边:

    dfs(5, 49)

    来到节点 5:

    current_num = 49 × 10 + 5
    = 495

    节点 5 是叶子:

    return 495

    回到节点 9:

    num_left = 495

    🟢 接下来处理右边:

    dfs(1, 49)

    来到节点 1:

    current_num = 49 × 10 + 1
    = 491

    节点 1 是叶子:

    return 491

    回到节点 9:

    num_right = 491

    于是节点 9 汇总:

    return 495 + 491

    得到:986

    🔵 这个 986 再返回节点 4:

    num_left = 986

    🟢 接下来节点 4 处理右边:

    dfs(0, 4)

    来到节点 0:

    current_num = 4 × 10 + 0
    = 40

    节点 0 是叶子:

    return 40

    回到节点 4:

    num_right = 40

    最后节点 4 汇总:

    return 986 + 40

    最终得到:

    1026


    六、这道题的递归到底在做什么?

    现在回头看,会发现这个递归其实有两个方向。

    往下递归时:构造当前路径

    例如:

    4

    49

    495

    我们不断更新:

    current_num = current_num * 10 + node.val

    所以:

    路径状态在不断往下传。


    往上返回时:汇总子树答案

    叶子节点先返回:

    495
    491
    40

    然后:

    495 + 491 = 986

    最后:

    986 + 40 = 1026

    所以:

    子树答案在不断往上收。

    可以把这道题最核心的递归过程记成一句话:

    状态往下传,答案往上收。


    七、完整代码

    等前面的递归过程想明白以后,再来看代码:

    class Solution:
    def sumNumbers(self, root: TreeNode | None) > int:

    def dfs(node, current_num):

    # 当前分支没有节点
    # 对最终结果没有贡献
    if node is None:
    return 0

    # 先把当前节点加入路径数字
    current_num = current_num * 10 + node.val

    # 左右孩子都不存在
    # 说明已经来到叶子节点
    if node.left is None and node.right is None:
    return current_num

    # 递归处理左右子树
    num_left = dfs(node.left, current_num)
    num_right = dfs(node.right, current_num)

    # 返回当前子树所有完整路径的数字之和
    return num_left + num_right

    return dfs(root, 0)


    八、最后总结

    这道题最开始并不需要直接想到完整代码。

    可以一步一步拆:

    第一步:
    一条路径上的数字怎么构造?

    current_num = current_num * 10 + node.val

    然后发现树会不断分叉:

    当前节点

    左子树
    右子树

    而每一棵子树面对的都是同样的问题,所以想到递归。

    接着构建:

    dfs(node, current_num)

    其中:

    node → 当前节点

    current_num → 当前路径已经组成的数字

    进入一层递归以后:

    1. 先处理当前节点

    2. 如果是叶子节点
    → 当前路径结束
    → 返回 current_num

    3. 如果不是叶子
    → 递归左右子树

    4. 等左右子树返回以后
    → 汇总结果
    → return num_left + num_right

    所以最终可以把它理解成:

    往下:
    构造每一条路径对应的数字

    往上:
    把每一棵子树的答案逐层汇总

    也就是:状态往下传,答案往上收。

    九、复杂度分析

    假设二叉树一共有 n 个节点。

    时间复杂度:O(n)

    递归过程中,每个节点只会被访问一次。

    对于每个节点,我们做的操作也都是常数级的,例如:

    current_num = current_num * 10 + node.val

    以及判断是否为叶子节点。

    所以总时间复杂度为:

    O(n)


    空间复杂度:O(h)

    这里没有额外创建数组来保存所有路径,主要的空间开销来自递归调用栈。

    其中 h 表示二叉树的高度。

    如果树比较平衡:

    1
    / \\
    2 3
    / \\ / \\

    树高大约是 log n,所以空间复杂度接近:

    O(log n)

    但如果树退化成一条链:

    1
    \\
    2
    \\
    3
    \\
    4

    递归最多会连续进入 n 层,因此最坏情况下:

    O(n)

    所以一般写成:

    时间复杂度:O(n)
    空间复杂度:O(h),最坏情况下为 O(n)


    十、其他两种实现方式

    前面的核心思路其实已经确定了:

    每往下一层,都需要保存当前路径已经组成的数字。

    区别主要在于:

    得到一条完整路径以后,我们准备怎么处理这个答案?

    还可以有两种写法。


    方法一:递归 + 直接累计答案

    其实我们也可以换一种想法。

    既然走到叶子节点时,就已经得到了一个完整数字,例如 495,那也可以不把它一层层 return 回去。

    而是直接:

    total += current_num

    也就是:

    往下递归

    构造 current_num

    来到叶子

    直接加入 total

    代码:

    class Solution:
    def sumNumbers(self, root: TreeNode | None) > int:

    total = 0

    def dfs(node, current_num):
    nonlocal total

    if node is None:
    return

    current_num = current_num * 10 + node.val

    if node.left is None and node.right is None:
    total += current_num
    return

    dfs(node.left, current_num)
    dfs(node.right, current_num)

    dfs(root, 0)

    return total

    复杂度同样是:

    时间:O(n)
    空间:O(h)

    和第一种方法相比,区别在于:

    方法一:

    叶子

    return

    父节点汇总

    继续 return

    方法二:

    叶子

    直接加入 total

    所以第二种不再需要:

    return num_left + num_right

    因为所有答案已经在叶子节点处直接累计起来了。


    方法二:不用递归,自己维护栈

    前两种方法都依赖递归。

    但递归本质上会利用函数调用栈保存:

    当前处理到哪里
    以及这一层的状态

    所以我们也可以自己创建一个栈,实现迭代版 DFS。

    不过栈里面不能只保存:

    node

    因为只知道当前节点是谁还不够。

    例如来到节点 9 时,我们还必须知道前面的路径已经组成了 4,才能继续得到 49。

    因此栈中保存:

    (node, current_num)

    代码:

    class Solution:
    def sumNumbers(self, root: TreeNode | None) > int:

    if root is None:
    return 0

    total = 0

    stack = [(root, 0)]

    while stack:

    node, current_num = stack.pop()

    current_num = current_num * 10 + node.val

    if node.left is None and node.right is None:
    total += current_num
    continue

    if node.left is not None:
    stack.append((node.left, current_num))

    if node.right is not None:
    stack.append((node.right, current_num))

    return total

    这里:

    stack.pop()

    体现的是栈的:

    后进先出

    所以它会沿着某一条路径不断往深处走,这就是 DFS。

    复杂度:

    时间:O(n)
    空间:O(h)

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » DAY12: LeetCode 129|求根节点到叶节点数字之和
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!