一、树的基本概念
树(Tree)是n(n>=0)个结点的有限集。当n=0时成为空树,在任意一棵非空树中:
- 有且仅有一个特定的称为根(Root)的结点;
- 当n>1时,其余结点可分为m(m>0)个互不相交的有限集T1、T2、…、Tm,其中每一个集合本身又是一棵树,并且称为根的子树(SubTree)。
注意:
- n>0时,根结点是唯一的,坚决不可能存在多个根结点。
- m>0时,子树的个数是没有限制的,但它们互相是一定不会相交的。
比如,下图中的就不符合树的定义:
树中结点的分类:
如图中所示,每一个圈圈我们就称为树的一个结点。结点拥有的子树数称为结点的度-(Degree),树的度取树内各结点的度的最大值。
- 度为0的结点称为叶结点(Leaf)或终端结点;
- 度不为0的结点称为分支结点或非终端结点,除根结点外,分支结点也称为内部结点。
树中结点之间的关系:
- 结点的子树的根称为结点的孩子(Child),相应的,该结点称为孩子的双亲(Parent),同一双亲的孩子之间互称为兄弟(Sibling)。
- 结点的祖先是从根到该结点所经分支上的所有结点。
结点的层次:
- 结点的层次(Level)从根开始,根为第一层,根的孩子为第二层。
- 其双亲在同一层的结点互为堂兄弟。
- 树中结点的最大层次称为树的深度(Depth)或高度。
二 、树的存储结构
要存储树,简单的顺序存储结构和链式存储结构是不行的!不过如果充分利用它们各自的特点,结合两种存储结构完全可以间接地来实现树的存储。
双亲表示法
- 双亲表示法,言外之意就是以双亲作为索引的关键词的一种存储方式。
- 我们假设以一组连续空间存储树的结点,同时在每个结点中,附设一个指示其双亲结点在数组中位置的元素。
- 也就是说,每个结点除了知道自己是谁之外,还知道它的粑粑麻麻在哪里。
- 根结点没有双亲结点,其Parent用 -1 表示。
这样的存储结构,我们可以根据某结点的parent指针找到它的双亲结点,所用的时间复杂度是O(1),索引到parent的值为-1时,表示找到了树结点的根。
可是,如果我们要知道某结点的孩子是什么?那么不好意思,请遍历整个树结构。那么我们是不是可以一起改进一下呢?
那现在我们又比较关心它们兄弟之间的关系呢?当然也是可以的:
但是我们可以发现,这张表格中存在大量的 -1,也就是说这样的存储方式浪费了大量的存储空间,这并不是我们所期望看到的,因此,我们来看一种更高效的树的存储结构。
孩子表示法
将树中的每个结点的孩子结点排列成一个线性表,用链表存储起来。对于含有 n 个结点的树来说,就会有 n 个单链表,将 n 个单链表的头指针存储在一个线性表中,这样的表示方法就是孩子表示法。
是不是瞬间感觉清爽多了,但是孩子表示法正好与双亲表示法相反,适用于查找某结点的孩子结点,不适用于查找其父结点。可以将两种表示方法合二为一,存储效果如下所示:
孩子兄弟表示法
使用链式存储结构存储普通树。链表中每个结点由 3 部分组成:
通过孩子兄弟表示法,普通树转化为了二叉树,所以孩子兄弟表示法又被称为“二叉树表示法”或者“二叉链表表示法”。如下图所示:
三、二叉树
二叉树(Binary Tree)是n(n>=0)个结点的有限集合,该集合或者为空集(空二叉树),或者由一个根结点和两棵互不相交的、分别称为根结点的左子树和右子树的二叉树组成。
二叉树的特点
- 每个结点最多有两棵子树,所以二叉树中不存在度大于2的结点。(注意:不是都需要两棵子树,而是最多可以有两棵,没有子树或者有一棵子树也都是可以的。)
- 左子树和右子树是有顺序的,次序不能颠倒。
- 即使树中某结点只有一棵子树,也要区分它是左子树还是右子树,下面是完全不同的二叉树:
二叉树的五种基本形态
若只从形态上来考虑,拥有三个结点的普通树只有两种情况:两层或者三层。而对于二叉树来说,由于要区分左右,所以就演变成五种形态(这也是为什么考试面试笔试总选择二叉树进行考察):
二叉树还可以继续分类,衍生出满二叉树和完全二叉树。
满二叉树
如果二叉树中除了叶子结点,每个结点的度都为 2,则此二叉树称为满二叉树。
满二叉树除了满足普通二叉树的性质,还具有以下性质(请注意图中的标注):
- 满二叉树中第 n 层的节点数为 2 n − 1 2^{n-1} 2n−1 个。
- 深度为 k 的满二叉树必有 2 k − 1 2^{k}-1 2k−1 个节点 ,叶子数为 2 k − 1 2^{k-1} 2k−1。
- 满二叉树中不存在度为 1 的节点,每一个分支点中都两棵深度相同的子树,且叶子节点都在最底层。
- 具有 n 个节点的满二叉树的深度为 l o g 2 ( n + 1 ) log_2(n+1) log2(n+1)。
完全二叉树
如果二叉树中除去最后一层节点为满二叉树,且最后一层的结点依次从左到右分布,则此二叉树被称为完全二叉树。
完全二叉树除了具有普通二叉树的性质,它自身也具有一些独特的性质,比如说,n 个结点的完全二叉树的深度为 ⌊
l
o
g
2
n
log_2n
log2n⌋+1(后面有证明)。
⌊
l
o
g
2
n
log_2{n}
log2n⌋ 表示取小于
l
o
g
2
n
log_2{n}
log2n 的最大整数,向下取整。例如,⌊
l
o
g
2
8
log_2{8}
log28⌋ = 3,而 ⌊
l
o
g
2
10
log_2{10}
log210⌋ 结果也是 3。
对于任意一个完全二叉树来说,如果将含有的结点按照层次从左到右依次标号(如图上图)),对于任意一个结点 i ,完全二叉树还有以下几个结论成立:
- 当 i>1 时,父亲结点为结点 [i/2] 。(i=1 时,表示的是根结点,无父亲结点)
- 如果 2i>n(总结点的个数) ,则结点 i 肯定没有左孩子(为叶子结点);否则其左孩子是结点 2i 。
- 如果 2i+1>n ,则结点 i i i 肯定没有右孩子;否则右孩子是结点 2i+1 。
二叉树的性质
-
二叉树的性质一:在二叉树的第i层上至多有 2 i − 1 2^{i-1} 2i−1个结点(i>=1)
这个性质其实很好记忆,考试的时候懂得画出二叉树的图便可以推出 -
二叉树的性质二:深度为k的二叉树至多有 2 k − 1 2^k-1 2k−1个结点(k>=1)
-
二叉树的性质三:对任何一棵二叉树T,如果其终端结点数为 n 0 n_0 n0,度为2的结点数为 n 2 n_2 n2,则 n 0 n_0 n0= n 2 n_2 n2+1
-
二叉树的性质四:具有n个结点的完全二叉树的深度为⌊log₂n⌋+1
二叉查找树的创建:
- 二叉树的结点类:
private class Node<Key,Value>{
//存储键
public Key key;
//存储值
private Value value;
//记录左子结点
public Node left;
//记录右子结点
public Node right;
public Node(Key key, Value value, Node left, Node right) {
this.key = key;
this.value = value;
this.left = left;
this.right = right;
}
}
- 二叉查找树实现:
public class BinaryTree<Key extends Comparable<Key>, Value> {
//记录根结点
private Node root;
//记录树中元素的个数
private int N;
private class Node {
//存储键
public Key key;
//存储值
private Value value;
//记录左子结点
public Node left;
//记录右子结点
public Node right;
public Node(Key key, Value value, Node left, Node right) {
this.key = key;
this.value = value;
this.left = left;
this.right = right;
}
}
//获取树中元素的个数
public int size() {
return N;
}
//向树中添加元素key-value
public void put(Key key, Value value) {
root = put(root, key, value);
}
//向指定的树x中添加key-value,并返回添加元素后新的树
private Node put(Node x, Key key, Value value) {
//如果x子树为空,
if (x==null){
N++;
return new Node(key,value, null,null);
}
//如果x子树不为空
//比较x结点的键和key的大小:
int cmp = key.compareTo(x.key);
if (cmp>0){
//如果key大于x结点的键,则继续找x结点的右子树
x.right = put(x.right,key,value);
}else if(cmp<0){
//如果key小于x结点的键,则继续找x结点的左子树
x.left = put(x.left,key,value);
}else{
//如果key等于x结点的键,则替换x结点的值为value即可
x.value = value;
}
return x;
}
//查询树中指定key对应的value
public Value get(Key key) {
return get(root,key);
}
//从指定的树x中,查找key对应的值
public Value get(Node x, Key key) {
//x树为null
if (x==null){
return null;
}
//x树不为null
//比较key和x结点的键的大小
int cmp = key.compareTo(x.key);
if (cmp>0){
//如果key大于x结点的键,则继续找x结点的右子树
return get(x.right,key);
}else if(cmp<0){
//如果key小于x结点的键,则继续找x结点的左子树
return get(x.left,key);
}else{
//如果key等于x结点的键,就找到了键为key的结点,只需要返回x结点的值即可
return x.value;
}
}
//删除树中key对应的value
public void delete(Key key) {
delete(root, key);
}
//删除指定树x中的key对应的value,并返回删除后的新树
public Node delete(Node x, Key key) {
//x树为null
if (x==null){
return null;
}
//x树不为null
int cmp = key.compareTo(x.key);
if (cmp>0){
//如果key大于x结点的键,则继续找x结点的右子树
x.right = delete(x.right,key);
}else if(cmp<0){
//如果key小于x结点的键,则继续找x结点的左子树
x.left = delete(x.left,key);
}else{
//如果key等于x结点的键,完成真正的删除结点动作,要删除的结点就是x;
//让元素个数-1
N--;
//得找到右子树中最小的结点
if (x.right==null){
return x.left;
}
if (x.left==null){
return x.right;
}
Node minNode = x.right;
while(minNode.left!=null){
minNode = minNode.left;
}
//删除右子树中最小的结点
Node n = x.right;
while(n.left!=null){
if (n.left.left==null){
n.left = n.left.right;
break;
}else{
//变换n结点即可
n = n.left;
}
}
//让x结点的左子树成为minNode的左子树
minNode.left = x.left;
//让x结点的右子树成为minNode的右子树
if (minNode == x.right) {
minNode.right = x.right.right;
}else {
minNode.right = x.right;
}
//假如删除的是根节点,这个方法首先会传入root节点,然后令x=root,所以对x进行的操作只能是对指向root的对象属性的操作。
//如果只是单纯的x=minNode,这只能修改根节点之外的其他的节点,因为x只是一个引用,让x=minNode,这只是修改了赋给x的地址
//而没有修改指向root的地址,因为遍历是从root节点开始的,所以这会导致遍历出现问题
if (x==root) {
root = minNode;
}else {
x = minNode;
}
}
return x;
}
}
查找二叉树中最小的键
在某些情况下,我们需要查找出树中存储所有元素的键的最小值,比如我们的树中存储的是学生的排名和姓名数据,那么需要查找出排名最低是多少名?这里我们设计如下两个方法来完成:
//查找整个树中最小的键
public Key min(){
return min(root).key;
}
//在指定树x中找出最小键所在的结点
private Node min(Node x){
//需要判断x还有没有左子结点,如果有,则继续向左找,如果没有,则x就是最小键所在的结点
if (x.left!=null){
return min(x.left);
}else{
return x;
}
}
查找二叉树中最大的键
在某些情况下,我们需要查找出树中存储所有元素的键的最大值,比如比如我们的树中存储的是学生的成绩和学生的姓名,那么需要查找出最高的分数是多少?这里我们同样设计两个方法来完成:
//在整个树中找到最大的键
public Key max(){
return max(root).key;
}
//在指定的树x中,找到最大的键所在的结点
public Node max(Node x){
//判断x还有没有右子结点,如果有,则继续向右查找,如果没有,则x就是最大键所在的结点
if (x.right!=null){
return max(x.right);
}else{
return x;
}
}
二叉树的基础遍历
很多情况下,我们可能需要像遍历数组数组一样,遍历树,从而拿出树中存储的每一个元素,由于树状结构和线性结构不一样,它没有办法从头开始依次向后遍历,所以存在如何遍历,也就是按照什么样的搜索路径进行遍历的问题。
- 前序遍历;
先访问根结点,然后再访问左子树,最后访问右子树
//使用前序遍历获取树中所有的键
public Queue<Key> preErgodic(){
Queue<Key> keys = new Queue<Key>();
preErgodic(root, keys);
return keys;
}
//使用前序遍历,获取指定树x中所有的键,并存放到key中
private void preErgodic(Node x,Queue<Key> keys){
if (x==null){
return;
}
//把x结点的key放入到keys中
keys.enqueue(x.key);
//递归遍历x结点的左子树
if (x.left!=null){
preErgodic(x.left,keys);
}
//递归遍历x结点的右子树
if (x.right!=null){
preErgodic(x.right,keys);
}
}
- 中序遍历;
先访问左子树,中间访问根节点,最后访问右子树
//使用中序遍历获取树中所有的键
public Queue<Key> midErgodic(){
Queue<Key> keys = new Queue<Key>();
midErgodic(root,keys);
return keys;
}
//使用中序遍历,获取指定树x中所有的键,并存放到key中
private void midErgodic(Node x,Queue<Key> keys){
if (x==null){
return;
}
//先递归,把左子树中的键放到keys中
if (x.left!=null){
midErgodic(x.left,keys);
}
//把当前结点x的键放到keys中
keys.enqueue(x.key);
//在递归,把右子树中的键放到keys中
if(x.right!=null){
midErgodic(x.right,keys);
}
}
- 后序遍历;
先访问左子树,再访问右子树,最后访问根节点
//使用后序遍历,把整个树中所有的键返回
public Queue<Key> afterErgodic(){
Queue<Key> keys = new Queue<Key>();
afterErgodic(root,keys);
return keys;
}
//使用后序遍历,把指定树x中所有的键放入到keys中
private void afterErgodic(Node x,Queue<Key> keys){
if (x==null){
return ;
}
//通过递归把左子树中所有的键放入到keys中
if (x.left!=null){
afterErgodic(x.left,keys);
}
//通过递归把右子树中所有的键放入到keys中
if (x.right!=null){
afterErgodic(x.right,keys);
}
//把x结点的键放入到keys中
keys.enqueue(x.key);
}
二叉树的层序遍历
所谓的层序遍历,就是从根节点(第一层)开始,依次向下,获取每一层所有结点的
//使用层序遍历,获取整个树中所有的键
public Queue<Key> layerErgodic(){
//定义两个队列,分别存储树中的键和树中的结点
Queue<Key> keys = new Queue<Key>();
Queue<Node> nodes = new Queue<Node>();
//默认,往队列中放入根结点
nodes.enqueue(root);
while(!nodes.isEmpty()){
//从队列中弹出一个结点,把key放入到keys中
Node n = nodes.dequeue();
keys.enqueue(n.key);
//判断当前结点还有没有左子结点,如果有,则放入到nodes中
if (n.left!=null){
nodes.enqueue(n.left);
}
//判断当前结点还有没有右子结点,如果有,则放入到nodes中
if (n.right!=null){
nodes.enqueue(n.right);
}
}
return keys;
}
二叉树的最大深度问题
需求:
给定一棵树,请计算树的最大深度(树的根节点到最远叶子结点的最长路径上的结点数)
//获取整个树的最大深度
public int maxDepth(){
return maxDepth(root);
}
//获取指定树x的最大深度
private int maxDepth(Node x){
if (x==null){
return 0;
}
//x的最大深度
int max=0;
//左子树的最大深度
int maxL=0;
//右子树的最大深度
int maxR=0;
//计算x结点左子树的最大深度
if (x.left!=null){
maxL = maxDepth(x.left);
}
//计算x结点右子树的最大深度
if (x.right!=null){
maxR = maxDepth(x.right);
}
//比较左子树最大深度和右子树最大深度,取较大值+1即可
max = maxL>maxR?maxL+1:maxR+1;
return max;
}
折纸问题
需求:
请把一段纸条竖着放在桌子上,然后从纸条的下边向上方对折1次,压出折痕后展开。此时 折痕是凹下去的,即折痕突起的方向指向纸条的背面。如果从纸条的下边向上方连续对折2 次,压出折痕后展开,此时有三条折痕,从上到下依次是下折痕、下折痕和上折痕。
给定一 个输入参数N,代表纸条都从下边向上方连续对折N次,请从上到下打印所有折痕的方向 例如:N=1时,打印: down;N=2时,打印: down down up
分析:
我们把对折后的纸张翻过来,这时把第一次对折产生的折痕看做是根结点,那第二次对折产生的下折
痕就是该结点的左子结点,而第二次对折产生的上折痕就是该结点的右子结点,这样我们就可以使用树型数据结构来描述对折后产生的折痕。
这棵树有这样的特点:
- 根结点为下折痕;
- 每一个结点的左子结点为下折痕;
- 每一个结点的右子结点为上折痕;
public class PagerFoldingTest {
public static void main(String[] args) {
//模拟这只过程,产生树
Node<String> tree = createTree(2);
//遍历树,打印每个结点
printTree(tree);
}
//通过模拟对折N次纸,产生树
public static Node<String> createTree(int N){
//定义根结点
Node<String> root=null;
for (int i = 0; i < N; i++) {
//1.当前是第一次对折
if (i==0){
root = new Node<>("down",null,null);
continue;
}
//2.当前不是第一次对折
//定义一个辅助队列,通过层序遍历的思想,找到叶子结点,叶子结点添加子节点
Queue<Node> queue = new Queue<>();
queue.enqueue(root);
//循环遍历队列
while(!queue.isEmpty()){
//从队列中弹出一个结点
Node<String> tmp = queue.dequeue();
//如果有左子结点,则把左子结点放入到队列中
if (tmp.left!=null){
queue.enqueue(tmp.left);
}
//如果有右子结点,则把右子结点放入到队列中
if (tmp.right!=null){
queue.enqueue(tmp.right);
}
//如果同时没有左子结点和右子结点,那么证明该节点是叶子结点,只需要给该节点添加左子结点和右子结点即可
if (tmp.left==null && tmp.right==null){
tmp.left = new Node<String>("down", null,null);
tmp.right = new Node<String>("up",null,null);
}
}
}
return root;
}
//打印树中每个结点到控制台
public static void printTree(Node<String> root){
//需要使用中序遍历完成
if (root==null){
return;
}
//打印左子树的每个结点
if (root.left!=null){
printTree(root.left);
}
//打印当前结点
System.out.print(root.item+" ");
//打印右子树的每个结点
if (root.right!=null){
printTree(root.right);
}
}
//结点类
private static class Node<T>{
public T item;//存储元素
public Node left;
public Node right;
public Node(T item, Node left, Node right) {
this.item = item;
this.left = left;
this.right = right;
}
}
}