二叉树及存储结构
摘要:二叉树的定义,遍历二叉树
1. 二叉树的定义
二叉树:一个有穷的结点集合。这个集合可以为空,若不为空,则它是由根结点和称为其左子树 TL 和右子树 TR 的两个不想交的二叉树组成。
二叉树具体五种基本形态
二叉树的子树有左右顺序之分
特殊二叉树
斜二叉树(Skewed Binary Tree)
完美二叉树(Perfect Binary Tree),又称为满二叉树(Full Binary Tree)
一棵完美二叉树所有的结点都有左右两个子结点。
完全二叉树(Complete Binary Tree)
完全二叉树的最后一层可以却是部分结点,例如右半部分缺失了12-15号结点,但是这样的二叉树不是完全二叉树:
2. 二叉树的几个重要性质
一个二叉树第 i 层的最大结点数为:$2^{i-1},i\geq1$
深度为 K 的二叉树有最大结点总数为:$2^{k}-1, k\geq1$
对任何非空的二叉树 T, 若 $n_0$ 表示叶结点的个数,$n_2$ 是度为2的非叶结点个数,那么两者满足关系 $n_0=n_2+1$
这个关系很容易可以推导出来,从边的角度出发,我们知道每个二叉树的边是确定的,从最后一层往上看,除根结点外,每个结点都有一条边和上一个结点连接,总的边数为: $n_0+n_1+n_2-1$;从根结点往下,度为2的结点对有两条边跟其他结点连接,度为1的结点为一条,叶结点为0条,即:$2n_2+n_1+0n_0$。 这两个式子相等: $$ n_0+n_1+n_2-1=2n_2+n_1 $$ 化简后就可以得到上面那个式子。
3. 二叉树的抽象数据类型定义
二叉树的结构由数据,左子树,右子树组成,操作集包括:判空,遍历,创建一个二叉树。
/** * 二叉树树节点定义 * @param <T> */ public class TreeNode<T> { public T data; //节点数据 public TreeNode<T> left; //指向左子树 public TreeNode<T> right; //指向右子树 public TreeNode(T data) { this.data = data; } public TreeNode(T data, TreeNode<T> left, TreeNode<T> right) { this.data = data; this.left = left; this.right = right; } } public interface Tree<T> { /** * 判别二叉树是否为空 * @param treeNode * @return */ boolean isEmpty(TreeNode<T> treeNode); /** * 遍历,按某顺序访问每个结点 * 遍历方法有四种: * 1. 先序遍历:preOrderTraversal * 2. 中序遍历:inOrderTraversal * 3. 后序遍历:postOrderTraversal * 4. 层次遍历:levelOrderTraversal * @param treeNode */ void traversal(TreeNode<T> treeNode); /** * 创建一个二叉树 * @return */ TreeNode<T> createBinTree(); }
其中,最重要的就是二叉树的遍历,包括先序,中序,后序和层次遍历四种,后面我们会详细讲一下。
4. 二叉树的存储结构
4.1 顺序存储结构
二叉树可以采用顺序存储结构来存储,对于一棵完全二叉树来说,按照从上到下,从左到右的顺序存储 n 个结点的完全二叉树的结点父子关系:
- 非根结点的父结点的序号是 $i/2$ 向下取整的值
- 结点的左孩子结点的序号是 $2i$,(若$2i\leq n$,否则没有左孩子 )
- 结点的右孩子的序号为$2i+1$,(若 $2i+1 \leq n$, 否则没有右孩子)
一般的二叉树也可以采用这种结构,但是需要补充空的结点,会造成空间浪费。
4.2. 链表存储
链表的存储结构,我们在前面已经展示过,这里不再过多赘述。
5. 二叉树的遍历
二叉树的遍历方式一共有四种,分别是:先序遍历,中序遍历,后序遍历和层序遍历。其中先序,中序和后序遍历的实现方式又有两种,分别是递归和非递归。下面,我们就来详细的介绍一下:
5.1 递归实现
5.1.1 先序遍历
先序遍历的过程可以描述为:
- 访问根结点
- 先序遍历其左子树
- 先序遍历其右子树
例如,我们有这样一棵树:
按照先序遍历的顺序将结点打印出来,依此是:A B D F E C G H I,程序描述为:
public void preOrderTraversal(TreeNode<T> binTree) {
if (binTree != null) {
System.out.println(binTree.data);
preOrderTraversal(binTree.left);
preOrderTraversal(binTree.right);
}
}
5.1.2 中序遍历
中序遍历的过程可以描述为:
- 中序遍历其左子树
- 访问根结点
- 中序遍历其右子树
按照中序遍历的方式将上面这棵树的结果输出,依次是:D B E F A G H C I ,使用程序描述为:
public void inOrderTraversal(TreeNode<T> binTree) {
if (binTree != null) {
inOrderTraversal(binTree.left);
System.out.println(binTree.data);
inOrderTraversal(binTree.right);
}
}
5.1.3 后序遍历
后序遍历的过程为:
- 后序遍历其左子树
- 后序遍历其右子树
- 访问根结点
按照后序遍历的方式将上面这棵树的结点输出,依此是:D E F B H G I C A
5.2 非递归实现
递归的本质是利用堆栈来做的,那么我们直接使用堆栈来实现上面的三种方式。
先从中序遍历开始,中序遍历的非递归实现过程可以描述为以下几个步骤:
遇到一个结点,就把它压栈,并去遍历它的左子树;
当左子树遍历结束后,从栈顶弹出这个结点并访问它;
然后按其右指针再去中序遍历该结点的右子树。
对于上面的这样一棵树,我们按照中序遍历的过程操作堆栈:
入栈和出栈的过程如上图所示。
public void nonRecursiveInOrderTraversal(TreeNode<T> binTree) {
TreeNode<T> tmpTree = binTree;
Stack<TreeNode<T>> stack = new Stack<>();
while ( tmpTree != null || !stack.isEmpty(stack)) {
// 一直向左并将沿途结点压入堆栈
while (tmpTree != null) {
stack.push(stack, tmpTree);
tmpTree = tmpTree.left;
}
if (!stack.isEmpty(stack)) {
// 结点弹出堆栈
tmpTree = stack.pop(stack);
// 访问结点
System.out.print(tmpTree.data + " ");
// 转向右子树
tmpTree = tmpTree.right;
}
}
}
先序遍历的过程跟中序遍历类似,只需要在第一次遍历到结点的时候把结点的值打印出来即可。
public void nonRecursivePreOrderTraversal(TreeNode<T> binTree) {
TreeNode<T> tmpTree = binTree;
Stack<TreeNode<T>> stack = new Stack<>();
while ( tmpTree != null || !stack.isEmpty(stack)) {
// 一直向左并将沿途结点压入堆栈
while (tmpTree != null) {
// 访问结点
System.out.print(tmpTree.data + " ");
stack.push(stack, tmpTree);
tmpTree = tmpTree.left;
}
if (!stack.isEmpty(stack)) {
// 结点弹出堆栈
tmpTree = stack.pop(stack);
// 转向右子树
tmpTree = tmpTree.right;
}
}
}
后序遍历的方式略有不同, 后序遍历应该把数据两次压入堆栈,第二次pop出来再 print 鉴于没有记录访问次数的结构,第二次pop的时候要么右节点是空的,要么右节点刚刚被print。所以,需要另一个指针pt来记录被刚刚print的节点。
public void nonRecursivepostOrderTraversal(TreeNode<T> binTree) {
TreeNode<T> tmpTree = binTree;
TreeNode<T> pt = null;
Stack<TreeNode<T>> stack = new Stack<>();
while ( tmpTree != null || !stack.isEmpty(stack)) {
while (tmpTree != null) {
stack.push(stack, tmpTree);
tmpTree = tmpTree.left;
}
if (!stack.isEmpty(stack)) {
// 结点弹出堆栈
tmpTree = stack.pop(stack);
if ((tmpTree.right== null)||(tmpTree.right == pt)) {//判断右节点为空或者右节点已经输出
System.out.print(tmpTree.data + " ");
pt = tmpTree; //记录下上一个被输出的
tmpTree = null;
} else {
stack.push(stack, tmpTree); //第二次入栈(相当于T没有出栈)
tmpTree = tmpTree.right; //转向右子树
}
}
}
}
5.3 层序遍历
层序遍历即将树从上到下,从左到右输出,例如下面这样的一棵树,按照层序遍历输出的结果为:A B C D F G I E H.
层序遍历可以通过队列来实现,遍历从根结点开始,首先将根结点入队,然后开始执行循环:结点入队,访问该结点、其左右儿子入队。
层序基本过程:先根结点入队,然后:
- 从队列中取出一个元素;
- 访问该元素所指的结点;
- 若该元素所指结点的左、右孩子结点非空,则将其左、右孩子的指针顺序入队。
public void levelOrderTraveral(TreeNode<T> binTree) {
SeqQueue<TreeNode<T>> queue = new SeqQueue<>(20);
TreeNode<T> t;
// 若是空树则直接返回
if (binTree == null) {
return;
}
queue.add(binTree);
while (!queue.isEmpty()) {
t = queue.delete();
System.out.print(t.data + " ");//访问取出队列中的结点
if (t.left != null) {
queue.add(t.left);//左结点不为空,则左节点入队
}
if (t.right != null) {
queue.add(t.right); //右节点不为空,则右节点入队
}
}
}
6. 二叉树应用的例子
例:输出二叉树中的叶子结点
在二叉树的遍历算法中增加检测结点的判断:左右子树是否都为空
public void preOrderPrintLeaves(TreeNode<T> binTree) {
if (binTree != null) {
//叶子结点的左右都为空
if (binTree.left == null && binTree.right == null) {
System.out.print(binTree.data + " ");
}
preOrderPrintLeaves(binTree.left);
preOrderPrintLeaves(binTree.right);
}
}
求二叉树的高度
二叉树的高度是左子树和右子树两者中最大的一个再加上根结点的高度1.
public int postOrderGetHight(TreeNode<T> binTree) {
int hl, hr, maxH;
if (binTree != null) {
hl = postOrderGetHight(binTree.left); //求左子树的高度
hr = postOrderGetHight(binTree.right); //求右子树的高度
maxH = Math.max(hl, hr); //取左右子树较大的深度
return maxH+1;
} else {
return 0;
}
}
由两种遍历序列确定二叉树,已知三种遍历中的任意两种遍历序列,能否唯一确定一棵二叉树呢?
答案是:必须要有中序遍历才行。
假如没有中序遍历,来看个例子:先序:A B,后序:B A,就会出现两种不同的结构。
在知道中序的情况下,可以利用中序序列分割出左右两个子序列。
参考:浙江大学陈越老师的数据结构课程