《程序员面试金典(第6版)》面试题 04.12. 求和路径

文章介绍了如何解决寻找二叉树中节点值之和等于给定值的路径数量的问题,通过深度优先搜索(DFS)和前序遍历的方法,利用递归实现。首先,解决了单个节点作为根节点的子树中符合条件的路径数量,然后扩展到整个二叉树。文章还讨论了时间复杂度和空间复杂度,并提出了使用前缀表进行优化,降低时间复杂度至O(N)并提高空间效率。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

题目描述

给定一棵二叉树,其中每个节点都含有一个整数数值(该值或正或负)。设计一个算法,打印节点数值总和等于某个给定值的所有路径的数量。注意,路径不一定非得从二叉树的根节点或叶节点开始或结束,但是其方向必须向下(只能从父节点指向子节点方向)。

示例:
给定如下二叉树,以及目标和 sum = 22,

          5
         / \
        4   8
       /   / \
      11  13  4
     /  \    / \
    7    2  5   1

返回: 3
解释:和为 22 的路径有:[5,4,11,2], [5,8,4,5], [4,11,7]

提示:节点总数 <= 10000

解题思路与代码

这道题,在我看来,是在考验你能不能从题目描述中,抽象出来它到底想表达什么意思。如果你做不到这一点,那对你来说就是hard难度,如果你能抽象出来它的意思,那就还不错,可能会觉得稍微有点简单。

下面就让我来带大家来领略这道题真正想要表达的意思:

思考这样一个问题,这道题,是让你去求题目所给的二叉树所有的路径和,然后简简单单的对比一下和sum相当吗?
如果你这么想,那就说明你想的还是太简单了。你还在第一层,而出题人在第二层。

这道题真正的题意,返回当前二叉树中所有节点都作为一颗二叉树,符合等于sum的路径和的数量。

了解了抽象出来的题意后,我们再来根据题意撰写我们的代码。

深度优先搜索(DFS)(前序遍历)

我们刚刚已经了解了真正的题意了。也就不难写出符合题意的优质代码。

如果说,返回当前二叉树中所有节点都作为一颗二叉树,符合等于sum的路径和的数量,这是一个大问题的话,那么返回单个节点作为一颗二叉树,返回所有等于sum的路径和,这就是一个小问题。

我们只有先将小问题的代码逻辑写好了,才能再去写大问题的代码逻辑。这是自底向上的,没有根基,也就没有高楼。

很巧的是这个小问题,其实也就是我们刚刚想的第一层,它本质上,也是一个递归函数。我们还能再去抽象出它的单层逻辑来,然后去撰写它的递归代码。下面是它的单层逻辑:

  • 首先先设置一个值ret置为0。先判断 单个节点的值是否与sum相等。如果相等,则这就是一个符合sum的路径和。

  • 然后因为判断完这单个节点了,接下来就该判断以这个节点作为一个二叉树的根节点,有没有符合sum的路径和。

现在对于单个节点的单层递归逻辑,到现在为止,我们就已经撰写完毕了。不难发现,这个递归函数的返回值是int类型的。然后需要TreeNode*与int作为形参传递进来。
那递归结束的条件呢?自然是如果root为空节点,然后返回0啦~。

下面给出单个节点作为一颗二叉树,返回所有等于sum的路径和的代码:

int oneNodeOfTree(TreeNode* root,int sum){

        if(root == nullptr) return 0;
        int ret = 0;
        
        if(root->val == sum) ++ret;
        ret += oneNodeOfTree(root->left,sum - root->val);
        ret += oneNodeOfTree(root->right,sum - root->val);
        return ret;
    }

现在我们再重申一下这道题抽象出来的题目要求:返回当前二叉树中所有节点都作为一颗二叉树,符合等于sum的路径和的数量

我们已经做完单个节点作为一颗二叉树,返回所有等于sum的路径和的代码了,现在就是想想这个二叉树有多少个节点嘛,然后我们把这些节点所有符合路径和的数量加起来就好了嘛。

这其实也是个递归函数。甚至可以说是前序遍历,因为我们要去统计二叉树中每一个节点。

这个函数逻辑也就更好写,其实和上面的逻辑差不多。唯一的不同就是 ret 的值是不同的。

单个节点的递归函数ret的初始值是0,这是因为我们只需要判断以这个节点为二叉树,他有多少条符合sum的路径和。
而整个二叉树所有节点的ret的初始值为 oneNodeOfTree(root,sum),这是因为,整个二叉树头节点也是一颗二叉树,它也有很多条路径,我们需要先把他作为单独的一个节点,扔进单个节点的递归函数,去作为ret的初始值。

