leetcode257.二叉树的所有路径(简单题,你能把全部细节想通吗?看懂它,你将不再害怕递归!)

正如读者所见,这是一道力扣上的简单题,一道简单题有什么好讲的呢?题解不是满网都是吗?

但是一道不起眼的简单题,可以藏着很多“秘密”,一道简单题,大家可能只是看题解或者随便写一下就过了,但是下面的问题你真的想过吗?

给我一点时间,耐心看完本篇文章,一定让你受益匪浅!

257. 二叉树的所有路径 - 力扣(LeetCode)icon-default.png?t=N7T8https://leetcode.cn/problems/binary-tree-paths/description/ 本文同样适合初学者、没有通过本题的读者,我将由浅入深的讲解思路。

首先是最浅显易懂的递归题解分析:

代码思路是创建一个递归函数去找寻路径,由于这道题要找寻所有的路径,所以一定涉及到回溯,也就是找到一条路径加到答案里后,还要向上回溯,以便查找其他的路径,这回题解我们使用vector数组来存储这条路径上各个遍历到的节点的数据,然后遍历到最后也就是遇到了叶子节点时候我们应该收获数据了,这个时候用一个字符串来承接,遍历刚才存储数据的数组,然后每遍历一个加上一个“->”就可以了,但是要注意遍历的范围只到最后一个减1的位置,单独处理最后一个,因为最后一个数据不加箭头,按照这样的思路,我们不难看懂如下代码。

/**
 * Definition for a binary tree node.
 * struct TreeNode {
 *     int val;
 *     TreeNode *left;
 *     TreeNode *right;
 *     TreeNode() : val(0), left(nullptr), right(nullptr) {}
 *     TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
 *     TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
 * };
 */
class Solution {
public:
    vectorres;vectorpath;
    void back(TreeNode* root){
         path.push_back(root->val);
        if(!root->left&&!root->right){
            string s;
            for(int i=0;i";
            }
            s+=to_string(path[path.size()-1]);
            res.push_back(s);return;
        }
        if(root->left){
           back(root->left);path.pop_back();
        }
        if(root->right){
           back(root->right);path.pop_back();
        }
    }
    vector binaryTreePaths(TreeNode* root) {
        back(root);
        return res;
    }
};

我们的回溯就显而易见的体现在了递归函数的后面,也就是path.pop那个,这个就是模拟了回溯,将之前装进去的数据删除了,怎么样是不是够浅显易懂?我相信接触过二叉树和回溯的题的读者应该能够很快的看懂题解,如果跟着我的思路来的话。

解答一些问题:

为什么我们使用vector数组来做中间路径的数据存储,这不是多此一举吗?直接用string类型不好吗?

这绝非是闲得慌,这是很重要的,原因在于string类型,我们在写回溯代码时候,比较难写,它没有vector类型简洁且易操作,当然这并不是最主要原因,更重要的原因是我们不知道上一次的数据我们存储的是多少位的数字,我们只知道它是一个整数,而它是几位的整数?这还要单独判断,然后删除,这无疑肯定会对代码整洁和易读性造成破坏!

第二个问题:可以把判断是否为叶子节点写成判断当前是否为空节点,然后再进行取路径吗?

一开始我的想法就是这个,一直到空节点才判断,这样不用在左右的子树遍历时候去判断左右子树是否为空了,而且对于我来说递归函数第一行就是填数据有些别扭,因为我的习惯是一条填数语句加上一条递归,再加上一条回溯,这样可以避免你忘记回溯,因为回溯和递归成对出现,这种写法不容易出错。所以我是这样想的,但是实际非常难以实现于这道题中。

第一个问题是:你怎么去加数据进数组中?

如果采用上面我说的一条取数据一条递归一条回溯的思路来看,会出现一些问题,首先的问题就是你的判断部分代码逻辑是当前节点为空所以取答案,那你中间记录数据的逻辑一定是在节点不为空时候取得,那么就一定是if(root!=NULL)对吧?然后去记录数据,这个时候又有新问题,记录数据写一个好还是写两个呢?既然是成对出现,大概思路应该是写两个,一个用于左子树递归,另一个用于右子树递归,大概你会把代码写成这样:

if(root){
       path.push_back(root->val);
       back(root->left);path.pop_back();
       path.push_back(root->val);
       back(root->right);path.pop_back();
         }

