代码随想录算法训练营第十三天| 二叉树理论基础 深度优先遍历 广度优先遍历(层次遍历)


一、二叉树理论基础

树的基本定义

树是 n ( n > 0 ) n(n>0) n(n>0) 个结点的有限集合, n = 0 n=0 n=0 时称为空树;

有且仅有一个结点被称为 ( r o o t ) (root) (root) ,每个结点可以有若干个子结点(子树),没有子结点的结点被称为 叶子结点

树的基本术语

  • 节点的度 ( d e g r e e ) (degree) (degree):节点拥有的子树个数成为节点的度;
  • 树的度:树的度是树内各节点度的最大值;
  • 层次:节点的层次从根开始定义,根为第一层,根的孩子为第二层树中任意节点的层次 = = = 它的双亲层次 + 1 +1 +1
  • 高度:树中节点的最大层次成为树的高度;
  • 森林:是 m ( m ≥ 0 ) m(m\ge0) m(m0) 颗互不相交的树的集合;对任意一棵树而言,其子树组成森林。

二叉树的定义

 对于一棵树 T T T ,树中每个结点都含有 T l , T r T_l, T_r Tl,Tr 两棵子树(可以为空树),则该树称为二叉树

二叉树的基本性质

  1. 二叉树的第 i i i 层有 2 ( i − 1 ) 2^{(i-1)} 2(i1) 个结点
  2. 深度为 k k k 的二叉树至多有 2 k − 1 2^k-1 2k1 个结点
  3. 任何一棵二叉树,若叶子数为 n 0 n_0 n0,度为 2 2 2 的节点数为 n 2 n_2 n2 , 则 :
    n 0 = n 2 + 1 n_0 = n_2 + 1 n0=n2+1
  4. 具有 n n n 个结点的完全二叉树的深度为 [ l o g 2 n ] + 1 [log2 n]+1 [log2n]+1 (不大于 x x x 的最大整数)
  5. 如果有一棵 n n n 个结点的完全二叉树,则对任一结点 i i i
    • i = 1 i = 1 i=1,则i是二叉树的根,无双亲;若 i > 1 i>1 i>1, 则其双亲是结点 [ i / 2 ] [i/2] [i/2]
    • 如果 2 i > n 2i > n 2i>n ,则结点 i i i 为叶子结点,无左孩子;否则,其左孩子是结点 2 i 2i 2i
    • 如果 2 i + 1 > n 2i + 1 > n 2i+1>n, 则结点 i i i 无右孩子;否则,右孩子是结点 2 i + 1 2i+1 2i+1

二叉树的存储方式(物理结构)

1. 顺序存储

在这里插入图片描述
按照层次遍历的顺序,将结点存储在数组中

2. 链式存储

在这里插入图片描述
结点与结点间通过指针相互关联;
链式存储的C++代码如下:

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

二、二叉树的深度优先遍历

 二叉树的结点遍历方式主要可分为深度优先遍历与广度优先遍历(层次遍历);而深度优先遍历按照遍历方法可分为递归迭代两种,按照结点遍历的顺序可分为前序、中序、后序三种方法。
在这里插入图片描述

递归遍历(最基础的遍历方法)

 对于任何问题的递归法,重要的是确定递归的终止条件递归操作主体函数传参与返回值,从而编写出完整的递归函数;

前序遍历(中左右)

class Solution {
public:
    void traversal(TreeNode* cur, vector<int>& vec) { //函数传参
        if (cur == NULL) return; //终止条件
        //递归主体操作
        vec.push_back(cur->val);    // 中
        traversal(cur->left, vec);  // 左
        traversal(cur->right, vec); // 右
    }
    vector<int> preorderTraversal(TreeNode* root) {
        vector<int> result;
        traversal(root, result);
        return result;
    }
};

中序遍历(左中右)

void traversal(TreeNode* cur, vector<int>& vec) {
    if (cur == NULL) return;
    traversal(cur->left, vec);  // 左
    vec.push_back(cur->val);    // 中
    traversal(cur->right, vec); // 右
}

后序遍历(左右中)

void traversal(TreeNode* cur, vector<int>& vec) {
    if (cur == NULL) return;
    traversal(cur->left, vec);  // 左
    traversal(cur->right, vec); // 右
    vec.push_back(cur->val);    // 中
}

迭代遍历(进阶版)

 通过栈数据结构的学习我们可以得知,递归的实现是需要依靠栈进行操作的,那么我们实际上可以自己构建一个栈,用迭代的方式实现和递归一样的效果,但具体的思路和递归有所区别;

前序遍历

在这里插入图片描述
 由于前序遍历的顺序为“中左右”,中间的双亲结点最先遍历输出,那么就很方便我们使用栈进行遍历,弹出一个结点直接存入要输出的数组中,并将他的右孩子、左孩子依次入栈;先右后左是为了保证中左右的输出顺序;

