数据结构之二叉搜索树

二叉搜索树的概念

二叉搜索树又称二叉排序树,它或者是一棵空树,或者是具有以下性质的二叉树:

1.若它的左子树不为空,则左子树上所有节点的值都小于根节点的值
2.若它的右子树不为空,则右子树上所有节点的值都大于根节点的值
3.它的左右子树也分别为二叉搜索树

为什么又叫二叉排序树呢?

如果对一棵搜索二叉树进行中序遍历的话,其实就能得到一个结点值的升序序列。

 

中序遍历结果: 1 3 4 6 7 8 10 13 14 

二叉搜索树的结构

首先我们来定义一下结点搜索二叉树的结构:

结点结构:

template<class K>
struct BSTreeNode
{
	BSTreeNode<K>* _left;
	BSTreeNode<K>* _right;
	K _key;

	BSTreeNode(const K& key)
		:_left(nullptr),
		_right(nullptr),
		_key(key)
	{}
};

 树结构:

template<class K>
class BSTree
{
	typedef BSTreeNode<K> Node;
public:
    //成员函数
private:
	Node* _root = nullptr;
};

二叉搜索树的操作

 中序遍历 

template<class K>
class BSTree
{
	typedef BSTreeNode<K> Node;
public:
	void InOrderPrint(Node* root)
	{
		if (root == nullptr)
			return;
		InOrder(root->_left);
		cout << root->_key << " ";
		InOrder(root->_right);
	}
private:
	Node* _root = nullptr;
};

如果是这样写main函数里调用需要传根结点(因为要递归,这个参数不能省),但是类外无法访问私有成员_root

可不可以函数参数给缺省值?

缺省值给一个_root不就行了,是不行的,因为缺省值必须是常量或者全局变量(但一般不用全局变量)而且在参数列表其实根本拿不到成员变量_root, 因为访问非静态成员要用this指针, 而this指针只能在成员函数内部使用, 参数列表也不行。

两个解决方法:

提供一个GetRoot的成员函数/方法,传参的时候通过该方法获取_root。
那其实有另外一种比较简便的方法,给InOrder函数在套一层.

template<class K>
class BSTree
{
	typedef BSTreeNode<K> Node;
public:
	void InOrderPrint()
	{
		InOrder(_root);
		cout << endl;
	}
private:
	Node* _root = nullptr;
	void InOrder(Node* root)
	{
		if (root == nullptr)
			return;
		InOrder(root->_left);
		cout << root->_key << " ";
		InOrder(root->_right);
	}
};

这样调的时候就不用传参了,当然我就可以把_InOrder变成私有的了

void test_BSTree1()
{
	BSTree<int> t1;
	int a[] = { 8, 3, 1, 10, 6, 4, 7, 14, 13 };
	for (auto e: a)
	{
		t1.Insert(e);
	}
	t1.InOrderPrint();
}

 


非递归

插入操作

首先对于搜索二叉树来说, 它的插入应该有插入成功和插入失败(因为搜索二叉树一般不允许出现重复元素)两种情况。

插入成功的情况:

在搜索二叉树中,要插入一个元素时,如果可以 插入,那么它插入的位置一定是确定的。 

还是以这棵树为例,假设我们现在要插入一个12,其实就是从根结点开始,去找出那个合适的位置,然后把12放进去。

根结点的值是8,12大于8,所以应该去右子树找,8的右子树是10,12依然大于10,那再看10的右子树,是14,此时12小于14,所以就要往14的左子树,14的左子树为13,12小于13,所以再看13的左子树,是空。所以,12就应该放在13的左子树上。

 插入失败的情况:

比如, 我们现在要插入一个13, 首先还是根据大小去比较,找合适的位置,但是走到13的位置发现要插入的值和已经存在的值相等,那就直接return false,插入失败。

 如果插入的是第一个结点,那就不需要比较了,直接让它成为根结点。

代码实现 

bool Insert(const K& key)
{
//如果根结点为空,那就证明是第一次插入,就把它作为根结点
	if (_root == nullptr)
	{
		_root = new Node(key);
		retrun true;
	}

	Node* cur = _root;
	Node* parent = nullptr;//定义一个parent结点保存cur的父结点方便后面链接
	while (cur)
	{
		parent = cur;
		if (key > cur->_key)
			cur = cur->_right;
		else if (key < cur->_key)
			cur = cur->_left;
		else
			return false;
	}
	//走到这里cur为空,是key要插入的位置
	if (key > parent->_key)
	    parent->_right = new Node(key);
    else
	    parent->_left = new Node(key);
	return true;
}