然后之后的逻辑也差不多,具体的看代码就好了,我也就不多赘述了:

int pathSum(TreeNode* root, int sum) {
        if(root == nullptr) return 0;
        int ret = oneNodeOfTree(root,sum);
        ret += pathSum(root->left,sum);
        ret += pathSum(root->right,sum);
        return ret;
    }

下面是整体的代码:

class Solution {
public:
    int oneNodeOfTree(TreeNode* root,int sum){
        if(root == nullptr) return 0;
        int ret = 0;
        if(root->val == sum) ++ret;
        ret += oneNodeOfTree(root->left,sum - root->val);
        ret += oneNodeOfTree(root->right,sum - root->val);
        return ret;
    }

    int pathSum(TreeNode* root, int sum) {
        if(root == nullptr) return 0;
        int ret = oneNodeOfTree(root,sum);
        ret += pathSum(root->left,sum);
        ret += pathSum(root->right,sum);
        return ret;
    }
 
};

在这里插入图片描述

复杂度分析

时间复杂度:

  • 小问题的递归函数的时间复杂度为 O ( n ) O(n) O(n),n为二叉树节点的数量。大问题呢,是要遍历整颗二叉树,也要遍历每个节点,每一个节点作为一个小问题,那就是n个小问题。所以总体的时间复杂度就是 O ( n 2 ) O(n^2) O(n2)

空间复杂度:

  • 关于空间复杂度,由于使用了递归,在递归过程中调用栈将保存在内存中,所以空间复杂度也是 O ( n ) O(n) O(n)
    其中 n n n表示树中节点的数量。这是由于在最坏情况下,当树退化为链表时,代码递归到最深处,将会有 n n n层的函数调用堆栈被记录下来。

优化:前缀表

关于什么是前缀表可以去看我的这篇文章什么是前缀表,下面来说说,前缀表,到底做了哪些优化。

该解决方案采用哈希表原理来存储数据,并利用前缀和的思想来解决子问题。在访问每个节点时,我们都检查是否有累加到期望和的路径存在。因此时间复杂度为:O(N),其中N是树中节点的数量。哈希表操作的空间要求是O(N),因为在最坏情况下哈希表将保存所有节点。同时, 这种做法的空间复杂度等价于我们使用队列迭代遍历的空间复杂度。所以空间复杂度也是O(N)。

综合而言,比较代码段1与代码段2,发现第二段代码是更好解决方案,因为它具有更低的时间复杂度和更高的空间效率,同时其递归深度更小,这使得其通常不会耗尽程序堆栈。

下面给出具体的代码实现:

class Solution {
public:
    unordered_map<long long,int> prefix; //前缀表
    int pathSum(TreeNode* root, int sum) {
        if(root == nullptr) return 0;
        prefix[0] = 1;//这么设置是因为,当表中第一次出现符合路径的时候,cur - sum的值会是0,所以我们的ret就会加上这个1,所以这里设置的键值对就为(0,1)
        int cur = 0; //这个cur是路径到当前节点的累加值。
        return dps(root,cur,sum);
    }

    int dps (TreeNode* root,int cur,int sum){
        if(root == nullptr) return 0;
        int ret = 0; //这个ret是最终返回的符合对应路径值的个数
        cur += root->val;
        
        if(prefix.count(cur - sum)) ret = prefix[cur-sum];
        //如果这个我们在前序表中,找到了这个键了,那么ret就加上对应的值。注意,键值可不只可能是0!!

        prefix[cur]++; //将当前路径的累加值作为一个键添加到前序表中,并将键对应的值加1。
        ret += dps(root->left,cur,sum); //遍历左子树,将左子树可能的路径都加入前序表中。
        ret += dps(root->right,cur,sum);//遍历右子树,将右子树可能的路径也都加入前序表中
        //当我们遍历完一条路径的时候,我们要将对应键值对中的值清空。如果不清空,前序遍历到右子树的时候,
        //就有可能出现找到了之前左子树没有清空的那个键,导致 多加了一个错误的1
        prefix[cur]--;
        
        return ret;
    }
};

总结

这道题一点也不算简单。真的是很考验,将题目抽象出具体的意思的能力。还有就是考虑你对一些算法技巧或思想的掌握情况,比如这道题就考验到了你关于算法前缀和这个思想理解的透不透彻,能不能想到用这种方式去解决问题。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

德亦周

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值