class Solution {
public:
    vector<int> preorderTraversal(TreeNode* root) {
        stack<TreeNode*> nodes_stack;
        vector<int> nodelist;
        if(root){
            nodes_stack.push(root); //根结点入栈
        }
        while(!nodes_stack.empty()){
            TreeNode* node = nodes_stack.top(); 
            nodelist.push_back(node->val); //双亲结点输出,即中
            nodes_stack.pop();
            if(node->right) nodes_stack.push(node->right); //右入栈
            if(node->left) nodes_stack.push(node->left); //左入栈
        }
        return nodelist;
    }
};

后序遍历

 后续遍历的输出顺序为“左右中”,注意到双亲结点最后输出,那么如果我们将顺序倒过来“中右左”,发现和前序遍历可以实现同样的思路,只需要将中间遍历时的左右孩子入栈顺序颠倒一下,并在程序最后将输出的数组进行翻转即可;
在这里插入图片描述

class Solution {
public:
    vector<int> postorderTraversal(TreeNode* root) {
        stack<TreeNode*> st;
        vector<int> result;
        if (root == NULL) return result;
        st.push(root);
        while (!st.empty()) {
            TreeNode* node = st.top();
            st.pop();
            result.push_back(node->val);
            if (node->left) st.push(node->left); // 相对于前序遍历,这更改一下入栈顺序 (空节点不入栈)
            if (node->right) st.push(node->right); // 空节点不入栈
        }
        reverse(result.begin(), result.end()); // 将结果反转之后就是左右中的顺序了
        return result;
    }
};

中序遍历

 中序遍历与前序后序较为不同,因为无法通过简单的顺序反转使得中间结点最先遍历;由于我们最先用指针或栈就是中间结点,因此中间结点无法最先遍历意味着:访问结点和遍历输出结点不能同时实现。因此我们需要对操作进行一些调整;

 笔者对于中序遍历代码的理解为:使用栈优先存入所有的最左边的结点(根结点及所有左孩子),而后利用二叉树的性质对结点依次输出,达到遍历的目的;

 由于中序遍历是优先输出左结点,那么第一个结点为从根结点出发一直寻找左孩子,直到找到第一个叶子结点,我们将寻找过程中访问到的结点入栈,便于后续操作:

