pycharm 递归 栈溢出_二叉树迭代 / 递归遍历模板和思路解析

二叉树遍历

相信大家对于二叉树的定义以及结构,在本科《数据结构与算法》课程中或者在其他同学的博客中有所了解。在此,不在过多地回顾课本理论知识。

我们编码语言主要为C++,如下是二叉树在C++中类的表现形式,这边使用的是 struct 结构体,在C++中类和结构体的区别在于不标志 public , private 以及 protected 时, 成员变量和成员函数是 public 还是 private 以及默认的继承方式是 public 还是 private。使用 struct 的原因在于,结构体更想表达的是一种数据的组织形式,而不是ADT。

struct TreeNode {
    int val;
    TreeNode* left;
    TreeNode* right;
    TreeNode*(int x):val(x),left(nullptr),right(nullptr){}
};

并且配图可以更好地理解二叉树的结构。

9567a41d9c16276a3b8f6c52c9434ffb.png

二叉树的遍历方式

在线性结构中,例如,在数组中我们采用索引值(即,地址偏移)的方式去遍历整个数组;在链表中我们采用迭代的方式去遍历整个链表。而二叉树和线性的数组和链表的最大区别在于,二叉树的每一个节点并非只有一个后继节点。因此就有了许多有趣的遍历方式。

分类

按照遍历顺序可以分为三种:

  • 前序
  • 中序
  • 后序
  • 层序

按照遍历算法的写法可以分为:

  • DFS(对应前中后的递归)
  • BFS(对应层序)

还可以根据是否采用迭代写法分为:

  • 迭代
  • 递归

框架

递归框架(前序中序后序框架)

首先是递归,也是最简单书写的一种,其代码框架如下:

void order(TreeNode * root){
  if(root == nullptr)   return;
  // 前序
  order(root->left);
  // 中序
  order(root->right);
  // 后序
}

优缺点

优点:递归很简单,我们只需要记住这一个代码框架就可以完成前中后序遍历;

缺点:递归过程中,从根节点出发不断递归左右节点,这一些函数调用的开销发生在栈空间,使得栈空间消耗巨大,甚至可能会导致栈溢出。

理解

那么,为什么这样就可以实现遍历呢?

  • 递归写法必须有一个递归出口,也就是我们框架函数体内第一个语句,也就是我们递归到了叶子节点的时候,会调用 order(叶子节点的左孩子)order(叶子节点的右孩子) 。那么这两个调用我们就应该停止递归了,也就是遇到了 root==nullptr 这个条件为 true ,递归返回。
  • 那么对于任意一个非空节点来说我们需要做什么?我们无非是要做 : 访问它,访问它的左孩子,访问它的右孩子这三件事。那么既然有三件事,一定会涉及到顺序问题。这三者的顺序不同,就分出了三种访问顺序:
    • 前序:根-左-右
    • 中序:左-根-右
    • 后序:左-右-根

比如,我们要按照中序的顺序输出二叉树的节点值:

void order(TreeNode* root){
     if(root == nullptr) return;
     order(root->left);    
     /*中序 begin */
     cout << root->val << ' '; //中序位置写执行语句,这一个区域写的root就是中序的节点
     /*中序 end */     
     order(root->right); 
}

迭代框架(前序框架 与 中序框架)

迭代框架没有递归框架那么简单,既没有对前中后序以及层序的通用模板,也没有递归那么语句短小,接下来主要介绍:前序,中序,后序,层序的迭代框架(模板)。

vector<int> PreOrder(TreeNode *root) {
    vector<int> res;
    if (!root) return res;
    stack<TreeNode *> s;
    while (root || !s.empty()) {
        if (root) {
            s.push(root);
            // res.push_back(root->val);  // PreOrder
            root = root->left;
        } else {
            root = s.top();
            s.pop();
            // res.push_back(root->val);  // InOrder
            root = root->right;
        }
    }
    return res;
}

代码在上方,可以看到前序和中序的框架比较相似,在不同的位置执行操作,那么它的遍历顺序就可以不同。

理解

