算法与数据结构--简单易懂的二分搜索树(java实现)

二分搜索树

二分搜索树,有时我们也简称二叉树,它是一种树形的数据结构,结构图如下:
在这里插入图片描述

但是二叉树结构不一定是完全二叉树(完全二叉树只是在有h层的二叉树中,在从1~h-1层的每个节点的子节点都是满的,且连续节点都集中在左边)

1、二分搜索树的优势

通常用于查找,通过键就可以获得值,插入也很方便,插入和查找时间复杂度都是O(logN).
在这里插入图片描述

从上图我们可以很清楚的比较,二分搜索树这种数据结构的性能是比较全面的,插入、删除、查找等常见操作的时间复杂度都是O(logN), 它可以做到:

  • 高效的执行插入、删除、查找等操作
  • 可以很方便的回答很多数据间的关系:min、max、floor、ceil、rank、select等值

2、二叉树的实现

在本文中,假设所有的key都不重复。

2.1定义节点对象

二叉树是由一个一个的节点组成的,每个节点存放一个键值对,定义如下:

//键和值的具体对象在使用时才传入,所以这里用泛型定义
//但是键Key对象一定要实现Comparable接口,才能比较大小
public class BinarySearchTree<Key extends Comparable<Key>, Value> {

	private Node root; //存放二叉树的根节点
	private int count;//存放二叉树节点数量
	
	//对于外界,不需要知道节点对象的存在,因此,定义一个私有内部类
	private class Node {
		private Node left;
		private Node right;
		private Key key;
		private Value value;
		//private Node parent; //根据实际情况,决定是否需要父节点
		
		public Node(Key key, Value value) {
			this.key = key;
			this.value = value;
			left = right = null;
		}
	}
...
}

2.2实现插入操作

实现插入操作,既可以用递归的方式,又可以用循环的方式实现,使用递归的方式进行插入,递归的优点是逻辑比较清楚简洁,只是性能上比使用循环插入稍微差一点点,越是复杂的结构,判断越多,这个时候使用递归就越能体现出逻辑的清楚简洁。我们先用递归的方式,代码如下:

//插入操作,外界只需要传入键值即可,不需要感知内部的节点对象
public void insert(Key key, Value value) {
	insert(key, value, root);
}

//以某个节点为根节点,将目标值插入这个子树, 返回值为当前子树的根节点
private Node insert(Key key, Value value, Node root) {
	
	//如果要插入子树的根节点为null,则直接插入这个位置
	if(root == null) {
		root = new Node(key, value);
		count ++;
	}
	
	if(key.compareTo(root.key) == 0) {
		//如果key相等,则更新值
		root.value = value;
	}
	
	if(key.compareTo(root.key) > 0) {
		//大于当前子树的根节点,则继续往右子树去找插入位置
		root.right = insert(key, value, root.right);
	} else {//小于当前子树根节点,则往右寻找插入位置
		root.left = insert(key, value, root.left);
	}
	
	return root;
}

使用循环的方式插入,代码如下:

public void insert2(Key key, Value value) {
	Node childTreeRoot = root; //首先给循环的判断条件赋值
	while(childTreeRoot != null) {
		if(key.compareTo(childTreeRoot.key) == 0) {
			//说明之前插入过这个key,此时,只需要更新value即可,因为value可能变化了
			childTreeRoot.value = value;
		}
		
		if(key.compareTo(childTreeRoot.key) > 0) { //大于当前子树的key,则往右子树查找插入位置
			childTreeRoot = childTreeRoot.right;
		} else {
			childTreeRoot = childTreeRoot.left;
		}
	}
	
	//走到此处,说明找到了要插入节点的位置,并且这个位置为null
	childTreeRoot = new Node(key, value);
	count ++;//新增了一个节点
}

2.3、实现查找操作

其实查找操作在具体需求中可以分为两种:1.是否包含某个键值;2.找到并返回这个节点的值。

首先来看是否包含某个Key值,先用递归实现:

//使用递归,是否包含某个值
public boolean contain(Key key) {
	return contain(root, key);
}

private boolean contain(Node node, Key key) {
	
	if(node == null) {
		return false; //在树中没找到
	}
	
	if(key.compareTo(node.key) == 0) {
		return true;
	}
	
	if(key.compareTo(node.key) < 0) {
		return contain(node.left, key);
	} else {
		return contain(node.right, key);
	}
}

