算法——查找之二叉查找树

我们之前了解了二分查找,对于二分查找,仅仅盯着查找的话,二分查找确实是不错的。但是如果我们需要查找的数组,会改变呢?

如果数组会改变,例如会删除其中某个元素,增加某个元素等。删除的话,如果需要删除的元素在数组的前面,那删除以后元素的移动又是一个很大的开销了。增加元素,也就是插入元素,需要再进行一次排序,开销也并不小。

所以能不能在保持查找效率的同时,数组的修改的开销也不大的方法呢?这就提到了二叉查找树。

二叉查找树并不是采用数组的方式,而是使用链表的方式。所以使得插入和删除变得容易。有人可能会说,那么将二分查找的方式也用在链表中不就可以了吗?乍一想之下好像可以,但是如果我们再深入思考一下,就会发现,二分查找的效率是因为数组访问能通过下标直接访问某个元素,而如果是链表呢?链表的方式的话,我想要获得中值,我都得遍历整个链表才能拿到,这显然就没什么价值了,效率还不如顺序查找。

而二叉查找树则不同,这个数据结构改变了查找的方式。


二叉查找树是怎么样的呢?

定义:一颗二叉查找树(BST)是一颗二叉树,每个节点都包含一个可比较的键,且每个节点的键都大于它的左子树的任意节点的键,小于它的右子树的任意节点的键。

如下所示:

《算法——查找之二叉查找树》


我们希望实现的目标是什么呢?

Node search(Comparable target) // 查找目标,返回树对应的节点
void put(Comparable target) // 插入元素
void delete(Comparable target) // 删除元素

这就是我们希望实现的。


那么我们首先来思考一下,如何构造二叉查找树呢?可以看到,一个节点至少包含三个元素:1.节点的键(用于比较) 2.节点的左节点 3.节点的右节点

我们的最简单的基础数据结构就出来了:

class Node {
	Comparable value;
	Node left;
	Node right;

	public Node(Comparable value) {
		this.value = value;
	}
}

我们还需要一个树的开头,也就是根节点:

private static Node root;

那么我们接下来先实现插入和查找吧。

查找如何实现呢?

查找
我们利用二叉查找树的性质就能很容易的进行查找。我们首先查看当前节点,一开始是根节点,如果目标比当前节点大,我们就去当前节点的右子树中查找,如果目标比当前节点小,我们就去当前节点的左子树中查找,如果找到了,我们直接返回找到的节点就可以了。我们这样递归的找下去,如果找到就返回节点,一直找不到就返回null。

《算法——查找之二叉查找树》
查找实现如下:

public static Node search(Comparable target) {
	return search(root, target);
}

private static Node search(Node x, Comparable target) {
	if (x == null)
		return null;
	int compared = target.compareTo(x.value);
	if (compared > 0) { // 目标比当前大
		return search(x.right, target);
	} else if (compared < 0) { // 目标比当前小
		return search(x.left, target);
	} else {
		return x;
	}
}

使用递归的方式非常简单。


查找实现完了,插入如何实现呢?

插入
我们可以仿照查找的思路,如果要插入的元素比当前元素小,就去左边插入,如果插入的元素比当前元素大,就去右边插入。

而如果当前元素相等的话,这就涉及到我们是否允许重复插入元素了,在这里我们不允许重复插入元素。如果你希望实现重复插入元素的功能,可以给每个节点一个count属性,标注当前这个元素被插入的次数等等。

如果最终找到了插入的位置,就新建一个节点,插入即可。

《算法——查找之二叉查找树》

实现:

public static void put(Comparable key) {
	root = put(root, key);
}

private static Node put(Node x, Comparable key) {
	if (x == null)
		return new BSTSearch().new Node(key);
	int compared = key.compareTo(x.value);
	if (compared > 0) {
		x.right = put(x.right, key);
	} else if (compared < 0) {
		x.left = put(x.left, key);
	}
	return x;
}

这里private函数返回的是修改之后的树。例如:root = put(root, key)。意思是root指向将key插入所得到的新的树。这样,很容易理解,如果没有修改的话,就直接返回他的节点本身,有修改的话,就返回修改的节点。对于所有需要对二叉查找树进行修改的操作,我们都采用这样的做法。

