树
先通过几张图来看看,什么是树,这些"树"都有哪些特征。
"树"这种结构就像我们生活中见到的"树", “树”中的每个元素称之为"节点",用连线来表示节点间的"父子关系"。
比如上面的图1,A节点就是B节点的"父节点",B节点就是A节点的"子节点"。B、C的父节点是同一个节点,所以他们是"兄弟节点"。我们把没有父节点的节点叫做"根节点",图中的根节点就是节点A。把没有子节点的节点叫做"叶子节点",这里的是E、F、G都是叶子节点。
除了了解树的基本特征之外,还有三个概念需要了解一下:高度、深度、层。
- 节点的高度=节点到叶子节点的最长路径
- 节点的深度=根节点到这个节点经历的边的个数
- 节点的层数=节点的深度 + 1
- 树的高度 = 根节点的高度
文字描述起来挺不容易理解的,通过图示来说明,会更直观明了。
关于高度、深度、层的,其实可以类比生活中的含义。
"高度"是从下往上度量的,比如建筑物的高度,是从地面开始度量的。这里树的高度类似,从最底层的叶子节点开始计数,计数起点是0。
"深度"是从上往下度量的,比如水井的深度,是从上开始度量的。这里树的深度也是类似,从最上层的根节点开始计数,计数起点是0。
"层"就是节点的深度加1,计数起点是1,根节点位于第1层。
二叉树
"树"有多种多样的,不过最常用的还是二叉树。
二叉树,就是每个节点最多只有两个子节点,分别是左子节点和右子节点。二叉树不要求每个节点都有左右子节点,有的节点只有左子节点,有的节点只有右子节点。
这里有两个比较特殊的二叉树,分别是图2的满二叉树,图3的完全二叉树。
满二叉树:叶子节点都在最底层,除了最底层的叶子节点之外,其他的每个节点都有左右子节点。
完全二叉树:叶子节点在最底下两层,除了最底层,其他层节点个数都是满的,最底层的节点都靠左排列。
二叉树的存储
二叉树有两种存储方式,一种是基于指针的链式存储法,还有一种是基于数组的顺序存储法。
首先看下最常用的“链式存储”,每个节点包含三个字段,分别是:数据、左节点指针、右节点指针。只要根据根节点,就可以通过每个节点的左右指针就能将整棵树进行遍历。
class Node{
Node left;
Node right;
T data;
}
再来看一下“数组存储”,这种存储方式一般应用于完全二叉树。为了方便计算,元素存储从数组下标1的位置开始,我么把根节点放置在数组下标为1的位置。对于下标为i的节点, 其左子节点的下标就是 i2,右子节点的下标为 i2 + 1,其父节点的数组下标为i / 2。通过下标,可以将整棵树进行遍历。采用“数组存储”只会浪费一个下标为0的位置,如果是非完全二叉树,那么会浪费比较多的数组存储空间。
二叉树的遍历
二叉树遍历方法比较经典的有三种,前序遍历、中序遍历、后续遍历。这里的前、中、后序指的是节点与它的左右子树遍历打印的先后顺序。
前序遍历:对于树中的任意节点来说,先打印这个节点,再打印它的左子树,最后打印它的右子树。
中序遍历:对于树中的任意节点来说,先打印它的左子树,再打印这个节点,最后打印它的右子树。
后续遍历:对于树中的任意节点来说,先打印它的左子树,再打印它的右子树,最后打印这个节点。
二叉树的前、中、后序遍历一般都是用递归来实现的。递归代码的关键,是写出递推公式。要解决问题A,先假设子问题B、C已经解决,然后再看如何利用B、C来解决A,递归终止条件就是出口。
public void preOrder(Node root){
if(root == null){
return;
}
print(root.data);
preOrder(root.left);
preOrder(root.right);
}
public void inOrder(Node root){
if(root == null){
return;
}
preOrder(root.left);
print(root.data);
preOrder(root.right);
}
public void postOrder(Node root){
if(root == null){
return;
}
preOrder(root.left);
preOrder(root.right);
print(root.data);
}
二叉树的遍历除了上面三种经典的方式外,还有一种是层序遍历。所谓层序遍历,就是利用"队列"这种数据结构实现,从第1层开始一直到最后一层通过顺序遍历每一层的所有节点来完成树的遍历。二叉树的层序遍历,与图的广度优先遍历的思路是如出一辙。
二叉树遍历的时间复杂度
在递归遍历二叉树的过程中,每个节点最多被访问两次。所以遍历的时间复杂度与节点个数n成正比,也就是说二叉树遍历的时间复杂度为O(n)。
GitHub 代码地址: 二叉树