数据结构与算法【树】

二叉树性质

满二叉树

在这里插入图片描述
深度为k,有 2 k − 1 2^{k}-1 2k1个结点的二叉树,为满二叉树。

完全二叉树

完全二叉树的定义如下:在完全二叉树中,除了最底层节点可能没填满外,其余每层节点数都达到最大值,并且最下面一层的节点都集中在该层最左边的若干位置。若最底层为第 h 层,则该层包含 1~ 2^(h-1) 个节点。

二叉树的存储方式

包括链式存储和顺序存储
由于链式存储的二叉树更有利于我们理解,所以我们一般都是用链式存储二叉树。
所以大家要了解,用数组依然可以表示二叉树。

在这里插入图片描述

二叉树链式存储代码

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

二叉树的遍历方式

遍历方式分两类,四种

关于二叉树的遍历方式,首先从深度和广度来区分。

  1. 深度优先遍历:先往深走,遇到叶子节点再往回走。
  2. 广度优先遍历:一层一层地去遍历。
    这两种遍历是图论中最基本的两种遍历方式,后面在介绍图论的时候,还会介绍到。

那么我们进一步扩展深度优先遍历和广度优先遍历,才会有更为细致的遍历方式的区分:

  • 深度优先遍历
    • 前序遍历(递归法,迭代法)
    • 中序遍历(递归法,迭代法)
    • 后序遍历(递归法,迭代法)
  • 广度优先遍历
    • 层次遍历(迭代法)
      在深度优先遍历中:有三个顺序,前中后序遍历,有同学总分不清这三个顺序,经常搞混,我这里教大家一个技巧。
      这里前中后,其实指的就是中间节点的遍历顺序,只要大家记住,前中后序指的就是中间节点的位置就可以了。看如下节点的遍历顺序,就可以发现中间节点的顺序就是所谓的遍历方式名称的由来:
  • 前(先)序遍历:中左右
  • 中序遍历:左中右
  • 后序遍历:左右中

遍历方式的实现

最后再说一说二叉树中深度优先遍历和广度优先遍历的实现方式。我们做二叉树相关的题目,经常会使用递归的方式来实现深度优先遍历。
之前讲栈的时候,说过栈其实就是递归的一种实现结构,先进后出。也就就是说前中后序遍历的逻辑其实都是可以借助栈使用非递归的方式来实现。(通过栈的结构避免了递归操作)

而广度优先遍历的实现,一般借助队列来实现,这也是由于队列先进先出的特点所决定的,因为需要先进先出的结构,才能一层一层的来遍历二叉树。

这里其实我们又了解了栈与队列的一个应用场景了。
具体的实现我们后面都会讲的,这里大家先要清楚这些理论基础。

二叉树与递归(二叉树的递归遍历)

说到二叉树,就不得不说递归,很多同学对递归都是又熟悉又陌生,递归的代码一般很简短,但每次都是一看就会,一写就废。

递归写不好的根本原因就是不成体系,没有递归方法论。通过二叉树的前中后序的递归写法,我们把递归方法论确定下来,进而应对复杂的递归题目。

首先,每次写递归算法,先确定三要素:

  1. 确定递归函数的参数和返回值: 确定哪些参数是递归的过程中需要处理的,那么就在递归函数里加上这个参数, 并且还要明确每次递归的返回值是什么进而确定递归函数的返回类型。
  2. 确定终止条件: 写完了递归算法, 运行的时候,经常会遇到栈溢出的错误,就是没写终止条件或者终止条件写的不对,操作系统也是用一个栈的结构来保存每一层递归的信息,如果递归没有终止,操作系统的内存栈必然就会溢出。
  3. **确定单层递归的逻辑:**确定每一层递归需要处理的信息。在这里也就会重复调用自己来实现递归的过程。

那三要素怎么切入,怎么找呢?
我们以前序遍历为例子,来找感觉!
1.确定递归函数的参数和返回值:因为我们要打印前序遍历节点的数值,因此参数里需要传入vector来放节点的数值,除了这一点就不需要再处理什么数据了,也不需要有返回值,所以递归函数返回类型就是void,代码如下:
(代码随想录点评:为什么要传入vector没讲清楚,另外代码还传入了节点本身,也很令人困惑?因为后面说的是除了vector不需要再处理什么数据了,也不需要有返回值,但是没提到传入TreeNode *cur的目的,但是我的猜想是,对树本身进行处理,肯定是要传入当前处理的树节点的指针的,至于为什么一定要用vector来放节点的数值,我想不通,希望后面会想通)

