最近在刷算法的时候刷到树的遍历的时候,有一道题引起了我的思考。
Leecode 144. 二叉树的前序遍历,同样还有两道道类似的题:
Leecode 94. 二叉树的中序遍历
Leecode 145. 二叉树的后序遍历
题意很简单,这三道题都是要求使用迭代的方式写得到这三种遍历方式的结果。题目不难,最开始想的是通过迭代进行模拟树的拓扑结构,先根再左再右,为了回到右节点需要一个栈进行存储右节点。(leecode上解题很多,常规的解法肯定不值得我专门写一篇博客来纪录一下)
我在想,如何用迭代和栈的方式去模拟递归。能不能找到一种通用的方式能解决一切迭代模拟递归的问题。因为递归也是压栈出栈嘛。
答案是肯定有的。我们要做的是去手动的模拟压栈出栈。如何模拟呢?请听我细细道来。
先简单说一下递归函数调用吧,证明一下这个方案的可行性。
递归函数调用实际上就是把当前的一些变量以及它的值给保存起来(保护现场)然后再去执行函数调用。当函数调用返回的时候,这时恢复原来的变量及其值。函数调用关系如下图所示
可见,第一层函数调用实际上是最后才返回的,这种先进后出的逻辑结构其实就是栈。也就是说用栈是完全可以模拟递归的。理论存在,开始实现。
以前序举例吧。
先来个递归写法:
void dfs(TreeNode* root,vector<int> &res){
if(root==NULL)
return ;
res.push_back(root->val); //根
dfs(root->left,res); //左
dfs(root->right,res); //右
}
vector<int> preorderTraversal(TreeNode* root) {
if(root==NULL)
return {};
vector<int> res;
dfs(root,res);
return res;
}
迭代写法:
前面说了,函数调用的时候要先做一个工作——保护现场,也就是压栈,怎么压?也就是把后面没执行的东西压到栈里面去嘛。具体一点,也就是我去遍历左子树的时候,要把右子树的压到栈里面去,因为我左子树遍历完后要去遍历右子树嘛。由于栈是先进后出的结构,所以我们要先压右子树再压左子树,这样出栈的顺序才是先左后右。
vector<int> preorderTraversal(TreeNode* root) {
vector<int> res;
stack<TreeNode*> sta;
sta.push(root);
while(!sta.empty()){
TreeNode* node=sta.top();
sta.pop();
if(node==NULL)
continue;
res.push_back(node->val); //由于是前序,直接加入结果集
sta.push(node->right); //先左后右
sta.push(node->left);
}
return res;
}
前序很容易理解,也很容易实现,现在来看看后序。
后序的递归写法:
void dfs(TreeNode* root,vector<int> &res){
if(root==NULL)
return ;
dfs(root->left,res); //左
dfs(root->right,res); //右
res.push_back(root->val); //根
}
vector<int> preorderTraversal(TreeNode* root) {
if(root==NULL)
return {};
vector<int> res;
dfs(root,res);
return res;
}
(这里插一句,如果你熟悉前中后序的遍历顺序,会发现前序是前左右,后序是左右前,当我前序先遍历右子树再遍历左子树的时候,前序就变成了前右左,这是我把前序的答案给逆序,不就变成左右前了吗。)
我们不搞那些花里胡哨的,还是模拟递归吧。当我们按照前面的理论,通过递归的遍历顺序反向压栈,写着写着才发现后序最后是要回到最开始的结点的啊!!!如果我们把当前结点也压进去,那么不就死循环了吗,无限压自己再出栈自己。这时我们需要一个标志结点(你也可以使用NULL,但是如果是NULL的话就不能把叶子结点的NULL加进去了),来标志我要恢复现场了,这时只能出栈不能再把自己压进去了。 在代码中的表现如下:
vector<int> postorderTraversal(TreeNode* root){
stack<TreeNode *> sta;
sta.push(root);
vector<int> res;
TreeNode *flag=new TreeNode(0); //1.一个标志结点,表示要恢复现场了
while(!sta.empty()){
TreeNode* node=sta.top();
sta.pop();
if(node==NULL)
continue;
if(node==flag){ //5.如果是标志结点,表示恢复现场,也就是去更新res
TreeNode* ROOT=sta.top(); //6.由于之前pop了一次,也就是把flag结点pop出去了,所以top等于原来的结点。
sta.pop();
res.push_back(ROOT->val);
continue; //7.注意继续压栈了,直接continue。
}
sta.push(node); //2.因为要恢复现场,所以把当前结点再次压入栈
sta.push(flag); //3.压入标志结点
sta.push(node->right); //4.先右后左
sta.push(node->left);
}
delete flag; //8. 防止内存泄漏
return res;
}
既然标志位我们都解决了,中序岂不是手到擒来?
中序遍历顺序为: 左 根 右
那么迭代实现为:
vector<int> inorderTraversal(TreeNode* root) {
stack<TreeNode*> sta;
vector<int> res;
TreeNode* flag=new TreeNode(0);
sta.push(root);
while(!sta.empty()){
TreeNode *node=sta.top();
sta.pop();
if(node==NULL)
continue;
if(node==flag){
TreeNode * ROOT=sta.top();
sta.pop();
res.push_back(ROOT->val);
continue;
}
sta.push(node->right);
sta.push(node);
sta.push(flag);
sta.push(node->left);
}
delete flag;
return res;
}
看完了这三道题,于是乎除了掌握了数的三种遍历顺序的迭代实现以外,我们还知道了如何用迭代去模拟递归的压栈。总结一下,需要注意的点:
- 考虑栈的先进后出,栈的压入顺序为逆序,和递归的实现相反。
- 恢复现场的时候需要用一个标志结点来标识,不然会造成死循环。
- 当结点为NULL或者flag结点的时候,一定要continue,不然也会造成重复压栈的死循环。
- 注意最后删除flag结点,防止内存泄漏。