查找操作

我们要查找某个结点,那就从根开始比较,查找,比根大则往右边走查找,比根小则往左边走查找,找到的话返回true

bool Find(const K& key)
{
	Node* cur = _root;
	while (cur)
	{
		if (key > cur->_key)
			cur = cur->_right;
		else if (key < cur->_key)
			cur = cur->_left;
		else
			return true;
	}
	return false;
}


删除操作--难

那如果要删除二叉搜索树中的某个结点,应该怎么处理呢?

情况分类及思路分析

首先查找元素是否在二叉搜索树中,如果不存在,则直接返回false, 否则要删除的结点可能分下面四种情况:

1.删除的结点无子结点(删除叶子结点)

 

我们现在要删除4这个结点, 那这种情况是不是直接删除就好了,把4这个结点释放,让6的左孩子指向空就行了。 

2. 要删除的结点只有右孩子结点,左为空

 

10要怎么删,那这里关键在于把10删了他的子结点怎么处理?
现在这种情况我们要删除的结点10只有一个孩子,而10被删除的话它的父亲结点8就只剩下一个孩子了,而二叉树中一个结点最多可以有两个孩子,所以10被删除之后,我们是不是可以把它的孩子14托管给10的父亲8, 被删除结点是父结点的右孩子就分配给右, 被删除的结点是父结点的左孩子就分配给左

3. 要删除的结点只有左孩子结点,右为空

比如我们现在要删除6
那这种情况处理方法其实和上一个一样,也是把被删除结点的孩子按情况托管给其父亲结点,另外其实第一种情况也可以归到第二种或第三种里面,第一种是两个孩子都为空,那也可以归到左为空或者右为空的情况里面。 

4.要删除的结点有左、右孩子结点

 比如我们要删除3或8,怎么删?
首先这里我们上面用到的托管给父结点的方法就不管用了,因为每个结点最多管两个孩子,而现在要删除的这个结点就有两个孩子,如果父亲原本就有两个,那这样父亲要管三个就超了。
况且对于8也就是根结点来说,它根本没有父结点。

那这种情况该如何处理呢?------使用替换法删除法/伪删除法: 

以删除8为例,大家看,如果把8删了,谁能够坐到8这个位置呢?
那对于8这棵树来说,其实这个替代者可以有两个人选:

1.左子树的最大值, 因为左子树的最大值一定都比左子树其他结点值要大,左子树还一定比右子树结点都小

2.右子树的最小值, 右子树的最小值一定比右子树其他结点值要小, 右子树的值一定比左子树大

 

那对于当前这棵树其实就是7或者10。

我们会发现,对于一棵搜索二叉树来说:
整棵树中最大的结点就是最右边的那个结点最小的结点就是最左边的那个结点。
那对于子树来说也是这样,我们看到7其实就是左子树的最右边的结点,10就是右子树最左边的结点。所以这里要想删除8,可以选择用7替换也可以用10。

以用7为例,怎么进行替换删除呢? 

把7这个结点的值赋给8这个结点,然后把原始的7结点删除了就行了 

整体的框架: 

bool Erase(const K& key)
{
	Node* parent = nullptr;
	Node* cur = _root;
	while (cur)
	{
		if (key > cur->_key)
		{
			parent = cur;
			cur = cur->_right;
		}
		else if (key < cur->_key)
		{
			parent = cur;
			cur = cur->_left;
		}
		else
		{
			//删除
		}
	}
	return false;
}

 左为空或者右为空得删除其实比较好处理,托管给父亲结点即可:

需要注意的是如果删除的是根结点需要单独处理, 直接把根结点赋值为根的左/右孩子即可,不然此时parent下面就会对空指针进行解引用

左右都不为空: 

那我们上面说了这个替换结点可以是左子树的最大结点(最右边的结点),也可以是右树得最小结点(最左边的结点)。
那这里我选择左子树的最大结点。
这里的替换删除分为两步:
第一步——找到左树的最大结点,然后替换要删除的结点(对应到代码中的swap语句之前)

