一、引言
二叉树的遍历,是每个学习过数据结构的人,接触过最频繁的东西之一。利用对于二叉树遍历的方法进行修改,我们可以解决很多更为复杂的问题。这么基础,我们真的很熟练的掌握了吗?我并没有。
二、题目
干巴巴的讲理论,不是我擅长的,我们结合题目来看看二叉树的遍历吧:
- leetcode 94
- 对二叉树进行中序遍历
三、解法
递归
看到题目我首先想到的就是递归,递归是很直观的,也是很容易理解的,他的容易理解在于和人的思维方式很像。二叉树的中序遍历:先遍历左子树,再遍历根节点,最后遍历右子树。下面的代码是递归实现的中序遍历的代码:
class Solution {
public List<Integer> inorderTraversal(TreeNode root) {
List<Integer> res = new ArrayList<>();
inorderTraversal(root, res);
return res;
}
public void inorderTraversal(TreeNode root, List<Integer> res){
if(root == null)
return;
inorderTraversal(root.left, res);
res.add(root.val);
inorderTraversal(root.right, res);
}
}
你可以发现,它与我们的语言描述很像,我们按照我们的语言描述,就可以很快的写出代码。但是能否顺利运行就是不一定的事了, 因为还有很多需要注意的东西。递归的过程,是把一个大问题分解若干相似子问题的过程。分解的过程,我们不断的把问题规模减小,直到可以处理为止。如果让我们人为去进行这一个过程无疑很费劲,因为我们不知道问题要进行多少次分解,所以我们把这个过程交给程序自己执行,也就是递归。我们需要做的就是告诉程序如何分解这个问题, 问题的规模小到一定程度的时候如何处理,最后就是如何结束递归。说着说着,有点偏题了,对于二叉树的中序遍历,我们信手拈来。
但是还有其他的方法吗?我们知道递归的实现,是函数自己调用自己,向计算机的函数栈不断的添加函数自身, 直到遇到递归的终止条件,从函数栈将自己弹出。我们自己去模拟这个过程不行吗?可行,事实基本所有的递归算法都可以通过循环+栈的形式去解决。
循环+栈
这次我们先上代码吧:
class Solution {
public List<Integer> inorderTraversal(TreeNode root) {
Stack<TreeNode> st = new Stack<>();
List<Integer> res = new ArrayList<>();
TreeNode cur = root;
while(cur != null || !st.isEmpty()){
while(cur !=null){
st.push(cur);
cur = cur.left;
}
cur = st.pop();
res.add(cur.val);
cur = cur.right;
}
return res;
}
}
代码似乎也不是很复杂,但是相比递归版本的,我相信循环+栈的形式,可就不是所有都能信手拈来的了。这种形式,相当于你自己去递归中将大问题分解为子问题中,函数栈中的变化过程。
我们从代码可以看到,我们每次先去找以当前结点为根结点子树的最左侧的结点。我们中序遍历的遍历顺序是:左 中 右,对于 左,我们又可以展开成新的 左 中 右 的形式的,一个树的中序遍历的问题的完全展开就变成,**左 (左(左(…))) **, 为了书写方便,我们只是对 左 这个问题一直进行展开。原先使用递归的时候这个问题的展开过程,你是完全交给了计算机,但是当你自己去模拟的这个过程,你就必须要想明白这个变化。
我们明白中序遍历问题展开过程后,我们知道为什么呢要先找到树的最左侧的结点了。为了不丢失展开过程每个结点,我们用栈将这个结点存储起来。遇到了结束条件,我们将当前结点弹出,继续处理。当前的结点左子树处理完毕后,就要处理右子树了,右 这个问题的展开又变成了这种形式 右(左(左(…)), 所以就利用循环去把这个问题展开。
其实无论是 递归 和 循环+栈 本质其实是一样,只是书写形式不一样。这些方法最终能够解决这类问题,是因为这类问题是由大量相似的子问题组成的,其中每个子问题也是以这种方式组成的。
说过两个方法,那还有其他的方法去解决这个中序遍历的问题呢?有的,莫里斯遍历
莫里斯遍历
莫里斯遍历利用新的数据结构:线索二叉树,在上一个方法中我们利用栈存储展开过程中的结点,导致我们的空间复杂度为:
O
(
n
)
O(n)
O(n), 那我们能不能省略掉这个栈呢?这个栈的作用,是让我们可以从结点可以重新回到上一个待处理的结点,然我们可以进行回退。如果我们不利用二维的辅助空间,如何回退呢?所以就有有人提出了利用叶子结点的两个为空的子节点去记录一些信息,然后我们可以完成回退的操作,于是就有了线索二叉树。莫里斯遍历也正是借助线索二叉树这一特性实现的二叉树的遍历。
在莫里斯遍历的过程中:
- 遍历的结点 n n n 没有左子树,即输出该节点,进入该结点右子树。
- 否则,令左子树的最右侧结点 r r r(中序遍历中 r r r 为 n n n 的前驱结点)指向 n n n 结点,进行入该结点的左子树,并令该结点左孩子指向空。
莫里斯遍历代码:
class Solution {
public List<Integer> inorderTraversal(TreeNode root) {
List<Integer> res = new ArrayList<>();
TreeNode cur = root, pre = null;
while(cur!=null){
if(cur.left == null){
res.add(cur.val);
cur = cur.right;
}else{
pre = cur.left;
while(pre.right!=null){
pre = pre.right;
}
pre.right = cur;
TreeNode temp = cur;
cur = cur.left;
temp.left = null;
}
}
return res;
}
}
莫里斯遍历很好的利用了叶子结点的空域,记录额外信息,完成了遍历。
四、运行效果
方法 | 执行时间 | 内存消耗 |
---|---|---|
递归 | 1ms | 35.5m |
循环+栈 | 2ms | 35.7m |
莫里斯遍历 | 1ms | 35.1m |
五、总结
即使很简单问题,也有很多种思考方式可以借鉴。