使用循环实现:

public boolean contain2(Key key) {
	Node childTreeRoot = root; 
	
	while(childTreeRoot != null) {//定义循环条件
		if(key.compareTo(childTreeRoot.key) == 0) {
			return true;
		} 
		
		if(key.compareTo(childTreeRoot.key) > 0) {
			childTreeRoot = childTreeRoot.right;
		} else {
			childTreeRoot = childTreeRoot.left;
		}
	}
	
	//走到这里,说明为childTreeRoot为null,都找到空节点了,说明不存在
	return false;
}

然后实现具体的查找某个值,也是分别用递归和循环实现:

//使用递归进行搜索
public Value search(Key key) {
	return search(root, key);
}

private Value search(Node node, Key key) {
	
	if(node == null) {
		return null;
	}
	
	if(key.compareTo(node.key) == 0) {
		return node.value;
	}
	
	if(key.compareTo(node.key) > 0) {
		return search(node.right, key);
	} else {
		return search(node.left, key);
	}
}

//使用循环进行搜索
public Value search2(Key key) {
	Node childTreeRoot = root;
	
	while(childTreeRoot != null) {
		if(key.compareTo(childTreeRoot.key) == 0) {
			return childTreeRoot.value;
		}
		
		if(key.compareTo(childTreeRoot.key) > 0) {
			childTreeRoot = childTreeRoot.right;
		} else {
			childTreeRoot = childTreeRoot.left;
		}
	}
	
	//走到这里,说明childTreeRoot为null,不存在这个key
	return null;
}

2.4、二分搜索树的遍历

2.4.1深度优先遍历

深度遍历,就是纵向的遍历,先沿着某条先纵向便利完毕后,再遍历另一条线,通常有三种遍历方式:前序遍历、中序遍历、后序遍历:

  • 前序遍历:先访问当前节点,再递归访问左子树,后递归访问右子树
  • 中序遍历:先递归访问左子树,再访问当前节点,后递归访问右子树
  • 后序遍历:先递归访问右子树,再递归访问当左子树,后访问当前节点

可以看到,前中后的定义,是以什么时候去访问当前节点为准的,先访问当前节点,就是前序遍历。

代码实现:

//前序遍历
public void preOrder(){
	preOrder(root);
}

private void preOrder(Node node) {
	//先访问当前节点
	if(node != null) {
		System.out.println(node.value);
		//然后访问左子树点
		preOrder(node.left);
		//然后访问右子树
		preOrder(node.right);
	}
}

//中序遍历
public void inOrder() {
	inOrder(root);
}

private void inOrder(Node node) {
	if(node != null) {
		//先访问左子树
		inOrder(node.left);
		System.out.println(node.value);
		//最后访问右子树
		inOrder(node.right);
	}
}

public void postOrder() {
	postOrder(root);
}

private void postOrder(Node node) {
	if(node != null) {
		inOrder(node.left);
		inOrder(node.right);
		System.out.println(node.value);
	}
}

这里的前中后序遍历,都只是打印了对应节点的值,如果有需要,可以把打印改为其他操作,如一个链表或者队列,就可以依次存入对应的节点或者value值。中序遍历可以从小打到顺序的打印整个二叉树。后续操作可以用来摧毁二叉树,先把左右节点都置为null后,再将当前节点置为null,然后向上递归。

使用后序遍历摧毁二叉树的代码如下:

public void destory() {
	destory(root);
}

private void destory(Node node) {
	if(node != null) {
		destory(node.left);
		destory(node.right);
		node = null;
	}
}
2.4.2 广度优先遍历

广度优先遍历通常也称为层序遍历,就是一层一层的遍历,便利了这一层,再访问下一层。如下所示:

在这里插入图片描述

对这么一个二叉树层序遍历,那么依次应该输出 28, 16,30,12,22,29,42。代码实现如下:

//层序遍历
public void levelOrder() {
	if(root == null) {
		return ;
	}
	//LinkedBlockingQueue 一个由链接节点支持的可选有界队列
	LinkedBlockingQueue<Node> queue = new LinkedBlockingQueue<>();
	queue.add(root);
	while(queue != null) {
		//移除并返回队列头部元素,因为进入了循环,所以node必不为null
		Node node = queue.poll(); 
		System.out.println(node.value); //打印节点的值
		if(node.left != null) {
			queue.add(node.left);//层序遍历左节点
		}
		if(node.right != null) {
			queue.add(node.right);//层序遍历右节点
		}
	}
}