第二步——删除替换结点
那这里其实有一些需要注意的地方:
我们在这里选择的是左树的最大结点,即左子树最右边的结点,那既然是最右边,他一定没有右孩子了,但是,它可能会有左孩子。

以删除根节点为例:

所以这里删除替换结点也要用托管的方式去删, 那就需要保存一下替换结点的父亲leftmax_p

那这里还有一些需要特别注意的点:
这个父结点的初始值可以给nullptr吗?如果看上面那个例子是可以的,因为会进入循环更新parent的值。但是如果是这样的情况呢?

如果删除的结点的左结点恰好就是左子树的最大值, 这种情况不会进入while循环,所以leftmax_p不会更新, 那后面托管的时候就会对空指针解引用, 所以这里初始值可以赋cur, 即要进行删除的那个结点(伪删除)。

另外我们上面的那个例子, 和之前不同, 它虽然是左子树的最大值, 但它却是它是leftmax_p的左孩子(之前都是右孩子), 将它的孩子托管的时候, 不像之前把leftmax的左孩子托管到leftmax_p的右孩子, 这次是要把leftmax左孩子托管到leftmax_p的左孩子(其实本质是因为要找左子树的最大值的位置, 第一步必须先把leftmax赋值为要删除结点的左才能去往下接着找,  这种情况恰好不用往下找了, 因为没有右孩子可了, 它就是最大了)

两种情况对应的托管代码:

bool Erase(const K& key)
{
	Node* parent = nullptr;
	Node* cur = _root;
	while (cur)
	{
		if (key > cur->_key)
		{
			parent = cur;
			cur = cur->_right;
		}
		else if (key < cur->_key)
		{
			parent = cur;
			cur = cur->_left;
		}
		else
		{
			//1.左为空
			if (cur->_left == nullptr)
			{
				if (cur == _root)
					_root = _root->_right;
				else
				{
					if (key > parent->_key)
						parent->_right = cur->_right;
					else
						parent->_left = cur->_right;
				}
				delete cur;
			}
			//2.右为空
			else if (cur->_right == nullptr)
			{
				if (cur == _root)
					_root = _root->_left;
				else
				{
					 if (key > parent->_key)
						parent->_right = cur->_left;
					else
						parent->_left = cur->_left;
				}
				delete cur;
			}
			//3.左右都不为空
			else
			{
				//Node* leftmax_p = nullptr;
				Node* leftmax_p = cur;
				Node* leftmax = cur->_left;
				//寻找左子树的最大值
				while (leftmax->_right)
				{
					leftmax_p = leftmax;
					leftmax = leftmax->_right;
				}
				swap(leftmax->_key, cur->_key);//交换删除结点的值
				//如果左子树最大值的父亲不是要删除的结点
				if (leftmax_p != cur)
					leftmax_p->_right = leftmax->_left;
				//如果左子树最大值的父亲是要删除的结点
				else
					leftmax_p->_left = leftmax->_left;

				delete leftmax;
			}
			return true;
		}
	}
	return false;
}

递归版

查找

查找用递归要怎么写呢?

从根结点开始,如果大于根结点的值,就转换为去右子树里面查找,如果小于根结点,就转换为去左子树查找,如果等于就直接返回;那对于子树也是这样,一步步转换为子问题。
如果最后都没找到,一直到空,那就返回false。
public:
    bool FindR(const K& key)
    {
	    return _FindR(_root, key);
    }
private:
    bool _FindR(Node* root,const K& key)
    {
	    if (root == nullptr)
		    return false;
	    if (key > root->_key)
		    return _FindR(root->_right,key);
	    else if (key < root->_key)
		    return _FindR(root->_left,key);
	    else
		    return true;
    }

插入

那插入用递归怎么做呢?

那也是类似的思路,从根节点开始,如果要插入的结点大于根,就转换为去右子树插入;如果要插入的结点小于根,就转换为去左子树插入;如果相等,返回false;如果一直走到空,那就就在该位置插入就行了。        

public:
    bool InsertR(const K& key)
    {
	    return _InsertR(_root,key);
    }