前序遍历,我们总是先读根节点(或者对根节点做一系列操作),如果左孩子存在,再去访问当前根节点的左孩子,那么我们要访问当前节点的左孩子,如果我们需要之后再访问右孩子,我们岂不是无法回到那个根节点了?(树的结构不支持子节点到父节点的指针)。所以,我们需要借助一个辅助栈来存放没有访问过右节点的根节点,等我们把左子树访问完毕之后,再提出之前的根节点,来访问他的右子树。

f643cb5449d2fe575365137c1edb235c.png

如图,我们访问了节点1,去访问了节点2,那么我们访问完节点5之后需要回到节点1,再去访问节点3,因此必须把1给保留下来,但是我们不能用单个变量来保存节点1,因为在访问过程中,访问了节点2之后,访问节点4,再之后需要通过节点2,再去访问节点5,这样又需要一个变量来保存节点2,因此再迭代过程中,这个节点记录是一个变长的,所以需要一个辅助栈。

迭代框架(后序框架)

前序和中序遍历的迭代框架是类似的,但是后序遍历的框架截然不同。后序遍历顺序:左-右-根。我们可以看到,后序的遍历顺序和前序遍历顺序(根-左-右)类似。我们可以稍微修改前序遍历的框架,再对数组进行反转,获得后序遍历顺序。

vector<int> PostOrder(TreeNode *root) {
    vector<int> res;
    if (!root) return res;
    stack<TreeNode *> visit;
    TreeNode *cur = root;
    TreeNode *pre = nullptr;
    while (cur != nullptr || !visit.empty()) {
        while (cur != nullptr) {
            visit.push(cur);
            cur = cur->left;
        }
        cur = visit.top();
        if (cur->right == nullptr || cur->right == pre) {
            visit.pop();
            res.push_back(cur->val);
            pre = cur;
            cur = nullptr;
        } else {
            cur = cur->right;
        }
    }
    return res;
}
vector<int> PostOrder_2(TreeNode *root) {
    vector<int> res;
    if (!root) return res;
    stack<TreeNode *> s;
    TreeNode *p = root;
    while (p != nullptr || !s.empty()) {
        if (p != nullptr) {
            res.push_back(p->val); //前序的位置
            s.push(p);
            p = p->right; //区别在于遍历顺序是 根-右-左
        }
        else if(!s.empty()){
            p = s.top();
            s.pop();
            p = p->left;
        }
    }
    reverse(res.begin(),res.end()); //得到 根-右-左, 反转之后得到 左-右-根
    return res;
}

理解

可以看一下以上的代码:PostOrder(TreeNode* root) 是正常思路的后序遍历迭代写法,而 PostOrder_2(TreeNode* root) 是前序遍历反转的写法。个人感觉前序遍历反转的模板和前序中序模板类似,思想方式较为统一。

迭代框架(层序遍历)

层序遍历其实就是BFS的思想,遍历的顺序是按层从低到高来遍历。

vector<vector<int>> levelOrder(TreeNode* root) {
    vector<vector<int>> res;
    if(!root)   return res;
    deque<TreeNode*> q;
    q.push_back(root);
    while(!q.empty()){
        int size = q.size();
        res.push_back(vector<int>());
        for(int i = 1;i <= size;++i){
            TreeNode* node = q.front();q.pop_front();
            res.back().push_back(node->val);
            if(node->left)  q.push_back(node->left);
            if(node->right) q.push_back(node->right);
        }
    }
    return res;
}

理解

455f8a5d3289f1c0f3fa36cf45a35f0a.png

层序遍历相较于前中后序来说,它访问到了当前节点的兄弟节点以后,我们无法访问前节点的孩子节点。因此,这里不同的是,需要一个辅助的队列来存放同层的节点。

总结

那么,二叉树的常见遍历写法都在这边啦。

递归写法因为每一个节点访问一次,所以时间复杂度为:O(N),而递归需要在栈空间里开辟许多空间,平均递归的深度是logN,因此空间复杂度也是:O(logN)

迭代写法因为每一个节点访问一次,所以时间复杂度为:O(N),而迭代需要一个辅助栈或者辅助队列,平均的情况是把logN的节点放入栈或队列,最差的情况是把大多数节点全部放入栈或队列,因此空间复杂度也是:O(logN)

而还有一个遍历写法叫做morris遍历,它能够实现空间复杂度为O(1)。但是写法较为复杂,准备另外再写一篇关于morris遍历的模板。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值