这里使用了一个队列来装数据,在实际应用中,多种不同数据结构配合使用,是一种很常见的形式。

2.5 删除二分搜索树的节点

这个操作应该是所有操作里面最难的了, 我们一步一部分分析。

2.5.1 删除节点的几种情况

  • 1.要删除的节点A 没有子节点,则直接删除即可
  • 2.要删除节点A 只有左子节点,则A的父节点的左子节点要指向A的左子节点,然后就可以删除A了。如果只有右子节点,情况类似。
  • 3.要删除的节点A 既有左子节点,又有右子节点。此时需要在 以A 为根节点的子树中,找出一个后继节点来代替A,组成一颗新的子树,然后删除A。

上面的第一、二种情况都好理解,关键是第三种情况,怎么找出一个后继节点来代替要删除的节点A呢? 这就要利用二叉树的规则了,二叉树中,节点左边的元素都要小于这个节点,节点右边的元素都要大于这个节点。 所以要找的这个后继节点,只能是左子树中的最大值,或者右子树中的最小值,因为这样,这个值才能比所有左子树的节点大且比右子树的所有节点小。

分析完了,那我们首先看看如何查找最小值或最大值以及删除他们。

###2.5.2 二分搜索树的最小值和最大值
寻找最大值或最小值代码入下:

//寻找最小值
public Value minimum() {
	if(root != null) {
		return minimum(root).value;
	}
	return null;
}

private Node minimum(Node node) {
	if(node.left == null) {
		return node;
	}
	return minimum(node.left);
}

//寻找最大值
public Value maximum() {
	if(root != null) {
		return maximum(root).value;
	}
	return null;
}
private Node maximum(Node node) {
	if(node.right == null) {
		return node;
	}
	return maximum(node.right);
}

删除最大值或最小值

public void removeMin() {
	root = removeMin(root);//删除并更新根节点
}

public void removeMax() {
	root = removeMax(root);//删除并更新根节点
}

// 删除掉以node为根的二分搜索树中的最大节点
// 返回删除节点后新的二分搜索树的根
private Node removeMax(Node node) {
	if(node.right == null) {
		//已经是最大值了
		Node leftNode = node.left;
		node = null;
		count--;
		return leftNode;
	}
	
	node.right = removeMax(node.right);
	return node;
}

// 删除掉以node为根的二分搜索树中的最小节点
// 返回删除节点后新的二分搜索树的根
private Node removeMin(Node node) {
	if(node.left == null) {
		Node rightNode = node.right;
		node.right = null;
		count--;
		//右子节点作为当前子树 新的根节点
		return rightNode;
	}
	
	//如果不是叶子节点,则 移除最小元素以后的 子树的根节点 , 需要赋值给当前根节点的 左孩子
	node.left = removeMin(node.left); 
	return node;
}
2.5.3 删除节点的代码实现

删除的操作比较复杂,但是耗时是比较少的,特别是在数据量很大的时候,可以忽略,耗时主要都在查找位置上,所以删除的时间复杂度和搜索的时间复杂度一样,都是O(logN)
上面的分析完毕, 此处我们直接实现代码:

public void remove(Key key) {
	if(root != null) {
		root = remove(root, key);//执行完后可能会更新根节点
	}
}

private Node remove(Node node, Key key) {
	
	if(key.compareTo(node.key) < 0) {
		//继续往左子树找,执行完后更新根节点
		node.left = remove(node.left, key);
		return node;
	} else if(key.compareTo(node.key) > 0) {
		//继续往右子树找,执行完后更新根节点
		node.right = remove(node.right, key);
		return node;
	} else { //等于当前节点,则删除当前节点
		
		if(node.left == null) {
			//左子树为null,后继节点就是当前节点的左子节点
			Node rightNode = node.right;//先保存好值
			node.right = null;//断开与其他节点的引用,则会被回收
			count --;//数量减一
			return rightNode;
		}
		
		if(node.right == null) {
			//右子树为null,后继节点就是当前节点的左子节点
			Node leftNode = node.left;//先保存好值
			node.left = null;//断开与其他节点的引用,则会被回收
			count --;//数量减一
			return leftNode;
		}
		
		//如果左右节点都不为空的情况,则需要找一个后继者
		//后继者可以是左子树的最大值或右子树最小值,这里我们使用右子树最小值
		
		//找到最小节点并用它的key value新建一个对象,因为称为后继者,就需要删除这个最小节点
		Node newRoot = new Node(minimum(node.right).key, minimum(node.right).value);
		
		//继任者的右子节点指向 要删除节点的右子树的根节点,同时删除右子树的最小值
		newRoot.right = removeMin(node.right);
		count ++; //特别注意,因为上一步执行移除操作,减少一次,这里要加回来
		newRoot.left = node.left;
		
		//把左右的引用去掉,等垃圾回收器回收
		node.left = null;
		node.right = null;
		
		count --; //执行删除完毕以后,需要减一次
		return newRoot; //返回当前子树的新的根节点,和其父节点建立关系
	}
}