但是仔细分析你会发现,当由于某次递归到某一侧子树而发生return回溯时候,比如我们拿回溯左子树举例,它会出现删除了当前左子树数据,完成回溯,而后又把左子树数据加进来然后进行右子树递归的问题。这很显然肯定是不对的,数据根本没有完成实际的回溯,因为刚删完,由于右子树要进行递归之前要加数据所以又加回去了。如果只进行一次填数据呢?加到左子树的递归上面和加在右子树递归上面是一样的效果,都会导致其中一个数据不能录入,而且都会导致异常退出,因为无论是哪个你都能举出一个例子,这个测试用例会导致它们在vector数组为空时候删除vector里的节点数据,这肯定是不对的。

这种思路下,唯一可能的话,也许是保存一下上一层的节点?不过这有点太别扭了,而且很容易出错,所以这道题优解只有判断当前节点是否为叶子节点,而每次进来都先加节点数据的行为,可以不用对叶子进行特判,递归条件的约束又不需要担心是否会使用空指针操作,这个思路还是很完美的。 

第二题解:

第二个题解并不是第二种思路,它是第一种题解的简化版本在第一种思路的基础上做了一些改进,那我们为什么要单独拿出来讲?因为其中有一些细节需要注意和想清楚。

首先这个题解我们不用vector来实现中间的存储,直接使用string记录,然后存储到答案里,这样可以更快,省去了遇见叶子节点时候,还需要将数据从vector导出的麻烦,那我们如何实现string的回溯呢?难道是写一个判断数据位数然后不停pop?那肯定不是,这样改代码太low了!

实现细节是体现在函数的参数上,请看如下代码:

/**
 * Definition for a binary tree node.
 * struct TreeNode {
 *     int val;
 *     TreeNode *left;
 *     TreeNode *right;
 *     TreeNode() : val(0), left(nullptr), right(nullptr) {}
 *     TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
 *     TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
 * };
 */
class Solution {
public:
    
    void back(TreeNode* root,vector& res,string s){
        s+=to_string(root->val);
        if(!root->left&&!root->right){
            res.push_back(s);return;
        }
        if(root->left)back(root->left,res,s+"->");
        if(root->right)back(root->right,res,s+"->");
    }
    vector binaryTreePaths(TreeNode* root) {
        vectorres;string s;
        back(root,res,s);
        return res;
    }
};

我们把string类型直接加进函数里了作为一个参数,而递归判断部分仍然是判断该节点是否是叶子节点,每次进来加数据,然后两个判断递归,避免对空指针进行取数据的操作,乍一看好像没改变什么啊。

请看接下来的问题:

1、这里的string类型为什么不写成&?

按照正常的思路我们总会传参写&,引用可以提高传数据效率,但这里我们不加引用是有意而为。这是为了回溯,我们传入下一层的string s是带箭头的,那么下一层的箭头就被加进来了,一直往后走就能起到又填数据又加箭头的效果,而回溯时候箭头作为参数之一,且该参数不是引用而是传值的参数的缘故,对上一层并没有影响,回溯时候直接跳到了递归函数的后边部分接着执行,对上一层是无影响的。

2、数据是如何回溯的?

那为什么传数据不是传参同样也可以回溯?它不需要写回溯代码能自动完成回溯?

这也得益于传参s是传值传参,而非引用或指针传参,也就是说传值传进去下一层那么下一层拥有的数据是这一层的拷贝,而下一层对数据做的处理和以后做的处理均不会影响着一层数据,传值传参采用的是传入实参的拷贝,拷贝改动不能影响实际数据,这一点需要额外注意。

3、那为什么要分开写,为什么把s+=“->“写在函数里面,而s加数据要写在外面,这有什么讲究吗

我们知道这是以传值传入的数据,那为什么不能把加箭头也写外面呢?

我们以三种放的位置举例为什么不能写外面:

第一种和加数据写一起,这很明显你和加数据写一起是不对的,数据每次都要加,即使此次是叶子节点也要加,但是叶子节点不该加箭头,按这种逻辑需要叶子节点处特判。

第二种写在if判断叶子节点的代码和递归判断代码的中间

         .......
        s+="->";
        if(root->left)back(root->left,res,s);
        s+="->";
        if(root->right)back(root->right,res,s);
        .......