private:
    bool _InsertR(Node* root, const K& key)
    {
	    if (root == nullptr)
	    {

	    }
	    if (key > root->_key)
		    return _InsertR(root->_right, key);
	    else if (key < root->_key)
		    return _InsertR(root->_left, key);
	    else
		    return false;
    }

但是有一个问题,我们找到空插入的时候,如何和它的父亲链接起来?

一种思路是把父亲作为递归的参数进行传递,或者去判断root的子树为空而不是它本身为空。

但这样更丝滑: 

直接传root的引用就可以了。
因为引用的话,走到空,他就是那个位置指针的引用,把这个root赋值就链接上了,而且不用像非递归实现的那样去用key判断要链接到左还是右.

那我们上面非递归的方式,可以用引用吗?

不可以,因为C++中的引用是不能改变指向的,引用一旦引用一个实体之后,就不能再引用其他实体,而这里递归的话,每次递归都相当于创建了一个新的引用,而不是改变上一层的引用的指向。

删除

其实还是我们上面分析的那三种情况:

左为空、右为空或者左右都不为空。传引用的话写起来还是比较简便的。

先写左为空和右为空的情况:

bool _EraseR(Node*& root, const K& key)
{
	if (root == nullptr)
		return false;
	if (key > root->_key)
		return _EraseR(root->_right, key);
	else if (key < root->_key)
		return _EraseR(root->_left, key);
	else
	{
		Node* del = root;
		if (root->_left == nullptr)
			root = root->_right;
		else if (root->_right == nullptr)
			root = root->_left;
		else
		{
            //..
		}
		delete del;
		return true;
	}
}

然后看一下比较麻烦的左右都不为空的情况:

之前非递归版本的实现是,找一个符合条件的结点替换它,然后把替换的结点删除掉,这里也可以用同样的方法, 但传了引用更简便了:
还是先找一个可以替代要删除结点的结点(左子树最大结点或右子树最小),以右子树最小结点替换为例,交换它们两个的值,然后删除右子树里面的这个结点就行了