2.6 二分搜索树的顺序性

2.6.1 查找最小值以及最大值

上一步已经实现了

2.6.2 实现前驱或后继方法

实现前驱successor或者后继predecessor方法,可以复用前面的代码,其实刚刚删除有左右孩子的节点是,就需要找到一个后继节点来代替被删除的节点。后继方法把刚刚的代码赋值一下即可

2.6.2 二分搜索树的floor和ceil方法
  • floor方法:查找比目标值小的最大值的位置
  • ceil方法: 查找比目标值大的最小值的位置

floor的实现如下,感兴趣的可以自己实现ceil方法。

//寻找比传入的key小的最大值,传入的key不一定在二叉树中存在(也可能存在)。即最接近key,但是又比key小的值
public Key floor(Key target) {
	if(root != null) {
		//有可能找不到floor值,即二叉树中可能不存在比传入的target小的值,所以初始为null
		return floor(root, null,target).key; 
	}
	return null;
}

private Node floor(Node node, Node floor, Key target) {
	if(node == null) {
		return floor;//如果当前节点已经是null了,则返回上一次找到的floor节点
	}
	if(target.compareTo(node.key) == 0) {
		return node;//如果存在相等,则返回相等的节点
	}
	
	if(target.compareTo(node.left.key) < 0) {
		//目标值比当前节点小,则继续往当前节点的左子树中去尝试查找,看是否有比目标值小的节点存在
		return floor(node.left, floor, target);
	} else {
		//目标值比当前节点大,则继续尝试往当前节点的右子树中去查找
		//看是否有比当前节点大又比目标节点小的节点存在
		return floor(node.left, floor, target);
	}
}
2.6.2 二分搜索树的rank方法

rank方法,就是回答要查找的元素在二分搜索树的排名,如果要实现这个方法,还需要给节点添加一个属性,用来记录以当前节点为根节点的子树总共有多少个节点(包含其本身),示例图如下:

这样,只需要找到要查找元素N的位置,如果N是父节点的左孩子,则比N小的就是N左子树的所有节点的数量totalLeft,则N的排名就是totalLeft+1。同理,如果要查找元素N是父节点的右孩子,则比N大的就只有N的右子树的所有节点totalRight, 所以排名就是二叉树总的节点数减去totalRight。

感兴趣的可以实现一下。

3.支持重复元素的二分搜索树

以上都假设的是没有重复元素的二分搜索树,如果想实现包含重复元素的二分搜索树,方法有很多,举几个例子:

  • 1.小于等于当前节点,就插入到左子树,否则插入右子树,这种方法每个元素占一个位置。
  • 2.如果有重复元素,则当前节点的key保持不变,value用一个链表或者其他容器来装,甚至可以记录插入的先后顺序。
  • 3.每个节点增加一个属性,用来记录当前节点重复元素的数量

4.二分查找树的不足

如果插入元素是有序的,或者大部分是有序的,那么二分查找树可能退化为链表,那插入查询删除的时间复杂度将退化为O(n)级别,所以在二分查找树上演化出了平衡二叉树等树结构,平衡二叉树左右高度差不会超过1,其中最著名的就是红黑树,红黑树将在下一篇讲解。

其他的平衡二叉树还有:2-3 tree, AVL tree,SPLAY tree

4.1平衡二叉树和堆的结合(Treap)

树和堆两种数据结构的组合的数据结构,tree + heap,称为Treap,中文称为树堆。相对于其他的平衡二叉搜索树,Treap的特点是实现简单,且能基本实现随机平衡的结构。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值