这样,插入和查找都完成了。我们还需要删除操作。

删除操作是其中最难的操作。我们需要对删除进行分析。

删除

首先我们可以将删除分成以下情况:

1.删除节点为叶节点 2.删除节点只有一个子节点 3.删除节点有两个子节点

对于第一种情况,其实是第二种情况的特殊情况。可以将两种情况合在一起进行讨论:删除节点没有左节点或者右节点。

对于这种情况,很容易想到,为了在删除之后保持二叉查找树的结构,我们仅仅需要将指向当前节点的指针,指向当前节点的子节点就可以了。

《算法——查找之二叉查找树》

因为在这种情况下,即使被删除节点没有任何子节点,也能刚好将null赋予父节点的指针。

稍微困难的是第三种情况:删除节点存在两个子节点。

这种情况我们怎么删除呢?

为了保证二叉查找树的结构,并且又不用大动干戈将整个树重新进行构造,我们就需要从当前树中找到一个合适的节点,让他替换掉被删除的节点。

那么这个合适的节点怎么找呢?因为对于当前节点来说,左子树的值都比当前值小,右子树都比当前值大。所以我们替换的值也要满足这个特性。这样我们就有两个选择了。

1.找到左子树的最大值  2.右子树的最小值

这两个选择都可以用来替换掉被删除的节点,而不会对整棵树造成影响。

《算法——查找之二叉查找树》

很显然,为了实现删除操作,我们需要一些辅助操作:找最小节点和最大节点。

最小节点:根据二叉查找树的性质,树的最左边的那个节点就是最小的。

public static Node min() {
	return min(root);
}

private static Node min(Node x) {
	if (x == null) return null;
	if (x.left != null) return min(x.left);
	return x;
}

同理,最大节点:

public static Node max() {
	return max(root);
}

private static Node max(Node x) {
	if (x == null) return null;
	if (x.right != null) return max(x.right);
	return x;
}

当然,这个仅仅是返回最小或者最大节点,我们替换掉被删除节点之后,需要将最大或者最小的节点从左子树或者右子树中删除掉。

private static Node delMin(Node x) {
	if (x.left == null) return x.right;
	x.left = delMin(x.left);
	return x;
}
private static Node delMax(Node x) {
	if (x.right == null) return x.left;
	x.right = delMax(x.right);
	return x;
}

我们需要的辅助方法都准备好了,最后就是删除方法了:

public static void delete(Comparable key) {
	root = delete(root, key);
}

private static Node delete(Node x, Comparable key) {
	if (x == null) return null;
	int compared = key.compareTo(x.value);
	if (compared > 0) { // 删除的东西比当前大
		x.right = delete(x.right, key);
	} else if (compared < 0) {
		x.left = delete(x.left, key);
	} else {
		// 找到了要删除的key
		if (x.left == null) { // 如果要删除的key左边为null
			return x.right;
		}
		if (x.right == null) { // 如果要删除的key右边为null
			return x.left;
		}
		// 如果左右都不为null
		if (Math.random() > 0.5) {
			Node temp = x;
			x = min(x.right);
			x.right = delMin(temp.right);
			x.left = temp.left;
		} else {
			Node temp = x;
			x = max(x.left);
			x.left = delMin(temp.left);
			x.right = temp.right;
		}
	}
	return x;
}

这样我们就完成了删除操作。在被删除节点的左右节点都不为null的时候,我们使用了概率的情况,这样可以提高树的搜索效率。如果我们一直删除同一边的子树,例如一直删除左子树,会造成左子树比右子树小很多,这样显然不利于我们的搜索。

二叉查找树的效率取决于元素的插入顺序,在最坏情况下,例如一直在右子树中插入,就完全没办法有效率的进行查找了。

例如:

《算法——查找之二叉查找树》

在这种情况下,完成退化成链表,查找效率就为O(n)。

在最好情况下,二叉查找树就是完全平衡的,这样情况下,和二分查找是非常类似的,效率极高。

所以一般为了保证二叉查找树的效率,我们都期望插入的键是随机分布的。

    原文作者:二叉查找树
    原文地址: https://blog.csdn.net/a60782885/article/details/72526910
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