省略号省略上下文代码,这样写有一些坏处,比如说当前遍历的节点没有左或者右子树的任意一颗,那就会导致实际上根本无法经过递归的判断代码,但是先把箭头加上了,那肯定不对,这会导致接下来正确递归时候中间连着出现两个箭头的情况。

第三种把加箭头放在判断部分的里面也就是这样

        ......
        if(root->left){s+="->";back(root->left,res,s);}
        
        if(root->right){s+="->";back(root->right,res,s);}
        ......

这其实也是不对的,但这种情况最难看的出来为什么不对,按照正常的逻辑从上往下看,就是很正常的先加数据,然后是判断当前是否为叶子节点,是的话取答案,不是判断是否有左右子树,是的话加箭头,然后递归。很正常的逻辑。

但实际上,左子树判断是可以进去的,而进行了递归,等某时候发生回溯了,这个时候我们之前说过由于回溯的特性,它会在当初进去递归的那里出来,然后去执行下面的剩余代码,这个时候如果当前节点右子树也不为空,那就进来判断了,加上了一个新箭头,虽然传值传参能够保证各个递归层面上互相不影响,但是同层的无法完成回溯,因为我们这里的思路是不同层自动回溯,而不是手动回溯所以不能写成这样,这样写的话会使得上一次加箭头还没回溯呢,又加进来一个箭头。

总结就是:这种自动回溯具有延时性,且出现的状况和上一种一样,都是会出现连续两个箭头的情况。

所以说箭头必须写在函数传参里!

第三题解:

第三题解是迭代法,这道题的迭代法不很好写,一开始看我看不是很懂,看一段时间才明白它的意思。

/**
 * Definition for a binary tree node.
 * struct TreeNode {
 *     int val;
 *     TreeNode *left;
 *     TreeNode *right;
 *     TreeNode() : val(0), left(nullptr), right(nullptr) {}
 *     TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
 *     TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
 * };
 */
class Solution {
public:
    vector binaryTreePaths(TreeNode* root) {
        stackst;stackss;
        vectorres;
        if(root!=NULL)st.push(root);ss.push(to_string(root->val));
        while(!st.empty()){
            TreeNode* node=st.top();st.pop();
            string path=ss.top();ss.pop();
            if(node->left==NULL&&node->right==NULL){
                res.push_back(path);
            }
            
            if(node->right){
                st.push(node->right);
                ss.push(path+"->"+to_string(node->right->val));
            }
            if(node->left){
                st.push(node->left);
                ss.push(path+"->"+to_string(node->left->val));
            }
        }
        return res;
    }
};

这里有两个栈,一个栈是为了保存将要遍历的节点,一个是为了保存遍历过的路径,你可能觉得遍历路径不就已经包含了遍历节点了吗?实际上这两个栈作用并不是相同的,也就是存取路径是为了答案的录入,而存要遍历的节点是要删除数据的,要删除遍历过的节点,但是路径是不直接参与删除,因为很困难,我们采用保存几种不同的路径的方法,去解决对于不同路径走向的问题。

先简要说一下方法:

一开始判断root是否为空,不是空把节点加到st,把root数值加到ss。
进来先取出数据,拿当前节点去看是否是叶子,如果是处理加进答案的逻辑代码,如果不是看该节点左右子孩子,拿出路径是为了在当前节点是叶子的时候,加入进去,如果不是的话,走下面的递归把箭头和下一个节点的数据加进来,这也是为什么不用单独处理叶子结点的原因。
怎么找到其他路径的?
通过模拟可知,根据栈先进后出原则我们先加入右侧子树信息,然后加入左侧,我们首先一开始就拿出来一条路径信息,如果该节点有左右子树那么向路径栈里加入右左路径各一条,否则只加一条,而如果某时候是叶子节点,那么取出来的路径将没有机会再进去路径栈内,所以也就实现了删除该路径的思想。总体来说思路很难想,没写过不可能想到

有没有发现三种解法都是前序遍历?

这是因为取路径要先处理节点,然后再往下递归,这道题只能前序遍历做。 


本期内容就到这里
如果对您有用的话别忘了一键三连哦,如果是互粉回访我也会做的!

大家有什么想看的题解,或者想看的算法专栏、数据结构专栏,可以去看看往期的文章,有想看的新题目或者专栏也可以评论区写出来,讨论一番,本账号将持续更新。
期待您的关注

你可能感兴趣的:(练习,算法,leetcode,c++)