C++之二叉树进阶
(一) 前言
二叉树在之前的 C 语言数据结构阶段已进行讲解,若存在知识遗忘,可点击此处回顾。本节优先介绍二叉树进阶内容,主要原因有两点:
- map 与 set 的特性讲解需以二叉搜索树为基础 —— 二叉搜索树本身属于树形结构,理解其特性能帮助大家更深入地掌握 map 与 set 的核心逻辑;
- 二叉树的部分面试题难度较高,若在前期讲解,不仅大家接受度较低,且长时间后易遗忘;
- 此外,部分相关 OJ 题用 C 语言实现时存在明显不便(例如需返回动态开辟的二维数组,操作流程繁琐)。
因此,本节将借助二叉搜索树,对二叉树相关内容进行收尾总结。
(二) 正文
(1) 二叉搜索树的概念
二叉搜索树(BST,Binary Search Tree)又称二叉排序树也称二叉查找树,它或者是一棵空树,或者是具有以下性质的二叉树:
- 若它的左子树不为空,则左子树上所有节点的值都小于根节点的值
- 若它的右子树不为空,则右子树上所有节点的值都大于根节点的值
- 它的左右子树也分别为二叉搜索树
图像如下:
(2) 二叉搜索树的操作
首先我们定义二叉搜索树的节点结构,并对树进行初始化:
template<class T>
class BSTreeNode
{
public:BSTreeNode<T>* _left; // 左子树指针BSTreeNode<T>* _right; // 右子树指针T _key; // 节点存储的值
public:// 构造函数,初始化节点BSTreeNode(const T& val = T()): _left(nullptr), _right(nullptr), _key(val){}
};template<class T>
class BSTree
{typedef BSTreeNode<T> Node; // 简化节点类型名
public:// 构造函数,初始化空树BSTree(): _root(nullptr){}private:Node* _root; // 根节点指针
};
1. 二叉搜索树的寻找
由二叉搜索树的特性可知:左子树节点值均小于根节点值,右子树节点值均大于根节点值。这一特性使得查找操作可以高效进行,具体逻辑如下:从根节点开始,比较目标值与当前节点值;
- 若目标值小于当前节点值,则向左子树继续查找;
- 若目标值大于当前节点值,则向右子树继续查找;
- 若两者相等,说明找到目标节点,返回true;
- 若遍历至空节点仍未找到,说明树中无该值,返回false。
实现代码如下:
bool find(const T& val)
{Node* cur = _root; // 从根节点开始查找while (cur) // 循环遍历,直至空节点{if (cur->_key > val) // 目标值更小,向左子树查找{cur = cur->_left;}else if (cur->_key < val) // 目标值更大,向右子树查找{cur = cur->_right;}else // 找到目标值{return true;}}return false; // 遍历完仍未找到
}
2. 二叉搜索树的插入
此时二叉搜索树会有两种大情况:树为空和树不为空
插入的核心原则
保持二叉搜索树的特性(左子树节点值均小于根节点,右子树节点值均大于根节点),且树中不允许存在值相等的节点(若插入值已存在则插入失败)。
插入的具体过程如下:
- 树为空,则直接新增节点,赋值给root指针
- 树不空,按二叉搜索树性质查找插入位置,插入新的节点
实现代码如下:
bool insert(const T& val)
{// 情况1:树为空,直接创建根节点if (_root == nullptr){_root = new Node(val);return true;}// 情况2:树非空,查找插入位置Node* cur = _root; Node* parent = nullptr; // 记录当前节点的父节点(用于后续连接新节点)while (cur != nullptr){if (cur->_key > val) // 目标值更小,向左子树查找{parent = cur;cur = cur->_left;}else if (cur->_key < val) // 目标值更大,向右子树查找{parent = cur;cur = cur->_right;}else // 树中已存在该值,插入失败{return false;}}Node* newNode = new Node(val);// 根据目标值与父节点值的大小,决定新节点是左孩子还是右孩子if (parent->_key < val){parent->_right = newNode; // 目标值更大,作为右孩子插入}else{parent->_left = newNode; // 目标值更小,作为左孩子插入}return true;
}
3. 二叉搜索树的删除
二叉搜索树的删除操作是树形结构中的核心难点,其关键在于:删除指定节点后,必须保持二叉搜索树的性质(左子树所有节点值 < 根节点值 < 右子树所有节点值)。
操作流程:
首先查找元素是否在二叉搜索树中,如果不存在,则返回, 否则要删除的结点可能分下面四种情况:
- 要删除的结点无孩子结点
- 要删除的结点只有左孩子结点
- 要删除的结点只有右孩子结点
- 要删除的结点有左、右孩子结点
看起来有待删除节点有4中情况,实际情况a可以与情况b或者c合并起来,因此真正的删除过程和图片解释如下:
情况2:要删除的结点只有左孩子结点
删除该结点且使被删除节点的双亲结点指向被删除节点的左孩子结点--直接删除
情况3:要删除的结点只有右孩子结点
删除该结点且使被删除节点的双亲结点指向被删除结点的右孩子结点--直接删除
情况4:要删除的结点有左、右孩子结点(重难点)
核心问题:若直接删除该节点,左、右子树会断裂,无法直接拼接。
解决方案:替换法(博主选择 “左子树最大值节点” 作为替换节点,也可选择 “右子树最小值节点”):
- 找左子树的最大值节点:左子树的最大值一定在其最右侧节点(记为lmax),因左子树所有节点值 < 待删节点值,而lmax是左子树最大,故lmax值满足 “左子树其他节点值 < lmax值 <待删节点右子树所有节点值”,替换后能保持 BST 性质。
- 替换:将lmax的值与待删除节点的值交换(相当于用lmax“覆盖” 待删节点)。
- 删除lmax:lmax是左子树最右侧节点,其右子树必为空,可按 “场景 2” 或 “场景 3” 直接删除(只需处理lmax的左子树,让lmax的父节点指向其左子树)。
实现代码如下:
bool erase(const T& val)
{Node* cur = _root;Node* parent = nullptr;while (cur != nullptr){if (cur->_key > val){parent = cur;cur = cur->_left;}else if (cur->_key < val){parent = cur;cur = cur->_right;}else{if (cur->_left == nullptr){if (parent == nullptr){_root = cur->_right;}else{if (parent->_left == cur){parent->_left = cur->_right;}else{parent->_right = cur->_right;}}}else if(cur->_right == nullptr){if (parent == nullptr){_root = cur->_left;}else{if (parent->_left == cur){parent->_left = cur->_left;}else{parent->_right = cur->_left;}}}else{Node* lmaxp = cur;Node* lmax = cur->_left;while (lmax->_right != nullptr){lmaxp = lmax;lmax = lmax->_right;}swap(cur->_key, lmax->_key);//处理左子树最大右子树的左子树if (lmaxp->_left == lmax){lmaxp->_left = lmax->_left;}else{lmaxp->_right = lmax->_left;}cur = lmax;}delete cur;return true;}}return false;
}
我们可以测试一下,在测试中为了方便观看,可以先写一个中序遍历
void InOrder()
{_InOrder(_root);cout << endl;
}void _InOrder(Node* node)
{if (node == nullptr)return; _InOrder(node->_left); cout << node->_key << " "; _InOrder(node->_right);
}
小思考:
为什么要有void InOrder()这个函数了?
因为_root为private私有成员外界访问不了,这样写外界就能得到中序遍历。它本质是为了在保护内部数据安全的前提下,给外界提供一个便捷的中序遍历接口。
测试代码:
void test1()
{int a[] = { 8, 3, 1, 10, 6, 4, 7, 14, 13 };BSTree<int> t;for (auto e : a){t.insert(e);}t.InOrder();t.erase(4);t.InOrder();t.erase(6);t.InOrder();t.erase(7);t.InOrder();t.erase(3);t.InOrder();for (auto e : a){t.erase(e);}t.InOrder();
}
运行结果:
(3) 递归的操作
上诉代码都是非递归的我们来写写递归的方法。
1. 二叉搜索树的寻找(递归法)
bool _findR(Node* root, const T& key)
{if (root == nullptr)return false;if (root->_key < key)return _findR(root->_right, key);else if (root->_key > key)return _findR(root->_left, key);elsereturn true;
}bool findR(const T& key)
{return _findR(_root, key);
}
2. 二叉搜索树的插入(递归法)
bool _insertR(Node*& root, const T& key)
{if (root == nullptr){root = new Node(key);return true;}if (root->_key < key)return _insertR(root->_right, key);else if (root->_key > key)return _insertR(root->_left, key);elsereturn false;
}bool insertR(const T& key)
{return _insertR(_root, key);
}
小思考:
Node*& root
Node*& root为指针的引用(原指针别名),在 C++ 中,函数参数默认是值传递(传递副本),引用传递(&)是传递变量的 “别名”,修改引用相当于直接修改原变量。
为什么要用它?
- 二叉搜索树的递归插入 / 删除,本质是 “逐层找到目标位置(父节点的左 / 右子树),然后修改父节点的指针”。若不用引用,递归函数无法触达并修改父节点的实际指针,树结构永远无法更新。
具体分析:
若不用引用(Node* root):
- 空树时,_insertR(_root, 5) 传的是 _root 的副本(初始为 nullptr);函数内执行 root = new Node(5),修改的只是副本指针,外部 _root 依然是 nullptr;最终树还是空树,插入失败。
若用引用(Node*& root):
- _insertR(_root, 5) 传的是 _root 的引用(别名);函数内 root = new Node(5) 直接修改原变量 _root,_root 从此指向新节点;树结构成功更新,插入生效。
总结:
Node*& root 的核心好处
1.直接更新树结构,保证操作生效
- 递归函数内对 root 的修改,就是对父节点左 / 右指针的直接修改(而非修改副本),确保插入的新节点能被父节点 “认领”,删除后父节点能正确指向子节点,树结构不会断裂。
2.无需记录父节点,简化递归逻辑
- 非递归操作需要用 parent 指针跟踪父节点,而 Node*& 让递归函数直接 “绑定” 父节点的指针,省去了记录父节点的代码,让递归逻辑更聚焦于 “找目标位置”,而非 “维护父节点关系”。
3.避免野指针与内存泄漏
- 若不用引用,插入的新节点无法被父节点指向(父节点指针未更新),会导致内存泄漏;删除时父节点指针未更新,会指向已释放的待删节点(野指针)。Node*& 能从根本上避免这些问题。
3. 二叉搜索树的删除(递归法)
bool _eraseR(Node*& root, const T& key)
{if (root == nullptr)return false;if (root->_key < key)return _eraseR(root->_right, key);else if (root->_key > 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* lmax = root->_left;while (lmax->_right != nullptr){lmax = lmax->_right;}swap(del->_key, lmax->_key);return _eraseR(root->_left, lmax->_key);}delete del;return true;}
}bool eraseR(const T& key)
{return _eraseR(_root, key);
}
由上面那个测试代码就可以判断正确性。
我们可以发现,循环大多能改成递归,递归代码更简洁,但通常优先选非递归 —— 因为递归会层层创建栈帧,函数调用栈空间有限,深度大了容易栈溢出,还可能有额外性能开销。除非二叉树深度可控等特殊情况,否则非递归更稳定。
(4) 完善代码
1.拷贝构造
因为可能会有字符串所有不能浅拷贝,就得自己写一个深拷贝
BSTree(const BSTree<T>& t)
{_root = Copy(t._root);
}Node* Copy(Node* root){if (root == nullptr)return nullptr;Node* copyRoot = new Node(root->_key);copyRoot->_left = Copy(root->_left);copyRoot->_right = Copy(root->_right);return copyRoot;}
2.赋值运算符重载
采用现代算法更加方便
BSTree<T>& operator=(BSTree<T> t)
{swap(_root, t._root);return *this;
}
3.析构函数
~BSTree()
{Destroy(_root);
}void Destroy(Node*& root){if (root == nullptr)return;Destroy(root->_left);Destroy(root->_right);delete root;root = nullptr;}
完整代码——博主的gitee
以上就是算法之滑动窗口的学习一点点,后续的会继续更新这个算法的题目,我们将留待日后进行。希望这些知识能为你带来帮助!如果觉得内容实用,欢迎点赞支持~ 若发现任何问题或有改进建议,也请随时与我交流。感谢你的阅读!