当前位置: 首页 > news >正文

【数据结构与算法】AVL树的插入与删除实现详解

文章目录

  • 前言
  • Ⅰ. AVL树的定义
  • Ⅱ. AVL树节点的定义
  • Ⅲ. AVL树的插入Insert
    • 一、节点的插入
    • 二、插入的旋转
      • ① 新节点插入较高左子树的左侧(左左):右单旋
      • ② 新节点插入较高右子树的右侧(右右):左单旋
      • ③ 新节点插入较高左子树的右侧(左右):先左单旋再右单旋
      • ④ 新节点插入较高右子树的左侧(右左):先右单旋再左单旋
      • 插入后旋转的总结:
  • Ⅳ. AVL树的验证
    • 验证用例
  • Ⅴ. AVL树的删除Erase
    • 一、节点的删除
    • 二、删除的旋转
  • Ⅵ. AVL树的性能
  • Ⅶ. AVL树的整体框架

在这里插入图片描述

前言

​ 之前对 map / multimap / set / multiset 进行了简单的介绍,在其文档介绍中发现,这几个容器有个共同点是:其底层都是按照二叉搜索树来实现的,但是二叉搜索树有其自身的缺陷,假如往树中插入的元素有序或者接近有序,二叉搜索树就会退化成单支树,时间复杂度会退化成 O(N),因此 mapset 等关联式容器的底层结构是对二叉树进行了平衡处理,即采用平衡树来实现。

注意:因为 mapset 主要是用红黑树进行封装,所以这里的 AVL 树我们主要是实现它的 插入删除operator[] (因为 insert 就是为了 operator[] 而存在的,还记得我们在二叉树进阶讲的吗?),所以我们这里不会去是实现 AVL 树的拷贝构造和赋值重载,这方面涉及到深拷贝,我们一并到红黑树才讲!

Ⅰ. AVL树的定义

​ 二叉搜索树虽可以缩短查找的效率,但 如果数据有序或接近有序二叉搜索树将退化为单支树,查找元素相当于在顺序表中搜索元素,效率低下。因此,两位俄罗斯的数学家 G.M.Adelson-VelskiiE.M.Landis1962 年发明了一种解决上述问题的方法:当向二叉搜索树中插入新结点后,如果能保证每个结点的左右子树高度之差的绝对值不超过 1( 需要对树中的结点进行调整 ),即可降低树的高度,从而减少平均搜索长度。

​ 一棵 AVL 树或者是空树,或者是具有以下性质的二叉搜索树:

  • 左右子树都是 AVL

  • 左右子树高度之差 ( 简称 平衡因子) 的绝对值不超过 1

在这里插入图片描述

​ 如果一棵二叉搜索树是高度平衡的,它就是 AVL 树。如果它有 n 个结点,其高度可保持在 l o g 2 n log_2 n log2n搜索时间复杂度 O( l o g 2 n log_2 n log2n)。

Ⅱ. AVL树节点的定义

​ 对于 AVL 树节点的定义,其实有很多个版本,各有优势,这里用的是三叉链的结构,这是为了方便后面的一些操作,但是也是有弊端的比如说在链接的时候要把三条链都考虑进去,但是三叉链的优势是利大于弊,所以这里采用三叉链!

​ 而至于 AVL 树的一些特点,比如说调整树的高度,那么我们就得知道该节点的 平衡因子(balance factor),所以这里我们给节点里面增加一个 bf,用于存放该节点的平衡因子。

注意:这里 平衡因子bf的值 等于:右子树高度 - 左子树高度

​ 根据AVL树的定义,每个节点的bf值理论上只可能有三种情况:10-1。所以当bf出现其他值的时候就代表树不平衡了,需要调整了。

template <class K, class V>
struct AVLTreeNode
{AVLTreeNode<K, V>* _left;AVLTreeNode<K, V>* _right;AVLTreeNode<K, V>* _parent;pair<K, V> _kv; // _kv是一个键值对int _bf; // 该点的平衡因子 --> balance factorAVLTreeNode(const pair<K, V>& kv): _left(nullptr), _right(nullptr), _parent(nullptr), _kv(kv), _bf(0){}
};

Ⅲ. AVL树的插入Insert

一、节点的插入

AVL 树的插入和之前搜索树的插入是一样的,只不过在插入之后,我们需要做出调整,因为有可能我们插入节点后会导致二叉树的不平衡,所以我们得重新调整高度!这里就涉及到 左单旋、右单旋、左右双旋和右左双旋 四个操作。

AVL 树的插入过程可以分为两步:

  1. 按照二叉搜索树的方式插入新节点

  2. 调整节点的平衡因子

    • 新增的节点如果在 parent 的左边,则 parent->bf--
    • 新增的节点如果在 parent 的右边,则 parent->bf++
      • 如果 parent 的平衡因子为 0,说明高度是平衡的,停止更新
      • 如果 parent 的平衡因子为 1 或者 -1,说明 parent 所在子树的高度变了,继续往上更新
      • 如果 parent 的平衡因子为 2 或者 -2,说明已经出现不平衡了,需要进行旋转调整(后面会讲如何旋转)
pair<Node*, bool> Insert(const pair<K, V>& kv)
{// 若树为空则直接开辟新节点if (_root == nullptr){_root = new Node(kv);return make_pair(_root, true);}// 先找到要插入的位置,用cur标记,而parent标记cur的上一个位置Node* cur = _root;Node* parent = _root;while (cur != nullptr){if (kv.first < cur->_kv.first){parent = cur;cur = cur->_left;}else if (kv.first > cur->_kv.first){parent = cur;cur = cur->_right;}else{return make_pair(cur, false);}}// 接着插入节点cur = new Node(kv);Node* newnode = cur; // 这一步是为了后面返回值返回的if (parent->_kv.first < kv.first){parent->_right = cur;cur->_parent = parent;}else{parent->_left = cur;cur->_parent = parent;}// 调整高度while (parent != nullptr) // 这里也可写出while(cur != _root){// 1、先调节平衡因子if (parent->_left == cur){parent->_bf--;}else{parent->_bf++;}// 2、判断是否需要旋转if (parent->_bf == 0){break;}else if (parent->_bf == 1 || parent->_bf == -1){// 插入前双亲的平衡因子是0,插入后双亲的平衡因为为1 或者 -1 ,说明以双亲为根的二叉树的高度增加了一层,因此需要继续向上调整cur = parent;parent = parent->_parent;}else if(parent->_bf == 2 || parent->_bf == -2){// 需要调整高度if (parent->_bf == -2){if (cur->_bf == -1){RotateR(parent); //右单旋}else if(cur->_bf == 1){RotateLR(parent); //左右双旋}}else if (parent->_bf == 2){if (cur->_bf == 1){RotateL(parent); //左单旋}else if (cur->_bf == -1){RotateRL(parent); //右左双旋 }}// 注意这里的break很关键,因为我们调整了子树的平衡因子后,它的父亲以上的树其实就已经不会受影响了break;}else{// 插入节点之前,树已经不平衡了,或者bf出错。需要检查其他逻辑assert(false);}}return make_pair(newnode, true);
}

