二叉搜索树(BST,Binary Search Tree),又称二叉排序树或二叉查找树,它或者是一棵空树,或者是具有以下性质的二叉树:
(1). 若它的左子树不为空,则左子树上所有节点的值都小于根节点的值。
(2). 若它的右子树不为空,则右子树上所有节点的值都大于根节点的值。
(3). 它的左右子树也分别为二叉搜索树。
下面我们来实现一个二叉搜索树。
我们先定义二叉搜索树的结点和类。
下面我们来实现二叉搜索树的插入,二叉搜索树的插入位置是非常确定的,默认的二叉搜索树是不允许数据冗余的,所以当插入相同的数据时会插入失败。
下面的实现是不行的,因为我们并没有将新结点链接到二叉搜索树中。
所以我们在找到新结点的插入位置之后,还需要知道这个位置的父结点,然后改变父结点的孩子,这样才能将新结点链接到二叉搜索树中。并且我们在创建新结点时会调用结构体BSTreeNode的构造函数,所以我们需要写出BSTreeNode的构造函数。
然后我们写一个递归版的中序遍历二叉树的函数来验证我们的搜索二叉树插入是否正确。但是我们在使用BSTree类实例化的对象调用InOrder方法时发现参数没法传递,因为我们需要传入二叉搜索树的根结点 _root,但是根节点 _root的访问权限为private,不可以在类外访问。并且因为使用递归的方法实现InOrder函数,所以必须要有形参。
上面的情况我们使用缺省值的话是不行的,因为缺省值必须是全局变量或者常量或者静态变量,需要生命周期和程序一样,即存储在静态区的变量,而 _root为局部成员变量。并且在将 _ root当作缺省值时,需要this指针,即其实是这样的Node * root = this-> _ root,但是在形参列表中this指针才刚定义,还不能使用,只有在函数内才可以使用this指针。所以实现缺省值的话是不行的。上面的情况我们一般有两种解决办法。
第一种办法:可以在类中定义一个GetRoot的方法,用来将 _root结点返回,我们看到这种办法成功的中序遍历了二叉搜索树。但是我们一般不推荐这种写法。
第二种办法:此时可以再嵌套一层,这样调用中序遍历就是无参的了,但是在底层调用了有参的。递归也使用的有参的。我们推荐这种写法,这样当BSTree类实例化的对象调用成员函数时比较统一。
我们看到了上面中序遍历二叉搜索树打印的结果为一个升序的数组,这也是二叉搜索树的特性之一,所以二叉搜索树又被称为二叉排序树。下面我们来实现二叉搜索树的查找函数。
下面再来实现二叉搜索树的删除.二叉搜索树的删除的情况很多,下面我们来看删除的最普遍的三种情况。
第1种情况删除的结点为叶子结点,我们直接将该结点删除即可,即将该结点的父结点的左孩子或右孩子指向nullptr。
第2种情况删除的结点有一个孩子结点,此时可以使用托孤法,即将该结点的孩子结点替代自己,让该结点的父结点的左孩子或右孩子指向该结点的孩子。
第3种情况删除的结点有两个孩子结点,此时删除该结点不能使用托孤法,因为两个孩子没办法都托孤给父结点,此时可以使用请保姆法,即将该结点的左子树最大结点或右子树最小结点选为保姆,然后将这两个结点的值换一下,此时对保姆结点使用托孤法,然后删除保姆结点,这样就达到了删除指定结点的效果。可以看到我们其实并没有删除指定结点,而是删除的和指定结点交换完值之后的保姆结点,这就是伪删法。
当分析了删除的一些情况后,下面我们就来使用代码实现。我们看到其实第1种方法和第2种方法都可以使用托孤,即删除结点的左为空让父结点指向删除结点的右孩子,删除结点的右为空让父节点指向删除结点的左孩子。
要删除该结点之前我们需要先找到该结点,如果找不到该结点就为删除失败。
当找到要删除的结点后,我们先将第1种情况和第2种情况的删除写好。因为前面我们分析了删除叶子结点也可以使用托孤法,所以我们就将删除叶子结点也使用托孤法来实现。
当要删除结点的左右结点都有孩子时,我们就需要使用请保姆法,但是因为保姆也可能有孩子,需要先进行托孤,所以我们需要记录保姆的父结点。我们需要找到要删除结点的左子树的最右结点或右子树的最左结点来作为保姆。然后将要删除结点的值和保姆结点的值互换,然后将保姆结点完成托孤,最后删除保姆结点即可。
但是我们上面写的代码有一个情况会出错。即当cur的右孩子就是cur的右子树的最左结点时,此时就不会进入while(minRight != nullptr)的循环中,那么此时pminRight就为nullptr,然后执行循环后面的pminRight->left == minRight时就会出现空指针错误。
所以我们不能将pminRight初始化为nullptr,而应该初始化为cur。
那么我们前面将parent = nullptr也会出现错误,即如果出现下面的情况就会出现错误。因为当要删除的结点为根节点,并且根节点的左子树或者右子树为空时,那么parent = nullptr,就会出现空指针异常。
上面的情况我们有两种解决办法,第一种解决办法就是找到根结点左子树最大值或右子树最小值来和根结点替换。
第二种解决办法就是将更新_root,即让根结点不为空的孩子结点更新为新的根结点。
上面我们使用循环实现了二叉搜索树,下面我们使用递归来实现二叉搜索树。我们先使用递归来实现FindR函数。
在类里面实现的递归函数,一般都需要嵌套一层,我们用递归函数实现FindR函数也是一样。
然后我们用递归实现InsertR插入函数,当我们找到结点要插入的位置时,我们需要将key结点与父结点链接起来,但是因为适用了递归实现,所以现在找不到父结点。此时我们有三种解决办法。第一种方法可以再加一个参数,传递父结点。第二种方法不要判断root=nullptr,而是判断root的左右孩子是否为空,这样root就是父结点了。但是这两种方法都不是最好的方法。
第三种方法为最优方案,在第一个参数中加一个引用即可。即将当前递归函数的root为上一个递归函数的root的左孩子或右孩子的别名,那么在当前递归函数中就能通过别名来修改父结点的左孩子或右孩子的值。
下面我们再来递归实现删除。我们在实现递归删除是也将递归函数的第一个参数为Node * 类型的引用。
我们看到当删除的结点只有一个孩子或者没有孩子时,我们可以很好的删除,但是当删除的结点有两个孩子时,我们很难将这个结点删除。
此时我们需要找到删除结点左子树的最大值或者右子树的最小值来作为保姆,然后将要删除结点的值和保姆结点的值互换,然后将保姆结点删除即可。删除保姆结点时我们可以再一次递归删除,因为此时保姆结点只有一个结点或者没有结点,所以肯定会在前面两个if语句内被删除。需要注意的是调用递归传入的值一定要为root->left或root->right,不能是maxleft,因为maxleft为一个临时结点指针,改变maxleft的话并不会改变二叉搜索树里面的内容,所以我们需要传二叉搜索树中的结点。
下面我们来看二叉搜索树的拷贝,我们没有写二叉搜索树的拷贝构造函数时,此时适用编译器默认生成的拷贝构造函数,为浅拷贝。可以看到t1和t2指向同一棵二叉搜索树。但是我们发现程序并没有报错,这是因为我们还没有写析构函数,所以并不会报错。
我们先实现二叉搜索树的析构函数,我们需要使用后序遍历来删除每一个结点。即先删除左孩子,再删除右孩子,最后删除根节点。这样才能确保每一个结点都被删除。我们适用递归来实现析构函数,所以也将析构函数进行了一层嵌套,其实析构函数底层调用的Destroy函数来实现结点的删除。
我们还可以将Destroy函数的参数设为Node * & ,这样在Destroy函数中将结点delete之后再将结点置为nullptr,并且在析构函数里面就不需要将_root = nullptr了。
当实现了析构函数之后,适用浅拷贝的默认拷贝构造函数时就会出错,因为已经delete的二叉搜索树的结点已经置为nullptr了,再次delete时就会出错。
所以我们下面来实现二叉搜索树的深拷贝的拷贝构造函数。实现二叉搜索树的深拷贝,就需要再创建一棵新的二叉搜索树,但是我们不能复用Insert函数来创建新的二叉搜索树,因为二叉搜索树顺序不一样,得到的二叉搜索树也不一样。
下面我们使用递归来实现深拷贝,相当于先采用前序遍历的顺序创建结点,然后采用后序遍历的顺序链接结点。
当我们实现了拷贝构造函数后,因为拷贝构造函数也为构造函数,所以编译器就不会自动生成默认的构造函数了,此时我们创建t1对象时就会因为没有默认的构造函数而报错,此时我们可以写一个无参的默认构造函数,也可以使用default强制生成默认构造函数。
在下面测试拷贝构造函数中,我们看到t1和t2两棵二叉搜索树的根节点不同,则说明此时t1和t2为两棵二叉搜索树,即我们实现了深拷贝的拷贝构造函数。
下面为BSTree类的赋值运算符重载函数。这样我们就基本实现了BSTree类。
二叉搜索树的插入和删除操作都必须先查找,查找效率代表了二叉搜索树中各个操作的性能。
对有n个结点的二叉搜索树,若每个元素查找的概率相等,则二叉搜索树平均查找长度是结点在二叉搜索树的深度的函数,即结点越深,则比较次数越多。但对于同一个关键码集合,如果各关键码插入的次序不同,可能得到不同结构的二叉搜索树。我们可以看到如果退化成单支树,二叉搜索树的性能就失去了。那能否进行改进,不论按照什么次序插
入关键码,二叉搜索树的性能都能达到最优?其实我们后面要学习的AVL树和红黑树就可以上场了。
1.K模型:K模型即只有key作为关键码,结构中只需要存储Key即可,关键码即为需要搜索到的值。
比如:给一个单词word,判断该单词是否拼写正确,具体方式如下:
以词库中所有单词集合中的每个单词作为key,构建一棵二叉搜索树。
在二叉搜索树中检索该单词是否存在,存在则拼写正确,不存在则拼写错误。
2.KV模型:每一个关键码key,都有与之对应的值Value,即
比如英汉词典就是英文与中文的对应关系,通过英文可以快速找到与其对应的中文,英文单词与其对应的中文
再比如统计单词次数,统计成功后,给定单词就可快速找到其出现的次数,单词与其出现次数就是
上面我们实现的二叉搜索树就是K模型版的,下面我们简单实现一个KV模型的二叉搜索树。
我们在向二叉搜索树中插入、删除、寻找结点时,还是按照key的值来进行寻找,只不过在二叉搜索树的结点中还有一个value可以存一个与key对应的值。
此时我们可以根据key的值从二叉搜索树中查找结点,然后打印出这个结点的value的值,这就相当于我们使用英语单词在二叉搜索树中查找,然后二叉搜索树的结点中存的有这个英语单词对应的中文意思,我们可以根据英语单词找到中文意思。
我们也可以创建一个二叉搜索树来存水果的个数,然后我们根据水果的名字就可以在二叉搜索树中找到水果的个数。并且因为我们将水果的名称作为key值了,所以二叉搜索树中的排序就是按照字符串的ASCII码来进行比较的。
题目链接
我们可以分析出共有下面的4种情况:
如果当前节点有两个孩子,那我们在递归时,需要在两个孩子的结果外都加上一层括号;
如果当前节点没有孩子,那我们不需要在节点后面加上任何括号;
如果当前节点只有左孩子,那我们在递归时,只需要在左孩子的结果外加上一层括号,而不需要给右孩子加上任何括号;
如果当前节点只有右孩子,那我们在递归时,需要先加上一层空的括号 ‘()’\text{`()'}‘()’ 表示左孩子为空,再对右孩子进行递归,并在结果外加上一层括号。
通过上面的分析我们可以写出代码如下,即直接按照分析的逻辑来写判断。
class Solution {
public:
string tree2str(TreeNode* root) {
string str;
if(root == nullptr)
{
return str;
}
str=to_string(root->val);
//左右孩子都不为空,则都加()
if(root->left!=nullptr&&root->right!=nullptr)
{
str+='(';
str+=tree2str(root->left);
str+=')';
str+='(';
str+=tree2str(root->right);
str+=')';
}
//左孩子为空,右孩子为空,那么都不加()
else if(root->left==nullptr&&root->right==nullptr)
{
}
//左孩子不为空,右孩子为空,只给左孩子加(),右孩子不需要加
else if(root->left!=nullptr&&root->right==nullptr)
{
str+='(';
str+=tree2str(root->left);
str+=')';
}
//左孩子为空,右孩子不为空,则给左右孩子都加()
else
{
str+='(';
str+=tree2str(root->left);
str+=')';
str+='(';
str+=tree2str(root->right);
str+=')';
}
return str;
}
};
但是上面的判断比较多,我们也可以使用下面的判断
class Solution {
public:
string tree2str(TreeNode* root) {
string str;
if(root == nullptr)
{
return str;
}
str=to_string(root->val);
//只有左右都为空的时候左边才不加(),所以我们可以使用下面的判断
//只要不是左右都为空,那么左边就加()
if(root->left!=nullptr || root->right!=nullptr)
{
str += '(';
str += tree2str(root->left);
str += ')';
}
//当左边不为空,右边为空时,右边才不加()。
if(root->right!=nullptr)
{
str += '(';
str += tree2str(root->right);
str += ')';
}
return str;
}
};
题目链接
第一种方法:我们使用两个队列,一个队列记录层序遍历的结点,另一个队列记录结点所在层数。
class Solution {
public:
vector<vector<int>> levelOrder(TreeNode* root) {
vector<vector<int>> vv;
//记录层序遍历结点
queue<TreeNode*> s1;
//记录结点所在的层数
queue<int> s2;
//记录当前遍历的层数
int count = 1;
s1.push(root);
s2.push(count);
while(!s1.empty() && s1.front()!=nullptr)
{
vector<int> v;
//如果记录结点层数的队列中还有当前层数的结点,那么继续遍历
while(s2.front() == count)
{
TreeNode* front = s1.front();
v.push_back(front->val);
s1.pop();
s2.pop();
//如果当前结点有左右孩子,那么入栈,并且左右孩子的层数要+1.
if(front->left!=nullptr)
{
s1.push(front->left);
s2.push(count+1);
}
if(front->right!=nullptr)
{
s1.push(front->right);
s2.push(count+1);
}
}
count++;
vv.push_back(v);
}
return vv;
}
};
第二种方法:我们也可以使用一个队列来解决这一题,即使用一个队列来记录层序遍历的结点,然后使用levelSize记录这一层结点的个数即可。
class Solution {
public:
vector<vector<int>> levelOrder(TreeNode* root) {
queue<TreeNode*> s1;
if(root!=nullptr)
{
s1.push(root);
}
int levelSize = 1;
vector<vector<int>> vv;
while(!s1.empty())
{
vector<int> v;
while(levelSize)
{
TreeNode* front = s1.front();
v.push_back(front->val);
s1.pop();
levelSize--;
if(front->left!=nullptr)
{
s1.push(front->left);
}
if(front->right!=nullptr)
{
s1.push(front->right);
}
}
levelSize=s1.size();
vv.push_back(v);
}
return vv;
}
};
题目链接
这一题我们可以使用一个巧的办法,即将上一题的结果使用reverse函数反转以下即可。
class Solution {
public:
vector<vector<int>> levelOrderBottom(TreeNode* root) {
queue<TreeNode*> s1;
if(root!=nullptr)
{
s1.push(root);
}
int levelSize = 1;
vector<vector<int>> vv;
while(!s1.empty())
{
vector<int> v;
while(levelSize)
{
TreeNode* front = s1.front();
v.push_back(front->val);
s1.pop();
levelSize--;
if(front->left!=nullptr)
{
s1.push(front->left);
}
if(front->right!=nullptr)
{
s1.push(front->right);
}
}
levelSize=s1.size();
vv.push_back(v);
}
reverse(vv.begin(),vv.end());
return vv;
}
};
题目链接
这个题我们看到它的结点中没有记录给父结点,如果是三叉链,即记录父结点的话,那么可以将这个题转换为链表相交问题了。但是这个题的结点没有提供父结点,那么我们就需要先找一下规律。
我们看到如果一个孩子在我的左子树,一个孩子在我的右子树,那么我就是公共祖先。而第三种情况我们需要特殊判断,如果我的右子树或者左子树包含一个结点,那么我就是公共祖先。
class Solution {
public:
bool IsInTree(TreeNode* node,TreeNode* key)
{
if(node == nullptr)
{
return false;
}
if(node == key)
{
return true;
}
return IsInTree(node->left,key) || IsInTree(node->right,key);
}
TreeNode* lowestCommonAncestor(TreeNode* root, TreeNode* p, TreeNode* q) {
if(root == nullptr)
{
return nullptr;
}
//p或q是根,另一个是孩子,root就是最近公共祖先
if(p == root || q == root)
{
return root;
}
bool pInLeft = IsInTree(root->left,p);
bool pInRight = !pInLeft;
bool qInLeft = IsInTree(root->left,q);
bool qInRight = !qInLeft;
//如果一个在左,一个在右,则当前结点就是公共祖先
if((pInLeft && qInRight) || (qInLeft && pInRight))
{
return root;
}
//如果两个都在左,就去当前结点的左子树找
else if(pInLeft && qInLeft)
{
return lowestCommonAncestor(root->left,p,q);
}
//如果两个都在右,就去当前结点的右子树找
else
{
return lowestCommonAncestor(root->right,p,q);
}
}
};
上面的写法的时间复杂度为O(N^2),因为每一次查找结点是否在左子树或右子树为O(N),因为二叉树可能为不平衡二叉树,这样极端情况下就需要查N次。即向下面的情况下,是效率最低的情况。
如果这个树是二叉搜索树的话,上面的方法就可以改变判断做到时间复杂度为O(N),因为只需要比较p、q是否比根节点大或小,如果比根小,递归左树查找,如果比根大,递归右树查找。但是题目中的树不是二叉搜索树,我们此时想要优化为O(N)的话,可以使用DFS深度优先遍历,然后将p和q的路径都存放到两个栈中,转换为p和q的路径相交问题。
lass Solution {
public:
bool GetPath(TreeNode* root, TreeNode* x, stack<TreeNode*>& path)
{
if(root == nullptr)
{
return false;
}
path.push(root);
if(root == x)
{
return true;
}
if(GetPath(root->left,x,path))
{
return true;
}
if(GetPath(root->right,x,path))
{
return true;
}
path.pop();
return false;
}
TreeNode* lowestCommonAncestor(TreeNode* root, TreeNode* p, TreeNode* q) {
stack<TreeNode*> pPath,qPath;
//找到p和q的路径
GetPath(root,p,pPath);
GetPath(root,q,qPath);
while(pPath.size()!=qPath.size())
{
if(pPath.size()>qPath.size())
{
pPath.pop();
}
else
{
qPath.pop();
}
}
while(pPath.top()!=qPath.top())
{
pPath.pop();
qPath.pop();
}
return pPath.top();
}
};
题目链接
例如下面的一棵二叉树,我们想要将这棵二叉树变为双向链表,我们需要将每一个结点的left指向它的上一个结点,上一个结点的right指向这一个结点,如果我们使用递归并且将递归函数的参数为指针的话,我们知道在当前的递归函数中没法改变prev结点的right和left,这时我们可以将参数prev设为指针引用,那么我们在当前递归函数中也可以改变当前结点的上一个结点prev的left和right的值。
class Solution {
public:
void InOrderConvert(TreeNode* cur,TreeNode*& prev)
{
if(cur == nullptr)
{
return;
}
//二叉搜索树中序遍历为升序
InOrderConvert(cur->left, prev);
//当前结点的left指向上一个结点
cur->left = prev;
//如果上一个结点不为空,即cur不为链表头结点,那么就将上一个结点的right指向当前结点cur。
if(prev)
{
prev->right = cur;
}
//此时将prev变为当前结点,然后再链接cur->right。
prev=cur;
InOrderConvert(cur->right, prev);
}
TreeNode* Convert(TreeNode* pRootOfTree) {
TreeNode* prev=nullptr;
InOrderConvert(pRootOfTree, prev);
TreeNode* head = pRootOfTree;
while(head!=nullptr&&head->left!=nullptr)
{
head =head->left;
}
return head;
}
};
题目链接
使用前序序列来确定根结点,使用中序序列来分割出根结点的左右子树区间。
class Solution {
public:
TreeNode* _buildTree(vector<int>& preorder, vector<int>& inorder,int& prei,int inbegin,int inend)
{
if(inbegin>inend)
{
return nullptr;
}
TreeNode* root = new TreeNode(preorder[prei]);
int rooti = inbegin;
while(rooti<=inend)
{
if(inorder[rooti]==preorder[prei])
{
break;
}
else
{
rooti++;
}
}
prei++;
root->left=_buildTree(preorder,inorder,prei,inbegin,rooti-1);
root->right=_buildTree(preorder,inorder,prei,rooti+1,inend);
return root;
}
TreeNode* buildTree(vector<int>& preorder, vector<int>& inorder) {
int i = 0;
return _buildTree(preorder,inorder,i,0,inorder.size()-1);
}
};
题目链接
根据前序序列和中序序列生成二叉树时,我们是从左子树开始链接,而因为后序序列中根结点在最后一个,并且倒着访问后序序列时,先访问的都是右结点,所以我们采用从右子树开始链接。
class Solution {
public:
TreeNode* _buildTree(vector<int>& inorder, vector<int>& postorder,int& posti,int inbegin,int inend)
{
if(inbegin>inend)
{
return nullptr;
}
TreeNode* root = new TreeNode(postorder[posti]);
int rooti = inbegin;
while(rooti<=inend)
{
if(inorder[rooti]==postorder[posti])
{
break;
}
else
{
rooti++;
}
}
posti--;
root->right = _buildTree(inorder,postorder,posti,rooti+1,inend);
root->left= _buildTree(inorder,postorder,posti,inbegin,rooti-1);
return root;
}
TreeNode* buildTree(vector<int>& inorder, vector<int>& postorder) {
int i = postorder.size()-1;
return _buildTree(inorder,postorder,i,0,i);
}
};
题目链接
我们使用非递归前序遍历二叉树时,可以先访问这棵树的左路结点,然后左路结点遇到空后,开始访问这个左路结点的右子树;访问右子树时就转换为子问题,即访问右子树也是先从右子树的左路结点开始访问,当右子树的左路结点遇到空时再重复上面的步骤。
class Solution {
public:
vector<int> preorderTraversal(TreeNode* root) {
vector<int> vv;
stack<TreeNode*> s;
TreeNode* cur = root;
//
while(cur!=nullptr || !s.empty())
{
//开始访问一棵树
//1.先访问左路结点,
//2.左路结点遇到空后,就开始访问左路结点的右子树
while(cur!=nullptr)
{
vv.push_back(cur->val);
s.push(cur);
cur=cur->left;
}
//左路结点为空了,开始访问右子树
//访问右子树也是像上面一样,先从右子树的左路结点开始访问。
cur = s.top()->right;
s.pop();
}
return vv;
}
};
题目链接
二叉树的中序遍历非递归实现和上面的前序遍历类似,只不过将元素插入到容器的时机不同。而非递归遍历二叉树的方式是一样的,都是先遍历这棵树的左路结点,然后左路结点为空后,再遍历这个左路结点的右子树。
class Solution {
public:
vector<int> inorderTraversal(TreeNode* root) {
vector<int> vv;
stack<TreeNode*> s;
TreeNode* cur = root;
//先访问一棵树的左路结点,当左路结点为空时,再访问该结点的右子树
//而访问右子树时也是先访问右子树的左路结点。
while(cur!=nullptr || !s.empty())
{
while(cur!=nullptr)
{
s.push(cur);
cur=cur->left;
}
//因为是中序遍历,所以当左路结点没有左孩子时,此时才能访问这个结点。
vv.push_back(s.top()->val);
cur = s.top()->right;
s.pop();
}
return vv;
}
};
题目链接
二叉树的后序遍历的非递归实现和前序遍历、中序遍历不同。因为有左右子树的结点会有两次都在栈顶的位置,我们按照上面前序遍历的写法无法区分哪一次将该结点进行出栈,所以我们在代码中加了一个prev指针,prev指针记录上一个栈顶结点,如果当前栈顶结点的右孩子为prev结点,说明当前栈顶结点的左右子树都已经遍历完毕,此时就需要将栈顶元素进行出栈了。
class Solution {
public:
vector<int> postorderTraversal(TreeNode* root) {
vector<int> vv;
stack<TreeNode*> s;
TreeNode* cur = root;
TreeNode* prev = nullptr;
while(cur!=nullptr || !s.empty())
{
while(cur!=nullptr)
{
s.push(cur);
cur=cur->left;
}
TreeNode* top = s.top();
if((top->right==nullptr) || (top->right == prev))
{
vv.push_back(top->val);
s.pop();
prev = top;
}
else
{
cur=top->right;
}
}
return vv;
}
};