通过前面的【数据结构和算法】系列,我们已经学习了数据结构中常用的线性结构。从物理存储方面来说,它们又分为顺序存储和链式存储结构。他们各自有自己的优缺点,顺序存储结构读快写慢,链式存储结构写快读慢。但是这些数据元素之间的关系都为一对一的关系,而我们生活中关系不止是一对一,有可能是一对多,多对多的情况… 本篇博客,我们就要学习一种新的数据结构——树,它将为我们展示一个全新的“世界”。
树
1、什么是树?
初学数据结构的朋友可能会感到疑惑,到底什么是树呢?其实在现实生活中有很多体现树逻辑的例子。
最好的一个例子就是“家谱图”,这就是一个“树”。
例如企业里的职级关系,也是一个“树”
类似的例子还有很多,这里就不一一列举了。不知道细心的朋友们,是否发现以上这些例子有什么共同点呢?为什么可以称它们为“树”呢?
因为它们都像自然界中的树一样,从同一个“根”衍生出许多“枝干”,再从每一个“枝 干”衍生出许多更小的“枝干”,最后衍生出更多的“叶子”。
在数据结构中,树的定义如下。
树(tree)是n(n≥0)个节点的有限集。当n=0时,称为空树。在任意一个非空树中,有如下特点。
1.有且仅有一个特定的称为根的节点。
2.当n>1时,其余节点可分为m(m>0)个互不相交的有限集,每一个集合本身又是一 个树,并称为根的子树。
下面这张图,就是一个标准的树结构。
在上图中,节点1是根节点(root);节点5、6、7、8、9是树的末端,没有“孩子”,被 称为叶子节点(leaf)。图中的虚线部分,是根节点1的其中一个子树。
同时,树的结构从根节点到叶子节点,分为不同的层级。从一个节点的角度来看,它的上下级和同级节点关系如下。
在上图中,节点4的上一级节点,是节点4的父节点(parent);从节点4衍生出来的 节点,是节点4的孩子节点(child);和节点4同级,由同一个父节点衍生出来的节点, 是节点4的兄弟节点(sibling)。树的最大层级数,被称为树的高度或深度。显然,上图这个树的高度是4。
了解了树的基本术语之后,我们来学习一种典型的树——二叉树。
2、二叉树
二叉树(binary tree)是树的一种特殊形式。二叉,顾名思义,这种树的每个节点最多有2个孩子节点。注意,这里是最多有2个,也可能只有1个,或者没有孩子节点。二叉树的结构如图所示。
二叉树节点的两个孩子节点,一个被称为左孩子(left child),一个被称为右孩子 (right child)。这两个孩子节点的顺序是固定的,就像人的左手就是左手,右手就是右手,不能够颠倒或混淆。此外,二叉树还有两种特殊形式,一个叫作满二叉树,另一个叫作完全二叉树。
2.1 满二叉树
什么是满二叉树呢?
一个二叉树的所有非叶子节点都存在左右孩子,并且所有叶子节点都在同一层级上, 那么这个树就是满二叉树。
简单点说,满二叉树的每一个分支都是满的。
2.2 完全二叉树
什么又是完全二叉树呢?完全二叉树的定义很有意思。
对一个有n个节点的二叉树,按层级顺序编号,则所有节点的编号为从1到n。如果这个树所有节点和同样深度的满二叉树的编号为从1到n的节点位置相同,则这个二叉树为完全二叉树。
如果觉得很绕的话,可以看看下面这个图。
在上图中,二叉树编号从1到12的12个节点,和前面满二叉树编号从1到12的节点位置完全对应。因此这个树是完全二叉树。完全二叉树的条件没有满二叉树那么苛刻:满二叉树要求所有分支都是满的;而完全二叉树只需保证最后一个节点之前的节点都齐全即可。
2.3 二叉树的存储
二叉树可以用链式存储结构和数组结构来表达。
2.3.1 链式存储结构
链式存储是二叉树最直观的存储方式。
之前介绍过的链表,是一对一的存储方式,每一个链表节点拥有data变量和一个指 向下一节点的next指针。而二叉树稍微复杂一些,一个节点最多可以指向左右两个孩子节点,所以二叉树的每 一个节点包含3部分。
- 存储数据的data变量
- 指向左孩子的left指针
- 指向右孩子的right指针
2.3.2 数组
使用数组存储时,会按照层级顺序把二叉树的节点放到数组中对应的位置上。如果某 一个节点的左孩子或右孩子空缺,则数组的相应位置也空出来。
为什么这样设计呢?因为这样可以更方便地在数组中定位二叉树的孩子节点和父节点。
假设一个父节点的下标是parent,那么它的左孩子节点下标就是2×parent + 1;右孩子节点下标就是2×parent + 2。
反过来,假设一个左孩子节点的下标是leftChild,那么它的父节点下标就是 (leftChild-1)/ 2。
假如节点4在数组中的下标是3,节点4是节点2的左孩子,节点2的下标可以直接通过计算得出。
显然,对于一个稀疏的二叉树来说,用数组表示法是非常浪费空间的。
那什么样的二叉树最适合用数组表示呢?
后边即将介绍的二叉堆,一种特殊的完全二叉树,就是用数组来存储的。
2.4 二叉树的应用
二叉树包含许多特殊的形式,每一种形式都有自己的作用,但是其最主要的应用还在于进行查找操作和维持相对顺序这两个方面。
2.4.1 查找
二叉树的树形结构使它很适合扮演索引的角色。
这里我们介绍一种特殊的二叉树:二叉查找树(binary search tree)。光看名字就可 以知道,这种二叉树的主要作用就是进行查找操作。
二叉查找树在二叉树的基础上增加了以下几个条件。
- 如果左子树不为空,则左子树上所有节点的值均小于根节点的值
- 如果右子树不为空,则右子树上所有节点的值均大于根节点的值
- 左、右子树也都是二叉查找树
下图就是一个标准的二叉查找树。
二叉查找树的这些条件有什么用呢?当然是为了查找方便。
例如查找值为4的节点,步骤如下。
- 访问根节点6,发现4<6。
- 访问节点6的左孩子节点3,发现4>3。
- 访问节点3的右孩子节点4,发现4=4,这正是要查找的节点。
对于一个节点分布相对均衡的二叉查找树来说,如果节点总数是n,那么搜索节点的时间复杂度就是O(logn),和树的深度是一样的**。
这种依靠比较大小来逐步查找的方式,和二分查找算法非常相似。
2.4.2 维持相对顺序
这一点仍然要从二叉查找树说起。二叉查找树要求左子树小于父节点,右子树大于父节点,正是这样保证了二叉树的有序性。
因此二叉查找树还有另一个名字——二叉排序树(binary sort tree)。
新插入的节点,同样要遵循二叉排序树的原则。例如插入新元素5,由于5<6,5>3, 5>4,所以5最终会插入到节点4的右孩子位置。
再如插入新元素10,由于10>6,10>8,10>9,所以10最终会插入到节点9的右孩子位置。
这一切看起来很顺利,然而却隐藏着一个致命的问题。什么问题呢?下面请试着在二 叉查找树中依次插入9、8、7、6、5、4,看看会出现什么结果。
不只是外观看起来变得怪异了,查询节点的时间复杂度也退化成 了O(n)。
怎么解决这个问题呢?这就涉及二叉树的自平衡了。二叉树自平衡的方式有多种, 如红黑树、AVL树、树堆等。
除二叉查找树以外,二叉堆也维持着相对的顺序。不过二叉堆的条件要宽松一些,只要求父节点比它的左右孩子都大。
3、二叉树的遍历
上面几节我们学习了二叉树的基础知识,接下来我们来探讨二叉树的遍历。
当我们介绍数组、链表时,为什么没有着重研究他们的遍历过程呢?
二叉树的遍历又有什么特殊之处?
在计算机程序中,遍历本身是一个线性操作。所以遍历同样具有线性结构的数组或链表,是一件轻而易举的事情。
反观二叉树,是典型的非线性数据结构,遍历时需要把非线性关联的节点转化成一个 线性的序列,以不同的方式来遍历,遍历出的序列顺序也不同。
从节点之间位置关系的角度来看,二叉树的遍历分为4种。
- 前序遍历。
- 中序遍历。
- 后序遍历。
- 层序遍历。
从更宏观的角度来看,二叉树的遍历归结为两大类。
- 深度优先遍历(前序遍历、中序遍历、后序遍历)
- 广度优先遍历(层序遍历)
3.1 深度优先遍历
深度优先和广度优先这两个概念不止局限于二叉树,它们更是一种抽象的算法思想, 决定了访问某些复杂数据结构的顺序。在访问树、图,或其他一些复杂数据结构时,这两 个概念常常被使用到。
所谓深度优先,顾名思义,就是偏向于纵深,“一头扎到底”的访问方式。可能这种说法有些抽象,下面就通过二叉树的前序遍历、中序遍历、后序遍历,来看一看深度优先是怎么回事吧。
3.1.1 前序遍历
二叉树的前序遍历,输出顺序是根节点、左子树、右子树。
上图就是一个二叉树的前序遍历,每个节点左侧的序号代表该节点的输出顺序,详细 步骤如下。
- 首先输出的是根节点1。
2. 由于根节点1存在左孩子,输出左孩子节点2。
3. 由于节点2也存在左孩子,输出左孩子节点4。
4. 节点4既没有左孩子,也没有右孩子,那么回到节点2,输出节点2的右孩子节点5。
5. 节点5既没有左孩子,也没有右孩子,那么回到节点1,输出节点1的右孩子节点3。
6. 节点3没有左孩子,但是有右孩子,因此输出节点3的右孩子节点6。
到此为止,所有的节点都遍历输出完毕。
3.1.2 中序遍历
二叉树的中序遍历,输出顺序是左子树、根节点、右子树。
上图就是一个二叉树的中序遍历,每个节点左侧的序号代表该节点的输出顺序,详细步骤如下。
- 首先访问根节点的左孩子,如果这个左孩子还拥有左孩子,则继续深入访问下去, 一直找到不再有左孩子的节点,并输出该节点。显然,第一个没有左孩子的节点是节点 4。
2. 依照中序遍历的次序,接下来输出节点4的父节点2。
3. 再输出节点2的右孩子节点5。
4. 以节点2为根的左子树已经输出完毕,这时再输出整个二叉树的根节点1。
5. 由于节点3没有左孩子,所以直接输出根节点1的右孩子节点3。
6. 最后输出节点3的右孩子节点6。
到此为止,所有的节点都遍历输出完毕。
3.1.3 后序遍历
二叉树的后序遍历,输出顺序是左子树、右子树、根节点。
上图就是一个二叉树的后序遍历,每个节点左侧的序号代表该节点的输出顺序。
由于二叉树的后序遍历和前序、中序遍历的思想大致相同,相信各位小伙伴已经可以 推测出分解步骤,这里就不再列举细节了。
下面展示不同遍历方式的代码书写。
3.1.4 递归实现三种遍历代码
public class BinaryTreeTraversal {
/**
* 构建二叉树
* @param inputList 输入序列
*/
public static TreeNode createBinaryTree(LinkedList<Integer> inputList){
TreeNode node = null;
if(inputList==null || inputList.isEmpty()){
return null;
}
Integer data = inputList.removeFirst();
//这里的判空很关键。如果元素是空,说明该节点不存在,跳出这一层递归;如果元素非空,继续递归构建该节点的左右孩子。
if(data != null){
node = new TreeNode(data);
node.leftChild = createBinaryTree(inputList);
node.rightChild = createBinaryTree(inputList);
}
return node;
}
/**
* 二叉树前序遍历
* @param node 二叉树节点
*/
public static void preOrderTraversal(TreeNode node){
if(node == null){
return;
}
System.out.println(node.data);
preOrderTraversal(node.leftChild);
preOrderTraversal(node.rightChild);
}
/**
* 二叉树中序遍历
* @param node 二叉树节点
*/
public static void inOrderTraversal(TreeNode node){
if(node == null){
return;
}
inOrderTraversal(node.leftChild);
System.out.println(node.data);
inOrderTraversal(node.rightChild);
}
/**
* 二叉树后序遍历
* @param node 二叉树节点
*/
public static void postOrderTraversal(TreeNode node){
if(node == null){
return;
}
postOrderTraversal(node.leftChild);
postOrderTraversal(node.rightChild);
System.out.println(node.data);
}
/**
* 二叉树节点
*/
private static class TreeNode {
int data;
TreeNode leftChild;
TreeNode rightChild;
TreeNode(int data) {
this.data = data;
}
}
public static void main(String[] args) {
LinkedList<Integer> inputList = new LinkedList<Integer>(Arrays.asList(3,2,9,null,null,10,null,null,8,null,4));
TreeNode treeNode = createBinaryTree(inputList);
System.out.println("前序遍历:");
preOrderTraversal(treeNode);
System.out.println("中序遍历:");
inOrderTraversal(treeNode);
System.out.println("后序遍历:");
postOrderTraversal(treeNode);
}
}
二叉树用递归方式来实现前序、中序、后序遍历,是最为自然的方式,因此代码也非 常简单。
这3种遍历方式的区别,仅仅是输出的执行位置不同:前序遍历的输出在前,中序遍
历的输出在中间,后序遍历的输出在最后。
代码中值得注意的一点是二叉树的构建。二叉树的构建方法有很多,这里把一个线性 的链表转化成非线性的二叉树,链表节点的顺序恰恰是二叉树前序遍历的顺序。链表中的 空值,代表二叉树节点的左孩子或右孩子为空的情况。
在代码的main函数中,通过{3,2,9,null,null,10,null,null,8,null,4}这样一个线性序列,构 建成的二叉树如下。
绝大多数可以用递归解决的问题,其实都可以用另一种数据结构来解决,这种数据结构就是栈。因为递归和栈都有回溯的特性。
如何借助栈来实现二叉树的非递归遍历呢?下面以二叉树的前序遍历为例,看一看具体过程。
- 首先遍历二叉树的根节点1,放入栈中。
- 遍历根节点1的左孩子节点2,放入栈中。
- 遍历节点2的左孩子节点4,放入栈中。
- 节点4既没有左孩子,也没有右孩子,我们需要回溯到上一个节点2。可是现在并不 是做递归操作,怎么回溯呢?
别担心,栈已经存储了刚才遍历的路径。让旧的栈顶元素4出栈,就可以重新访问节 点2,得到节点2的右孩子节点5。
此时节点2已经没有利用价值(已经访问过左孩子和右孩子),节点2出栈,节点5入栈。
- 节点5既没有左孩子,也没有右孩子,我们需要再次回溯,一直回溯到节点1。所以 让节点5出栈。
根节点1的右孩子是节点3,节点1出栈,节点3入栈。
- 节点3的右孩子是节点6,节点3出栈,节点6入栈。
- 节点6既没有左孩子,也没有右孩子,所以节点6出栈。此时栈为空,遍历结束。
3.1.5 非递归前序遍历的代码
/**
* 二叉树非递归前序遍历
* @param root 二叉树根节点
*/
public static void preOrderTraveralWithStack(TreeNode root){
Stack<TreeNode> stack = new Stack<TreeNode>();
TreeNode treeNode = root;
while(treeNode!=null || !stack.isEmpty()){
//迭代访问节点的左孩子,并入栈
while (treeNode != null){
System.out.println(treeNode.data);
stack.push(treeNode);
treeNode = treeNode.leftChild;
}
//如果节点没有左孩子,则弹出栈顶节点,访问节点右孩子
if(!stack.isEmpty()){
treeNode = stack.pop();
treeNode = treeNode.rightChild;
}
}
}
至于二叉树的中序、后序遍历的非递归实现,思路和前序遍历差不太多,都是利用栈 来进行回溯。各位读者要是有兴趣的话,可以自己尝试用代码实现一下。
3.2 广度优先遍历
3.2.1 流程
如果说深度优先遍历是在一个方向上“一头扎到底”,那么广度优先遍历则恰恰相反: 先在各个方向上各走出1步,再在各个方向上走出第2步、第3步……一直到各个方向全部走完。听起来有些抽象,下面让我们通过二叉树的层序遍历,来看一看广度优先是怎么回事。
层序遍历,顾名思义,就是二叉树按照从根节点到叶子节点的层次关系,一层一层横向遍历各个节点。
上图就是一个二叉树的层序遍历,每个节点左侧的序号代表该节点的输出顺序。
可是,二叉树同一层次的节点之间是没有直接关联的,如何实现这种层序遍历呢?
这里同样需要借助一个数据结构来辅助工作,这个数据结构就是队列。
详细遍历步骤如下。
- 根节点1进入队列。
- 节点1出队,输出节点1,并得到节点1的左孩子节点2、右孩子节点3。让节点2和节 点3入队。
3. 节点2出队,输出节点2,并得到节点2的左孩子节点4、右孩子节点5。让节点4和节 点5入队。
4. 节点4出队,输出节点4,由于节点4没有孩子节点,所以没有新节点入队。
5. 节点5出队,输出节点5,由于节点5同样没有孩子节点,所以没有新节点入队。
6. 节点6出队,输出节点6,节点6没有孩子节点,没有新节点入队。
到此为止,所有的节点都遍历输出完毕。
3.2.2 代码
//二叉树层序遍历
public static void levelOrderTraversal(TreeNode root){
Queue<TreeNode> queue = new LinkedList<TreeNode>();
queue.offer(root);
while(!queue.isEmpty()){
TreeNode node = queue.poll();
System.out.println(node.data);
if(node.leftChild != null){
queue.offer(node.leftChild);
}
if(node.rightChild != null){
queue.offer(node.rightChild);
}
}
}
因为篇幅问题,本该属于树范围内的知识——二叉堆和优先队列的内容,就先不在本篇博客中展示了。