问题:这里要注意的是 Insert 的返回值为什么是 pair

​ 还记得我们在实现二叉搜索树的时候说到,Insert 的实现其实是为了 operator[] 而产生的,而 operator[] 需要接收到是一个键值对里面的 value,所以我们的 Insert 需要返回一个 pair,且 pair 里面第一个键值放的是迭代器,第二个值放的是 bool 值,这是为了和 STL 里面的实现保持一致!详情可查看官方文档。

二、插入的旋转

​ 如果在一棵原本是平衡的 AVL 树中插入一个新节点,可能造成不平衡,此时必须调整树的结构,使之平衡化。根据节点插入位置的不同,AVL 树的旋转分为四种

① 新节点插入较高左子树的左侧(左左):右单旋

​ 如下图,我们在 a 树下插入新节点,此时 30 节点和 60 节点的平衡因子就变了,则需要调整。

在这里插入图片描述

​ 上图在插入前,AVL 树是平衡的,新节点插入到 30 的左子树(注意:此处不是左孩子)中,30 的左子树增加了一层,导致以 60 为根的二叉树不平衡,要让 60 平衡,只能将 60 左子树的高度减少一层,右子树增加一层,即60 的左子树往上提,这样 60 转下来,因为 6030 大,只能将其放在 30 的右子树,而如果 30 有右子树,右子树根的值一定大于 30,小于 60,只能将其放在 60 的左子树,旋转完成后,更新节点的平衡因子即可。在旋转过程中,有以下几种情况需要考虑:

  1. 30 节点的右孩子可能存在,也可能不存在

  2. 60 节点可能是根节点,也可能是子树

    • 如果是根节点,旋转完成后,要更新根节点

    • 如果是子树,可能是某个节点的左子树,也可能是右子树

  3. 最后记得将 3060 节点的平衡因子调为 0

void RotateR(Node* parent)
{// SubL: Parent的左孩子// SubLR: Parent左孩子的右孩子Node* subL = parent->_left;Node* subLR = subL->_right;// 先将parent的左子树连上subLR,注意要双向链接parent->_left = subLR;if (subLR != nullptr) // 注意要判断subLR是否为空,为空则不需要链接subLR->_parent = parent;// 让parent作为subL的右子树subL->_right = parent;Node* parent_parent = parent->_parent; // 先将parent的parent记录下来,后面链接要用到parent->_parent = subL;// 判断一下parent是否为二叉树的根节点if (parent == _root){_root = subL;_root->_parent = nullptr;}else{// 如果60是子树,可能是parent_parent的左子树,也可能是右子树if (parent_parent->_left == parent){parent_parent->_left = subL;}else{parent_parent->_right = subL;}subL->_parent = parent_parent;}// 最后记得要将平衡因子置零subL->_bf = parent->_bf = 0;
}

② 新节点插入较高右子树的右侧(右右):左单旋

在这里插入图片描述

​ 实现及情况考虑可参考右单旋,与右单旋基本一致,只不过要旋转的节点不同而已!

void RotateL(Node* parent)
{Node* subR = parent->_right;Node* subRL = subR->_left;// 先将parent的左子树连上subRL,注意要双向链接parent->_right = subRL;if (subRL != nullptr)subRL->_parent = parent;// 让parent作为subR的右子树subR->_left = parent;Node* parent_parent = parent->_parent;parent->_parent = subR;// 判断一下parent是否为二叉树的根节点if (parent == _root){_root = subR;_root->_parent = nullptr;}else{if (parent_parent->_left == parent){parent_parent->_left = subR;}else{parent_parent->_right = subR;}subR->_parent = parent_parent;}// 最后记得要将平衡因子置零subR->_bf = parent->_bf = 0;
}

③ 新节点插入较高左子树的右侧(左右):先左单旋再右单旋

​ 将双旋变成单旋后再旋转,即:先对 30 进行左单旋,然后再对 90 进行右单旋,旋转完成后再考虑平衡因子的更新。

​ 而这里双旋后平衡因子更新的情况比较多,我们一一列举出来:

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

可以看出来,三种情况可以根据 60 这个节点来进行区分:

  • 60 这个节点的 bf0:旋转后该子树的节点的平衡因子都为 0
  • 60 这个节点的 bf1:也就是在右子树插入了新节点,则最后 subL->bf = -1,其他都为 0
  • 60 这个节点的 bf-1:也就是在左子树插入了新节点,则最后 parent->bf = 1,其他都为 0
void RotateLR(Node* parent)
{Node* subL = parent->_left;Node* subLR = subL->_right;// 旋转之前,保存subLR的平衡因子,旋转完成之后,需要根据该平衡因子来调整其他节点的平衡因子int bf = subLR->_bf;RotateL(parent->_left);RotateR(parent);if (bf == 1){subL->_bf = -1;subLR->_bf = 0;parent->_bf = 0;}else if (bf == -1){subL->_bf = 0;subLR->_bf = 0;parent->_bf = 1;}else if (bf == 0){subL->_bf = 0;subLR->_bf = 0;parent->_bf = 0;}else{assert(false);}
}

④ 新节点插入较高右子树的左侧(右左):先右单旋再左单旋

​ 大体与左右双旋一致,具体参考左右双旋。这里只画出其中一种情况,剩下的两种自主分析。

在这里插入图片描述

void RotateRL(Node* parent)
{Node* subR = parent->_right;Node* subRL = subR->_left;// 旋转之前,保存subRL的平衡因子,旋转完成之后,需要根据该平衡因子来调整其他节点的平衡因子int bf = subRL->_bf;RotateR(parent->_right);RotateL(parent);// 平衡因子的调节与左右双旋不太一样,具体自己分析if (bf == 1){parent->_bf = -1;subRL->_bf = 0;subR->_bf = 0;}else if (bf == -1){parent->_bf = 0;subRL->_bf = 0;subR->_bf = 1;}else if (bf == 0){parent->_bf = 0;subRL->_bf = 0;subR->_bf = 0;}else{assert(false);}
}