void traversal(TreeNode* cur, vector<int>& vec)

2.确定终止条件:在递归过程中,如何算递归结束?对于前序遍历,如果当前遍历的节点是空了,就说明递归结束了,所以如果当前遍历的节点是空,就直接return,代码如下:

if (cur == NULL) return;

3.确定单层递归的逻辑:前序遍历是中左右的顺序,因此单层递归的逻辑就是,先取中点节点的数值,(单层递归的逻辑这个概念讲的也不通!! 首先,什么是单层递归?如果重复调用单层递归实现递归的过程? 我的理解就是,每读到一个新数据,应当怎么处理这个数据,这个就是单层递归的数据处理逻辑!!!数据处理完成之后,就到了递归的逻辑了,单层递归本质上在数据处理完之后就结束了!!!后面就是递归的逻辑,例如这里递归下一个处理的数据是左子树的节点,因此对左子树进行递归,然后处理右子树,这里就继续对右子树进行递归!! 因此这里所说的单层递归,本质上就是一次数据处理过程+后面需要处理的数据的逻辑,也就是后面需要处哪些数据的递归安排,其实就是在此调用这个递归数据处理方法而已,只不过递归的顺序需要按照问题需要来,例如这里的前序遍历,因此对于读入的根需要先打印存储到结果中,然后再对左右子树的数据进行打印。!!!)

因此,代码如下

vec.push_back(cur->val);    // 中
traversal(cur->left, vec);  // 左
traversal(cur->right, vec); // 右

到这,我仍然看不懂,原因在于,我不知道TreeNode* cur的意义,以及vector &vec的意义,这就是代码随想录这一部分的败笔,读者很难理解!!!

然而,当读者继续往下读,读到整体代码的时候,就会恍然大悟:

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; //vector就是我们的遍历结果存储向量
        traversal(root, result);//遍历需要输入树的根节点
        return result;
    }
};

vector就是我们的遍历结果存储向量;遍历需要根据树逐步往下走,因此需要传入树根,并根据树根往下走,因此需要传入TreeNode* cur这参数。

因此我们写中序和后序遍历的逻辑就有了:

  1. **递归函数的参数和返回值:一次递归,即一个数据处理单元包括传入树和传入存储列表的向量,不需要返回值,做数据处理即可。
  2. **终止条件:遇到空间点,就立刻返回
  3. 单层递归的逻辑:找到合适位置读节点数据,安排好后面的递归数据流。

中序遍历

void traversal(TreeNode *cur, vector<int>& vec){
	if(cur==NULL) return;
	traversal(cur->left, vec);
	vec.posh_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);
}

二叉树的非递归遍历(迭代遍历)

迭代的概念:百度百科
迭代是重复反馈过程的活动,其目的通常是为了逼近所需目标或结果。每一次对过程的重复称为一次“迭代”,而每一次迭代得到的结果会作为下一次迭代的初始值。
在计算机中,我们通常采用循环结构程序设计即可实现这一迭代的过程。(另外两种:选择结构程序设计、用函数实现的模块化程序设计 《谭浩强C程序设计》)

我们在栈和队列部分知道,递归的实现就是:每一次递归调用都会把函数的局部变量、参数值和返回地址等压入栈中,(也可以理解为,一旦遇到递归就把当前这个递归的当前执行状态压入栈中)等到递归返回的时候,从栈顶弹出上一次递归的各项参数,所以这就是递归为什么可以返回上一层位置的原因。(也可以理解为,当递归返回的时候,就从栈中依次去取之前入栈的递归状态,然后执行即可,再遇到递归再用同样的操作)

因此,我们用栈也可以实现二叉树的遍历了,本质上就是用栈模拟递归的过程。这个过程,我们称之为迭代。

前(先)序遍历的迭代写法

class Solution {
public:
    vector<int> preorderTraversal(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->right) st.push(node->right);// 右(空节点不入栈)           
            if (node->left) st.push(node->left);// 左(空节点不入栈)             
        }
        return result;
    }
};

