深度阅读:二叉树操作详解
【导读】:树是数据结构中的重中之重,尤其以各类二叉树为学习的难点。在面试环节中,二叉树也是必考的模块。本文主要讲二叉树操作的相关知识,梳理面试常考的内容。请大家跟随小编一起来复习吧。
本篇针对面试中常见的二叉树操作作个总结:
-
前序遍历,中序遍历,后序遍历;
-
层次遍历;
-
求树的结点数;
-
求树的叶子数;
-
求树的深度;
-
求二叉树第k层的结点个数;
-
判断两棵二叉树是否结构相同;
-
求二叉树的镜像;
-
求两个结点的最低公共祖先结点;
-
求任意两结点距离;
-
找出二叉树中某个结点的所有祖先结点;
-
不使用递归和栈遍历二叉树;
-
二叉树前序中序推后序;
-
判断二叉树是不是完全二叉树;
-
判断是否是二叉查找树的后序遍历结果;
-
给定一个二叉查找树中的结点,找出在中序遍历下它的后继和前驱;
-
二分查找树转化为排序的循环双链表;
-
有序链表转化为平衡的二分查找树;
-
判断是否是二叉查找树。
小编推荐一个学C语言/C++的学习裙【 712,284,705】,无论你是大牛还是小白,是想转行还是想入行都可以来了解一起进步一起学习!裙内有开发工具,很多干货和技术资料分享!
1 前序遍历,中序遍历,后序遍历;
1.1 前序遍历
对于当前结点,先输出该结点,然后输出它的左孩子,最后输出它的右孩子。以上图为例,递归的过程如下:
-
输出 1,接着左孩子;
-
输出 2,接着左孩子;
-
输出 4,左孩子为空,再接着右孩子;
-
输出 6,左孩子为空,再接着右孩子;
-
输出 7,左右孩子都为空,此时 2 的左子树全部输出,2 的右子树为空,此时 1 的左子树全部输出,接着 1 的右子树;
-
输出 3,接着左孩子;
-
输出 5,左右孩子为空,此时 3 的左子树全部输出,3 的右子树为空,至此 1 的右子树全部输出,结束。
而非递归版本只是利用 stack 模拟上述过程而已,递归的过程也就是出入栈的过程。
1.2 中序遍历
对于当前结点,先输出它的左孩子,然后输出该结点,最后输出它的右孩子。以(1.1)图为例:
-
1-->2-->4,4 的左孩子为空,输出 4,接着右孩子;
-
6 的左孩子为空,输出 6,接着右孩子;
-
7 的左孩子为空,输出 7,右孩子也为空,此时 2 的左子树全部输出,输出 2,2 的右孩子为空,此时 1 的左子树全部输出,输出 1,接着 1 的右孩子;
-
3-->5,5 左孩子为空,输出 5,右孩子也为空,此时 3 的左子树全部输出,而 3 的右孩子为空,至此 1 的右子树全部输出,结束。
1.3 后序遍历
对于当前结点,先输出它的左孩子,然后输出它的右孩子,最后输出该结点。依旧以(1.1)图为例:
-
1->2->4->6->7,7 无左孩子,也无右孩子,输出 7,此时 6 无左孩子,而 6 的右子树也全部输出,输出 6,此时 4 无左子树,而 4 的右子树已全部输出,接着输出 4,此时 2 的左子树全部输出,且 2 无右子树,输出 2,此时 1 的左子树全部输出,接着转向右子树;
-
3->5,5 无左孩子,也无右孩子,输出 5,此时 3 的左子树全部输出,且 3 无右孩子,输出 3,此时 1 的右子树全部输出,输出 1,结束。
非递归版本中,对于一个结点,如果我们要输出它,只有它既没有左孩子也没有右孩子或者它有孩子但是它的孩子已经被输出(由此设置 pre 变量)。若非上述两种情况,则将该结点的右孩子和左孩子依次入栈,这样就保证了每次取栈顶元素的时候,先依次遍历左子树和右子树。
2 层次遍历
3 求树的结点数
4 求树的叶子数
5 求树的深度
6 求二叉树第k层的结点个数
7 判断两棵二叉树是否结构相同
不考虑数据内容。结构相同意味着对应的左子树和对应的右子树都结构相同。
bool StructureCmp(Node * node1, Node * node2)
{
if (node1 == nullptr && node2 == nullptr)
return true;
else if (node1 == nullptr || node2 == nullptr)
return false;
return StructureCmp(node1->left, node2->left) && Str1uctureCmp(node1->right, node2->right);
}
8 求二叉树的镜像
对于每个结点,我们交换它的左右孩子即可。
9 求两个结点的最低公共祖先结点
最低公共祖先,即 LCA(Lowest Common Ancestor),见下图:
结点 3 和结点 4 的最近公共祖先是结点 2,即 LCA(3,4)=2。在此,需要注意到当两个结点在同一棵子树上的情况,如结点 3 和结点 2 的最近公共祖先为 2,即 LCA(3,2)=2。同理 LCA(5,6)=4,LCA(6,10)=1。
10 求任意两结点距离
11 找出二叉树中某个结点的所有祖先结点
如果给定结点 5,则其所有祖先结点为 4,2,1。
12 不使用递归和栈遍历二叉树
1968 年,高德纳(Donald Knuth)提出一个问题:是否存在一个算法,它不使用栈也不破坏二叉树结构,但是可以完成对二叉树的遍历?随后 1979 年,James H. Morris 提出了二叉树线索化,解决了这个问题。(根据这个概念我们又提出了一个新的数据结构,即线索二叉树,因线索二叉树不是本文要介绍的内容,所以有兴趣的朋友请移步线索二叉树)
前序,中序,后序遍历,不管是递归版本还是非递归版本,都用到了一个数据结构--栈,为何要用栈?那是因为其它的方式没法记录当前结点的 parent,而如果在每个结点的结构里面加个 parent 分量显然是不现实的,而线索化正好解决了这个问题,其含义就是利用结点的右孩子空指针,指向该结点在中序序列中的后继。下面具体来看看如何使用线索化来完成对二叉树的遍历。
-
如果当前结点的左孩子为空,则输出当前结点并将其右孩子作为当前结点;
-
如果当前结点的左孩子不为空,在当前结点的左子树中找到当前结点在中序遍历下的前驱结点;
-
2.1如果前驱结点的右孩子为空,将它的右孩子设置为当前结点,输出当前结点并把当前结点更新为当前结点的左孩子;
-
2.2如果前驱结点的右孩子为当前结点,将它的右孩子重新设为空,当前结点更新为当前结点的右孩子;
-
重复以上步骤 1 和 2,直到当前结点为空。
再来看中序遍历,和前序遍历相比只改动一句代码,步骤如下:
-
如果当前结点的左孩子为空,则输出当前结点并将其右孩子作为当前结点;
-
如果当前结点的左孩子不为空,在当前结点的左子树中找到当前结点在中序遍历下的前驱结点;
-
2.1. 如果前驱结点的右孩子为空,将它的右孩子设置为当前结点,当前结点更新为当前结点的左孩子;
-
2.2. 如果前驱结点的右孩子为当前结点,将它的右孩子重新设为空,输出当前结点,当前结点更新为当前结点的右孩子;
-
重复以上步骤 1 和 2,直到当前结点为空。
最后看下后序遍历,后序遍历有点复杂,需要建立一个虚假根结点 dummy,令其左孩子是 root。并且还需要一个子过程,就是倒序输出某两个结点之间路径上的各个结点。步骤如下:
-
如果当前结点的左孩子为空,则将其右孩子作为当前结点;
-
如果当前结点的左孩子不为空,在当前结点的左子树中找到当前结点在中序遍历下的前驱结点;
-
2.1. 如果前驱结点的右孩子为空,将它的右孩子设置为当前结点,当前结点更新为当前结点的左孩子;
-
2.2. 如果前驱结点的右孩子为当前结点,将它的右孩子重新设为空,倒序输出从当前结点的左孩子到该前驱结点这条路径上的所有结点,当前结点更新为当前结点的右孩子;
-
重复以上步骤 1 和 2,直到当前结点为空。
dummy 用的非常巧妙,建议读者配合上面的图模拟下算法流程。
13 二叉树前序中序推后序
以上面图表为例,步骤如下:
-
根据前序可知根结点为1;
-
根据中序可知 4 7 2 为根结点 1 的左子树和 8 5 9 3 6 为根结点 1 的右子树;
-
递归实现,把 4 7 2 当做新的一棵树和 8 5 9 3 6 也当做新的一棵树;
-
在递归的过程中输出后序。
当然我们也可以根据前序和中序构造出二叉树,进而求出后序。
14 判断二叉树是不是完全二叉树
若设二叉树的深度为 h,除第 h 层外,其它各层 (1~h-1) 的结点数都达到最大个数,第 h 层所有的结点都连续集中在最左边,这就是完全二叉树(Complete Binary Tree)。如下图:
首先若一个结点只有右孩子,肯定不是完全二叉树;其次若只有左孩子或没有孩子,那么接下来的所有结点肯定都没有孩子,否则就不是完全二叉树,因此设置 flag 标记变量。
15 判断是否是二叉查找树的后序遍历结果
在后续遍历得到的序列中,最后一个元素为树的根结点。从头开始扫描这个序列,比根结点小的元素都应该位于序列的左半部分;从第一个大于跟结点开始到跟结点前面的一个元素为止,所有元素都应该大于跟结点,因为这部分元素对应的是树的右子树。根据这样的划分,把序列划分为左右两部分,我们递归地确认序列的左、右两部分是不是都是二元查找树。
16 给定一个二叉查找树中的结点(存在一个指向父亲结点的指针),找出在中序遍历下它的后继和前驱
一棵二叉查找树的中序遍历序列,正好是升序序列。假如根结点的父结点为 nullptr,则:
-
如果当前结点有右孩子,则后继结点为这个右孩子的最左孩子;
-
如果当前结点没有右孩子;
-
2.1. 当前结点为根结点,返回 nullptr;
-
2.2. 当前结点只是个普通结点,也就是存在父结点;
-
2.2.1. 当前结点是父亲结点的左孩子,则父亲结点就是后继结点;
-
2.2.2. 当前结点是父亲结点的右孩子,沿着父亲结点往上走,直到 n-1 代祖先是 n 代祖先的左孩子,则后继为 n 代祖先或遍历到根结点也没找到符合的,则当前结点就是中序遍历的最后一个结点,返回 nullptr。
仔细观察上述代码,总觉得有点啰嗦。比如,过多的 return,步骤 2 的层次太多。综合考虑所有情况,改进代码如下:
上述的代码是基于结点有 parent 指针的,若题意要求没有 parent 呢?网上也有人给出了答案,个人觉得没有什么价值,有兴趣的朋友可以到这里查看。
而求前驱结点的话,只需把上述代码的 left 与 right 互调即可,很简单。
17 二分查找树转化为排序的循环双链表
二分查找树的中序遍历即为升序排列,问题就在于如何在遍历的时候更改指针的指向。一种简单的方法时,遍历二分查找树,将遍历的结果放在一个数组中,之后再把该数组转化为双链表。如果题目要求只能使用 O(1)O(1) 内存,则只能在遍历的同时构建双链表,即进行指针的替换。
我们需要用递归的方法来解决,假定每个递归调用都会返回构建好的双链表,可把问题分解为左右两个子树。由于左右子树都已经是有序的,当前结点作为中间的一个结点,把左右子树得到的链表连接起来即可。
18 有序链表转化为平衡的二分查找树(Binary Search Tree)
我们可以采用自顶向下的方法。先找到中间结点作为根结点,然后递归左右两部分。所以我们需要先找到中间结点,对于单链表来说,必须要遍历一边,可以使用快慢指针加快查找速度。
由 f(n)=2f(\frac n2)+\frac n2f(n)=2f(2n)+2n 得,所以上述算法的时间复杂度为 O(nlogn)O(nlogn)。
不妨换个思路,采用自底向上的方法:
如此,时间复杂度降为 O(n)O(n)。
19 判断是否是二叉查找树
我们假定二叉树没有重复元素,即对于每个结点,其左右孩子都是严格的小于和大于。
下面给出两个方法:
方法 1:
方法 2:
利用二叉查找树中序遍历时元素递增来判断。