前几章介绍了数组和链表等数据存储结构
对于有序数组,查找很快(二分查找),插入数据必须找到插入数据项的位置,插入位置后的数据项全部向后挪动,来给新插入数据项腾出空间,平均移动次数N/2,这是很费时的,删除也是。
对于链表,链表插入和删除数据项很快,但查找很慢,需要从头到尾遍历,这个查找平均需要N/2次。
那么同时具备两种数据结构特性:查找快,插入删除快,引入了另外一种数据结构:树。
一、树
树(tree)是一种抽象数据类型(ADT),用来模拟具有树状结构性质的数据集合。它是由n(n>0)个有限节点通过连接它们的边组成一个具有层次关系的集合。把它叫做树是因为它看起来像一棵倒挂的树,也就是说它是根朝上,而叶朝下的。
树的术语
根结点: 根结点是没有双亲的结点,一棵树中最多有一个根结点。
孩子结点:一棵树中,一个结点的子树的根结点称为其孩子结点,如上图的A的孩子结点右B、C、D。
父母结点:相对于孩子结点而已其前驱结点即为父母结点,如上图的B、C、D 三个结点的父母结点都为A,当然E、F结点的父母结点则是B。
兄弟结点:拥有相同的父母结点的所有孩子结点叫作兄弟结点,如上图B、C、D 三个结点共同父结点为A,因此它们是兄弟结点,E、F也是兄弟结点,但是F、G就肯定不是兄弟结点了。
祖先结点:如果存在一条从根结点到结点Q的路径,而且结点P出现在这条路径上,那么P就是Q的祖先结点,而结点Q也称为P的子孙结点或者后代。如上图的E的祖先结点有A和B,而E则是A和B的子孙结点。
叶子结点:没有孩子结点的结点叫作叶子结点,如E、F、G、H等。
结点的度:指的是结点所拥有子树的棵数。如A的度为3,F的度为0,即叶子结点的度为0,而树的度则是树中各个结点度的最大值,如图(d)树的度为3(A结点) 。
树的层:又称结点的层,该属性反映结点处于树中的层次位置,我们约定根结点的层为1,如上图所示,A层为1,B层为2,E的层为3。
边:边表示从父母结点到孩子结点的链接线,如上图(d)中A到B间的连线则称为边。
深度:对于任意节点n,n的深度为从根到n的唯一路径长,根的深度为0。
高度:对于任意节点n,n的高度为从n到一片树叶的最长路径长,所有树叶的高度为0。
关于树的深度和高度的直观理解
二、二叉树
树的每个节点最多只能有两个子节点。
如果我们给二叉树加一个额外的条件,就可以得到一种被称作二叉搜索树(binary search tree)的特殊二叉树。
二叉搜索树要求:若它的左子树不空,则左子树上所有结点的值均小于它的根结点的值; 若它的右子树不空,则右子树上所有结点的值均大于它的根结点的值; 它的左、右子树也分别为二叉排序树。
二叉树的节点查找、插入、删除以及遍历树是怎么实现的?
二叉树节点类:
static class Node{
private Node left;
private Node right;
private String key;
public Node(String key){
this(key, null, null);
}
public Node(String key, Node left, Node right){
this.key = key;
this.left = left;
this.right = right;
}
public Node getLeft() {
return left;
}
public void setLeft(Node left) {
this.left = left;
}
public Node getRight() {
return right;
}
public void setRight(Node right) {
this.right = right;
}
public String getKey() {
return key;
}
public void setKey(String key) {
this.key = key;
}
}
查找节点
查找某个节点,我们必须从根节点开始遍历。
①、查找值比当前节点值大,则搜索右子树;
②、查找值等于当前节点值,停止搜索(终止条件);
③、查找值小于当前节点值,则搜索左子树;
public Node find(int key) {
Node current = root;
while (current != null) {
if (current.key > key) {//当前值比查找值大,搜索左子树
current = current.left;
} else if (current.key < key) {//当前值比查找值小,搜索右子树
current = current.right;
} else {
return current;
}
}
return null;//遍历完整个树没找到,返回null
}
插入节点
要插入节点,必须先找到插入的位置。与查找操作相似,由于二叉搜索树的特殊性,待插入的节点也需要从根节点开始进行比较,小于根节点则与根节点左子树比较,反之则与右子树比较,直到左子树为空或右子树为空,则插入到相应为空的位置,在比较的过程中要注意保存父节点的信息及待插入的位置是父节点的左子树还是右子树,才能插入到正确的位置。
public boolean insert(int data) {
Node newNode = new Node(data);
if(root == null){//当前树为空树,没有任何节点
root = newNode;
return true;
}else{
Node current = root;
Node parentNode = null;
while(current != null){
parentNode = current;
if(current.data > data){ // 当前值比插入值大,搜索左子节点
current = current.left;
if(current == null){ // 左子节点为空,直接将新值插入到该节点
parentNode.left = newNode;
return true;
}
}else{
current = current.right;
if(current == null){//右子节点为空,直接将新值插入到该节点
parentNode.rightChild = newNode;
return true;
}
}
}
}
return false;
}
遍历树
遍历树是根据一种特定的顺序访问树的每一个节点。比较常用的有前序,中序,后序遍历。而二叉搜索树最常用的是中序遍历。
// 前序遍历递归
public void preOrder(Node node){
if(node != null){
System.out.println(node.getKey());
preOrder(node.getLeft());
preOrder(node.getRight());
}
}
// 中序遍历递归
public void midOrder(Node node){
if(node != null){
midOrder(node.getLeft());
System.out.println(node.getKey());
midOrder(node.getRight());
}
}
// 后序遍历递归
public void postOrder(Node node){
if(node != null){
postOrder(node.getLeft());
postOrder(node.getRight());
System.out.println(node.getKey());
}
}
另外介绍三种迭代的遍历方式:
/**
*
* @Description: 迭代前序
* @param node
* @return void
* @author jiangtj
* @date 2017年10月19日 上午10:07:57
* @version 1.0
*/
public void iteratePreOrder(Node node){
Stack<Node> stack = new Stack<Node>();
while(node != null || !stack.isEmpty()){
while(node != null){
stack.push(node);
System.out.println(node.getKey());
node = node.getLeft();
}
if(!stack.isEmpty()){
Node p = stack.pop();
node = p.getRight();
}
}
}
/**
*
* @Description: 迭代中序
* @param node
* @return void
* @author jiangtj
* @date 2017年10月19日 上午10:08:22
* @version 1.0
*/
public void iterateMidOrder(Node node){
Stack<Node> stack = new Stack<Node>();
while(node != null || !stack.isEmpty()){
while(node != null){
stack.push(node);
node = node.getLeft();
}
if(!stack.isEmpty()){
Node p = stack.pop();
System.out.println(p.getKey());
node = p.getRight();
}
}
}
/**
*
* @Description: 迭代后序:判断上一次访问的节点是左子树还是右子树,如果左子树,先访问右子树再到父节点,如果是右子树,直接访问父节点
* @param node 根节点
* @return void
* @author jiangtj
* @date 2017年10月19日 上午10:08:39
* @version 1.0
*/
public void iteratePostOrder(Node node){
Node currNode = null;
Node lastVisitNode = null;
Stack<Node> stack = new Stack<Node>();
while(node != null || !stack.isEmpty()){
while(node != null){
stack.push(node);
node = node.getLeft();
}
if(!stack.isEmpty()){
currNode = stack.pop();
if(currNode.getRight() != null && currNode.getRight() != lastVisitNode){
stack.push(currNode);
currNode = currNode.getRight();
while(currNode != null){
stack.push(currNode);
currNode = currNode.getLeft();
}
}else{
System.out.println(currNode.getKey());
lastVisitNode = currNode;
}
}
}
}
查找最大最小值
要找最小值,先找根的左节点,然后一直找这个左节点的左节点,直到找到没有左节点的节点,那么这个节点就是最小值。同理要找最大值,一直找根节点的右节点,直到没有右节点,则就是最大值。
// 查找最大值
public Node findMax(){
Node current = root;
Node maxNode = current;
while(current != null){
maxNode = current;
current = current.right;
}
return maxNode;
}
// 查找最小值
public Node findMin(){
Node current = root;
Node minNode = current;
while(current != null){
minNode = current;
current = current.left;
}
return minNode;
}
删除节点
删除节点是二叉搜索树中最复杂的操作,删除的节点有三种情况,前两种比较简单,但是第三种却很复杂。
1、该节点是叶节点(没有子节点)
2、该节点有一个子节点
3、该节点有两个子节点
①、删除没有子节点的节点
要删除叶节点,只需要改变该节点的父节点引用该节点的值,即将其引用改为 null 即可。要删除的节点依然存在,但是它已经不是树的一部分了,由于Java语言的垃圾回收机制,我们不需要非得把节点本身删掉,一旦Java意识到程序不在与该节点有关联,就会自动把它清理出存储器。
public boolean delete(int key) {
Node current = root;
Node parent = root;
boolean isLeft = false;
// 查找删除值,找不到直接返回false
while (current.data != key) {
parent = current;
if (current.data > key) {
isLeft = true;
current = current.left;
} else {
isLeft = false;
current = current.right;
}
if (current == null) {
return false;
}
}
// 如果当前节点没有子节点
if (current.left == null && current.right == null) {
if (current == root) {
root = null;
} else if (isLeft) {
parent.left = null;
} else {
parent.right = null;
}
return true;
}
return false;
}
删除节点,我们要先找到该节点,并记录该节点的父节点。在检查该节点是否有子节点。如果没有子节点,接着检查其是否是根节点,如果是根节点,只需要将其设置为null即可。如果不是根节点,是叶节点,那么断开父节点和其的关系即可。
②、删除有一个子节点的节点
删除有一个子节点的节点,我们只需要将其父节点指向该删除节点的子节点即可。
//当前节点有一个子节点
if (current.left == null && current.right != null) { // 删除节点有右子节点
if (current == root) {
root = current.right;
} else if (isLeft) {
parent.left = current.right;
} else {
parent.right = current.right;
}
return true;
} else {
// current.left != null && current.right == null // 删除节点有左子节点
if (current == root) {
root = current.left;
} else if (isLeft) {
parent.left = current.left;
} else {
parent.right = current.left;
}
return true;
}
③、删除有两个子节点的节点
当删除节点存在两个节点时,使用某个节点来替代删除节点,寻找替代节点:二叉搜索树中的节点是按照关键字来进行排列的,所以找寻该删除节点的次大节点,也就是该节点中序遍历的后继节点(比删除节点大的最小节点),只有这样代替删除节点后才能满足二叉搜索树的特性。
算法:程序找到删除节点的右节点,(注意这里前提是删除节点存在左右两个子节点,如果不存在则是删除情况的前面两种),然后转到该右节点的左子节点,依次顺着左子节点找下去,最后一个左子节点即是后继节点;如果该右节点没有左子节点,那么该右节点便是后继节点。
需要确定后继节点没有子节点,如果后继节点存在子节点,那么又要分情况讨论了。
①、后继节点是删除节点的右子节点
②、后继节点是删除节点的右子节点的左子节点
public Node getSuccessor(Node delNode){
Node successorParent = delNode;
Node successor = delNode;
Node current = delNode.rightChild;
while(current != null){
successorParent = successor;
successor = current;
current = current.leftChild;
}
//将后继节点替换删除节点
if(successor != delNode.rightChild){
successorParent.leftChild = successor.rightChild;
successor.rightChild = delNode.rightChild;
}
return successor;
}
二叉树的效率
特定节点数量的满树的层数
节点数N与层数L的关系:N=2^L-1,L=log(N+1)以2为底。
常见树的操作时间复杂度大致是N以2为底的对数,大O表示法O(logN)。
对于查询:1000000 个数据项的无序数组和链表中,查找数据项平均会比较500000 次,但是在有1000000个节点的二叉树中,只需要20次或更少的比较即可。
对于插入:有序数组可以很快的找到数据项,但是插入数据项的平均需要移动 500000 次数据项,在 1000000 个节点的二叉树中插入数据项需要20次或更少比较,在加上很短的时间来连接数据项。
对于删除:1000000 个数据项的数组中删除一个数据项平均需要移动 500000 个数据项,而在 1000000 个节点的二叉树中删除节点只需要20次或更少的次数来找到他,然后在花一点时间来找到它的后继节点,一点时间来断开节点以及连接后继节点。
所以,树对所有常用数据结构的操作都有很高的效率。遍历可能不如其他操作快,但是在大型数据库中,遍历是很少使用的操作,它更常用于程序中的辅助算法来解析算术或其它表达式。
参考:https://www.cnblogs.com/ysocean/p/8032642.html