此时会发现貌似使用迭代法写出前序遍历并不难,确实不难。

此时是不是想改一点前序遍历代码顺序就把中序遍历搞出来了?

其实还真不行!

但接下来,再用迭代法写中序遍历的时候,会发现套路又不一样了,目前的前序遍历的逻辑无法直接应用到中序遍历上。

中序遍历(迭代法代码写法)

class Solution{
public:
	vector<int> inorderTraversal(TreeNode* root){
		vector<int> result;
		stack<TreeNode*> st;
		TreeNode* cur = root;
		while(cur!=NULL||!st.empty()){
		//只要当前的指针不为空或者栈不空
		if(cur!=NLL){//如果指针不空
			st.push(cur);//访问节点入栈
			cur=cur->left;//继续往左走
			
		}else{
			//当往左走空了,开始出栈
			cur = st.top();
			st.pop();
			result.push_back(cur->val);
			cur = cur->right; //往右走
		}
		}
		return result;
	}
	
};

后序遍历

class Solution{
public:
   vector<int> postorderTraversal(TreeNode* root){
   	stack<TreeNode*> st;
   	vector<int> result;
   	//注意,当root为空时要返回;
   	if (root==NULL) return;
   	st.push(root);
   	while(!st.empty()){
   			TreeNode* node = st.top();
   			st.pop();
   			result.push_back(node->val);
   			if (node->left!=NULL) st.push(node->left);
   			if (node->right!=NULL) st.push(node->right);
   		}
   	reverse(result.begin(),result.end());
   	return result;

   }
}

二叉树的迭代遍历(前中后序统一格式)

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;
    }
};

二叉树层序遍历

两套代码(背过即可)

迭代实现


递归实现

二叉树递归中带着回溯

《代码随想录》算法视频公开课:递归中带着回溯,你感受到了没?| LeetCode:257. 二叉树的所有路径 (opens new window),相信结合视频在看本篇题解,更有助于大家对本题的理解。
回溯(也可以称之为回退)
回溯函数其实就是说的递归函数,因为没有一个单独的回溯函数的实现,回溯是蕴含于递归之中的概念。

如何理解回溯法:回溯就是一个递归的过程,递归一定是有终止的,回溯法要解决的问题, 都可以抽象为1个n叉树,这棵树的宽度就是我们要处理的问题的集合的大小,我们用for循环来遍历;这棵树的深度就是递归的深度,终止后一层一层往上反。

在这里插入图片描述

一般来说回溯法中递归函数递归函数都是没有返回值的,就是void,这些递归函数的起名一般为backtracking,当然了大家可以有自己的习惯,但是一般业界都这样叫,回溯法的参数一般情况下是比较多的,因此不太方便在一开始的时候就把参数定下来,我们可以在写逻辑部分的时候,用到什么,就在这里添加参数就可以了。接着我们就要进行一个终止条件,因为递归一定是要有终止的, 在到终止条件的时候,一般情况下就到了我们收集结果的时候了。大多数问题(除了子集问题)都是在叶子结点上收集结果,只有子集问题是在每一个节点都要去收集结果。【这里不好理解,需要结合具体问题】。终止条件要收集结果,通常在叶子结点收集结果,那收集什么结果呢? 例如组合问题:组合这些结果就要放到结果集里,记住不要忘记return. 处理完终止条件之后,就进入了单层搜索的逻辑。
单层搜索的逻辑,一般情况下是一个for循环,这个for循环的参数适用来处理集合里的每一个元素,通常for循环里面放的是集合里的每一个元素,for循环遍历的也就是集合里的每一个元素,也可以对应所有子节点的个数(处理节点的个数),处理节点是处理什么节点呢? 例如组合问题:就是在for循环的处理节点过程中把1,2放到一个数组里。 以至于在终止条件里面,收集结果的时候才会把1,2放进结果集里。处理节点下面就是递归,递归函数,递归的过程。也就是树形图里一层层往下走,递归的下面就是回溯操作。回溯操作就是撤销处理节点的操作。回溯操作的意义就是撤销:因为对于组合问题,如果1,2,3,4选2;1,2有了,只有2撤销,再放进3,才会有1,3

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值