while(node || !nodes_stack.empty()){
    if(node){
        nodes_stack.push(node);
        node = node->left;
    }

 找到叶子结点以后,由于叶子结点的左右孩子都为空,我们可以以此为判定条件输出结点,即node == NULL时,输出当前栈顶的结点:

if(node==NULL){
	node = nodes_stack.top();
	nodes_stack.pop();
	tralist.push_back(node->val);
}

 要维持迭代就不能保持node不变,因此在输出结点以后需要对node值进行修改;而我们一路访问过来实际上只有双亲和左孩子结点(左、中),这些在后续迭代过程中会按照左中的顺序弹出,缺少了右孩子的遍历,因此我们在弹出结点的时候,访问该结点的右孩子,就可以保证左中右的结点按照顺序输出:

node = node->right;

完整流程图如下:
在这里插入图片描述
 对于中序遍历代码的理解,笔者认为是应用了二叉树的性质:
 第一遍所有左孩子和根结点入栈时,实际上是存储了左、中的子树,而结点的输出实际上是依靠对当前访问节点是否为空的判断,即:

if(node==NULL)

 这个方法可行之处恰恰在于二叉树的空子树数量和结点数量的关系,我们可以作简单的数学推导:

n 0 , 1 , 2 n_{0, 1, 2} n0,1,2 分别代表度为 0 , 1 , 2 0, 1, 2 0,1,2 的结点个数, n n n 为总结点个数, N N U L L N_{NULL} NNULL 为空子树的个数;

我们已知二叉树中的结点个数为: n = n 2 + n 1 + n 0 n = n_2+n_1+n_0 n=n2+n1+n0;

若将二叉树看做图,图中结点和边的关系满足:边数 = 结点数 + 1,即:
n = 2 ∗ n 2 + n 1 − 1 n=2*n_2+n_1-1 n=2n2+n11

联立上述两式可得二叉树定理: n 2 = n 0 + 1 n_2=n_0+1 n2=n0+1

二叉树中的空子树个数可表示为: N N U L L = 2 ∗ n 0 + n 1 N_{NULL}=2*n_0+n_1 NNULL=2n0+n1

结合上面的式子可以联立求出:
N N U L L = n + 1 N_{NULL}=n+1 NNULL=n+1

即对任意二叉树,空子树个数为结点个数+1,因而依靠访问空子树时输出,一定可以达到遍历到全部结点的目的。

完整C++代码如下:

class Solution {
public:
    vector<int> inorderTraversal(TreeNode* root) {
        vector<int> tralist;
        stack<TreeNode*> nodes_stack;
        TreeNode* node = root;
        while(node || !nodes_stack.empty()){
            if(node){
                nodes_stack.push(node);
                node = node->left;
            }
            else{
                node = nodes_stack.top();
                nodes_stack.pop();
                tralist.push_back(node->val);
                node = node->right;
            }
        }
        return tralist;
    }
};

二叉树统一规格迭代方法

 在上文的迭代遍历中,我们编写中序遍历的方法与前后序差别很大,不便于统一的贯通理解;本部分将记录三种顺序的遍历统一规格的迭代方法。

 基础思路来源于,笔者自己在编写中序遍历代码时,想象如果可以在函数中操作二叉树(删除遍历过的结点)或对二叉树结点作标记(更改二叉树结点的定义,加上一个bool值判断是否遍历过,用来控制输出),就可以达到统一三种迭代遍历的代码风格了。

 统一迭代的思路其实就是做标记判断是否遍历输出,但是标记会当作元素直接入栈,当访问到NULL时才对栈中结点进行输出;
在这里插入图片描述
代码如下:

中序

class Solution {
public:
    vector<int> inorderTraversal(TreeNode* root) {
        vector<int> result;
        stack<TreeNode*> st;
        if (root != NULL) st.push(root);
        while (!st.empty()) {
            TreeNode* node = st.top();
            if (node != NULL) {
                st.pop(); // 将该节点弹出,避免重复操作,下面再将右中左节点添加到栈中
                if (node->right) st.push(node->right);  // 添加右节点(空节点不入栈)

                st.push(node);                          // 添加中节点
                st.push(NULL); // 中节点访问过,但是还没有处理,加入空节点做为标记。

                if (node->left) st.push(node->left);    // 添加左节点(空节点不入栈)
            } else { // 只有遇到空节点的时候,才将下一个节点放进结果集
                st.pop();           // 将空节点弹出
                node = st.top();    // 重新取出栈中元素
                st.pop();
                result.push_back(node->val); // 加入到结果集
            }
        }
        return result;
    }
};

前序

class Solution {
public:
    vector<int> preorderTraversal(TreeNode* root) {
        vector<int> result;
        stack<TreeNode*> st;
        if (root != NULL) st.push(root);
        while (!st.empty()) {
            TreeNode* node = st.top();
            if (node != NULL) {
                st.pop();
                if (node->right) st.push(node->right);  // 右
                if (node->left) st.push(node->left);    // 左
                st.push(node);                          // 中
                st.push(NULL);
            } else {
                st.pop();
                node = st.top();
                st.pop();
                result.push_back(node->val);
            }
        }
        return result;
    }
};

后序

class Solution {
public:
    vector<int> postorderTraversal(TreeNode* root) {
        vector<int> result;
        stack<TreeNode*> st;
        if (root != NULL) st.push(root);
        while (!st.empty()) {
            TreeNode* node = st.top();
            if (node != NULL) {
                st.pop();
                st.push(node);                          // 中
                st.push(NULL);

                if (node->right) st.push(node->right);  // 右
                if (node->left) st.push(node->left);    // 左

            } else {
                st.pop();
                node = st.top();
                st.pop();
                result.push_back(node->val);
            }
        }
        return result;
    }
};

三、二叉树的广度优先遍历(层次遍历)

 上文提到的深度优先遍历采用递归的思想,在使用迭代实现时则采用了递归实现的数据结构——栈;层次遍历满足广度优先搜索的特性,则更适合使用先入先出的队列结构进行实现;

 实现思路为:在遍历输出本层元素的同时,将本层元素的所有孩子入队,那么当本层元素遍历完以后,下一层的所有元素刚好全部入队,如此循环即可;
在这里插入图片描述

 需要注意的一点即是,在循环输出单层元素时,需要提前记录本层的结点个数来控制循环遍历的次数,因为在遍历过程中队列中的结点个数会变化。

class Solution {
public:
    vector<vector<int>> levelOrder(TreeNode* root) {
        queue<TreeNode*> que;
        if (root != NULL) que.push(root);
        vector<vector<int>> result;
        while (!que.empty()) {
            int size = que.size();
            vector<int> vec;
            // 这里一定要使用固定大小size,不要使用que.size(),因为que.size是不断变化的
            for (int i = 0; i < size; i++) {
                TreeNode* node = que.front();
                que.pop();
                vec.push_back(node->val);
                if (node->left) que.push(node->left);
                if (node->right) que.push(node->right);
            }
            result.push_back(vec);
        }
        return result;
    }
};

总结

 本日内容主要是对二叉树数据结构的基本理论以及遍历方式的复习,题目并不算难,在于打好基本功。


文章图片来源:代码随想录 (https://programmercarl.com/)

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值