二叉树进阶 --- 中

目录

1. find 的递归实现

2. insert 的递归实现

3. erase 的递归实现

3.1. 被删除的节点右孩子为空

3.2. 被删除的节点左孩子为空

3.3. 被删除的节点左右孩子都不为空

4. 析构函数的实现

5. copy constructor的实现

6. 赋值运算符重载

7. 搜索二叉树的完整实现


1. find 的递归实现

find的递归实现较为简单,思路是:根据当前节点的 key 与传入的 key 作比较:

  • 如果前者大于后者,那么当前节点往左子树走;
  • 如果前者小于后者,那么当前节点往右子树走;
  • 如果两者相等,返回true;
  • 走到空,返回false。

代码实现:

// 对外提供的
bool find_recursion(const T& key)
{
	return _find_recursion(_root, key);
}
// 类中私有的
bool _find_recursion(Node* root, const T& key)
{
	if (root == nullptr)
		return false;
	else
	{
		if (root->_key < key)
			return _find_recursion(root->_right, key);
		else if (root->_key > key)
			return _find_recursion(root->_left, key);
		else
			return true;
	}
}

2. insert 的递归实现

insert分为两个过程

  • 第一个过程:找到合适位置;
  • 第二个过程:构建新节点,并完成连接关系。

假如现在我们已经得到了合适的插入位置,那么如何连接呢?

例如,如下图所示:我们要插入13这个数据,现在的关键问题是,如何将15和13这两个节点连接起来呢? 具体如下:

第一种方法:调用函数时,将父亲节点即这里的15也传进来。找到合适位置,创建节点并连接。

但是我们在这里提出一个较好玩的玩法,利用引用传参,如下所示:

// 对外提供的
bool insert_recursion(const T& key)
{    
	return _insert_recursion(_root, key);
}
// 类中私有的
bool _insert_recursion(Node*& root, const T& key)
{
	if (root == nullptr)
	{
		// 走到空, 说明找到了目标位置, 需要构建新节点, 并完成连接关系
        // 在这里, 我们用上图解释:
        // root就是15这个节点的左孩子的引用,即root就是15的左孩子
        // 给root new了一个node(key),等价于插入了这个节点,并连接了起来.
		root = new Node(key);
		return true;
	}
	else
	{
		if (root->_key < key)
			return _insert_recursion(root->_right, key);
		else if (root->_key > key)
			return _insert_recursion(root->_left, key);
		else
			return false;
	}
}

3. erase 的递归实现

对于erase的递归实现,其实也可以分为两个过程:

  • 第一个过程:找到这个要删除的特殊节点;
  • 第二个过程:可以分为三种情况(左孩子为空、右孩子为空、左右孩子都不为空),根据不同情况进行删除。

假设我们现在已经得到了要删除节点的位置,该如何删除呢?

3.1. 被删除的节点右孩子为空

如图所示:我们要删除6号节点(其右孩子为空),该如何删除:

 

由于 root 是4的右孩子的引用,且 root 的右孩子为空,那么root = root->_left,就可以将4的右孩子由6变更为5,我们在删除6即可,因此我们需要提前保存6节点,当指向变更之后,delete 6。

3.2. 被删除的节点左孩子为空

如图所示:我们要删除15号节点(其左孩子为空),该如何删除:

由于 root 是8的右孩子的引用,且 root 没有左孩子,那么我们此时只需要更改 root 即可,让 root 到它的右孩子 (root = root->_right),等价于将8连接了19,当然我们也需要提前将 root 节点进行保存,更改指向后,在释放 root 节点即可。

3.3. 被删除的节点左右孩子都不为空

较为复杂的就是第三种情况了,由于此时被删除节点有两个孩子,因此无法像上面两种情况进行处理。此时我们还是要利用循环实现的思路:

  • (1):从被删除的节点开始,先找到左子树的最大节点or右子树的最小节点(我在这里称之为"合适节点");
  • (2):交换这个"合适结点"和被删除节点的key;
  • (3):将删除原节点转化为删除我们后找的这个"合适节点"。

在这里我们用实例说明,如下图所示:如果我要删除下图中的4,该如何删除?

我在这里实现的"合适节点"是: 左子树的最大(右)节点