bool _EraseR(Node*& root, const K& key)
{
	if (root == nullptr)
		return false;
	if (key > root->_key)
		return _EraseR(root->_right, key);
	else if (key < root->_key)
		return _EraseR(root->_left, key);
	else
	{
		Node* del = root;
		if (root->_left == nullptr)
			root = root->_right;
		else if (root->_right == nullptr)
			root = root->_left;
		else
		{
			Node* rightmin = root->_right;
			while (rightmin->_left)
				rightmin = rightmin->_right;
			swap(root->_key, rightmin->_key);
			return _EraseR(root->_right, key);
		}
		delete del;
		return true;
	}

这里一定要是return _EraseR, 否则还会向下执行再删除一次 

其它相关成员函数的实现

如果我们想在相对搜索二叉树的对象进行拷贝构造可以吗?

是可以的,虽然我们没写,但是拷贝构造属于默认成员函数,编译器会自动生成,不过默认生成的只完成浅拷贝。如果有析构就会出问题,因为搜索二叉树涉及资源申请,这样如果是浅拷贝的话,在析构的时候就会对一块空间析构两次,所以就会出问题。

构造函数:

public:
    BSTree(const BSTree<K>& t)
    {
	    _root = Copy(t._root);
    }

private:
    Node* Copy(Node* root)
    {
	    if (root == nullptr)
		    return nullptr;

	    Node* newroot = new Node(root->_key);
	    newroot->_left = Copy(root->_left);
	    newroot->_right = Copy(root->_right);

	    return newroot;
    }

写了拷贝构造就要写默认构造: 

C++11有一个关键字——default,可以强制编译器生成默认构造

BSTree() = default;

 析构函数:

public:
    ~BSTree()
    {
	    Destroy(_root);
    }
private:
    void Destroy(Node*& root)
    {
	    if (root == nullptr)
		    return;
	    Destroy(root->_left);
	    Destroy(root->_right);
	    delete root;
	    root = nullptr;
    }

赋值重载: 

BSTree<K>& operator=(BSTree<K> t)
{
	if (_root == t._root)
		return *this;
	std::swap(_root, t._root);
	return *this;
}

测试: 

void test_BSTree1()
{
	BSTree<int> t1;
	int a[] = { 8, 3, 1, 10, 6, 4, 7, 14, 13 };
	for (auto e: a)
	{
		t1.Insert(e);
		//t1.InsertR(e);
	}
	BSTree<int> t2(t1);
    BSTree<int> t3;
    t3 = t2;
	t1.InOrderPrint();
	t2.InOrderPrint();
    t3.InOrderPrint();
}

二叉搜索树的性能分析

插入和删除操作都必须先查找,所以查找效率就代表了二叉搜索树中各个操作的性能

搜索二叉树的查找的时间复杂度是多少?

那这个其实在不同情况下是不一样的:
1.
如果二叉搜索树处于比较平衡的情况(接近完全二叉树)这种情况最坏的查找无非也就查找高度次(那如果结点数量为N,它的高度通常保持在logN的水平), 所以这样它的时间复杂度就是O(logN)

2. 二叉搜索树退化为单支树(或者接近单支),那这时查找的时间复杂度就应该是O(N)

如果退化成单支树,二叉搜索树的性能就失去了。那能否进行改进,不论按照什么次序插入关键码,二叉搜索树的性能都能达到最优?AVL树和红黑树就可以上场了, 后面再说 

二叉搜索树的应用

K模型

搜索二叉树的第一个应用是K模型,什么是K模型呢

,介绍一下:

其实就是一个在不在的问题,
K模型:K模型即只有key作为关键码,结构中只存储Key,关键码即为需要搜索的值。

比如:

给一个单词word,判断该单词是否拼写正确,具体方式如下:
以词库中所有单词集合中的每个单词作为key, 建一棵二叉搜索树, 在二叉搜索树中检索该单词是否存在, 存在则拼写正确, 不存在则拼写错误

 KV模型

KV模型即每一个关键码key, 都有与之对应的值Value, 即结构中存储<Key, Value>键值对

该种方式在现实生活中非常常见:

比如英汉词典就是英文与中文的对应关系,通过英文可以快速找到与其对应的中文,英文单词与其对应的中文<word, chinese>就构成一种键值对;
再比如统计单词次数,统计成功后,给定单词就可快速找到其出现的次数,单词与其出现次数就是<word, count>就构成一种键值对。

那接下来我们就来改造一下我们上一篇文章实现的搜索二叉树,改造成KV模型来简单解决一下上面提到的两个问题。

增加一个模板参数,结点里面增加一个成员_val;

然后涉及到的模板的地方和相关的成员函数改一下就行了

1.插入的函数肯定得改一下 :

2.查找要改一下:

因为现在是KV模型,应该是查到返回对应的结点,通过结点我们可以拿到对应的val,查不到返回nullptr。

3.然后删除就不用改了,因为删除还是用key去找对应的结点删除就行了

4.中序遍历的函数稍微改一下, 打印出对应的键值对: 

5.构造函数和赋值重载的参数改一下: 

测试一下:

void test_BSTree2()
{
	kv::BSTree<string, string> t;
	t.Insert("苹果", "apple");
	t.Insert("香蕉", "banana");
	t.Insert("排序", "sort");
	t.Insert("插入", "insert");
	t.Insert("删除", "delete");

	string str;
	while (cin >> str)
	{
		auto ret = t.Find(str);
		if (ret)
		{
			cout <<":"<< ret->_value << endl;
		}
		else
		{
			cout << "查找不到" << endl;
		}
	}
}

int main()
{
	test_BSTree2();
	return 0;
}

统计次数

接下来我们再来搞一个,还是用KV模型:

 现在有一些水果,我们想统计每种水果出现的次数。

我们还是用KV来存储键值对,key是水果的名字,,al是出现的次数。
遍历存储水果的string数组,判断每一个水果在不在countTree里面,不在,就添加进去,把val置成1,在的话,就只让val++就行了,这样遍历一遍,就统计出次数了。

void test_BSTree3()
{
	// 统计水果出现的次数
	string arr[] = { "苹果", "西瓜", "苹果", "西瓜", "苹果", "苹果", "西瓜",
	"苹果", "香蕉", "苹果", "香蕉" };
	kv::BSTree<string, int> t;
	for (const auto& str : arr)
	{
		auto ret = t.Find(str);
		if (ret)
		{
			ret->_value++;
		}
		else
		{
			t.Insert(str, 1);
		}
	}
	t.InOrderPrint();
}

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值