数据结构和算法--二叉树的实现
几种二叉树
1、二叉树
和普通的树相比,二叉树有如下特点:
- 每个结点最多只有两棵子树,注意是最多。这意味着任意结点的度小于等于2。
- 子树有左右之分;某个结点只有一个孩子时,它位于左边和右边组成的是不同的树。如下图,左图是作为根结点的左孩子,右图是作为根结点的右孩子,这是两棵树,他们结构不同。
顺便一提,具有n个结点的二叉树,共有$h(n) = C(2n, n) - C(2n, n+1)$种形态,C即组合。像上图就是$h(2) = 2$。这个知识点是卡特兰数,类似的问题还有可能的进出栈顺序,括号化问题等。
2、斜树
好了继续,还有更特殊的二叉树,如果一棵二叉树只有左孩子,则称该树为左斜树,类似的如果只有右孩子,就称为右斜树,他们统称为斜树。仔细一看,这时候树就演化成了链表!易知,树的深度就是树的结点个数。
3、满二叉树
如果二叉树中所有的非叶子结点都有左孩子和右孩子,而且叶子结点位于同一层。我们称这样的树为满二叉树。如下
看起来十分美观。满二叉树有一些特点:
- 叶子结点只能在最后一层
- 非叶子结点的度一定是2
- 在同样深度的二叉树中,满二叉树的结点数最多(因为每个结点都有两个孩子);拥有的叶子结点的个数也最多(因为每个结点都有两个孩子,一直到最后一层叶子结点,必然是最多的);在相同结点数的树中,满二叉树的深度最小。
4、完全二叉树
这个怎么说,如果对每个结点按照层序编号,根结点为1,然后按照从上到下,从左到右依次编号。如下
其实上图就是一个完全二叉树。现在来说什么样的树才能称为完全二叉树。二叉树可能有的结点只有一个孩子,不是最后一层也可能出现叶子结点,但是我们编号的时候不跳过,始终按照左孩子 -> 右孩子的顺序去编号,如果发现某个编号处位置空缺,这棵树就不是完全二叉树。举几个例子
树1的结点5,按编号顺序它的左孩子应该编号10,右孩子11,但它没有左孩子,位置10就空缺了,所以不是完全二叉树;树2中第二层的结点3的两个孩子应分别编号6和7的,然后是下一层的8和9,但是由于结点3没有孩子,所以造成位置6、7空缺,也不是完全二叉树。树3也是不是完全二叉树,结点5的孩子应编号为10和11,然后是结点6的孩子编号为12,但是结点5没有孩子,造成了位置10、11空缺。
总结完全二叉树的特点:
- 首先满二叉树一定是完全二叉树,完全二叉树不一定是满二叉树
- 某结点的度如果为1,则它只有左孩子
- 叶子结点只能出现在最后两层(考虑树2)
- 相同结点的树中,完全二叉树的深度最小
二叉树的性质
- 二叉树的第i层至多有$2^{i-1}$个结点,这个看图很容易得出结论
- 深度为$k$的二叉树最多有$2^k-1$个结点;拥有最多结点的是满二叉树,根据第一条其实就是$$\sum_{i=1}^n(2^0+2^1+2^2+...+2^{k-1})$$
- 任意一棵二叉树,如果叶子结点数为$n_0$,度为2的结点数为$n_2$,则$n_0=n_2+1$。设$n$为二叉树的总结点数,那么树分支(即连线)条数为$n-1$,这个值的由来可以从下往上看,除了根结点外,每个结点都有一条指向父结点的连线,所以是$n-1$。另外用$n_1$表示度为1的结点,则$n = n_0+n_1+n_2$;计算分支条数还可以从上到下,叶子结点没有孩子,拥有一个孩子的结点可引出一条连线,拥有两个孩子的结点棵引出两条连线。所以
分支的条数
= $n_1 + 2n_2 = n -1$,将$n= n_0+n_1+n_2$代入得证。这个结论是说,二叉树叶子结点一定比的个数比度为2的结点个数多一个。 - 有$n$个结点的完全二叉树的深度为$\lfloor\log_2n\rfloor+1$,其中$\lfloor x \rfloor$表示向下取整。因为完全二叉树额结点数肯定不大于满二叉树的结点数$2^k-1$个,但是也肯定大于$2^{k-1}-1$个(最少的时候,第k层就1个结点,但是上面的$k-1$层都是满的),也就是$2^{k-1}-1 < n \le 2^k-1$,又n为正整数,该不等式等价于$2^{k-1} \le n < 2^k$,两边取对数得到$k-1 \le \log_2n < k$
- 按照层序编号,根结点编号为1,对于任意一个编号为$i$的结点,编号$2i$为其左孩子,$2i+1$为其右孩子;相反,对于任意一个编号为$i$的结点,其父结点编号为$\lfloor i/2\rfloor$
- 如果$2i > n$则结点$i$无孩子;如果$2i+1 > n$,结点$i$只有左孩子没有右孩子。
二叉树的存储结构及实现
二叉树每个结点最多两个孩子,自然想到设置两个指针域。其实就是二叉链表,回忆孩子兄弟表示法——一个指针指向左孩子,另一个指针指向其兄弟,现在将这个指向兄弟的指针指向右孩子就可以实现二叉树了。
package Chap6;
import java.math.BigInteger;
public class BinaryTree<Item> {
public static class Node<T> {
private T data;
private Node<T> lchild;
private Node<T> rchild;
public Node(T data) {
this.data = data;
}
public T getData() {
return data;
}
public Node<T> getLchild() {
return lchild;
}
public Node<T> getRchild() {
return rchild;
}
@Override
public String toString() {
String lchildInfo = lchild == null ? null : lchild.getData().toString();
String rchildInfo = rchild == null ? null : rchild.getData().toString();
return "Node{" +
"data=" + data +
", lchild=" + lchildInfo +
", rchild=" + rchildInfo +
'}';
}
}
private Node<Item> root;
private int nodesNum;
public void setRoot(Item data) {
root = new Node<>(data);
nodesNum++;
}
public void addLeftChild(Item data, Node<Item> parent) {
parent.lchild = new Node<>(data);
nodesNum++;
}
public void addRightChild(Item data, Node<Item> parent) {
parent.rchild = new Node<>(data);
nodesNum++;
}
public Node<Item> parentTo(Node<Item> node) {
return parentTo(root, node);
}
public Node<Item> parentTo(Node<Item> currentNode, Node<Item> node) {
if (currentNode == null) {
return null;
}
if (node == currentNode.lchild || node == currentNode.rchild) {
return currentNode;
}
// 如果当前结点没找到,递归查找其左右子树
Node<Item> p;
if ((p = parentTo(currentNode.lchild, node)) != null) {
return p;
// 如果左子树中没找到,返回右子树查找结果
} else {
return parentTo(currentNode.rchild, node);
}
}
public Node<Item> root() {
return root;
}
public int degreeForNode(Node<Item> node) {
if (node.lchild != null && node.rchild != null) {
return 2;
} else if (node.lchild != null || node.rchild != null) {
return 1;
} else {
return 0;
}
}
public int degree() {
// 无非三种情况
// 1. 只有一个根结点,度为0
// 2. 斜树,度为1
// 3.其余情况度是2
if (root.lchild == null && root.rchild == null) {
return 0;
// 斜树的结点数等于其深度,包括了只有根结点的情况,所以上面的条件要先判断
} else if (nodesNum == depth()) {
return 1;
} else {
return 2;
}
}
public int depthForNode(Node<Item> node) {
if (node == null) {
return 0;
}
// 从上到下递归,从下到上返回深度,下面就是返回某结点两个孩子中深度最大的那个,加1继续返回到上一层
int lDepth = depthForNode(node.lchild);
int rDepth = depthForNode(node.rchild);
return lDepth > rDepth ? lDepth + 1 : rDepth + 1;
}
public int depth() {
return depthForNode(root);
}
public int nodesNum() {
return nodesNum;
}
public void preOrder(Node<Item> node) {
if (node == null) {
return;
}
System.out.print(node.getData() + " ");
preOrder(node.lchild);
preOrder(node.rchild);
}
public void midOrder(Node<Item> node) {
if (node == null) {
return;
}
midOrder(node.lchild);
System.out.print(node.getData() + " ");
midOrder(node.rchild);
}
public void postOrder(Node<Item> node) {
if (node == null) {
return;
}
postOrder(node.lchild);
postOrder(node.rchild);
System.out.print(node.getData() + " ");
}
public void preOrder() {
preOrder(root);
}
public void midOrder() {
midOrder(root);
}
public void postOrder() {
postOrder(root);
}
public boolean isEmpty() {
return nodesNum == 0;
}
// 实际上是删除以该结点为根结点的子树,后序遍历
public void deleteNode(Node<Item> node) {
if (node == null) {
return;
}
// 结点信息被清空了,但是结点本身不是null,对data进行判断,如果data已经为空就不自减了
if (node.data != null) {
nodesNum--;
}
deleteNode(node.lchild);
deleteNode(node.rchild);
// 删除根结点结点信息
node.lchild = null;
node.rchild = null;
node.data = null;
}
public void clear() {
deleteNode(root);
// root.lchild和root.rchild虽然为空了但是root还不为空
root = null;
}
// 根据卡特兰数递推公式 h(n)=h(n-1)*(4*n-2)/(n+1)
// 已知 h(1) = 1;
// 无穷数列,越到后面数字越大,使用BigInteger
public static BigInteger numOfTreeShape(int n) {
BigInteger a = BigInteger.ONE;
for (int i = 2; i <= n; i++) {
a = a.multiply(BigInteger.valueOf(4 * i - 2)).divide(BigInteger.valueOf(i + 1));
}
return a;
}
public static void main(String[] args) {
BinaryTree<String> tree = new BinaryTree<>();
tree.setRoot("A");
Node<String> root = tree.root();
tree.addLeftChild("B", root);
tree.addRightChild("C", root);
tree.addLeftChild("D", root.getLchild());
tree.addLeftChild("E", root.getRchild());
tree.addRightChild("F", root.getRchild());
tree.addLeftChild("G", root.getLchild().getLchild());
tree.addRightChild("H", root.getLchild().getLchild());
tree.addRightChild("I", root.getRchild().getLchild());
System.out.print("前序遍历如下\n");
tree.preOrder();
System.out.println("\n中序遍历如下");
tree.midOrder();
System.out.println("\n后序遍历如下");
tree.postOrder();
System.out.println();
System.out.println(root.getRchild().getLchild().getData() + "的父结点是" + tree.parentTo(root.getRchild().getLchild()).getData());
System.out.println("树的深度是" + tree.depth());
System.out.println("树的度是" + tree.degree());
System.out.println("树的结点数是" + tree.nodesNum());
System.out.println("结点数为" + tree.nodesNum() + "的二叉树,共有" + numOfTreeShape(tree.nodesNum()) + "种不同的形态");
// 删除左子树
tree.deleteNode(root.getLchild());
System.out.println("还剩" + tree.nodesNum() + "个结点");
// 删除右结点的左子树
tree.deleteNode(root.getRchild().getLchild());
System.out.println("还剩" + tree.nodesNum() + "个结点");
// 清空树
tree.clear();
System.out.println(tree.isEmpty());
}
}
/* Outputs
前序遍历如下
A B D G H C E I F
中序遍历如下
G D H B A E I C F
后序遍历如下
G H D B I E F C A
E的父结点是C
树的深度是4
树的度是2
树的结点数是9
结点数为9的二叉树,共有4862种不同的形态
还剩5个结点
还剩3个结点
true
*/
因为每个结点的两个指针域就是其左右孩子,所以我们把获取孩子结点的实现放到了Node类中。获取某个结点的父结点比较麻烦,我这里使用了递归的方式,如果当前结点为空,就返回null,不为空就判断其左右孩子中是否有一个域所求结点相同,若是就返回当前结点。若不是,递归查找左右子树。很麻烦,而且递归算法复杂度不敢恭维。基于此考虑,可以给Node类新增一个parent的指针域。求树的度,我们换了种方法考虑问题,以前的实现中都遍历了所有结点,从中选出孩子结点最多的那个。基于二叉树的种种性质,树的度无非就三种情况:
- 度为0。一种情况,只有一个根结点时。
- 度为1。当二叉树为斜树时候,即结点数等于树的深度时。
- 度为2。除以上两种情况的其他情况。
既然要求树的深度,这里接着说。求树的深度也用了递归算法——从上往下递归,从最后一层往根结点返回。如果某个结点为空,当然深度返回0;否则递归查找其左右子树,直到最后一层,开始返回。返回当前结点左右子树的深度值较大者并加上1,这里加上1的意义是因为函数返回对应着返回到上一层中的父结点了,深度自然增加1。
再说删除子树的方法,实际上利用了后序遍历删除了子树所有结点的信息。因为删除子树总结点数nodesNum也必须减少。这里由于结点本身还不是null(从代码看出只是其lChild,rChild,data被置空),所以判断其data是否为空来递减nodesNum是个明智的选择。清空整棵树的话,调用删除子树的方法,传入root作为参数就好了。我们知道最后处理完毕后只是root的信息被置空了,为了达到真正意义上的空树,手动将root置null就好了。
重点说几个方法。先说求二叉树的不同形态的数目。前面提到过这是卡特兰数的应用。由于卡特兰数是无穷序列,越到后面数值越大,所以得用Java的大整数BigInteger
实现。卡特兰数列的递推公式为$h(n)=h(n-1)(4n-2)/(n+1)$且已知$h(1) = 1$,有了这些信息就可以用编程的手段快速计算出卡特兰数了。
public static BigInteger numOfTreeShape(int n) {
BigInteger a = BigInteger.ONE;
// i从2开始因为h(1)已知从h(2)开始计算
for (int i = 2; i <= n; i++) {
a = a.multiply(BigInteger.valueOf(4 * i - 2)).divide(BigInteger.valueOf(i + 1));
}
return a;
}
然后最后说说二叉树的遍历。
二叉树的遍历
分为前序遍历、中序遍历、后序遍历三种。
前序遍历
前序遍历操作结点的顺序是根结点 -> 左子树-> 右子树。前序遍历的代码如下
public void preOrder(Node<Item> node) {
if (node == null) {
return;
}
System.out.print(node.getData() + " ");
preOrder(node.lchild);
preOrder(node.rchild);
}
因为打印等操作在递归查找左右子树之前,所以看代码就可以说出这是前序遍历。分析代码,判空是递归终止的条件,从根结点开始,不为空就打印。所以前序遍历最先打印的必然是根结点,然后先查找左子树,遇到不为空的就打印出来,直到叶子结点,其孩子为空,返回上一层中的父结点,开始执行右子树的遍历。就这样不断执行,直到所有的结点都被访问过,且只会访问一次。总结一下,前序遍历就是当前结点只要不为空就打印;为空,就返回到父结点,继续开始处理父结点的右子树。了解了这些规律,下图为何是这样的访问顺序应该也就清楚了。
A不为空打印之,然后递归左子树,打印B、D、G,G的左子树为空,返回父结点G,处理其右子树,也为空,继续返回到G的父结点D,开始处理D的右子树,打印H,然后不断返回到A,开始处理右子树右子树....最终打印顺序是A B D G H C E I F
中序遍历
public void inOrder(Node<Item> node) {
if (node == null) {
return;
}
inOrder(node.lchild);
System.out.print(node.getData() + " ");
inOrder(node.rchild);
}
只要将打印或其他操作放到递归左右子树之间,也就是中序遍历了。中序遍历操作结点的顺序是先左子树-> 根结点 -> 右子树。具体来说先沿着树的左孩子深入,当某个结点的左孩子不存在时开始返回,打印该结点,之后继续处理这个结点的右子树。由于先是深入到左子树,直到其没有左孩子,所以最先打印一般不是根结点了。还是结合图来理解。
从根结点A开始一直深入左子树直到G,由于G没有左孩子,开始返回并打印父结点G,然后处理G的右孩子,没有继续返回到G的父结点D,打印之,处理D的右子树,然后打印H,返回多次回到A,然后开始处理右子树...最终打印顺序是G D H B A E I C F
后序遍历
public void postOrder(Node<Item> node) {
if (node == null) {
return;
}
postOrder(node.lchild);
postOrder(node.rchild);
System.out.print(node.getData() + " ");
}
后序遍历操作结点的顺序是先左子树-> 右子树 -> 根结点。从代码中可以看出,后序遍历是当左右子树都访问过之后才打印,(可以是遇到叶子结点,由于它没有孩子所以打印会得到执行;也可以是该结点的左右子树不为空但是已经访问过了,函数即将返回时执行打印操作。)所以这种遍历的规律是:递归直到遇到左子树的叶子结点,打印该叶子结点,返回到父结点,开始处理右子树该结点的右子树,左右子树都处理了后返回到父结点并打印父结点。最后遍历的一定是树的根结点。
如图沿着左子树深入,直到遇到叶子结点G,打印之,然后返回到父结点D,处理其右子树H,H又是叶子结点,打印之,返回父结点D,此时D的左右子树都处理过了所以打印D,继续返回到B,接着处理B的右子树,为空,打印B,返回到结点A处理A的右子树...最终打印顺序为G H D B I E F C A
二叉树遍历的非递归实现
如果三种遍历的思想都已经理解透彻,可以尝试用非递归的方式重写。三种遍历的实现都会使用到栈(Stack),而LinkedList就具备栈的功能。每访问一个结点,就将其压入栈。
1、前序遍历
每访问一个结点,若不为空,存入栈,并立即打印。然后不断深入左子树,直到为空,此时返回到父结点(对应的栈操作是出栈),接着处理它的右子树。
/**
* 前序遍历--非递归
*/
public void preOrder2(Node<Item> root) {
// 用栈保存已经访问过的结点,便于返回到父结点
LinkedList<Node<Item>> stack = new LinkedList<>();
// 当前结点不为空,或者为空但有可以返回的父结点(可以进行pop操作)都可以进入循环
while (root != null || !stack.isEmpty()) {
// 只要当前结点,就打印,同时入栈
while (root != null) {
stack.push(root);
System.out.print(root.getData()+" ");
root = root.lchild;
}
// 上面while终止说明当前结点为空;返回到父结点并处理它的右子树。由于要执行pop操作,先判空
if (!stack.isEmpty()) {
// 返回到父结点。由于左孩子为空返回时已经弹出过父结点了,所以若是由于右孩子为空返回,相当于一次性返回到父结点的父结点(两层)
root = stack.pop();
// 开始右子树的大循环(第一个while)
root = root.rchild;
}
}
}
2、中序遍历
/**
* 中序遍历--非递归
*/
public void inOrder2(Node<Item> root) {
LinkedList<Node<Item>> stack = new LinkedList<>();
while (root != null || !stack.isEmpty()) {
while (root != null) {
stack.push(root);
root = root.lchild;
}
if (!stack.isEmpty()) {
// 和前序遍历唯一不同的是,前序遍历是入栈时打印,中序遍历是出栈时返回到父结点才打印
// 和前序遍历一样,由于左孩子为空返回时已经弹出过父结点了,所以若是由于右孩子为空返回,相当于一次性返回到父结点的父结点(两层)
root = stack.pop();
System.out.print(root.getData()+" ");
root = root.rchild;
}
}
}
中序遍历和前序遍历的非递归实现难度是一样的,进出栈的时机也是一样的,关键是何时打印的问题。前序遍历由于每访问一个结点就立即打印,所以在进栈时就被打印。而中序遍历是当某个结点没有左孩子时,返回到父结点(对应的栈操作是出栈)并打印,接着处理右子树...
3、后序遍历
后序遍历的实现稍微麻烦一点,因为必须当某结点的两个孩子都访问过之后才可以打印,我们实在不能判断到底是因为某结点的孩子结点都为空被打印的,还是该结点已经访问过它的非空左右子树后被打印的。所以判空的方法肯定是不行了,考虑用另外一个栈记录结点访问其孩子的信息。当某结点访问过其左孩子后,结点访问信息的栈存入1(和存入结点同步进行),当该结点访问过其右孩子后,将值更新为2。若某结点的访问信息值为2,这时才将结点出栈并打印(同时该结点的访问信息出栈)。
/**
* 后序遍历--非递归
*/
public void postOrder2(Node<Item> root) {
LinkedList<Node<Item>> stack = new LinkedList<>();
// 存放结点被访问的信息,1表示只访问过左孩子,2表示右孩子也访问过了(此时可以打印了)
LinkedList<Integer> visitedState = new LinkedList<>();
while (root != null || !stack.isEmpty()) {
while (root != null) {
stack.push(root);
root = root.lchild;
// 上句访问过左孩子了,放入1
visitedState.push(1);
}
// 这个while和下面的if不可交换执行顺序,否则变成了中序遍历
// 用while而不是if是因为:结点已经访问过它的两个孩子了,先不打印而处于等待状态。随即判断若它的右孩子不为空,则仍会被push进去,待右孩子处理完后按照递归思想应该返回到等待中父结点,由于父结点访问状态已经是2,直接打印
while (!stack.isEmpty() && visitedState.peek() == 2) {
visitedState.pop();
// 这里不能root = stack.pop()然后在打印root,因为如果这样的话,最后一个元素弹出赋值给root,而这个root不为空,一直while循环不会跳出
System.out.print(stack.pop().getData()+" ");
}
if (!stack.isEmpty()) {
// 注意先取出来而不删除,等到访问状态为2才能删除
root = stack.peek();
root = root.rchild;
// 上句访问过右孩子了,应该更新访问状态到2
visitedState.pop(); // 弹出1,压入2
visitedState.push(2);
}
}
}
一定要注意的是,判断结点访问信息的while语句和将访问信息更新为2的if语句不可交换顺序。否则就变成了中序遍历了,有兴趣的可以试试,这是因为刚压入了1,马上就更新为2,然后判断结点访问信息的while语句肯定能进入(那么这个记录结点访问信息做的是无用功),出栈接着会打印出来,这流程和中序遍历是一模一样的。然后再说判断结点访问信息的while为什么不能用if?当某个结点的左右孩子都已经访问,我们不是立即打印的,而是让其处于“已准备好”的等待状态,接着判断该结点的右孩子如果不为空,这个孩子结点也是要入栈的。待右结点处理完毕并打印,按照后序遍历递归的思想,应该返回上一层函数中,而刚好上一层的父结点已经准备好,所以直接打印。
由遍历次序的确定一棵二叉树
已知前序遍历序列和中序遍历序列或者已知中序序列序列和后序遍历序列是可以确定一棵二叉树的,这就是说推导出的二叉树有唯一形态。
已知前序和中序
比如前序遍历序列为ABCDEF,中序遍历序列是CBAEDF。问中序遍历序列?
由于二叉树形态唯一,中序遍历只有一种结果。现在来分析:前序中,A为根结点。于是中序中,C、B为左子树,E、D、F为右子树。回到前序中,A下一个是B,B肯定就是左孩子了,C是B的孩子但不确定是左孩子还是右孩子;再看中序,先打印的C说明C是B的左孩子。然后看右子树DEF,前序中先打印D说明D是A的右孩子,接着打印了E说明E是D的左孩子。F可能是D的右孩子也可能是E的某一个孩子。再看中序,E之后是D这就说明了E没有孩子,只能F是D的右孩子了。完毕。由此画出二叉树就能得到后序遍历序列。
已知中序和后序
比如中序序列ABCDEFG,后序序列BDCAFGE,求前序序列?
先看后序确定根结点为E,则在中序中,ABCD为左子树,FG为右子树。由后序BDCA的顺序,知到A是根结点E的左孩子,后序中的FG可以看出G是根结点E的右孩子。中序中先打印A说明A没有左孩子,BCD位于A的右侧;结合后序中BDCA,说明C是A的右孩子,则中序中ABCD的打印顺序,知道B是C的左孩子,D是C的右孩子。接着来到根结点的右子树FG,因为中序中先打印F,说明F是G的左孩子。完毕。由此画出二叉树就能得到前序遍历序列。
上面的两个推导过程看得人很晕,最好是自己在纸上画画,比想象中要简单!
那么能否根据前序遍历序列和后序遍历序列确定一棵二叉树呢?不能。
必须知道中序序列才能确定一棵唯一的二叉树,因为中序遍历序列可以区分出左右子树。(根结点左边的是左子树,右边的是右子树),所以只根据前序遍历序列和后序遍历序列,可能得到多个形态的二叉树,它们前序、后序遍历出来是的结果相同。
by @sunhaiyu
2017.9.12