相信前两个过程是没有困难的,最后一步可能不好实现,但是当我们经过了前两个过程,我们发现被删除节点变成了我们找到的"合适节点",而且这个"合适节点"很有特征,如果它是左子树的最大值,那么它一定不会有右子树,反之,如果他是右子树的最小节点,那么它一定不会有左子树。因此我们可以在递归一次,如果"合适节点"是左子树的最大节点,那么我们递归树的左子树即可,反之如果是右子树的最小节点,那么我们递归树的右子树即可。

代码如下:

// 对外提供的
bool erase_recursion(const T& key)
{
	return _erase_recursion(_root, key);
}
// 类中私有的
bool _erase_recursion(Node*& root, const T& key)
{
	if (!root)
		return false;
	else
	{
		// 如果当前节点的key > 目标key,那么递归它的左子树即可
		if (root->_key > key)
			return _erase_recursion(root->_left, key);
		// 如果当前节点的key < 目标key,那么递归它的右子树即可
		else if (root->_key < key)
			return _erase_recursion(root->_right, key);
		// 如果找到了,进行删除
		else
		{
			// 此时的root就是要删除的节点
			Node* del = root;
			// a. 左子树为空
			if (root->_left == nullptr)
				root = root->_right;
			// b. 右子树为空
			else if (root->_right == nullptr)
				root = root->_left;
			// c. 左右子树都不为空
			else
			{
				// 左子树的最右节点
				Node* left_max = root->_left;
				while (left_max->_right)
					left_max = left_max->_right;
				// 交换"合适节点"和"被删除节点"的key
				std::swap(left_max->_key, root->_key);
				// 在这里递归左子树即可
				return _erase_recursion(root->_left, key);
			}
			delete del;
			del = nullptr;
			return true;
		}
	}
}

4. 析构函数的实现

析构函数的实现我们依据的是后序的思想(LRN),先析构左子树、然后是右子树、最后才是根。这种实现的原因是是少了许多的记录信息,例如在这里我们就不用记录下一个节点。因为我们释放的就是当前的叶子节点。

具体实现如下:

~BinarySearchTree()
{
	_BSTDestroy(_root);
}
// 注意我们这里传递的是根的引用
void _BSTDestroy(Node*& root)
{
	if (root == nullptr)
		return;
	else
	{
		// 依据后序的思想
		_BSTDestroy(root->_left);
		_BSTDestroy(root->_right);
		delete root;
		root = nullptr;
	}
}

5. copy constructor的实现

老生常谈的问题,如果我们没有显示实现拷贝构造函数,那么编译器默认生成的拷贝构造会对内置类型按照字节序的方式进行拷贝,对自定义类型成员属性会去调用它的拷贝构造函数。而字节序的方式进行拷贝会带来两个问题:

  • 其一,其中一个对象的修改会影响另一个对象;
  • 其二,同一空间会被析构两次,进程crash。

因此,我们在这里必须要实现深拷贝,那如何实现呢?我们可以借助前序的思想(NLR)。从根节点开始进行构造节点,然后递归构造它的左子树和右子树。注意构造的时候需要它们的连接关系。

代码如下:

BinarySearchTree(const BinarySearchTree<T>& copy)
{
	_root = _creat_new_root(copy._root);
}
Node* _creat_new_root(Node* root)
{
	// 如果遇到空了,就不用构造了
	if (root == nullptr)
		return nullptr;
	else
	{
		// 根据前序的思想(NLR),依次构造它的根、左子树、右子树   
		// 同时将它们连接起来
		Node* new_root = new Node(root->_key);
		new_root->_left = _creat_new_root(root->_left);
		new_root->_right = _creat_new_root(root->_right);
		return new_root;
	}
}

6. 赋值运算符重载

赋值运算符重载就比较简单了,因为我们已经实现了copy constructor,在这里利用传值传参会进行拷贝构造的特性实现我们的赋值

代码如下:

// 传值传参会进行拷贝构造
BinarySearchTree<T>& operator=(BinarySearchTree<T> copy)
{
	std::swap(_root, copy._root);
	return *this;
}

7. 搜索二叉树的完整实现

代码如下:

#ifndef _BINARY_SEARCH_TREE_HPP_
#define _BINARY_SEARCH_TREE_HPP_

#include <iostream>

namespace Xq
{

