Practice 2025.5.29 —— 二叉树进阶面试题(1)
文章目录
- 部分二叉树进阶面试题(1)
- Leetcode_606 根据二叉树创建字符串
- Leetcode_236 二叉树的最近公共祖先
- Leetcode_105 从前序与中序遍历构造出二叉树
- Leetcode_LCR 155 将二叉搜索树转化为双向链表
部分二叉树进阶面试题(1)
本篇文章将对一些面试中比较常见的、难度稍微大一些的面试题进行讲解。这些面试题特点就是,使用c语言特别难写。因为c语言很多时候需要我们自行造轮子(数据结构)。
所以之前在使用c语言讲解一些题的时候不会涉及到今天要讲的这些题目。因为使用c语言写起来是真的比较困难。但是使用c++就很方便了。接下来我们将重点讲解这些题目。
Leetcode_606 根据二叉树创建字符串
原题链接:
https://leetcode.cn/problems/construct-string-from-binary-tree/description/
分析题目:
这个题目时要我们把一个二叉树转成字符串,而且发现是前序遍历的顺序。
但是我们发现并没有那么简单,因为不是单纯的把里面的数字转化成前序遍历的序列。我们观察后发现,要求的字符串是有括号的,而且是用括号来表示了根与左右子树的关系的。这是我们需要特别注意的一点。
我们来看看以上两个例子,我们需要观察给定括号的条件是什么。
我们经过一番观察,最直观的感受:
- 对于所有子树而言,其左右子树是需要用括号包起来的。
- 但是有些时候又发现,不需要括号。即子树为空的时候,括号可以省略。
- 但是有一种情况,即使子树为空,也不能省略括号——左子树空但右子树不为空。
- 对于某个树而言,是不需要被括号包起来的。除非它是某个节点的子树。
有的人看到会说:(2()(4))这不是包起来吗?这里需要注意后面的提示,它是否为某个节点的子树。发现这个树是节点1的子树。所以当然要被包起来。像以1为根节点的树就不需要使用括号包起来,因为不是某个节点的真正意义上的子树。
当然还需要注意一些的点就是整个树的节点数目至少为1个。
思路:
我们现在提供两种思路:
- 啥也不管,先无论三七二十一,将所有的位置都用括号包起来,最后得到序列再去消除括号,我们来看一下:
比如这个树,先不管怎么样,直接按照对应的关系输入括号,这是很容易做到的,只要强制地遍历左子树前后加上括号,右子树也是一样的:
class Solution {
public:void _push(TreeNode* root, string& str){if(!root) return;str += to_string(root->val);str += '(';_push(root->left, str);str += ')';str += '(';_push(root->right, str);str += ')';}string tree2str(TreeNode* root) {string ret;ret = _push(root, ret);//消除指定括号...return ret;}
};
这样写得到的序列是:1(2()(4))(3()())
然后我们针对于这个序列去消除括号得到要求序列即可。但是这有个很大的问题就是,这个操作真的做起来是很难的。很难控制逻辑。所以这种方法还是需要放弃的。
- 找到括号使用的规律,通过递归过程去控制括号的插入
这个方法其实更可靠,我们只需要探寻一下括号插入的逻辑就可以了。
经过我们的仔细观察,我们发现括号大部分时间下是不能省略的。只有极个别场景是可以舍去的,即有空子树存在。但是有时候即使空子树也要强行加上括号。根节点为空不需要考虑,因为遍历到空节点返回就好了。
所以我们只需要探讨一下左右子树是否为空的一系列关系就好了:
我们发现:
1.当子树不为空的时候,括号必不能省略
2.子树为空,有可能省略,还有分类讨论:
(1)右边为空一定可以省略
(2)左边为空的情况下,右边为空可以省略左边的括号,反之不行。
对于2(2)这一条规则讲一下,因为这里的序列是需要能够展现出子树之间的关系的。如果左子树空了,右子树不空。把左子树括号省略掉了,那么哪个右子树可能会被误解为左子树的。因为左子树总是出现在前面先。但是右子树为空就不担心这个问题了。
所以总结一下就是,左子树和右子树只要有一个不为空,那么就不能省略左子树的括号。
反之是可以的。
所以基于此,我们得到以下代码:
class Solution {
public:void _push(string& _str, TreeNode* _root){if(_root == nullptr) return;_str += to_string(_root->val);if(_root->left == nullptr && _root->right ==nullptr) return;else{_str += '(';_push(_str, _root->left);_str += ')';if(_root->right != nullptr){_str += '(';_push(_str, _root->right);_str += ')';}}}string tree2str(TreeNode* root) {string str;_push(str, root);return str;}
};
这里使用了一个子函数,对外界传入的string进行插入即可。
核心的部分就是处理括号的插入逻辑,只要能够正确控制住逻辑不出错就可以了。
当然,上面是使用另一个子函数对string进行操作的。因为在主函数tree2str的返回值是string,但是有需要构建一个string,需要特别注意细节,但是也可以进行实现:
class Solution {
public:string tree2str(TreeNode* root) {if(root == nullptr) return ""; string ret;ret += to_string(root->val);//有三种情况 此时左子树必须加()if(root->left || root->right){ret += '(';ret += tree2str(root->left);ret += ')';}if(root->right) {ret += '(';ret += tree2str(root->right);ret += ')';}return ret;}
};
这样写起来看着代码更简洁一点。都可以,这个可以自行选择。
Leetcode_236 二叉树的最近公共祖先
原题链接:
https://leetcode.cn/problems/lowest-common-ancestor-of-a-binary-tree/description/
分析:
首先我们得搞清楚最近公共祖先的概念。
祖先节点就是,从整个树的根节点开始,到某个具体节点x,有一条最短的单向路径。这些路径上除了x这个节点都可以称作为x的祖先节点。
现在是要给出两个节点p和q,找到它们的公共祖先节点,但是是最近的那个。其实就是从根节点到p的路径path1和根节点到q的路径path2,这两条路径的相交节点,但是这个节点处在树的深度需要尽可能地深。
然后需要注意题目给定一些条件:
1.树节点至少有两个,并且不存在重复的值(这点埋个伏笔)
2.最近公共祖先节点可以是自己
思路:
- 这是我个人想到的思路,看似可行,实则困难
可以开辟一个数组vector<TreeNode*> vT,将输入的树当作成满二叉树。没有节点的地方直接放nullptr就可以了。
然后正因为节点中值不重复,所以必然可以找得到对应节点的在数组中的唯一下标。之前讲过,对于满二叉树而言,知道子节点的坐标,是有公式可以算出来父亲节点的坐标的。所以可以把p和q的所有父亲节点的坐标(包括自己)全部按一定顺序分别放在两个数组。然后从在数组中,从根节点坐标0开始找就可以了,最后一个相同的数子就是指定的坐标。
然后再返回vT中根据坐标查询即可。
这个思路看似很简单,也很好实现。其实不然。
这个需要通过层序遍历将数据一个个放到vT中。层序遍历是简单,但是我们之前讲的都是,只要空指针了就不往队列里面放了。
即使空指针往里面放也很难,因为这个树的结构不确定,很困难空指针节点的下一层是还是有别的节点的孩子节点。这就很难操作了。
再一个就是空间复杂度太大了,要搞一个满二叉树出来,空间个数就是2h - 1个空间。空间复杂度是O(2n),这是很消耗空间的。
- 找规律
我们可以试着找一下规律,发现p和q两个节点一定是位于最近公共祖先的两侧的。对于其它的节点,就没有这个特性。这点我们是可以随便找一个树去进行测试的。
而且又是因为节点的值各不相同,所以必然是可以找到唯一的对应地址。就不用担心说左边有一个x,右边又有一个x的情况发生。所以这题我们可以往这个方向走:
//这个方法是使用到一个特性:
//p和q两个节点必然是最近公共祖先的左右节点 其它的都不是
class Solution {
public:bool IsInTree(TreeNode* root, TreeNode* x){//用前序遍历找就可以了if(root == nullptr) return false;if(root == x) return true;return IsInTree(root->left, x) || IsInTree(root->right, x);}TreeNode* lowestCommonAncestor(TreeNode* root, TreeNode* p, TreeNode* q) {//这里也是用前序去走if(root == nullptr) return nullptr;if(root == p || root == q) return root;bool pInLeft = IsInTree(root->left, p), pInRight = !pInLeft;bool qInLeft = IsInTree(root->left, q), qInRight = !qInLeft;//p q分别在当前root节点的左右两侧if((pInLeft && qInRight) || (pInRight && qInLeft)) return root;else{if(pInLeft && qInLeft) return lowestCommonAncestor(root->left, p, q);else return lowestCommonAncestor(root->right, p, q);}}
};
我们需要用到一个子函数,用来判断节点是否在一个root指向的树里面(可以是任何子树)。
然后就是在主函数里面进行查找:
1.如果发现此时遍历到的某个节点(root)就是p和q其中一个,那就是要找的。因为公共节点可能是输入的p和q的其中一个。
但是这点是需要好好琢磨的,因为直接这么说可能觉得有点奇怪。但是我们先看后面的逻辑,最后再回过头来说这里的原因。
2.这个时候就需要借助查找的子函数IsInTree,进行搜寻一下p和q在当前左子树还是右子树的情况了。根据不同情况进行分析:
(1)如果p和q分别在当前root的左侧和右侧,那就说明当前root指向的就是最近公共祖先。
(2)剩下的肯定就说明,p和q在同侧了。那么只需要往同侧的那一面去找就好了。因为最近公共祖先肯定不可能在另一侧的。(比如p和q在root的左侧,那最近公共祖先节点只可能往左边去找了,右边的所有节点根本不是p和q的祖先节点)。
所以只需要根据位置关系判断一下去递归查找就好了。
这里我们要注意的是,最近公共节点必然是可以找到的。
然后我们再回过头看第一种情况:
为什么root碰到p或q就可以停止。因为p和q和root三者关系就三种:
1.p q在root的两侧
2.p q在同侧
3.p q可能在root上
对于第一种,这就是答案,都不用再查找了。
第二种,这里我们要注意的是,因为我们的查找逻辑是把左子树和右子树传进去的,是不包含当前指向的子树的根节点的。也就是说,对于同侧的概念是,p和q在root的左子树或者右子树内,是不包括root这个地方的。所以肯定要往存在的那一侧去递归查找。
然后就是,这个最近的公共祖先节点必然是能够找到的。我们做的一切操作都是从根节点开始出发去找的。沿着路径我们其实很容易发现,如果出现了公共祖先节点是自己的情况下,那么这个是公共祖先的被查找节点是一定会给遍历到的。
如果没有这种情况,那么我们发现是必然遍历不到p和q这两个节点的任意一个的。所以基于此,我我们是可以肯定地判断当root是p和q的其中一个的时候,就可以返回当前节点了。
但是我们发现了一个很大的问题,就是这个思路的效率是很低的。其实很好理解。因为我们不断地从上往下去找p和q两个节点。会有很多的重复计算。查找的时间复杂度就是O(n)了,这还嵌套在主函数内。主函数时间复杂度也是O(n),这就直接导致时间复杂度是O(n2)了。
- 第三种思路
我们刚刚是从上面往下走的,这样有太多的重复路径要计算了。要是有办法像第一种思路那样就好了。倒着走走到根节点去,找到从根节点到指定节点的唯一路径。然后两条路径之间找最近的公共祖先节点就好了。
1.如果这是一个二叉搜索树(不重复的值),找路径很简单。直接从上往下找都ok。可以根据搜索树的规则进行搜查,是很简单的。
2.又或是个完全二叉树,也很简单,可以根据下标计算。
3.如果是三叉链,也不错,有指向父亲节点的指针。这也很容易找。
可惜现在上面的三点一点都不是。当然可以考虑转化成上面的形式。但是会有一些麻烦。
在这里提出一种新的方式获取路径,用前序遍历 + 栈进行记录:
先给出思路:
假设现在有上面这样一棵树,我们要找出6和4的路径,怎么办呢?
我们采用的方法就是,递归前序遍历,顺便入栈。
比如要找6,就进行递归前序遍历,先不管怎么样,先把节点入栈。
先入栈3,递归左子树,入栈5,再递归左子树,入栈6,发现找到了,可以退出。
上面那个情况还是太简单了,如果是找4呢?
还是一样,前序遍历,不断地走左边入栈,但是发现:
入栈6后发现左边为空,空不入栈。没有找到,这怎么办?
还是一样,递归到右子树去。让右子树重复走上面那个逻辑。当然这里的6的右边是空,所以一进去又回来了。现实的情况可能复杂得多。但是没关系,这里使用的是递归,如果右边有要找的节点时一定能找到的。要不然一定会回退回来。
如果6的左边和右边都没有,说明4根本就不在以6为根节点的子树里面,所以路径不能有6这个节点,所以需要让6出栈。然后取栈顶的5,判断它的右边。然后重复上述逻辑就可以了。
这里能这么做的原因就是因为,所有入栈的元素,都是可以认为是作为左子树的节点被访问过的。所以只要入栈的元素就不需要再考虑往它的左边找路径了。这一点很重要,有助于我们理解后面要讲的二叉树非递归遍历方法。
其实就是前序遍历找节点,然后顺便把路径记录下来。
我们来直接看代码:
class Solution {
public:bool GetPath(TreeNode* root, TreeNode* find, stack<TreeNode*>& path){//通过前序遍历可以获得一个节点的从根到节点的路径if(root == nullptr) return false;//不管什么情况 先入栈path.push(root);//如果当前root(某个子树的根节点)就是要找的 就直接返回if(root == find) return true;//判断一下左边是否找得到节点if(GetPath(root->left, find, path)) return true;if(GetPath(root->right, find, path)) return true;//左右都没有find节点path.pop();//表示当前子树中没有找到对应节点return false;}TreeNode* lowestCommonAncestor(TreeNode* root, TreeNode* p, TreeNode* q) {stack<TreeNode*> Ppath;stack<TreeNode*> Qpath;GetPath(root, p, Ppath);GetPath(root, q, Qpath);while(Ppath.size() != Qpath.size()){if(Ppath.size() > Qpath.size())Ppath.pop();else Qpath.pop();}while(Ppath.top() != Qpath.top()){Ppath.pop();Qpath.pop();}return Ppath.top();}
};
辅助函数就是用来找到p和q的路径的,注意函数内的栈要传引用。因为我们要不断的修改这个栈。用到的是同一个。如果是传值,那么每个栈帧的stack都是独立的,达不到效果。
然后主函数内就是获取到p和q的路径放在栈里面,然后按照思路1的思想去取出最近的公共祖先节点就可以了。这个方法时间复杂度降到了O(n),利用了一些空间换取时间。可以看成是对思路1的优化实践版本。
Leetcode_105 从前序与中序遍历构造出二叉树
原题:
https://leetcode.cn/problems/construct-binary-tree-from-preorder-and-inorder-traversal/description/
分析:
我们曾经学过,如果给出(前序遍历 + 中序遍历)或是(后序遍历 + 中序遍历),是一定可以还原出一个二叉树出来的。
因为前序遍历/后序遍历时可以去确定根的位置的。只不过确定的顺序不太一样而已。一个是从前往后确定根,一个是从后往前确定根。然后可以通过中序遍历分割出某个根的左右子树,然后就可以成功地还原出一个二叉树了。
本部分将讲解如何从前序遍历和中序遍历中还原出一个二叉树。
注意本题中的一些条件:
1.无重复元素
2.确保中序遍历和前序遍历是匹配的
3.两个序列中至少有一个值
思路:
假设现在是下面这样一个场景,我们
假设现在给出的是这样的两个序列,我们应该怎么做呢?
这题的思路比较直接,没有太多很好的思路:
即我们需要通过前序遍历的序列来确定树(有可能是子树)的根位置,然后通过这个根去分割左右子树,然后再把左右子树再插入到树中。
就以上面那个图为例子:
1.我们可以先确定3是根节点。然后在中序遍历中分割出两个子区间,这是3为根节点的树的左右子树。
2.划分出来后,左区间是[9],右区间是[15, 20, 7]。
3.然后应该继续插入子树,我们这里面临着一个问题,先插入左子树还是右子树呢?
这个取决于题目给的是前序遍历还是后序遍历。因为我们在前序/后序遍历中是不断找根的。前序遍历需要我们从前往后找,那么必然是根 左 右的顺序。所以插入3后,必然是先去判断是否有左子树插入的。如果是后序遍历就是相反的,先判断右子树。
4.再而其次,可能会出现一些情况就是,想要递归插入左右子树的时候,是没有左子树/右子树的。这个时候就需要返回来,往另一边插入了。
这题不用担心,插入根为9的时候,正好只有区间对应的操作区间只有一个[9],直接插入。然后再插入下一个根20。但是[9]区间左右都为空,所以插入失败了,需要返回到3的右边去插入。然后右边插入逻辑也是差不多的,就是把右边子树当成整体,递归走上面的逻辑。
如果这个树长这样又如何呢?
这个树就是上面那个树中,把9变成3的右节点,变成20的父亲节点就可以了。
这个时候会发现,我们找到3这个根分割区间的时候,3左边是没有内容的。也就是不需要往3的左边去递归查找了。就得进入3的右边插入一个9。
然后插入9后又是先去判断左边,发现还是没有,又返回到9的右边插入20。
插入20后分割区间,发现左边20,右边是7,按照逻辑走就可以了。
面临的问题:
但是这题是典型的,思路看着很简单,很好理解,但是写起来却很难,因为递归本身就是比较难以去展开联想的一个部分,还有就是,我们需要不断划分区间的同时还要进行树的构造。这些都是我们面临的问题,我们应该怎么样控制这里的逻辑呢?
这里还要提出一个问题,我们在前序遍历中不断地遍历,把前序遍历中的值都当作根节点进行插入的时候,会不会出现下面的中序遍历不匹配呢?能够保证我们遍历到某个位置的时候就一定能正确插入这个根对应子树所在的位置吗?
答案是可以的,我们先来看代码:
class Solution {
public:TreeNode* Bulid(vector<int>& preorder, vector<int>& inorder, int& prei, int inbegin, int inend){if(inbegin > inend) return nullptr;TreeNode* NewRoot = new TreeNode(preorder[prei]);int rooti = 0;while(inorder[rooti] != preorder[prei]){++rooti;}++prei;//[inbegin rooti - 1] rooti [rooti + 1, inbegin]NewRoot->left = Bulid(preorder, inorder, prei, inbegin, rooti - 1);NewRoot->right = Bulid(preorder, inorder, prei, rooti + 1, inend);return NewRoot;}TreeNode* buildTree(vector<int>& preorder, vector<int>& inorder) {int i = 0;TreeNode* root = Bulid(preorder, inorder, i, 0, inorder.size() - 1);return root;}
};
在主函数buildTree里面肯定是不好划分区间的,因为这里的划分区间就很像在实现快排的时候进行pivot两侧的区间划分,是要有参数传入的。这里很明显没有。
所以我们需要一个辅助函数进行辅助操作。我们还需要一直遍历前序遍历中的根。那需要传入一个遍历的坐标prei。但是注意,这里必须是传引用或者传地址。还是那个老问题,我们在遍历前序序列的时候,我们希望的是这个prei是一直在动的,就是根据它的移动去不断地递归构建子树。那么所有的递归栈帧都要用的是同一个坐标。要不然每个栈帧里面都是独立的一个prei那就不可能得到正确答案了。传引用代码的可读性会更好。
然后我们还需要去控制区间,这里只需要一对坐标就好了。因为我们分割区间的时候,左右子区间是需要分开进行构造的。传两对区间构造两次逻辑会变得更复杂一些。
这里的代码本质就是:
先把当前prei在前序遍历序列中遍历到的数据强行构造一个根节点出来。然后需要找出这个根节点在中序序列的位置。然后划分左右区间。然后因为新的左右区间进行构造新的左子树,根必须是新的。所以需要对prei进行自增操作。指向下一个被插入子树的根节点。
然后先对左边区间进行构造。再对右边进行构造。当然需要判断一下截止条件,即prei指向的根节点在传入的区间内不存在。这种情况只有一种可能:即区间不存在。
因为这里的逻辑是高度匹配的,即prei指向的根,必然是在左右区间的其中一个部分。但是这里我们要清楚,prei插入的是根节点。也就是说prei是准备插入的左右区间子树的其中的一个根节点。根节点都没有了那哪来的子树呢?
所以这里如果发现prei和区间不匹配,只可能是区间当前不存在数据。经过分析,当话分区区间开头inbegin比inend还要大的时候,子区间不存在。(这一点和快排那里的划分很像)。所以对于这种特殊情况,就返回一个空指针。返回到上层调用的栈帧那里,就是子树为空。
所以这里面临的问题就是应该如何进行中序序列区间的划分,而且我们还要清楚的是我们prei在移动插入根的时候,是和下面进行匹配的。
这里可能会有人担心prei越界的问题。但是这点不需要害怕。因为prei走到最后一个位置的时候,如果插入完了再进行++,虽然是越界了,但是前序遍历的最后一个节点不就是整个树的最后一个节点吗?这是个叶子节点,左右都为空。按照我们上面例子所讲,以7划分区间的时候,左右区间不存在,那么自动会返回一个空指针。
此时7这个节点左右都插入完成,当前栈帧返回一个根节点(7)给上一层20。然后7是在20的右边进行递归的,20也插入完成,再返回上一层给3,可能还会有很多层。总之它一定是以此类推回到整个树为根节点的那一次的栈帧,这是第一层栈帧,再返回根的值就是返回给函数外去了,自动结束了。所以不需要担心prei越界的问题。
Leetcode_LCR 155 将二叉搜索树转化为双向链表
原题:
https://leetcode.cn/problems/er-cha-sou-suo-shu-yu-shuang-xiang-lian-biao-lcof/
分析:
我们之前就学过搜索二叉树。搜索二叉树有一个特性,就是它的中序遍历是有序的。可能是升序,有可能是降序。这取决与搜索树的搜索规则。
这一题是要将一个搜索二叉树转化为一个循环的双向链表。方法很多。
最简单的方法就是开一个数组,然后将每个节点存储到数组里面,然后在数组里面进行链接。这种方法很简单,就不多说了。
但是真的到面试的时候,面试官是更希望能够让我们在原地进行操作。不借助任何的其它的数据结构进行辅助操作。这是很合理的,因为这完全做的到。
本题也给出了一些特别注意的点:所有的值不相同。
思路:
- 第一种思路其实不太好想出来,其实也是找规律。
我们知道,搜索树走中序本身就是一个有序的。然后如果我们能够以仔细观察后会发现(我们以题目给出的搜索规则进行讲解):
(1) 对于一个根节点来说,它的左子树在都比它小,所以中序遍历在它们的前面。然后这个根节点在中序遍历中的前一个节点,正好是左子树中最右边的节点。
(2) 换到右边来也是一样的,右边的节点都比根节点大,右子树的最左节点(右子树中最小节点)是此时根节点在中序遍历中的后一个节点。
我们拿一个具体的树来看:
那我们现在就尝试以上面讲的逻辑去看看可不可行。
我们的操作就是,遍历每个根节点,然后让根节点的左边指向左子树最右节点,右边指向左边的最右节点。这样子递归下去,那不就完成了链表转化了吗?
但是这里有一个问题就是,应该以什么方式遍历这个树呢?前中后序总得选一个。
答案是应该选择后序:
因为如果是前序,那么假设遍历4这个节点就会这样:
我们直接把4和2之间的逻辑破坏掉了。4和3连接。但是我们本来是要递归下去让1和2先链接的。如果使用前序变成这样子肯定是不行的。
中序和前序的区别就是访问根的时机不同。前序是先访问完根然后再左递归。中序是先左递归,回退的时候再访问根。
如果使用中序,假设现在回退到2,直接进行了修改操作,那根和左子树的关系是没有被破坏的。但是右子树是否有呢?
很多人一看这张图就说没有破坏,直接让2和3之间链接就好了。这是部队的,因为2的右子树只有一个节点。如果是这样呢?
我们直接让2和右子树最左节点链接,那么我们怎么递归到右子树去进行右子树的链接操作呢?所以,我们应当选择后序才是最合理的。
所以最终,这第一个思路就是,通过后序遍历来遍历根节点,然后让每次遍历的根节点左边指向左子树最右节点,右边指向右子树最左节点。
//后序遍历修改指针指向
//对于根节点而言
//它对应链表的前一个节点就是左子树的最右边
//后一个节点是右子树的最左边
class Solution {
public:void transit(Node* root){if(root == nullptr) return;transit(root->left);transit(root->right);Node* LRMost = root->left, *RLMost = root->right;while(LRMost && LRMost->right){LRMost = LRMost->right;}root->left = LRMost;if(LRMost) LRMost->right = root;while(RLMost && RLMost->left){RLMost = RLMost->left;}root->right = RLMost;if(RLMost) RLMost->left = root;}Node* treeToDoublyList(Node* root) {if(root == nullptr) return nullptr;transit(root);Node* leftmost = root, *rightmost = root;while(leftmost->left) leftmost = leftmost->left;while(rightmost->right) rightmost = rightmost->right;leftmost->left = rightmost;rightmost->right = leftmost;return leftmost;}
};
这里还是有一些细节要注意的:
1.我们这个方法搞出来的链表确实是双向链表,但是是一个不带头不循环的。题目中要求的是带头循环的链表,所以转化完后还是要对链表进行修改结构。所以这就是为什么要引入辅助函数进行转化,因为主函数如果又要转化,又要修改结构是很难的。
2.对于RLMost和LRMost的定义:
因为我们这里是强行把初始化成root的左右两边。但是可能为空。所以空节点不进入循环,我们把空节点本身当成一个节点,空节点也可以是左子树最右节点,也可以是右子树最左节点。
但是有可能不是空,那么这个时候对于最左节点的定义不能是空的了。
所以这就是为什么循环找这两个节点的时候循环条件有两个。
- 第二种思路不会像上面那么难想到:
第二种思路我们采用前后节点的方式,什么意思呢?
还是来看这张图,假设如果我们知道当前遍历根节点的前一个节点在哪里,那不就是可以很轻松的完成指针修改工作了吗?第一个节点的前一个节点我们设为空。
还是一样,我们这一次该怎么选择遍历顺序呢?
这一次要选择中序,因为我们是以前后节点的形式走这个逻辑,那么要修改成有序链表,肯定是中序遍历才可以的。
假设我们现在传入根节点,和第一个节点的前一个节点prev的地址(初始为nullptr):
- 走中序遍历,先不断地向左递归,然后空了会返回。
- 等到回退到某个节点的时候,第一次应该是1这个节点。此时前一个节点的地址是nullptr。
我们没办法知道当前访问的节点下一个是哪一个,但是前一个是知道的。所以我们可以让当前节点的左边指向prev。前一个节点的指向需要特别注意,因为prev可能为空。所以prev需要判空后才决定是否让prev的右边指向当前节点(prev的后一个节点)。 - 现在已经完成了当前节点和前一个节点的互相指向,这个时候递归会回退到上一个调用的位置。这个时候prev必须要修改一下位置。因为递归回退后,上一个节点就是刚刚被prev指向的节点。所以prev修改后才能回退。
- 这里也是一样的,prev必须传引用,要不然每个栈帧里面的prev地址都是独立的。这是我们一直反复在强调的东西了。
- 当prev走到最后一个节点(整个树的最右节点)的时候,访问节点变成prev的右边,但是右边是空。又返回来。然后按照逻辑来讲,会一直回退,知道第一层栈帧右递归的位置,然后就是退出这个函数了,所以最终结束转化后,prev是在最末尾的位置的。
我们来直接看看代码:
//方法2 通过前后指针来操作
class Solution {
public:void transit(Node* root, Node*& prev){if(root == nullptr) return;transit(root->left, prev);root->left = prev;if(prev) prev->right = root;prev = root;transit(root->right, prev);}Node* treeToDoublyList(Node* root) {if(root == nullptr) return nullptr;Node* prev = nullptr;transit(root, prev);Node* leftmost = root;while(leftmost->left){leftmost = leftmost->left;}leftmost->left = prev;prev->right = leftmost;return leftmost;}
};
也是一样的,转化后的链表是不循环的,需要我们在主函数进行修改。只不过由于刚刚的分析,发现prev一定是在链表最后一个节点的位置,所以就不用手动地去搜索了。总的来说,这个方法相比思路1简单理解一些,也更好写。