插入后旋转的总结:

假如以 parent 为根的子树不平衡,即 parent 的平衡因子为 2 或者 -2,分以下情况考虑:

  1. parent 的平衡因子为 2,说明 parent 的右子树高,设 parent 的右子树的根为 subR

    • subR 的平衡因子为 1 时,执行左单旋

    • subR 的平衡因子为 -1 时,执行右左双旋

  2. parent 的平衡因子为 -2,说明 parent 的左子树高,设 parent 的左子树的根为 subL

    • subL 的平衡因子为 -1 是,执行右单旋

    • subL 的平衡因子为 1 时,执行左右双旋

旋转完成后,原 parent 为根的子树个高度降低,已经平衡,不需要再向上更新

Ⅳ. AVL树的验证

AVL 树是在二叉搜索树的基础上加入了平衡性的限制,因此要验证 AVL 树,可以分两步:

  1. 验证其为二叉搜索树

    • 如果中序遍历可得到一个有序的序列,就说明为二叉搜索树
  2. 验证其为平衡树

    • 每个节点子树高度差的绝对值不超过 1(注意节点中如果没有平衡因子)
    • 节点的平衡因子是否计算正确
int Height(Node* root)
{if (root == nullptr)return 0;int leftH = Height(root->_left);int rightH = Height(root->_right);return leftH > rightH ? leftH + 1 : rightH + 1;
}bool IsBalanceTree(Node* root)
{if (root == nullptr)return true;int leftH = Height(root->_left);int rightH = Height(root->_right);// 检查一下平衡因子是否正确 (右平衡因子 - 左平衡因子)if (rightH - leftH != root->_bf){cout << "平衡因子异常:" << root->_kv.first << endl;return false;}if (abs(rightH - leftH) > 2)return false;return IsBalanceTree(root->_left) && IsBalanceTree(root->_right);
}// 这个是调用验证AVL树的主函数
bool IsAVLTree()
{return IsBalanceTree(_root);
}

验证用例

结合上述代码按照以下的数据次序,自己动手画 AVL 树的创建过程,验证代码是否有漏洞。

​ 常规场景1:{16, 3, 7, 11, 9, 26, 18, 14, 15}

​ 特殊场景2:{4, 2, 6, 1, 3, 5, 15, 7, 16, 14}

在这里插入图片描述

在这里插入图片描述

Ⅴ. AVL树的删除Erase

一、节点的删除

​ 因为 AVL 树也是二叉搜索树,可按照二叉搜索树的方式将节点删除,然后再更新平衡因子,只不过与插入不同的是,删除节点后的平衡因子更新,最差情况下一直要调整到根节点的位置。

AVL 树删除节点的过程是,先找到该节点,然后进行删除。由于删除节点的位置不同,导致删除后节点进行移动的方式不同。删除节点的位置分为以下 4 类:

  1. 删除叶子结点。操作:直接删除,然后依次向上调整为 AVL 树。

    在这里插入图片描述

    这里叶子节点还有两种比较特殊的情况:

    在这里插入图片描述

  2. 删除非叶子节点,该节点只有左孩子。操作:该节点的值替换为左孩子节点的值,然后删除左孩子节点。

    • 为什么左孩子节点为叶子节点,因为删除节点前,该树是 AVL 树,由 AVL 树的定义知,每个节点的左右子树的高度差的绝对值 <=1,由于该节点只有左孩子,没有右孩子,如果左孩子还有子节点,那么将不满足每个节点的左右子树的高度差的绝对值 <=1,所以左孩子节点为叶子结点。

      在这里插入图片描述

      在这里插入图片描述

  3. 删除非叶子节点,该节点只有右孩子。操作:该节点的值替换为右孩子节点的值,然后删除右孩子节点

    • 【右孩子节点为叶子结点,所以删除右孩子节点的情况为第1种情况。】

    • 【为什么右孩子节点为叶子节点?答案和第二种情况一样】
      在这里插入图片描述

      在这里插入图片描述

  4. 删除非叶子节点,该节点既有左孩子,又有右孩子。操作:该节点的值替换为该节点的前驱节点(或者后继节点),然后删除前驱节点(或者后继节点)

    • 前驱结点:在中序遍历中,一个节点的前驱结点,先找到该节点的左孩子节点,再找左孩子节点的最后一个右孩子节点。向左走一步,然后向右走到头。最后一个右孩子节点即为前驱节点

    • 后继节点: 在中序遍历中,一个节点的后继结点,先找到该节点的右孩子节点,再找右孩子节点的最后一个左孩子节点。向右走一步,然后向左走到头。最后一个左孩子节点即为前驱节点

这里我们选择的是后继节点!说的简单一点就是右子树的最小节点

在这里插入图片描述

总结:对于非叶子节点的删除,最终都将转化为对叶子节点的删除。