	template<class T>
	struct BinarySearchTreeNode
	{
		BinarySearchTreeNode<T>* _left;
		BinarySearchTreeNode<T>* _right;
		T _key;

		BinarySearchTreeNode(const T& key) :_key(key), _left(nullptr), _right(nullptr) {}
	};

	template<class T>
	class BinarySearchTree
	{
	private:
		typedef BinarySearchTreeNode<T> Node;
	public:
		BinarySearchTree(Node* root = nullptr) :_root(root) {}

		bool insert(const T& key)
		{
			// 1. 如果是空树,直接对_root赋值即可,插入成功并返回true
			if (_root == nullptr)
			{
				_root = new Node(key);
				return true;
			}
			else
			{
				// step 1: 先找目标位置
				Node* cur = _root;
				// 为了更好的连接新节点, 因此记录父节点
				Node* parent = nullptr;

				while (cur)
				{
					// 如果当前节点的Key大于目标Key
					// 当前节点应该向左子树走
					if (cur->_key > key)
					{
						parent = cur;
						cur = cur->_left;
					}
					// 如果当前节点的Key小于目标Key
					// 当前节点应该向右子树走
					else if (cur->_key < key)
					{
						parent = cur;
						cur = cur->_right;
					}
					else
					{
						// 找到了相同的 key, 在这里不插入
						return false;
					}
				}

				// cur 走到了空, 即 cur 就是合适的位置
				cur = new Node(key);
				// 我们需要判断cur是parent的左节点还是右节点
				// 如果key小于parent的key,那么插入左节点
				if (key < parent->_key)
					parent->_left = cur;
				// 反之连接到右节点
				else
					parent->_right = cur;
				return true;
			}
		}

		bool find(const T& key)
		{
			// 1. 从根节点开始
			Node* cur = _root;
			while (cur)
			{
				// 2. 如果当前关键字大于目标关键字,那么向左子树走
				if (cur->_key > key)
					cur = cur->_left;
				// 3. 如果小于目标关键字,那么向右子树走
				else if (cur->_key < key)
					cur = cur->_right;
				// 4. 相等,就返回true
				else
					return true;
			}
			// 5. 循环结束,说明没找到, 返回false
			return false;
		}

		bool erase(const T& key)
		{
			// 先找要删除的节点
			Node* del = _root;
			Node* del_parent = nullptr;

			while (del)
			{
				if (del->_key < key)
				{
					del_parent = del;
					del = del->_right;
				}
				else if (del->_key > key)
				{
					del_parent = del;
					del = del->_left;
				}
				else
				{
					// 锁定了要删除的节点
					// 分三种情况:
					// case 1: 左子树为空
					if (del->_left == nullptr)
					{
						// 如果要删除的节点是根
						if (del == _root)
						{
							Node* newroot = del->_right;
							delete _root;
							_root = newroot;
						}
						else
						{
							// 托孤法删除
							if (del_parent->_left == del)
								del_parent->_left = del->_right;
							else
								del_parent->_right = del->_right;
							delete del;
							del = nullptr;
						}
					}
					// case 2: 右子树为空
					else if (del->_right == nullptr)
					{
						if (_root == del)
						{
							Node* newroot = del->_left;
							delete _root;
							_root = newroot;
						}
						else
						{
							if (del_parent->_left == del)
								del_parent->_left = del->_left;
							else
								del_parent->_right = del->_left;
							delete del;
							del = nullptr;
						}
					}
					// case 3: 左右子树都不为空
					else
					{
						// 从被删除节点开始, 找右子树的最小(左)节点 || 找左子树的最大(右)节点
						if (del->_right)
							_erase_right_min_node(del);
						else
							_erase_left_max_node(del);
					}
					return true;
				}
			}

			return false;
		}

		bool find_recursion(const T& key)
		{
			return _find_recursion(_root, key);
		}

		bool insert_recursion(const T& key)
		{
			return _insert_recursion(_root, key);
		}

		bool erase_recursion(const T& key)
		{
			return _erase_recursion(_root, key);
		}

		~BinarySearchTree()
		{
			_BSTDestroy(_root);
		}

		BinarySearchTree(const BinarySearchTree<T>& copy)
		{
			_root = _creat_new_root(copy._root);
		}

