二叉树
为什么要用到二叉树?
先来看看有序数组的优缺点:
在有序数组中做查询,可以使用二分查找法,会提高查找效率,缩短查找次数,而用二分查找法的效率是O(logN)。也可以使用顺序遍历访问每个数据项。
然而,在有序数组中,想要插入数据项和删除数据项,需要先找到数据项的位置,然后将数据项后面的数据移动,平均移动数据项次数是N/2,所以很慢。
查找插入位置如果用遍历查找的是O(n),用二分查找是O(log2n)。
但是数组的插入操作需要将插入位置后的元素全部后移一位,这需要O(n)。
所以总的时间复杂度是O(n)。(O(n)+O(n)=O(n),O(log2n)+O(n)=O(n))。
显而易见,需要多次插入和删除数据,不适合使用有序数组。
再来看看链表的优缺点:
在链表中做删除和插入操作,需要改变一些引用的值就行了,这些操作的时间复杂度是O(1)。
遗憾的是,链表中查找数据项可不是那么容易。查找必须从头开始,依次访问链表中的每一个数据项,直到找到该数据项为止。因此,需要平均访问N/2个数据项,并且每次都要和数据项比较,这个过程很慢,费时O(N)。
用树解决问题:
要是能有一种数据结构,既能像有序数组那样快速查找数据,又可以向链表一样快速插入和删除。
根:树的顶层,一棵树只有一个根,一个根可以有多个子节点。
叶节点:也叫叶子节点,在一棵树中,没有子节点的节点,称为叶子节点,说明叶子节点已经是树的底部了。
路径:从一个节点,希望走到另一个节点,所经过的节点的顺序排列就被称为“路径”。比如在windows系统中,我们要查找一个文件,需要先找到C盘,然后进入C盘后,再查找包含文件的文件夹,打开之后,再一级级打开文件夹,直到找到文件,那么我们访问过的文件夹目录,就是文件的路径。
子树:每个节点都可以作为子树的根节点,这个节点和它包含的子节点,孙节点,组成的树就是子树。
层:一棵树中,可以把根节点看做是0层,根节点的子节点,看做是1层,根节点的孙节点,看做是2层等等。一棵树的层数,体现了这棵树的深度。
二叉树:每个节点最多只有两个子节点,我们称作二叉树,并且二叉树的两个子节点称作,左子节点,右子节点。
二叉搜索树:一个二叉树中,父节点的左子节点小于这个节点,右子节点大于等于父节点,称作二叉搜索树。
非平衡树:树中大部分节点在根的一边或者另一边,个别子树也可能是非平衡的
二叉树查找节点:
代码实现
public class Node {
Person data;
Node leftNode;
Node rightNode;
public void displayNode() {
}
}
class Person {
int iData;
double dData;
public Person(int i, double d) {
iData = i;
dData = d;
}
}
class Tree {
Node root;
public Node find(int key) {
Node current = root;
while (current.data.iData != key) {
if (key < current.data.iData) {
if (current.leftNode != null) {
current = current.leftNode;
} else {
return null;
}
} else {
if (current.rightNode != null) {
current = current.rightNode;
} else {
return null;
}
}
}
return current;
}
}
树的查找效率:
查找节点的时间,取决于该节点在树中的层数。最多31个节点,不超过5层----则最多只需要5次比较,就可以找到这个节点。他的时间复杂度度是O(logN),更紧缺的说是O(log2N),以2为底的对数。
插入一个节点:
向树中插入新节点45
代码实现:
public void insert(int i, double d){
Node newNode = new Node(i, d);
if(root == null){
root = newNode;
}else{
Node current = root;
while(true){
if(i < current.data.iData){
current = current.leftChild;
if(current == null){
current.leftChild = newNode;
return;
}
}else{
current = current.rightChild;
if(current == null){
current.rightChild = newNode;
return;
}
}
}
}
}
遍历树:
遍历树的意思是根据一种特定顺序,访问树的每一个节点。有三种简单方法:前序(preorder),中序(inorder),后序(postorder)。二叉树最常用的方法是中序遍历。
中序遍历:
中序遍历二叉树会使所有的节点按关键字值升序被访问到。如果希望二叉树创建有序的数据序列,这是一种方法。
遍历数最简单的方法是递归,用递归方法遍历整棵树要用一个节点作为参数,初始化这个节点为根节点,这个方法只需要做三件事:
1.调用自身来遍历节点的左子树
2.访问这个节点
3.调用自身来遍历节点的右子树
中序遍历java代码实现:
public void inOrder(Node current) {
if(current != null){
inOrder(current.leftChild);
System.out.println(current.data.iData);
inOrder(root.rightChild);
}
}
前序遍历:
同中序遍历一样,前序遍历也要经过三个步骤,但是前序遍历的顺序与中序遍历不一致:
1.访问自身节点
2.调用自身遍历该节点的左子树
3.调用自身遍历该节点的右子树
后序遍历:
后序遍历,把三个步骤的顺序又换了一下:
1.调用自身遍历节点的左子树
2.调用自身遍历节点的右子树
3.访问自身节点
中序遍历输出结果:A*B+C
前序遍历输出结果:*A+BC
后序遍历输出结果:ABC+*
查询最大值和最小值:
在二叉搜索树中想得到最大值和最小值是很容易的事情,因为二叉搜索树已经将最小的节点放在树的最左叶节点上了,最大节点放在树的最右叶节点上了。
取最小值,最大值的JAVA代码实现:
public Node minimum() { Node current = root; Node last = null; while (current != null) { last = current; current = current.leftChild; } return last; } public Node maximum(){ Node current = root; Node last = null; while (current != null) { last = current; current = current.rightChild; } return last; }
删除节点:
删除节点是二叉树常用操作,但是也是最复杂的。
删除节点要从查找开始入手,找到这个节点后,删除操作要经过下面的步骤:
1.该节点是否是叶子节点
2.该节点有一个子节点
3.该节点有两个子节点
下面将依次介绍这三种情况。第一种最简单:第二种也比较简单;第三种相对复杂了。
情况一:删除没有子节点的叶节点
要删除叶节点,只需要改变该节点的父节点的对应子节点的值,比如之前父节点是指向该叶节点的,现在把父节点的这个指向改为null。虽然要删除的节点还存在,但是它已经不属于这棵树了。java的自动垃圾回收机制,发现该节点没有任何引用了,就会回收掉。
删除没有任何子节点的节点JAVA代码实现:
public boolean delete(int key) {
Node parent = root;
Node current = root;
boolean isLeftChild = true;
// 循环查找待删除结点,并记录查找到的节点与父节点的关系:左子节点还是右子节点
while (current.data.iData != key) {
if (key < parent.data.iData) {
current = parent.leftChild;
} else if (key > parent.data.iData) {
isLeftChild = false;
current = parent.rightChild;
}
}
// 没找到节点,返回false
if(current == null){
return false;
}
// 没有子节点的节点:判断是否是根节点;判断找到的节点是父节点的左子节点还是右子节点
if(current.leftChild == null && current.rightChild == null){
if(current == root){
root = null;
}else if(isLeftChild){
parent.leftChild = null;
}else{
parent.rightChild = null;
}
}
return true;
}
情况二删除有一个子节点的节点JAVA代码实现:
// 删除有一个子节点的节点
if(current.leftChild != null && current.rightChild == null){
if(current == root){
root = current.leftChild;
}else if(isLeftChild){
parent.leftChild = current.leftChild;
}else{
parent.rightChild = current.leftChild;
}
}
if(current.rightChild != null && current.leftChild == null){
if(current == root){
root = current.rightChild;
}else if(isLeftChild){
parent.leftChild = current.leftChild;
}else{
parent.rightChild = current.leftChild;
}
}
情况三删除有两个子节点的节点:
当我们要删除节点25时,恰好节点25包含两个子节点15和35,且子节点15和35,都包含有各自的子节点,那么该使用哪个节点作为后继节点?因为搜索二叉树中,比当前节点大的节点一定在当前节点的右边,所以我们需要在删除节点25的右边的集合里,寻找一个最小的值,来当后继节点。就是说在待删除节点的右子节点里,寻找左子节点,如果左子节点还有左子节点,则一路向下返回最后一个左子节点,此时该左子节点就是比删除节点大的集合里的最小节点,来当后继节点。
二叉树的效率
树的大部分工作,都是需要从上到下一层一层地查找到某个节点。一棵满树中,大约有一半的节点,在树的最底层(准确的说,一棵满树,最底层的节点梳篦上面的节点数多一个),因此,查找、插入或删除节点的操作大约有一半都需要找到最底层的节点。(另外还有四分之一节点的这些操作要到倒数第二层,以此类推)。
二叉搜索树比较的次数 L=log2(N+1) 大概是N以2为底的对数。
在大O表示法中,表示为O(logN),如果树不满,那么不满的树平均查找时间比满树要短,因为在它层数较低的子树上完成查找的次数要比满树时少。
树的优点是,查找和删除元素比较快,但是遍历不如其他操作快,但是遍历在大型数据结构中不是常用的操作。
用数组表示树
用数组的方式存储树时,节点存在数组中,而不是由引用相连。节点在数组中的位置对应于它在树中的位置。下标为0的节点是根,下标为1的是左子节点,下标为的2是右子节点,以此类推,按照从左到右的顺序,依次存储树的每一层。
树中的每个位置,无论节点是否存在,都对应数组中的一个位置。把节点插入树的一个位置,意味着要在数组的相应的位置插入一个数据项,树中没有节点的位置在数组中对应0或null。
基于这种思想,要在数组中查找树的节点可以利用简单的算术计算它们在数组中的索引值。
设节点索引值为index,则节点的左子节点是:
2*index+1
右子节点是
2*index+2
它的父节点是
(index-1)/2 (“/”表示整除运算)
大多数情况下,用数组表示树不是很有效率。不满的节点和删除后的节点,在数组中会留下洞,浪费空间。更坏的是,如果删除节点时需要移动子树,子树中的每个节点都要移动到新位置上去,这在比较大的树中很费时。
不过,如果不允许删除,数组存储树会很有用,特别是某些原因要动态地为每个节点分配存储空间比较费时。数组表示发在特定的情况下也很有用,比如要将树的节点,画到屏幕上固定的位置上,就可以使用数组先存储整棵树,通过遍历数组,取出树的节点用于显示。
重复关键字
和其他数据结构一样,重复的关键字必须要被提到。
有重复关键字的节点都插入到与他关键字相同的节点的右子节点处。
问题是,find()方法只能找到两个(或多个)相同关键字节点中的第一个。可以修改查找方法,区分相同关键字的数据项,但是这样做很耗时。
一种简单的选择是禁止重复关键字,使用树来存储不会具有相同关键字的数据,比如员工id。再或者就是在插入操作时,检查关键字是否相同,相同时放弃插入操作。
哈夫曼(huffman)编码
二叉树并不全是搜索树,很多二叉树用于其他情况。
哈夫曼编码,是使用二叉树来压缩数据,1952年被david huffman发现这种方法后,就称它为哈弗曼编码。数据压缩在很多领域都很重要。