// 删除的情况:
// 1.删除的节点为叶子节点,直接删除,修改父节点的bf并从该节点的父节点向上调整
//  下面两种情况由于删除之前就是AVL树,又因为有一个子树为空,所以另一个子树(非空)一定只包含一个节点!,搞清楚这点很重要,这种节点一定是叶子节点的上一层!!!!这里虽然是删除该节点实际上删除的是他的唯一一个非空节点
// 2.删除的节点左子树为空,右子树非空: 相当于删除左子树,修改该节点的bf并向上调整
// 3.删除的节点右子树为空,左子树非空: 相当于删除右子树,修改该节点的bf并向上调整
// 4.左右子树都不为空,用替换删除法,找右子树的最小节点(最左边节点,这个节点左子树一定为空)实际上就转化成了上面三种情况// bf调整原则:
// 1. 删左节点,父节点的bf++
// 2. 删右节点,父节点的bf--
// 3. bf为0继续向上调整,bf为1或-1停止向上调整
// 4. cur->bf为2的时候情况就与插入不同了,插入的时候调整的是插入的节点所在cur的半边子树,而删除要调整的是删除节点对面那一半进行旋转(这点很重要!!!,我在这上面卡了半天),旋转的操作与插入相同
bool Erase(const K& key)
{// 开头检查一下是否是空树if (_root == nullptr)return false;Node* cur = _root;Node* parent = nullptr;while (cur != nullptr){if (cur->_kv.first > key){parent = cur;cur = cur->_left;}else if (cur->_kv.first < key){parent = cur;cur = cur->_right;}else{// 找到了该节点,准备删除// 1、左右都为空或者其中一个为空if (cur->_left == nullptr || cur->_right == nullptr){// 删除的节点就是根节点话,则先判断是否有左右子树然后在deleteif (_root == cur){if (cur->_left == nullptr)_root = _root->_right;else_root = _root->_left;delete cur;// 平衡因子调节,注意要加这个判断,否则当左右子树不存在的时候,_root是nullptr,修改bf会报错if(_root != nullptr)_root->_bf = 0;}else if (cur->_left == nullptr && cur->_right == nullptr) // 左右子树均为空{if (parent->_left == cur){parent->_left = nullptr;parent->_bf++;}else{parent->_right = nullptr;parent->_bf--;}delete cur;// 调节高度Erase_rotate(parent);}else if (cur->_left == nullptr && cur->_right != nullptr) // 左为空右不为空{// 用右节点来代替作为删除的节点cur->_kv = cur->_right->_kv;delete cur->_right;cur->_right = nullptr;// 调节高度cur->_bf--;Erase_rotate(cur);}else if (cur->_left != nullptr && cur->_right == nullptr) // 右为空左为空{// 用左节点来代替作为删除的节点cur->_kv = cur->_left->_kv;delete cur->_left;cur->_left = nullptr;// 调节高度cur->_bf++;Erase_rotate(cur);}}else // 2、左右都不为空{// 找到右子树中的最小值与cur节点的值进行替换// 找右子树最小节点,也就是右子树的最左边的节点,这个节点:左子树一定为nullptr,右子树未知Node* minRight = cur->_right;Node* minParent = cur;while (minRight->_left != nullptr){minParent = minRight;minRight = minRight->_left;}cur->_kv = minRight->_kv;// 现在要搞清楚等效删除的是哪个节点,以及从哪个节点开始向上检查!// 将删除节点转化为上面左右都为空或者其中一个为空的情况解决if (cur == minParent){// 相当于删除的是minRight->_right,改变minRight的bf,并从minRight节点向上检查if (minRight->_right != nullptr) {minRight->_kv = minRight->_right->_kv;delete minRight->_right;minRight->_right = nullptr;minRight->_bf++;Erase_rotate(minRight);}// 相当于删除的是minRight节点,改变minParent的bf,并从minParent向上检查else{minParent->_right = nullptr;delete minRight;minParent->_bf--;Erase_rotate(minParent);}}else{// 相当于删除的是minRight->_right,改变minRight的bf,并从minRight节点向上检查if (minRight->_right != nullptr){// 左子树为空右子树不为空minRight->_kv = minRight->_right->_kv;delete minRight->_right;minRight->_right = nullptr;minRight->_bf++;Erase_rotate(minRight);}//相当于删除的是minRight,改变minParent的bf,并从minParent向上检查else{// 左右子树 均为空的删除情况minParent->_left= nullptr;delete minRight;minParent->_bf++;Erase_rotate(minParent);}}}return true;}}return false;
}

二、删除的旋转

bf 调整原则:

  1. 删左节点,父节点的 bf++
  2. 删右节点,父节点的 bf--
  3. bf0 继续向上调整,bf1-1 停止向上调整(与插入正好反过来)
  4. cur->bf2 的时候情况就与插入不同了,插入的时候调整的是插入的节点所在的半边子树,而删除要调整的是删除节点对面那一半进行旋转(这点很重要!!!),也就是如果 cur 节点的 bf2,意味着右边高删除的节点一定在 cur 的左子树,接下来要调整右子树

🏗 与插入不同的是:删除左右单旋各自会出现一种新的情况,这种情况是插入中不可能发生的(也就是上面删除叶子节点的两种特殊情况):

在这里插入图片描述

​ 由于插入的时候一定是插入的那半边子树高,所以插入的时候只能在 B 的左右一个子树插入,所以 B 树的平衡因子不可能为 0,而删除就不同了删除节点影响的是另一半边子树,旋转的也是另一半边子树(上面删除的地方一定是是高度为h的那颗子树),所以这种情况就出现了,这种情况依然是按照左单旋和右单旋处理。旋转完成之后记得要调整整个树的 bf 值。