		// 传值传参会进行拷贝构造
		BinarySearchTree<T>& operator=(BinarySearchTree<T> copy)
		{
			std::swap(_root, copy._root);
			return *this;
		}

		void InOrder()
		{
			_InOrder(_root);
			std::cout << std::endl;
		}

	private:
		void _InOrder(Node* root)
		{
			if (root)
			{
				_InOrder(root->_left);
				std::cout << root->_key << " ";
				_InOrder(root->_right);
			}

		}

		bool _find_recursion(Node* root, const T& key)
		{
			if (root == nullptr)
				return false;
			else
			{
				if (root->_key < key)
					return _find_recursion(root->_right, key);
				else if (root->_key > key)
					_find_recursion(root->_left, key);
				else
					return true;
			}
		}

		bool _insert_recursion(Node*& root, const T& key)
		{
			if (root == nullptr)
			{
				root = new Node(key);
				return true;
			}
			else
			{
				if (root->_key < key)
					return _insert_recursion(root->_right, key);
				else if (root->_key > key)
					return _insert_recursion(root->_left, key);
				else
					return false;
			}
		}

		void _erase_right_min_node(Node* del)
		{
			// 从被删除结点开始, 找右子树的最小(左)节点
			Node* right_min = del->_right;
			// 并记录这个节点的父亲节点, 让其从del开始
			Node* right_min_parent = del;
			while (right_min->_left)
			{
				right_min_parent = right_min;
				right_min = right_min->_left;
			}
			// 交换这个节点和要删除节点的 key
			std::swap(del->_key, right_min->_key);

			// 将删除 del 转化为删除 right_min (托孤法删除)
			if (right_min_parent->_left == right_min)
				right_min_parent->_left = right_min->_right;
			else
				right_min_parent->_right = right_min->_right;
			delete right_min;
			right_min = nullptr;
		}

		void _erase_left_max_node(Node* del)
		{
			// 从被删除节点开始, 找左子树的最大(右)节点
			Node* left_max = del->_left;
			// 并记录这个节点的父亲节点, 让其从del开始
			Node* left_max_parent = del;
			while (left_max->_right)
			{
				left_max_parent = left_max;
				left_max = left_max->_right;
			}
			// 交换这个节点和要删除节点的 key
			std::swap(del->_key, left_max->_key);

			// 将删除 del 转化为删除 left_max (托孤法删除)
			if (left_max_parent->_left == left_max)
				left_max_parent->_left = left_max->_left;
			else
				left_max_parent->_right = left_max->_left;
			delete left_max;
			left_max = nullptr;
		}

		bool _erase_recursion(Node*& root, const T& key)
		{
			if (!root)
				return false;
			else
			{
				// 如果当前节点的key > 目标key,那么递归它的左子树即可
				if (root->_key > key)
					return _erase_recursion(root->_left, key);
				// 如果当前节点的key < 目标key,那么递归它的右子树即可
				else if (root->_key < key)
					return _erase_recursion(root->_right, key);
				// 如果找到了,进行删除
				else
				{
					// 此时的root就是要删除的节点
					Node* del = root;
					// a. 左子树为空
					if (root->_left == nullptr)
						root = root->_right;
					// b. 右子树为空
					else if (root->_right == nullptr)
						root = root->_left;
					// c. 左右子树都不为空
					else
					{
						// 左子树的最右节点
						Node* left_max = root->_left;
						while (left_max->_right)
							left_max = left_max->_right;
						// 交换"合适节点"和"被删除节点"的key
						std::swap(left_max->_key, root->_key);
						// 在这里递归左子树即可
						return _erase_recursion(root->_left, key);
					}
					delete del;
					del = nullptr;
					return true;
				}
			}
		}

		Node* _creat_new_root(Node* root)
		{
			// 如果遇到空了,就不用构造了
			if (root == nullptr)
				return nullptr;
			else
			{
				// 根据前序的思想(NLR),依次构造它的根、左子树、右子树   
				// 同时将它们连接起来
				Node* new_root = new Node(root->_key);
				new_root->_left = _creat_new_root(root->_left);
				new_root->_right = _creat_new_root(root->_right);
				return new_root;
			}
		}

		// 注意我们这里传递的是根的引用
		void _BSTDestroy(Node*& root)
		{
			if (root == nullptr)
				return;
			else
			{
				// 依据后序的思想
				_BSTDestroy(root->_left);
				_BSTDestroy(root->_right);
				delete root;
				root = nullptr;
			}
		}

