什么样的二叉树适合用数组来存储
首先给出问题:二叉树有几种存储方式?什么样的二叉树适合用数组来存储?
树
首先你需要知道什么是树什么不是树。
树的三个概念:
- 节点的高度 = 节点到叶子节点的最长路径(边数)
- 节点的深度 = 根节点到节点所经历的边的个数
- 节点的层数 = 节点的深度+1
二叉树
树的结构多种多样,不过我们最常用的是二叉树。
二叉树:顾名思义,每个节点最多有两个“叉”,也就是两个子节点,分别是左子节点和右子节点。不过,二叉树并不是要求每个节点都有两个子节点。
其中,叶子节点全部都在最底层,且每个节点又有两个子节点,这种二叉树就叫作满二叉树。
若是叶子节点都在最底下两层,最后一层的叶子节点都靠左排序,且其它层的节点个数都要达到最大,这种二叉树就叫做完全二叉树。
那我们为什么还要特意把它拎出来讲呢?为什么偏偏把最后一层的叶子节点靠左排列的叫完全二叉树?如果靠右排列就不能叫完全二叉树了吗?这个定义的由来或者说目的在哪里?
要理解完全二叉树定义的由来,我们需要先了解,如何表示(或者存储)一棵二叉树?
我们先来看比较简单的、直观的链式存储法。每个节点有三个字段,其中一个是存储数据,另外两个是指向左右子节点的指针。我们只要拎住根节点,就可以通过左右子节点的指针,把整棵树都串起来。这种存储方式我们比较常用,大部分二叉树代码都是通过这种结构来实现的。
我们再来看基于数组的顺序存储发。我们把根节点存储在下标 i = 1 的位置,那左子节点存储在下标 2 × i + 1 的位置存储的就是右子节点。反过来,下标为 i / 2 的位置存储就是它的父亲节点。通过这种方式知道根节点存储的位置(一般情况下,为了方便计算子节点,根节点会存储在下标为1的位置),这样就可以根据下标计算,把整个树都串起来。
不过,我刚刚举的例子是一棵完全二叉树,所以仅仅“浪费”了一个下标为0的存储位置。如果是非完全二叉树。,其实会浪费比较多的数组存储空间。
所以,如果某树是一棵完全二叉树,那用数组存储无疑是最节省内存的一种方式。因为数组的存储方式并不需要像链式存储发那样,需要额外的左右子节点的指针。这也是为什么完全二叉树会单独领出来的原因,也是为什么完全二叉树要求最后一层的子节点都靠左的原因。
二叉树的遍历
经典的方法有三种:前序遍历、中序遍历、后序遍历。其中前中后序表示的是节点与它的左右子树节点遍历打印的先后顺序。
- 前序遍历是指,对于树中的任意节点来说,先打印这个节点,然后再打印它的左子树,最后打印它的右子树。
- 中序遍历是指,对于树中的任意节点来说,先打印它的左子树,然后打印它本身,最后打印它的右子树。
- 后序遍历是纸,对于树中的任意节点来说,先打印它的左子树,然后再打印它的右子树,最后再打印它本身。
二叉树遍历的时间复杂度是多少呢?
我们知道前中后序遍历,可以看出,每个节点最多被访问两次,所以遍历操作的时间复杂度,跟节点的个数 n 成正比,也就是所二叉树遍历的时间复杂度是 O(n)。
还有一种按层次遍历的方式:
可以用队列来实现,将树的根节点压入队列,然后根节点出队列的时候判断它有没有子节点,有的话,先把左子节点压入队列,然后在将右字节点压入队列,然后根节点出队,第一层就遍历完了,然后左子节点出队,再判断它是否有子节点,如果有的话,再将其子节点压入队列,左子节点出队,然后右子节点重复此操作。。。。。
二叉查找树
二叉查找树是二叉树中最常用的一种类型,也叫二叉搜索树。顾名思义,二叉查找树是为了实现快速查找而生的。不过,它不仅仅支持快速查找一个数据,还支持快速插入、删除一个数据。
这些都依赖于二叉查找树的特殊结构。二叉查找树要求,在树中的任意一个节点,其左子树中的每个节点的值,都小于这个节点的值,而右子节点的值都大于这个节点的值。
-
二叉查找树的查找操作
首先,我们看如何在二叉查找树中查找一个节点。我们先取根节点,如果它等于我们要查找的数据,那就返回。如果要查找的数据比根节点的值小,那就在左子树中 递归查找;如果要查找的数据比根节点的值要大,那就在右子树中递归查找。下面是二叉查找树的查找代码:
public class BinarySearchTree{ private Node tree; public Node find(int data){ Node p = tree; while(p != null){ if( data<p.data ) p=p.left; else if( data>p.data ) p = p.right; else return p; } return null; } public static class Node{ int data; Node left; Node right; public Node(int data){ this.data = data; } }
-
二叉查找树的插入操作
二叉查找树的插入过程有点类似于查找的过程。新插入的数据一般都是在叶子节点上,所以我们只需要从根节点开始,依次比较要插入的数据和节点的大小关系。
如果要插入的数据比节点的数据大,并且右子树为空,就将新数据直接插到右子节点的位置,如果不为空,就再递归遍历右子树,查找插入位置。同理,如果要插入的数据比节点数值小,并且节点的左子树为空,就将新数据插入到左子节点的位置;如果不为空,则遍历左子节点,查找插入位置。
代码如下:
public void insert(int data){ if(tree == null){ tree = new Node(data); return; } Node p = tree; while(p != null ){ if(data > p.data){ if(p.right == null){ p.right = new Node(data); return; } p = p.right; }else { // data <p.data if(p.left == null){ p.left = new Node(data); return; } p=p.left; } } }
-
二叉查找树的删除操作
二叉查找树的查找、删除操作都比较简单易懂,但是它的删除操作就比较复杂了。针对要删除的节点的子节点树的不同,我们需要分三种情况来处理。第一种情况是,如果删除的节点没有子节点,我们只需要直接将父节点中,指向要删除节点的指针置为null。
第二种情况是,如果删除的节点只有一个节点(只有左子节点或者右子节点),我们只需要更新父节点中,指向要删除节点的指针,让它指向要删除节点的子节点就可以了。
第三种情况是,如果要删除的节点有两个子节点,这就比较复杂了。我们需要找到这个节点的右子树中最小节点,把它替换到要删除的节点上。然后,再删除掉这个最小的节点,因为要删除的节点肯定没有左子节点(如果有左子节点那就不是最小节点了),所以我们可以应用上面两条规则来删除这个最小节点。
public void delete(int data){ Node p = tree; Node pp = null;//pp记录的是p的父节点 while(p != null && p.data != data){ pp=p; if(data>p.data) p=p.right; else p = p.left; } if(p == null) return; //没有找到 //要删除的节点有两个子节点 if(p.left != null && p.right != null){ //查找右子树中的最小节点 Node minP = p.right; Node minPP = p;// minPP表示minP的父节点 while(minP.left != null){ minP = minP.left; } p.data = minP.data; // 将minP的数据替换到p中 p=minP;//下面就变成删除minP了 pp = minP; } //删除的节点是叶子节点或者仅有一个子节点 Node child;//p的子节点 if(p.left !=null) child = p.left; else if( p.right != null) child = p.right; else child = null; if( pp == null) tree = child//删除的是根节点 else if (pp.left == p) pp.left = child; else pp.right = child; }
实际上,关于二叉查找树的删除操作,还有个非常简单、取巧的方法,就是单纯将要删除的节点标记为“已删除”,但是并不真正从树中将这个节点去掉。这样原本删除的节点还需要存储在内存中,比较浪费内存空间,但是删除操作就变得简单了很多。而且这种处理方法也并没有增加插入、查找操作胆码实现的难度。
二叉查找树的时间复杂度分析
对于二叉查找树的插入、删除、查找操作的时间复杂度,来分析一下。
实际上,二叉查找树的形态各式各样。对于同一组数据,可以构建成三种二叉查找树。它们的查找、插入、删除的执行效率都是不一样的。其中有一种,根节点的左右子树极度不平衡,已经退化成了链表,所以查找的时间复杂度就变成了O(n)。这是一种特别糟糕的情况。
一个最理想的情况,二叉查找树是一棵完全二叉树(或者满二叉树)。这个时候,插入、删除、、查找的时间复杂度是多少呢?
看前面的代码,不管是插入、删除、还是查找,时间复杂度其实都跟树的高度成正比,也就是O(height)。既然是这样,现在的问题就变成了另外一个,也就是,如何求一棵树包含n个节点的完全二叉树的高度?
我们知道树的高度等于最大层数减一,为了方便计算,我们转换成层来表示。包含n个节点的完全二叉树中,第一层包含1个节点,第二层包含2各节点,第三层包含4各节点,以此类推,下面一层节点数是上一层的2倍,第k层包含的节点数就是2^(k-1)。
不过,对于完全二叉树来书,最后一层的节点个数有点不遵守上面的规律了。它包含的节点个数在1个到2^(lL-1)个之间(我们假设最大层是L)。我们如果把每一层的节点个数加起来就是总的节点个数n。也就是说,如果节点的个数是n,那么n满足这样一个关系:
n >= 1+2+4+8+…+2^(L-2)+1
n <= 1+2+4+8+…+2^(L-2)+这个数2^(L-1)
借助等比数列的求和公式,我们可以计算出,L的范围是[log2(n+1), log2n +1](这里的2是底数)。也就是说完全二叉树的高度小于等于log2n。
显然,极度不平衡的二叉查找树,它的查找性能肯定不能满足我们的需求。我们需要构建一种不管怎么删除、插入数据,在任何时候,都能保持任意节点左右子树都比较平衡的二叉查找树,这就是我们下一节课要详细讲的,一种特殊的二叉查找树,平衡二叉查找树。平衡二叉查找树的高度接近logn,所以插入、删除、查找操作的时间复杂度也比较稳定,是O(logn)。