// bf调整原则:
// 1. 删左节点,父节点的bf++
// 2. 删右节点,父节点的bf--
// 3. bf为0继续向上调整,bf为1或-1停止向上调整
// 4. cur->bf为2的时候情况就与插入不同了,插入的时候调整的是插入的节点所在cur的半边子树,而删除要调整的是删除节点对面那一	   半进行旋转(这点很重要!!!)
//   旋转的操作与插入相同
void Erase_rotate(Node* cur) // 删除节点的操作函数 传入的是已经修改过bf的删除节点的父节点
{Node* prev = nullptr;while (cur != nullptr){if (cur->_bf == 1 || cur->_bf == -1){break;}else if (cur->_bf == 0){prev = cur;cur = cur->_parent;}else if (cur->_bf == 2 || cur->_bf == -2){if (cur->_bf == 2){if (cur->_right->_bf == 0) // 这种情况是插入没有的,这里要特殊处理一下{RotateL(cur);cur->_parent->_bf = -1;cur->_bf = 1;break;		// 由于旋转完的树的bf的值为-1,所以不用继续循环}else if (cur->_right->_bf == 1) //左单旋{RotateL(cur);// 下面这两步置零其实可以不用写,因为在左旋的实现里面已经置零了// cur->_parent->_bf = 0;// cur->_bf = 0;prev = cur->_parent;cur = prev->_parent;continue;}else if (cur->_right->_bf == -1) //先来一个右单旋 再来一个左单旋{RotateRL(cur);prev = cur->_parent;cur = prev->_parent;continue;}}else if(cur->_bf == -2){if (cur->_left->_bf == 0) // 这种情况是插入没有的,这里要特殊处理一下{RotateR(cur);cur->_bf = -1;cur->_parent->_bf = 1;break;}else if (cur->_left->_bf == -1) //右单旋{RotateR(cur);prev = cur->_parent;cur = prev->_parent;continue;}else if (cur->_left->_bf == 1) // 先来一个左单旋 再来一个右单旋{RotateRL(cur);prev = cur->_parent;cur = prev->_parent;continue;}}}else{assert(false);}// 更新平衡因子if (cur && cur->_left == prev)cur->_bf++;else if (cur && cur->_right == prev)cur->_bf--;}
}

Ⅵ. AVL树的性能

AVL 树是一棵绝对平衡的二叉搜索树,其要求每个节点的左右子树高度差的绝对值都不超过 1,这样可以保证查询时高效的 时间复杂度,即 O( l o g 2 n log_2 n log2n)。但是如果要对 AVL 树做一些结构修改的操作,性能非常低下,比如:插入时要维护其绝对平衡,旋转的次数比较多,更差的是在删除时,有可能一直要让旋转持续到根的位置

​ 因此:如果需要一种查询高效且有序的数据结构,而且数据的个数为静态的(即不会改变),可以考虑 AVL 树,但一个结构经常修改,就不太适合。

Ⅶ. AVL树的整体框架

​ 前言中说到,我们这里不实现 AVL 树的拷贝构造以及赋值重载,但是我们这里会实现一下 operator[],毕竟 insert 函数的出现就是为了它的!

​ 🏗 operator[] 的实现代码:

V& operator[](const K& key)
{pair<Node*, bool> res = Insert(make_pair(key, V()));return res.first->_kv.second;
}

测试代码:

#include "AVLTree.h"void TestTree1()
{AVLTree<int, int> t;int arr[] = { 3,10,1,2,9,4,5,6,7 };//int arr[] = { 16, 3, 7, 11, 9, 26, 18, 14, 15 };//int arr[] = { 4, 2, 6, 1, 3, 5, 15, 7, 16, 14 };for (auto e : arr){t.Insert(make_pair(e, 1));}t.Inorder();cout << t.IsAVLTree() << endl;t[3] *= 102;t[1] *= 10;t[2] *= 10;t.Inorder();t.Erase(3);t.Inorder();cout << t.IsAVLTree() << endl;t.Erase(1);t.Inorder();cout << t.IsAVLTree() << endl;t.Erase(1);t.Inorder();cout << t.IsAVLTree() << endl;t.Erase(2);t.Inorder();cout << t.IsAVLTree() << endl;t.Erase(4);t.Inorder();cout << t.IsAVLTree() << endl;t.Erase(5);t.Inorder();cout << t.IsAVLTree() << endl;t.Erase(6);t.Inorder();cout << t.IsAVLTree() << endl;t.Erase(7);t.Inorder();cout << t.IsAVLTree() << endl;t.Erase(8);t.Inorder();cout << t.IsAVLTree() << endl;t.Erase(9);t.Inorder();cout << t.IsAVLTree() << endl;t.Erase(10);t.Inorder();cout << t.IsAVLTree() << endl;t.Erase(10);t.Inorder();cout << t.IsAVLTree() << endl;
}int main()
{TestTree1();return 0;
}

AVLTree.h

#pragma once
#include <iostream>
#include <string>
#include <cassert>
using namespace std;template <class K, class V>
struct AVLTreeNode
{AVLTreeNode<K, V>* _left;AVLTreeNode<K, V>* _right;AVLTreeNode<K, V>* _parent;pair<K, V> _kv;int _bf; //该点的平衡因子 --> balance factorAVLTreeNode(const pair<K, V>& kv): _left(nullptr), _right(nullptr), _parent(nullptr), _kv(kv), _bf(0){}
};template <class K, class V>
class AVLTree
{typedef AVLTreeNode<K, V> Node;
public:AVLTree():_root(nullptr){}V& operator[](const K& key){pair<Node*, bool> res = Insert(make_pair(key, V()));return res.first->_kv.second;}~AVLTree(){Destory(_root);_root = nullptr;}pair<Node*, bool> Insert(const pair<K, V>& kv){if (_root == nullptr){_root = new Node(kv);return make_pair(_root, true);}// 先找到该节点Node* cur = _root;Node* parent = _root;while (cur != nullptr){if (kv.first < cur->_kv.first){parent = cur;cur = cur->_left;}else if (kv.first > cur->_kv.first){parent = cur;cur = cur->_right;}else{return make_pair(cur, false);}}// 接着插入节点cur = new Node(kv);Node* newnode = cur; // 这一步是为了后面返回值返回的if (parent->_kv.first < kv.first){parent->_right = cur;cur->_parent = parent;}else{parent->_left = cur;cur->_parent = parent;}// 1、更新平衡因子while (parent != nullptr) // 或者while(cur != _root){if (parent->_left == cur){parent->_bf--;}else{parent->_bf++;}if (parent->_bf == 0){break;}else if (parent->_bf == 1 || parent->_bf == -1){cur = parent;parent = parent->_parent;}else if(parent->_bf == 2 || parent->_bf == -2){// 2、调整高度if (parent->_bf == -2){if (cur->_bf == -1){RotateR(parent); //右单旋}else if(cur->_bf == 1){RotateLR(parent); //左右双旋}}else if (parent->_bf == 2){if (cur->_bf == 1){RotateL(parent); //左单旋}else if (cur->_bf == -1){RotateRL(parent); //右左双旋 }}// 注意这里的break很关键,因为我们调整了子树的平衡因子后,它的父亲其实就已经不会有影响了break;}else{// 插入节点之前,树已经不平衡了,或者bf出错。需要检查其他逻辑assert(false);}}return make_pair(newnode, true);}void RotateR(Node* parent){// SubL: Parent的左孩子// SubLR: Parent左孩子的右孩子Node* subL = parent->_left;Node* subLR = subL->_right;// 先将parent的左子树连上subLR,注意要双向链接parent->_left = subLR;if (subLR != nullptr)subLR->_parent = parent;// 让parent作为subL的右子树subL->_right = parent;Node* parent_parent = parent->_parent; // 先将parent的parent记录下来,后面链接要用到parent->_parent = subL;// 判断一下parent是否为二叉树的根节点if (parent == _root){_root = subL;_root->_parent = nullptr;}else{if (parent_parent->_left == parent){parent_parent->_left = subL;}else{parent_parent->_right = subL;}subL->_parent = parent_parent;}// 最后记得要将平衡因子置零subL->_bf = parent->_bf = 0;}void RotateL(Node* parent){Node* subR = parent->_right;Node* subRL = subR->_left;parent->_right = subRL;if (subRL != nullptr)subRL->_parent = parent;subR->_left = parent;Node* parent_parent = parent->_parent;parent->_parent = subR;if (parent == _root){_root = subR;_root->_parent = nullptr;}else{if (parent_parent->_left == parent){parent_parent->_left = subR;}else{parent_parent->_right = subR;}subR->_parent = parent_parent;}// 最后记得要将平衡因子置零subR->_bf = parent->_bf = 0;}void RotateLR(Node* parent){Node* subL = parent->_left;Node* subLR = subL->_right;// 旋转之前,保存subLR的平衡因子,旋转完成之后,需要根据该平衡因子来调整其他节点的平衡因子int bf = subLR->_bf;RotateL(parent->_left);RotateR(parent);if (bf == 1){subL->_bf = -1;subLR->_bf = 0;parent->_bf = 0;}else if (bf == -1){subL->_bf = 0;subLR->_bf = 0;parent->_bf = 1;}else if (bf == 0){subL->_bf = 0;subLR->_bf = 0;parent->_bf = 0;}else{assert(false);}}void RotateRL(Node* parent){Node* subR = parent->_right;Node* subRL = subR->_left;int bf = subRL->_bf;RotateR(parent->_right);RotateL(parent);if (bf == 1){parent->_bf = -1;subRL->_bf = 0;subR->_bf = 0;}else if (bf == -1){parent->_bf = 0;subRL->_bf = 0;subR->_bf = 1;}else if (bf == 0){parent->_bf = 0;subRL->_bf = 0;subR->_bf = 0;}else{assert(false);}}Node* Find(const K& key){Node* cur = _root;while (cur != nullptr){if (cur->_kv.first < key){cur = cur->_right;}else if (cur->_kv.first > key){cur = cur->_left;}else{return cur;}}return nullptr;}bool Erase(const K& key){if (_root == nullptr)return false;Node* cur = _root;Node* parent = nullptr;while (cur != nullptr){if (cur->_kv.first > key){parent = cur;cur = cur->_left;}else if (cur->_kv.first < key){parent = cur;cur = cur->_right;}else{// 找到了该节点,准备删除// 1、左右都为空或者其中一个为空if (cur->_left == nullptr || cur->_right == nullptr){if (_root == cur){if (cur->_left == nullptr)_root = _root->_right;else_root = _root->_left;delete cur;// 平衡因子调节if(_root != nullptr)_root->_bf = 0;}else if (cur->_left == nullptr && cur->_right == nullptr){if (parent->_left == cur){parent->_left = nullptr;parent->_bf++;}else{parent->_right = nullptr;parent->_bf--;}delete cur;// 调节高度Erase_rotate(parent);}else if (cur->_left == nullptr && cur->_right != nullptr){cur->_kv = cur->_right->_kv;delete cur->_right;cur->_right = nullptr;// 调节高度cur->_bf--;Erase_rotate(cur);}else if (cur->_left != nullptr && cur->_right == nullptr){cur->_kv = cur->_left->_kv;delete cur->_left;cur->_left = nullptr;// 调节高度cur->_bf++;Erase_rotate(cur);}}else // 2、左右都不为空{Node* minRight = cur->_right;Node* minParent = cur;while (minRight->_left != nullptr){minParent = minRight;minRight = minRight->_left;}cur->_kv = minRight->_kv;// 将删除节点转化为上面左右都为空或者其中一个为空的情况解决if (cur == minParent){if (minRight->_right != nullptr){minRight->_kv = minRight->_right->_kv;delete minRight->_right;minRight->_right = nullptr;minRight->_bf++;Erase_rotate(minRight);}else{minParent->_right = nullptr;delete minRight;minParent->_bf--;Erase_rotate(minParent);}}else{if (minRight->_right != nullptr){minRight->_kv = minRight->_right->_kv;delete minRight->_right;minRight->_right = nullptr;minRight->_bf++;Erase_rotate(minRight);}else{minParent->_left= nullptr;delete minRight;minParent->_bf++;Erase_rotate(minParent);}}}return true;}}return false;}void Erase_rotate(Node* cur){Node* prev = nullptr;while (cur != nullptr){if (cur->_bf == 1 || cur->_bf == -1){break;}else if (cur->_bf == 0){prev = cur;cur = cur->_parent;}else if (cur->_bf == 2 || cur->_bf == -2){if (cur->_bf == 2){if (cur->_right->_bf == 0) // 这种情况是插入没有的,这里要特殊处理一下{RotateL(cur);cur->_parent->_bf = -1;cur->_bf = 1;break;		// 由于旋转完的树的bf的值为-1,所以不用继续循环}else if (cur->_right->_bf == 1){RotateL(cur);// 下面这两步置零其实可以不用写,因为在左旋的实现里面已经置零了// cur->_parent->_bf = 0;// cur->_bf = 0;prev = cur->_parent;cur = prev->_parent;continue;}else if (cur->_right->_bf == -1){RotateRL(cur);prev = cur->_parent;cur = prev->_parent;continue;}}else if(cur->_bf == -2){if (cur->_left->_bf == 0) // 这种情况是插入没有的,这里要特殊处理一下{RotateR(cur);cur->_bf = -1;cur->_parent->_bf = 1;break;}else if (cur->_left->_bf == -1){RotateR(cur);prev = cur->_parent;cur = prev->_parent;continue;}else if (cur->_left->_bf == 1){RotateRL(cur);prev = cur->_parent;cur = prev->_parent;continue;}}}else{assert(false);}// 更新平衡因子if (cur && cur->_left == prev)cur->_bf++;else if (cur && cur->_right == prev)cur->_bf--;}}bool IsAVLTree(){return IsBalanceTree(_root);}void Inorder(){_Inorder(_root);cout << endl;}
private:void Destory(Node* root){if (root == nullptr)return;Destory(root->_left);Destory(root->_right);delete root;}void _Inorder(Node* root){if (root == nullptr)return;_Inorder(root->_left);cout << root->_kv.first << ":" << root->_kv.second << endl;_Inorder(root->_right);}int Height(Node* root){if (root == nullptr)return 0;int leftH = Height(root->_left);int rightH = Height(root->_right);return leftH > rightH ? leftH + 1 : rightH + 1;}bool IsBalanceTree(Node* root){if (root == nullptr)return true;int leftH = Height(root->_left);int rightH = Height(root->_right);// 检查一下平衡因子是否正确 (右平衡因子 - 左平衡因子)if (rightH - leftH != root->_bf){cout << "平衡因子异常:" << root->_kv.first << endl;return false;}if (abs(rightH - leftH) > 2)return false;return IsBalanceTree(root->_left) && IsBalanceTree(root->_right);}Node* _root;
};

在这里插入图片描述

相关文章:

【数据结构与算法】AVL树的插入与删除实现详解

文章目录 前言Ⅰ. AVL树的定义Ⅱ. AVL树节点的定义Ⅲ. AVL树的插入Insert一、节点的插入二、插入的旋转① 新节点插入较高左子树的左侧&#xff08;左左&#xff09;&#xff1a;右单旋② 新节点插入较高右子树的右侧&#xff08;右右&#xff09;&#xff1a;左单旋③ 新节点插…...

【机器学习】自定义数据集 使用pytorch框架实现逻辑回归并保存模型,然后保存模型后再加载模型进行预测,对预测结果计算精确度和召回率及F1分数

一、使用pytorch框架实现逻辑回归 1. 数据部分&#xff1a; 首先自定义了一个简单的数据集&#xff0c;特征 X 是 100 个随机样本&#xff0c;每个样本一个特征&#xff0c;目标值 y 基于线性关系并添加了噪声。将 numpy 数组转换为 PyTorch 张量&#xff0c;方便后续在模型中…...

unity学习23:场景scene相关,场景信息,场景跳转

目录 1 默认场景和Assets里的场景 1.1 scene的作用 1.2 scene作为project的入口 1.3 默认场景 2 场景scene相关 2.1 创建scene 2.2 切换场景 2.3 build中的场景&#xff0c;在构建中包含的场景 &#xff08;否则会认为是失效的Scene&#xff09; 2.4 Scenes in Bui…...

AI(计算机视觉)自学路线

本文仅用来记录一下自学路线方便日后复习&#xff0c;如果对你自学有帮助的话也很开心o(*&#xffe3;▽&#xffe3;*)ブ B站吴恩达机器学习->B站小土堆pytorch基础学习->opencv相关知识&#xff08;Halcon或者opencv库&#xff09;->四类神经网络&#xff08;这里跟…...

Linux第104步_基于AP3216C之I2C实验

Linux之I2C实验是在AP3216C的基础上实现的&#xff0c;进一步熟悉修改设备树和编译设备树&#xff0c;以及学习如何编写I2C驱动和APP测试程序。 1、AP3216C的原理图 AP3216C集成了一个光强传感器ALS&#xff0c;一个接近传感器PS和一个红外LED&#xff0c;为三合一的环境传感…...

常用Android模拟器(雷电 MuMu 夜神 Genymotion 蓝叠) - 20250131

常用Android模拟器(雷电 MuMu 夜神 Genymotion 蓝叠) - 20250131 Android模拟器概述 Android 模拟器是一种软件工具&#xff0c;允许用户在 Windows、Linux 或 macOS 电脑上运行 Android 操作系统&#xff0c;以模拟 Android 设备的行为。它广泛用于 开发测试、应用运行、游戏…...

算法题(53):对称二叉树

审题&#xff1a; 需要我们判断二叉树是否满足对称结构&#xff0c;并返回判断结果 思路&#xff1a; 方法一&#xff1a;递归 其实是否对称分成两部分判断 第一部分&#xff1a;根节点是否相等 第二部分&#xff1a;根节点一的左子树和根节点二的右子树是否相等&#xff0c;根…...

Golang 并发机制-2:Golang Goroutine 和竞争条件

在今天的软件开发中&#xff0c;我们正在使用并发的概念&#xff0c;它允许一次执行多个任务。在Go编程中&#xff0c;理解Go例程是至关重要的。本文试图详细解释什么是例程&#xff0c;它们有多轻&#xff0c;通过简单地使用“go”关键字创建它们&#xff0c;以及可能出现的竞…...

深入剖析 CSRF 漏洞:原理、危害案例与防护

目录 前言 漏洞介绍 漏洞原理 产生条件 产生的危害 靶场练习 post 请求csrf案例 防御措施 验证请求来源 设置 SameSite 属性 双重提交 Cookie 结语 前言 在网络安全领域&#xff0c;各类漏洞层出不穷&#xff0c;时刻威胁着用户的隐私与数据安全。跨站请求伪造&…...

C++和Python实现SQL Server数据库导出数据到S3并导入Redshift数据仓库

用C实现高性能数据处理&#xff0c;Python实现操作Redshift导入数据文件。 在Visual Studio 2022中用C和ODBC API导出SQL Server数据库中张表中的所有表的数据为CSV文件格式的数据流&#xff0c;用逗号作为分隔符&#xff0c;用双引号包裹每个数据&#xff0c;字符串类型的数据…...

AI大模型开发原理篇-5:循环神经网络RNN

神经概率语言模型NPLM也存在一些明显的不足之处:模型结构简单&#xff0c;窗口大小固定&#xff0c;缺乏长距离依赖捕捉&#xff0c;训练效率低&#xff0c;词汇表固定等。为了解决这些问题&#xff0c;研究人员提出了一些更先进的神经网络语言模型&#xff0c;如循环神经网络、…...

4-图像梯度计算

文章目录 4.图像梯度计算(1)Sobel算子(2)梯度计算方法(3)Scharr与Laplacian算子4.图像梯度计算 (1)Sobel算子 图像梯度-Sobel算子 Sobel算子是一种经典的图像边缘检测算子,广泛应用于图像处理和计算机视觉领域。以下是关于Sobel算子的详细介绍: 基本原理 Sobel算子…...

数据结构与算法 —— 常用算法模版

数据结构与算法 —— 常用算法模版 二分查找素数筛最大公约数与最小公倍数 二分查找 人间若有天堂&#xff0c;大马士革必在其中&#xff1b;天堂若在天空&#xff0c;大马士革必与之齐名。 —— 阿拉伯谚语 算法若有排序&#xff0c;二分查找必在其中&#xff1b;排序若要使用…...

DDD - 领域事件_解耦微服务的关键

文章目录 Pre领域事件的核心概念领域事件的作用领域事件的识别领域事件的技术实现领域事件的运行机制案例领域事件驱动的优势 Pre DDD - 微服务设计与领域驱动设计实战(中)_ 解决微服务拆分难题 EDA - Spring Boot构建基于事件驱动的消息系统 领域事件的核心概念 领域事件&a…...

芯片AI深度实战:实战篇之vim chat

利用vim-ollama这个vim插件&#xff0c;可以在vim内和本地大模型聊天。 系列文章&#xff1a; 芯片AI深度实战&#xff1a;基础篇之Ollama-CSDN博客 芯片AI深度实战&#xff1a;基础篇之langchain-CSDN博客 芯片AI深度实战&#xff1a;实战篇之vim chat-CSDN博客 芯片AI深度…...

【产品经理学习案例——AI翻译棒出海业务】

前言&#xff1a; 本文主要讲述了硬件产品在出海过程中&#xff0c;翻译质量、翻译速度和本地化落地策略是硬件产品规划需要考虑的核心因素。针对不同国家&#xff0c;需要优化翻译质量和算法&#xff0c;关注市场需求和文化差异&#xff0c;以便更好地满足当地用户的需求。同…...

解决运行npm时报错

在运行一个Vue项目时报错&#xff0c;产生下面问题 D:\node\npm.cmd run dev npm WARN logfile could not be created: Error: EPERM: operation not permitted, open D:\node\node_cache\_logs\2025-01-31T01_01_58_076Z-debug-0.log npm WARN logfile could not be created:…...

【07-编译工程与导入网表】

这里写自定义目录标题 一丶编译原理图编译默认属性一丶编译项目二丶输出BOM材料报告优化EXCEL-BOM清单 三丶输出PDF原理图给维修人员看 四丶导入网格表查看是否有错误常见错误 其他问题什么是位号(C1)?EXCEL添加序号列和居中显示?位号(序号)与单位(型号)EXCEL设置自动换行 编…...

FireFox | Google Chrome | Microsoft Edge 禁用更新 final版

之前的方式要么失效&#xff0c;要么对设备有要求&#xff0c;这次梳理一下对设备、环境几乎没有要求的通用方式&#xff0c;universal & final 版。 1.Firefox 方式 FireFox火狐浏览器企业策略禁止更新_火狐浏览器禁止更新-CSDN博客 这应该是目前最好用的方式。火狐也…...

conda配置channel

你收到 CondaKeyError: channels: value https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/main not present in config 错误是因为该镜像源&#xff08;https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/main&#xff09;可能没有被正确添加到 Conda 的配置文件中&…...

XCTF-web-easyupload

试了试php&#xff0c;php7&#xff0c;pht&#xff0c;phtml等&#xff0c;都没有用 尝试.user.ini 抓包修改将.user.ini修改为jpg图片 在上传一个123.jpg 用蚁剑连接&#xff0c;得到flag...

CVPR 2025 MIMO: 支持视觉指代和像素grounding 的医学视觉语言模型

CVPR 2025 | MIMO&#xff1a;支持视觉指代和像素对齐的医学视觉语言模型 论文信息 标题&#xff1a;MIMO: A medical vision language model with visual referring multimodal input and pixel grounding multimodal output作者&#xff1a;Yanyuan Chen, Dexuan Xu, Yu Hu…...

关于iview组件中使用 table , 绑定序号分页后序号从1开始的解决方案

问题描述&#xff1a;iview使用table 中type: "index",分页之后 &#xff0c;索引还是从1开始&#xff0c;试过绑定后台返回数据的id, 这种方法可行&#xff0c;就是后台返回数据的每个页面id都不完全是按照从1开始的升序&#xff0c;因此百度了下&#xff0c;找到了…...

【磁盘】每天掌握一个Linux命令 - iostat

目录 【磁盘】每天掌握一个Linux命令 - iostat工具概述安装方式核心功能基础用法进阶操作实战案例面试题场景生产场景 注意事项 【磁盘】每天掌握一个Linux命令 - iostat 工具概述 iostat&#xff08;I/O Statistics&#xff09;是Linux系统下用于监视系统输入输出设备和CPU使…...

【JavaSE】绘图与事件入门学习笔记

-Java绘图坐标体系 坐标体系-介绍 坐标原点位于左上角&#xff0c;以像素为单位。 在Java坐标系中,第一个是x坐标,表示当前位置为水平方向&#xff0c;距离坐标原点x个像素;第二个是y坐标&#xff0c;表示当前位置为垂直方向&#xff0c;距离坐标原点y个像素。 坐标体系-像素 …...

【Oracle】分区表

个人主页&#xff1a;Guiat 归属专栏&#xff1a;Oracle 文章目录 1. 分区表基础概述1.1 分区表的概念与优势1.2 分区类型概览1.3 分区表的工作原理 2. 范围分区 (RANGE Partitioning)2.1 基础范围分区2.1.1 按日期范围分区2.1.2 按数值范围分区 2.2 间隔分区 (INTERVAL Partit…...

Java多线程实现之Thread类深度解析

Java多线程实现之Thread类深度解析 一、多线程基础概念1.1 什么是线程1.2 多线程的优势1.3 Java多线程模型 二、Thread类的基本结构与构造函数2.1 Thread类的继承关系2.2 构造函数 三、创建和启动线程3.1 继承Thread类创建线程3.2 实现Runnable接口创建线程 四、Thread类的核心…...

鸿蒙DevEco Studio HarmonyOS 5跑酷小游戏实现指南

1. 项目概述 本跑酷小游戏基于鸿蒙HarmonyOS 5开发&#xff0c;使用DevEco Studio作为开发工具&#xff0c;采用Java语言实现&#xff0c;包含角色控制、障碍物生成和分数计算系统。 2. 项目结构 /src/main/java/com/example/runner/├── MainAbilitySlice.java // 主界…...

重启Eureka集群中的节点,对已经注册的服务有什么影响

先看答案&#xff0c;如果正确地操作&#xff0c;重启Eureka集群中的节点&#xff0c;对已经注册的服务影响非常小&#xff0c;甚至可以做到无感知。 但如果操作不当&#xff0c;可能会引发短暂的服务发现问题。 下面我们从Eureka的核心工作原理来详细分析这个问题。 Eureka的…...

Web后端基础(基础知识)

BS架构&#xff1a;Browser/Server&#xff0c;浏览器/服务器架构模式。客户端只需要浏览器&#xff0c;应用程序的逻辑和数据都存储在服务端。 优点&#xff1a;维护方便缺点&#xff1a;体验一般 CS架构&#xff1a;Client/Server&#xff0c;客户端/服务器架构模式。需要单独…...