	private:
		Node* _root;
	};
}

#endif

二叉树进阶 --- 中,结束。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/614626.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

IM 是什么?

在当今数字化的时代&#xff0c;即时通讯&#xff08;IM&#xff09;已经渗透到人们的日常生活和企业的工作环境中。IM技术的快速i发展为人们提供了一种高效、便捷的沟通方式&#xff0c;不仅推动了社会的信息化进程&#xff0c;也提升了企业的协同效率和竞争力。 作为企业级I…

程序环境和预处理、编译链接过程、编译的几个阶段、运行环境、预定义符号等的介绍

文章目录 前言一、程序的翻译环境和执行环境二、编译链接过程三、编译的几个阶段四、运行环境五、预定义符号总结 前言 程序环境和预处理、编译链接过程、编译的几个阶段、运行环境、预定义符号的介绍。 一、程序的翻译环境和执行环境 在 ANSI C 的任何一种实现中&#xff0c…

建模电梯的状态图和学生选课ER图

第一题 尝试建模电梯的状态图&#xff08;选做&#xff09; 第二题 学校规定&#xff1a; 一个学生可选修多门课&#xff0c;一门课有若于学生选修。 一个教师可讲授多门课&#xff0c;一门课只有一个教师讲授。 一个学生选修一门课&#xff0c;仅有一个成绩。 学生的属性有学号…

【力扣】63.不同路径 II

原题链接&#xff1a;. - 力扣&#xff08;LeetCode&#xff09; 目录 1.题目描述 2.思路分析 3.代码实现 1.题目描述 一个机器人位于一个 m x n 网格的左上角 &#xff08;起始点在下图中标记为 “Start” &#xff09;。 机器人每次只能向下或者向右移动一步。机器人试…

C++11:并发新纪元 —— 深入理解异步编程的力量(1)

hello &#xff01;大家好呀&#xff01; 欢迎大家来到我的Linux高性能服务器编程系列之《C11&#xff1a;并发新纪元 —— 深入理解异步编程的力量》&#xff0c;在这篇文章中&#xff0c;你将会学习到C新特性以及异步编程的好处&#xff0c;以及其如何带来的高性能的魅力&…

RabbitMQ(安装配置以及与SpringBoot整合)

文章目录 1.基本介绍2.Linux下安装配置RabbitMQ1.安装erlang环境1.将文件上传到/opt目录下2.进入/opt目录下&#xff0c;然后安装 2.安装RabbitMQ1.进入/opt目录&#xff0c;安装所需依赖2.安装MQ 3.基本配置1.启动MQ2.查看MQ状态3.安装web管理插件4.安装web管理插件超时的解决…

操作系统基础之磁盘

概述 基本概念 磁盘有正反两个盘面&#xff0c;每个盘面有多个同心圆&#xff0c;每个同心圆是一个磁道&#xff0c;每个同心圆又被划分为多个扇区&#xff0c;数据就被存在扇区中。 磁头首先寻找到对应磁道&#xff0c;然后等到磁盘进行周期旋转到指定的扇区&#xff0c;才…

【SpringBoot篇】基于Redis分布式锁的 误删问题 和 原子性问题

文章目录 &#x1f354;Redis的分布式锁&#x1f6f8;误删问题&#x1f388;解决方法&#x1f50e;代码实现 &#x1f6f8;原子性问题&#x1f339;Lua脚本 ⭐利用Java代码调用Lua脚本改造分布式锁&#x1f50e;代码实现 &#x1f354;Redis的分布式锁 Redis的分布式锁是通过利…

工业中常见大数据技术组件

随着大数据技术在各行各业的广泛应用&#xff0c;数据产品经理的角色也变得越来越重要。了解常见的大数据技术组件对于数据产品经理来说至关重要&#xff0c;因为这有助于他们更好地设计产品架构和功能模块&#xff0c;满足数据处理和分析的需求。在处理海量数据的产品中&#…

【Linux】-网络请求和下载、端口[6]

目录 一、网络请求和下载 1、ping命令 2、wget命令 3、curl命令 二、端口 1、虚拟端口 2、查看端口占用 一、网络请求和下载 1、ping命令 可以通过ping命令&#xff0c;检查指定的网络服务器是否可联通状态 语法&#xff1a;ping [ -c num ] ip或主机名 选项&…

