目录
1. 什么是二叉搜索树
在数据结构初阶阶段,我们学习二叉树,对二叉树有了一定的理解。
本章学习的二叉搜索树会为以后学习的AVL树、红黑树做铺垫。
二叉搜索树概念:
二叉搜索树又称二叉排序树,它或者是一棵空树,或者是具有以下性质的二叉树:
若它的左子树不为空,则左子树上所有节点的值都小于根节点的值
若它的右子树不为空,则右子树上所有节点的值都大于根节点的值
它的左右子树也分别为二叉搜索树
例如下面这颗树:注意节点不允许有重复元素。
同时,由于二叉搜索树本身的特性,如果中序遍历二叉搜索树,得到的序列一定是一个有序的升序序列!
2. 二叉搜索树的实现
与普通二叉树相比二叉搜索树的结构实现增删查改更有意义。
而且根据它的一些思想更有助于学习后面的知识。
前提准备:创建节点
#pragma once
#include<iostream>
using namespace std;
template<class K>
struct BSTNode
{
BSTNode<K>* _left;
BSTNode<K>* _right;
K _key;
BSTNode(const K& key = k())
:_left(nullptr)
,right(nullptr)
,_key(key)
{}
};
template<class K>
class BSTree
{
typedef BSTNode<K> Node;
public:
BSTNode()
:_root(nullptr)
{}
// 中序遍历二叉树,方便后面打印二叉树节点
void InOrder()
{
_InOrder(_root);
}
void _InOrder(Node* root)
{
if (root == nullptr)
{
return;
}
_InOrder(root->_left);
cout << root->_key << " ";
_InOrder(root->_right);
}
private:
Node* _root;
};
2.1. 二叉搜索树的插入
二叉搜索树的插入时有一下几种情况:
根节点为空,直接创建节点
如果当前节点大于插入节点,遍历左子树,直到遍历到空节点
如果当前节点小于插入节点,遍历右子树,直到遍历到空节点
同时后面两种情况中,由于需要链接到父节点的左节点或右节点中,所以应该要保存父节点,方便后续链接。
实现代码:
// 非递归
bool Insert(const K& key)
{
if (_root == nullptr) // 根节点为空,直接创建节点
{
_root = new Node(key);
return true;
}
Node* parent = nullptr; // 记录父节点
Node* cur = _root;
while (cur)
{
parent = cur;
if (cur->_key == key) // 当前节点等于插入节点,不允许出现重复值,插入失败
{
return false;
}
else if (cur->_key > key) // 当前节点大于插入节点,遍历左子树
{
cur = cur->_left;
}
else // 当前节点小于插入节点,遍历右子树
{
cur = cur->_right;
}
}
cur = new Node(key);
if (parent->_key > key) // 父节点值大于插入节点,插入到左节点
{
parent->_left = cur;
}
else // 父节点值小于插入节点,插入到右节点
{
parent->_right = cur;
}
return true;
}
// 递归
bool InsertR(const K& key)
{
return _InsertR(key, _root); // 由于递归需要根节点参数,而根节点是私有的,不能在类外访问,
// 所以这里可以使用一个子函数,传入根节点。
}
bool _InsertR(const K& key, Node*& root)
{
if (root == nullptr)
{
root = new Node(key);
return true;
}
if (root->_key > key)
{
return _InsertR(key, root->_left);
}
else if (root->_key < key)
{
return _Insert(key, root->_right);
}
else
{
return false;
}
}
2.2. 二叉搜索树的删除
二叉搜索树的删除相对比较麻烦。
删除节点分为一下几种情况:
1.删除节点无左子树。
(1). 删除节点为根节点
(2). 删除节点为父节点的左节点
(3). 删除节点为父节点的右节点
如图所示:
2.删除节点无右子树。
(1). 删除节点为根节点
(2). 删除节点为父节点的左节点
(3). 删除节点为父节点的右节点
如图所示:
3.删除节点左右子树都不为空
如图所示:
实现代码:
// 非递归
bool Erase(const K& key)
{
Node* parent = nullptr; //保存父节点
Node* cur = _root;
while (cur)
{
if (cur->_key > key)
{
parent = cur;
cur = cur->_left;
}
else if (cur->_key < key)
{
parent = cur;
cur = cur->_right;
}
else //找到了
{
Node* del = cur; // 保存需删除节点
if (cur->_left == nullptr) // 情况1:无左子树
{
if (parent == nullptr) // 1.(1):删除节点为根节点
{
_root = cur->_right; // 直接将根节点置为右子树根节点
}
else if (parent->_left == cur) // 1.(2):删除节点为父节点的左节点
{
parent->_left = cur->_right;// 将父节点的左指针指向删除节点的右子树
}
else // 1.(3):删除节点为父节点的右节点
{
parent->_right = cur->_right;// 将父节点的右指针指向删除节点的右子树
}
}
else if (cur->_right == nullptr) // 情况2:删除节点无右子树
{
if (parent == nullptr) // 2.(1):删除节点为根节点
{
_root = cur->_left; // 直接将根节点置为左子树根节点
}
else if (parent->_left == cur) // 2.(2):删除节点为父节点的左节点
{
parent->_left = cur->_left; // 将父节点的左指针指向删除节点的右子树
}
else // 2.(3):删除节点为父节点的右节点
{
parent->_right = cur->_left; // 将父节点的右指针指向删除节点的右子树
}
}
else // 情况3:左右子树都不为空
{ // 思路:删除节点后该二叉树的结构还是二叉搜索树,而直接删除该节点
Node* midparent = cur; // 不容易删除,所以可以找到该节点左子树的最右节点(左子树最大值),
Node* midnode = cur->_right; // 或者右子树的最左节点(右子树最小值)替换该节点,然后删除替换节点
while (midnode->_left)
{
midparent = midnode; // 保存父节点
midnode = midnode->_left; // 查找右子树最左节点
}
cur->_key = midnode->_key; // 替换节点
if (midparent->_left == midnode)
{
midparent->_left = midnode->_right;
}
else
{
midparent->_right = midnode->_right;
}
del = midnode; // 替换删除节点
}
delete del;
return true;
}
}
return false; // 没找到
}
// 递归
bool EraseR(const K& key)
{
return _EraseR(_root, key);
}
bool _EraseR(Node*& root, const K& key) // 使用引用传参,好处在于可以不用记录父节点,且不用考虑删除节点是父节点
{ // 的左子树还是右子树
if (root == nullptr) // 没找到
{
return false;
}
if (root->_key > key)
{
return _EraseR(root->_left, key);
}
else if (root->_key < key)
{
return _EraseR(root->_right, key);
}
else
{
Node* del = root;
if (root->_left == nullptr)
{
root = root->_right; // root就是父节点的left或right指针,可以直接改变指向
}
else if (root->_right == nullptr)
{
root = root->_left;
}
else
{
Node* cur = root->_right;
Node* parent = root;
while (cur->_left)
{
parent = cur;
cur = cur->_left;
}
swap(root->_key, cur->_key); // 交换删除节点和替换节点的key值
return _EraseR(root->_right, cur->_key); // 递归删除右子树的最左节点
}
delete del;
return true;
}
}
2.3. 二叉搜索树的查找
查找比较简单,根据特性,比查找的值小就去左树找,比查找的值大就去右树找。
// 非递归
Node* find(const K& key)
{
Node* cur = _root;
while (cur)
{
if (cur->_key > key)
{
cur = cur->_left;
}
else if (cur->_key < key)
{
cur = cur->_right;
}
else
{
return cur;
}
}
return nullptr;
}
// 递归
Node* findR(const K& key)
{
return _findR(_root, key);
}
Node* _findR(Node*& root, const K& key)
{
if (root == nullptr)
{
return nullptr;
}
if (root->_key > key)
{
return _findR(root->_left, key);
}
else if (root->_key < key)
{
return _findR(root->_right, key);
}
else
{
return root;
}
}
ps:二叉搜索树的实现中没有修改,是因为修改节点后可能会导致整颗树的结构不满足二叉搜索树。
3. 二叉搜索树的应用
二叉搜索树在一些场景中可以有不同的应用模型。例如:K 模型 和KV模型。
K模型:K模型上面我们已经实现了,K模型即只有key作为关键码,结构中只需要存储Key即可,关键码即为需要搜索到的值。
应用:排序,判断指定值是否存在等
KV模型:每一个关键码key,都有与之对应的值Value,即<Key, Value>的键值对 。
应用:计数,英汉互译等
KV模型的实现与K模型大同小异,在每个节点中加入value即可,这里不再实现。
template<class K, class V>
struct BSTNode
{
BSTNode<K>* _left;
BSTNode<K>* _right;
K _key;
V _val;
BSTNode(const K& key = k(), const V& val = V())
:_left(nullptr)
,right(nullptr)
,_key(key)
,_val(val)
{}
};
4.二叉搜索树的性能
插入和删除操作都必须先查找,查找效率代表了二叉搜索树中各个操作的性能。
对有n个结点的二叉搜索树,若每个元素查找的概率相等,则二叉搜索树平均查找长度是结点在二叉搜索树的深度的函数,即结点越深,则比较次数越多。
但对于同一个关键码集合,如果各关键码插入的次序不同,可能得到不同结构的二叉搜索树 :
如果退化成单支树,二叉搜索树的性能就失去了。
最优情况下,二叉搜索树为完全二叉树,其平均比较次数为:logN(log以2为底N的对数) 最差情况下,二叉搜索树退化为单支树,其平均比较次数为:N/2
5. 二叉树面试题
5.1. 根据二叉树创建字符串
思路:
- 前序遍历二叉树,根节点后前不用加括号,
- 若节点无左子树,有右子树,左子树括号不能省略
- 若节点有左子树,无右子树,右子树括号省略
- 若为叶子节点,左右子树括号均省略
class Solution {
public:
string tree2str(TreeNode* root) {
string ret;
_tree2str(root, ret); // 由于参数限制,这里需使用string对象,使用子函数
return ret;
}
void _tree2str(TreeNode*root, string&ret)
{
if(root == nullptr)
{
return;
}
ret+=to_string(root->val); // 将val转化成字符串插入
if(root->left||root->right) // 如果左子树不为空或者左子树为空但右子树不为空,括号不能省略
{
ret+="(";
_tree2str(root->left, ret); // 递归插入左树
ret+=")";
}
if(root->right) // 若右子树不为空
{
ret+="(";
_tree2str(root->right, ret); // 递归插入右子树
ret+=")";
}
}
};
5.2. 二叉树的层序遍历
思路:
利用队列存放二叉树的每一层节点。
出队时,一次性出一层的节点,每出一个节点就将子节点入队。
class Solution {
public:
vector<vector<int>> levelOrder(TreeNode* root) {
vector<vector<int>> vv;
queue<TreeNode*> q;
if(root)
q.push(root);
while(!q.empty()) // 队列为空遍历结束
{
vector<int> v;
int size = q.size(); // 记录每层节点个数
while(size--) // 一次出一层节点
{
TreeNode* front = q.front();
q.pop();
if(front->left)
{
q.push(front->left);
}
if(front->right)
{
q.push(front->right);
}
v.push_back(front->val);
}
vv.push_back(v);
}
return vv;
}
};
5.3. 二叉树的最近公共祖先
思路:
保存根节点到pq节点路径上的所有祖先节点。
然后一一比较两个节点的祖先节点。
查找2:
查找8:
class Solution {
public:
bool findAncestor(TreeNode* root, TreeNode*node, stack<TreeNode*>&st) // 查找根节点到各自节点的路径,保存到栈中
{ // 根据返回值判定是否找到目标节点
if(root == nullptr) // 空节点直接返回false 表示没找到
{
return false;
}
st.push(root); // 遇到一个节点先将他入栈
if(root == node) // 如果这个节点是目标节点,返回true表示找到了
{
return true;
}
if(findAncestor(root->left, node, st)) // 如果当前节点不是目标节点,就去它的左树找,如果找到了返回true
{
return true;
}
if(findAncestor(root->right, node, st))// 如果左树没有找到,就去右树找,如果找到了返回true
{
return true;
}
st.pop(); // 如果当前节点的左右子树都没找到,就表示以当前节点为根节点的树中没有目标节点,就pop掉当前节点
return false;
}
TreeNode* lowestCommonAncestor(TreeNode* root, TreeNode* p, TreeNode* q) {
stack<TreeNode*> pAncestor; // 保存p的祖先节点
stack<TreeNode*> qAncestor; // 保存q的祖先节点
findAncestor(root, p, pAncestor);
findAncestor(root, q, qAncestor);
while(pAncestor.size() != qAncestor.size()) // 若祖先节点个数不一样,则将个数多的栈先出,直到节点个数相等
{
if(pAncestor.size() > qAncestor.size())
{
pAncestor.pop();
}
else
{
qAncestor.pop();
}
}
while(pAncestor.top() != qAncestor.top()) // 寻找相同祖先节点
{
pAncestor.pop();
qAncestor.pop();
}
return pAncestor.top();
}
};
5.4. 二叉搜索树与双向链表
思路:
- 利用中序遍历,找到每一个节点,让每一个节点的left指向前一个节点,right指向后一个节点用一个prev保存前一个节点。
- 同时遍历到当前节点后,我们只知道前一个节点,但不知道后一个节点。
- 所以前一个节点可以直接使用当前节点的left指向prev,但是后一个节点的链接需要遍历到后一个节点时,让prev的right链接。
class Solution {
public:
TreeNode* Convert(TreeNode* pRootOfTree) {
if(pRootOfTree == nullptr) // 空树直接返回空
{
return nullptr;
}
TreeNode* prev = nullptr; // 记录当前节点的前一个节点
TreeNode*head = pRootOfTree; // 记录链表的头节点,即二叉树的最左节点
InOrder(pRootOfTree,prev); // 中序遍历
while(head->left) // 找链表头节点
{
head = head->left;
}
return head;
}
void InOrder(TreeNode*root, TreeNode*&prev)
{
if(root == nullptr)
{
return;
}
InOrder(root->left, prev);
root->left = prev; // 将当前节点的left指向前一个节点prev
if(prev) // 一个节点的前一个节点为空,需特判
{
prev->right = root; // 使用prev的righ链接后一个节点
}
prev = root; // 移动prev到下一节点
InOrder(root->right, prev);
}
};
5.5.从前序与中序遍历序列构造二叉树
思路:
- 通过前序确定根,中序确定左右子树
- 每确定一个根,就在中序中找到根的位置,然后以根为界限将中序分为左右区间,代表左右子树的节点
class Solution {
public:
TreeNode* buildTree(vector<int>& preorder, vector<int>& inorder) {
// 思路:利用前序确定每颗子树的根,中序确定根的左右子树
int prev = 0; // 标记利用前序创建根节点时创建到了哪个位置
TreeNode* root = _buildTree(preorder, inorder, prev, 0, preorder.size()-1); // 使用子函数去递归,不然参数不好搞
return root;
}
// 参数:begin:在中序中确定了根之后,用来标记左子树区间的范围
// end:同理用来标记右子树的区间范围
// 注意:prev一定要传引用,不然每次递归创建左子树后,跳回到右子树时,prev的值不会变化,导致前序中的位置不变,出现错误。
TreeNode* _buildTree(vector<int>& preorder, vector<int>& inorder, int&prev, int begin, int end)
{
// 如果区间中没有节点,表示该子树已经全部创建完,返回空
if(begin > end)
{
return nullptr;
}
// 由于是前序,当遍历到一个节点时,可直接将他看作是子树的根节点,直接创建
TreeNode* root = new TreeNode(preorder[prev]);
int rooti = 0; // 用来表示,上面创建的根节点在中序中的位置,方便确定左右子树在中序中的区间
while(rooti <= end)
{
if(preorder[prev] == inorder[rooti])
{
break; // 找到了根节点在中序中的位置
}
++rooti;
}
++prev; // 当前前序位置已经被创建了节点,所以直接跳到下一个
// 然后根据中序中划分的左右子树的区间,依次创建
// 左子树:[begin, rooti-1] 右子树:[rooti+i, end]
root->left = _buildTree(preorder, inorder, prev, begin, rooti-1);
root->right = _buildTree(preorder, inorder, prev, rooti+1, end);
return root;
}
};
5.6. 二叉树的前序遍历(非递归实现)
思路:
- 先将根节点入栈,然后将节点弹出,如果如果右子树不为空就先将右节点入栈,如果左子树不为空就再将左节点入栈。
- 然后将节点的val,放入vector,按照这样的操作循环往复直到栈为空表示二叉树中的所有节点都被弹出。
- 这样根据栈的特性,就保证了左子树的节点能够先被弹出,且弹出时就能访问到根节点,就符合了二叉树的前序遍历
class Solution {
public:
vector<int> preorderTraversal(TreeNode* root) {
vector<int> prev;
stack<TreeNode* > st;
if(root)
{
st.push(root); // 先将根节点入栈
}
while(!st.empty()) // 栈不为空,则二叉树还有节点未入栈
{
TreeNode*top = st.top();
st.pop();
if(top->right) // 先入右
{
st.push(top->right);
}
if(top->left) // 后入左
{
st.push(top->left);
}
prev.push_back(top->val); // 先访问根
}
return prev;
}
};
5.7. 二叉树的中序遍历(非递归实现)
思路:
- 由于是中序遍历,访问顺序是左子树 根 右子树
- 所以先将左子树的最左节点入栈,入完后弹出的第一个节点即为二叉树的最左节点,也就是需要第一个遍历的节点
- 先将该节点的val放入vector,然后再去访问它的右树,然后按照相同的方法遍历即可
具体方法在代码后解释。
class Solution {
public:
vector<int> inorderTraversal(TreeNode* root) {
vector<int> in;
stack<TreeNode*> st;
TreeNode*cur = root; // 遍历二叉树节点
while(!st.empty()||cur)
{
while(cur)
{
st.push(cur);
cur = cur->left; //将cur节点下的所有左节点入栈,因为需要先访问左子树
}
TreeNode* top = st.top(); // 保存栈顶节点
in.push_back(top->val); // 将左节点值直接放入vector
st.pop(); // 弹出节点
cur = top->right; // 再去访问右子树,如果右子树为空,那么将不会进入上面的入左节点的循环,而是退回父节点
} // 再将访问父节点,在按照相同的方式遍历,从而就达到了 左右根 顺序的遍历
return in;
}
};
5.8. 二叉树的后序遍历(非递归实现)
思路:
- 同样由于是后序遍历,访问顺序是 左右根 ,所以还是需要先将最左节点入栈。
- 然后弹出节点,弹出时不能直接将该节点的val放入vector,因为如果该节点是根节点,
- 存在右子树不存在左子树就不能先访问该节点,所以需要进行判断该节点是否存在右子树,同时如果该节点存在右子树,还需要判断该右子树是否被访问过。
具体细节在代码中解释。
class Solution {
public:
vector<int> postorderTraversal(TreeNode* root) {
vector<int> post;
stack<TreeNode*> st;
TreeNode* prev; // 用来记录右子树节点,用于判断右子树是否被访问
TreeNode* cur = root; // 记录d
while(!st.empty() || cur)
{
while(cur) // 先入左节点
{
st.push(cur);
cur = cur->left;
}
TreeNode* top = st.top(); // 保存根节点
// 判断top的右子树是否被访问,如果访问过了就可以访问top
// 如果top的右子树为空,或者上一个访问的节点是top的右节点,就表示top的右子树被访问过了
if(top->right==nullptr||top->right==prev)
{
post.push_back(top->val);
prev = top;
st.pop();
}
else // 否则需访问右子树
{
cur = top->right;
}
}
return post;
}
};
当然其实也可以使用前序遍历的方法,但是这里先将左子树入栈,再入右子树,最后将弹出的节点再放入一个栈中,最后栈中的节点出栈顺序就是后序。