window.location.href的介绍及使用

目录 介绍&#xff1a; 获取当前 URL 设置新的 URL URL 的组成部分 解析 URL 参数 什么是片段标识符的URL&#xff1f; 使用new URL&#xff1a; 输出的部分解释&#xff1a; 假如我们需要获取路径上的最后一级的路径名&#xff1a; 介绍&#xff1a; window.location.h…

2023版brupsuite专业破解安装

安装教程&#xff0c;分两部分&#xff1a; 1、安装java环境、参考链接JAVA安装配置----最详细的教程&#xff08;测试木头人&#xff09;_java安装教程详细-CSDN博客 2、安装2023.4版本brupsuite&#xff1a;参考链接 2023最新版—Brup_Suite安装配置----最详细的教程&…

【数据分析】 JupyterNotebook安装及使用简介

各位大佬好 &#xff0c;这里是阿川的博客 &#xff0c; 祝您变得更强 个人主页&#xff1a;在线OJ的阿川 大佬的支持和鼓励&#xff0c;将是我成长路上最大的动力 阿川水平有限&#xff0c;如有错误&#xff0c;欢迎大佬指正 在数据分析中&#xff0c;一般用Pycharm编辑代…

大模型面试常考知识点1

文章目录 1. 写出Multi-Head Attention2. Pre-Norm vs Post-Norm3. Layer NormRMS NormBatch Norm 4. SwiGLU从ReLU到SwishSwiGLU 5. AdamW6. 位置编码Transformer位置编码RoPEALibi 7. LoRA初始化 参考文献 1. 写出Multi-Head Attention import torch import torch.nn as nn …

按键配合LDO实现开关功能

今天给大家分享一个学到的按键开关电路&#xff0c;适合没有足够空间给自锁开关的场景&#xff0c;既可以用于USB供电控制也可以用于电池供电控制。话不多说上电路图先。 核心任务就是通过按键控制LDO芯片的使能管脚的电平状态&#xff0c;这枚NCP芯片高电平使能&#xff0c;VB…

ETLCloud中如何执行Java Bean脚本

ETLCloud中如何执行Java Bean脚本 在ETLCloud这一强大的数据集成和转换平台中&#xff0c;执行Java Bean脚本的能力为其增添了更多的灵活性和扩展性。Java Bean脚本不仅仅是一段简单的代码&#xff0c;而是一种强大的工具&#xff0c;可以帮助用户定制和优化数据处理的每一个环…

AI 绘画神器 Fooocus 2.3.1 汉化教程(中文界面/汉化包下载/持续更新最新版本...)

本文收录于《AI绘画从入门到精通》专栏&#xff0c;专栏总目录&#xff1a;点这里&#xff0c;订阅后可阅读专栏内所有文章。 大家好&#xff0c;我是水滴~~ Fooocus 是一款功能强大的 AI 绘画神器&#xff0c;它能够帮助我们以更高效、更创意的方式进行绘画创作。本教程将详细…

C++入门系列-构造函数

&#x1f308;个人主页&#xff1a;羽晨同学 &#x1f4ab;个人格言:“成为自己未来的主人~” 类的6个默认成员函数 如果一个类中什么成员都没有&#xff0c;简称为空类。 空类中真的什么都没有吗&#xff1f;并不是&#xff0c;任何类在什么都不写时&#xff0c;编译器会…

社交媒体数据恢复:飞书

飞书数据恢复过程包括以下几个步骤&#xff1a; 确认数据丢失&#xff1a;首先要确认数据是否真的丢失&#xff0c;有时候可能只是被隐藏或者误操作删除了。 检查回收站&#xff1a;飞书中删除的文件会默认保存在回收站中&#xff0c;用户可以通过进入回收站找到被删除的文件&…

推荐全网最全的AI小白进阶指南

1. 引言 您想学习人工智能&#xff1f;但不知道如何开始&#xff0c;也不知道从哪里开始&#xff1f;互联网上的资源总是丰富多彩&#xff0c;质量参差不齐&#xff0c;往往容易看花眼而无从下手。 鉴于此&#xff0c;本文重点推荐一些个人收集的还不错的一些资源供大家学习参…