C++ - 开散列的拉链法(哈希桶) 介绍 和 实现

news2024/11/24 11:56:40

前言

 之前我们介绍了,闭散列 的 开放地址法实现的 哈希表:C++ - 开放地址法的哈希介绍 - 哈希表的仿函数例子_chihiro1122的博客-CSDN博客

 但是 闭散列 的 开放地址法 虽然是哈希表实现的一种,但是这种方式实现的哈希表,有一个很大的弊端,就是可能会引起一大片的哈希冲突,因为当发生哈希冲突的时候,他是按照线性探测的方式去找新的位置的,那么在冲突的位置之后,可能有一大片都是有数据存在的,那么每一次寻找都会发生哈希冲突。

而且使用 开放地址法 实现的哈希表,在查找数据的时候,计算出的起始位置不是要查找的数据的时候,也是按照上述哈希冲突的方式去寻找数据的,那么这样的效率就更低了。

更不用想,当 这种哈希表需要扩容的时候,还是类似想 realloc 函数一样,先开一个更大空间,然后再把之前的值都赋值进去之后,再计算新插入的数的初始位置,然后按照上述的方式进行插入。

 这种在表当中相邻位置元素比较多的情况,使用开放地址法就会发生拥堵的情况。把本来没有发生哈希冲突的值,都加到 冲突当中了。

二次探测哈希表介绍

 在上述线性探测的基础之上,引出了二次探测,如果说线性探测是按照 每次 起始位置 + i (i >= 0,i 往后迭代) 这样的方式实现 一次往后遍历,那么 二次探测就是  起始位置 + i^2 (i>=0) 这样的方式进行探测,遮掩的话就缓解了一些 拥堵情况。但是,这并不是最优解,他的本质上还是 一种线性探测,只不过把 冲突的值 分开了一些而已。

两者的本质都是在块共用的空间当中,进行存储值,这样不管你怎么进行探测,当数据量比较密集之后,很难做到 不发生 一大片的拥堵情况。

 在发生哈希冲突之后,只是无脑的往后寻找空位置插入,那么这个位置本来应该插入的元素就被占了,归根结底还是占了别人的空间,而且这种情况随着冲突的变多之后,会发生得更多。

开放地址法的缺点:冲突会互相影响。 

 所以,此时就有人想了,开放地址法都是在一个空间当中寻找可以插入的地址,那么能不能走出这块空间呢?不要让自己冲突之后,去占用别人的位置,来诱发哈希冲突

 开散列的拉链法(哈希桶)

 如果知道计数排序的小伙伴应该很好理解,这里的哈希桶和计数排序当中有点类似。

如果他的规则是 hash = key % mol这个哈希函数的话。那么他会先开一个 mol 大小的指针数组,结点也不会像之前一样用一个 数组来存储,而且是把一个元素用一个结点存储,把 hash 值相同的元素用类似链表的方式链接在 对应 指针数组下标上元素指针来指向,如下图所示:

 大体框架

	template<class K, class V>
	struct HashNode
	{
		pair<K, V> _kv;
		hashNode* next;  // 每个结点都有一个指针指向这个结点的下一个结点
	};

	template<class K, class V>
	class Hash
	{
		typedef HashNode<K, V> Node;
	public:
		Hash()
		{
			_table.resize(10, nullptr);
		}

	private:
		vector<Node*> _table;  // 指针数组
		int _n = 0;   // 用于记录哈希桶当中的有效数据个数
	};

 指针数组我们使用 vector 的容器来实现,这样就方便我们对这个数组进行管理。每个结点要存储的是 本节点的 key 和 value 以及 指向下一个 结点的指针。

insert()插入函数

 同样要先按照key值大小计算出 这个结点的 hash 值,然后再 对应数组下标位置进行链表的 头插或者尾查,两种插入都行。因为,后头插的元素可能会被先找到,但是我们不知道哪一个元素被查找的次数多,所以用哪一种都行。

核心插入逻辑:
 

			// 计算hash值
			size_t hash = kv.first % _table.size();

			Node* newnode = new Node(kv);
			newnode->_next = _table[hash];
			_table[hash] = newnode;
			++_n;

			return true;

当然,因为哈希桶也是一种 开放地址法,那么只要是开放地址法都是需要扩容的,在哈希桶当中就是要扩容 指针数组。因为当数据很多的时候,就会导致 每一个 指针数组元素下面的链表会很长,那么对于查找时候的效率就会降低

 我们应当控制 负载因子 尽量到 1 ,也就是让每个桶当中都尽量存储一个 数据,这样的话,查找的效率就保持在一个很高的 标准。

 而且扩容还有一个好处,我们发现一种极端情况,就会有大量的数据集中在一个桶当中,这种情况虽然概率小,但是还是有可能发生的,那么当我扩容的时候,每一个桶当中的数据都会得到分散,这样可以缓解一个桶的压力

 库容的思路不能再使用 闭散列开发地址法当中,在把 旧表当中的数据插入到 新表当中,直接复用 insert()函数,这样不好

 以为按照我们上述写 insert 的逻辑,如复用的话,每一次复用,都需要重新使用 new Node(kv) 开辟一个新结点出来,而且还有把旧表当中的该结点给释放了,但是,在旧表当中的结点空间是完全可以复用的

所以,我们在依次变量的时候就 直接依次遍历链表和其中的结点,把每个结点直接挪到 新表的 指针数组后面,按照新表当中的 插入规则插入即可。 

 扩容代码:
 

// 负载因子 到 1 就扩容(每一个桶当中都有数据)
			if (_n == _table.size())
			{
				size_t newsize = _table.size() * 2;
				vector<Node*> newTable;
				newTable.resize(newsize, nullptr);

				for (int i = 0; i < _table.size(); i++)
				{
					Node* cur = _table[i];
					while (cur)
					{
						Node* next = cur->_next;  // 保存链表的下一个结点

						// 头插到新表当中
						size_t hashi = kv.first % newTable.size();
						cur->_next = newTable[hashi];
						newTable[hashi] = cur;

						// 向链表后迭代
						cur = next;
					}
				}

				// 交换 两个表在 对象当中的指向,让编译器 帮我们释放旧表的空间
				_table.swap(newTable); 
			}

 上述使用现代写法,直接交换指针,出了insert()作用域,让编译器把旧表空间释放掉,但是释放的 vector 只是释放 vector 容器的空间,并不会释放 Node* 结点空间。vector 的销毁是调用 delete[] ,而 delete[] 分为两种,先要调用容器中与元素的析构函数,遍历析构各个元素,但是这个容器当中的 每一个元素的类型是一个指针,是一个内置类型,内置类型没有析构函数。所以不会调用析构函数,直接进行第二部,直接对 vector 空间释放。

 只有 类似 vector<list> _table 这种结构,那么vector当中的每一个元素存储的都是一个 list 自定义类型,那么都是有析构函数的,那么我们上述 扩容逻辑使用的是 这种结构的话,就不能复用 结点了, delete[] vector 就会先遍历调用其中每个元素的析构函数来进行析构

哈希表当中的扩容所带来的消耗是无法避免的,但是其实哈希表扩容次数并不多,按照我们上述书写的逻辑,一次扩容两倍的话,2^20 大概 100w,也就是说插入100w 个数据 只用扩容 20次,已经非常的少了。

但是,虽然哈希表的扩容优化不好写,但是还是有人提出:
 

 比如上述,他是用两个表,一个是当前的表,一个是扩容之后的表,当 我们想要 没有桶的位置(比如指针数组下标1位置)插入一个数据的时候,就直接把这个桶移到 扩容的 指针数组当中的对应位置。他用这样的方式,相当于是把 一次扩容的消耗,分散到了每一次插入数据当中,那么在用户体验来说,就大大增强了

 但是这种方法实现起来非常麻烦,其中还有很多细节,我们就是用之前实现的扩容逻辑就已经很好用了。

写一个 print()函数方便查看 哈希桶当中的数据:
 

		void Print()
		{
			for (size_t i = 0; i < _table.size(); i++)
			{
				printf("[%d]->", i);
				Node* cur = _table[i];
				while (cur)
				{
					cout << cur->_kv.first << "->";
					cur = cur->_next;
				}

				printf("NULL\n");
			}
		}

insert()代码:

		bool insert(const pair<K , V>& kv)
		{
			if (find(kv.first))
			{
				return false;
			}

			// 负载因子 到 1 就扩容(每一个桶当中都有数据)
			if (_n == _table.size())
			{
				size_t newsize = _table.size() * 2;
				vector<Node*> newTable;
				newTable.resize(newsize, nullptr);

				for (int i = 0; i < _table.size(); i++)
				{
					Node* cur = _table[i];
					while (cur)
					{
						Node* next = cur->_next;  // 保存链表的下一个结点

						// 头插到新表当中
						size_t hashi = cur->_kv.first % newTable.size();
						cur->_next = newTable[hashi];
						newTable[hashi] = cur;

						// 向链表后迭代
						cur = next;
					}
				}

				// 交换 两个表在 对象当中的指向,让编译器 帮我们释放旧表的空间
				_table.swap(newTable); 
			}

			// 计算hash值
			size_t hashi = kv.first % _table.size();

			Node* newnode = new Node(kv);
			newnode->_next = _table[hashi];
			_table[hashi] = newnode;
			++_n;

			return true;
		}

find()函数

 哈希桶的find()查找函数,先利用哈希函数计算链表起始位置,然后就是一个链表的遍历查找。

		Node* find(const K& key)
		{
			size_t hash = key % _table.size();
			Node* cur = _table[hash];
			while (cur)
			{
				if (cur->_kv.first == key)
					return cur;
				
				cur = cur->_next;
			}
			return nullptr;
		}

erase()函数

 删除结点函数,在寻找结点这一块,我们不复用 find()函数,因为find()函数返回的是该结点指针,但是我们删除链表当中的结点需要修改链接关系,需要找到该结点的上一个结点,或者是指针数组当中元素,所以我们可以字节写查找逻辑:
 

		bool erase(const K& key)
		{
			size_t hashi = key % _table.size();
			Node* cur = _table[hashi];
			Node* prev = nullptr;
			while (cur)
			{
				if (cur->_kv.first == key)
				{
					if (!prev)
					{
						_table[hashi] = cur->_next;
					}
					else
					{
						prev->_next = cur->_next;
					}
					delete cur;
					return true;
				}
				prev = cur;
				cur = cur->_next;
			}
			return false;
		}

析构函数

 因为,指针数组当中存储的是一个 Node* 是一个指针,也就是一个内置类型,在释放的时候编译器对内置类型不处理,但是对自定义类型会去调用这个自定义类型的析构函数

所以,因为 _table 的类型是 vector<Node*> ,vector 当中的数组空间是属于 vector 自定义类型的空间,那么回去调用vector 的析构函数,但是对于 Node* 就不会了,所以我们要写析构函数把 哈希桶,也就是每一个 指针数组元素指向的链表的空间给释放了。

		~hash()
		{
			for (size_t i = 0; i < _table.size(); i++)
			{
				Node* cur = _table[i];
				while (cur)
				{
					Node* next = cur->_next;
					delete cur;
					cur = next;
				}
				_table[i] = nullptr;
			}
		}

把 哈希当中的key 值转换成适用多多种类型的仿函数写法

 当然,哈希表当中不能可能只存储 int 这一种类型,如果是 string类,也是有自己的比较方式的,所以,我们可以像 在优先级队列当中,实现多种类型的适配仿函数,利用模版参数的控制来控制key 值的取出方法:

template<class K>
struct DefaultHashFunc
{
	size_t operator()(const K& key)
	{
		// 不管是什么类型的,都转换成 size_t
		// 不管是 负数还是正数,都转换为 正数
		return (size_t)key;
	}
};

// string 的特化
template<>
struct DefaultHashFunc<string>
{
	size_t operator()(const string& str)
	{
		// 字符串转 int 的算法
		int hash = 0;
		for (auto& ch : str)
		{
			hash *= 131;
			hash += ch;
		}

		return hash;
	}
};

template<class K, class V, class HashFunc = DefaultHashFunc<K>>
	class hash
	{
		typedef HashNode<K, V> Node;
	public:
····································
····································
····································


bool insert(const pair<K , V>& kv)
		{
			HashFunc hf;

			if (find(kv.first))
			{
				return false;
			}

			// 负载因子 到 1 就扩容(每一个桶当中都有数据)
			if (_n == _table.size())
			{
				size_t newsize = _table.size() * 2;
				vector<Node*> newTable;
				newTable.resize(newsize, nullptr);

				for (int i = 0; i < _table.size(); i++)
				{
					Node* cur = _table[i];
					while (cur)
					{
						Node* next = cur->_next;  // 保存链表的下一个结点

						// 头插到新表当中
						size_t hashi = hf(cur->_kv.first) % newTable.size();
						cur->_next = newTable[hashi];
						newTable[hashi] = cur;

						// 向链表后迭代
						cur = next;
					}
				}

				// 交换 两个表在 对象当中的指向,让编译器 帮我们释放旧表的空间
				_table.swap(newTable); 
			}

			// 计算hash值
			size_t hashi = hf(kv.first) % _table.size();

			Node* newnode = new Node(kv);
			newnode->_next = _table[hashi];
			_table[hashi] = newnode;
			++_n;

			return true;
		}

 把各个函数需要取出key 值的方法都用仿函数封装。

 具体的实现逻辑,可以看看下面博客当中对 闭散列开放式地址法哈希表当中对 仿函数的书写:

C++ - 开放地址法的哈希介绍 - 哈希表的仿函数例子_chihiro1122的博客-CSDN博客

 哈希桶的完整代码:

namespace hash_bucket
{
	template<class T>
	struct HashNode
	{
		T _data;
		HashNode* _next;  // 每个结点都有一个指针指向这个结点的下一个结点

		HashNode(const T& data)
			:_data(data)
			,_next(nullptr)
		{}
	};

	template<class K, class T,class KeyOfT , class HashFunc = DefaultHashFunc<K>>
	class hash
	{
		typedef HashNode<T> Node;
	public:
		hash()
		{
			_table.resize(10, nullptr);
		}

		bool Insert(const T& data)
		{
			HashFunc hf;
			KeyOfT kot;

			if (find(data))
			{
				return false;
			}

			// 负载因子 到 1 就扩容(每一个桶当中都有数据)
			if (_n == _table.size())
			{
				size_t newsize = _table.size() * 2;
				vector<Node*> newTable;
				newTable.resize(newsize, nullptr);

				for (int i = 0; i < _table.size(); i++)
				{
					Node* cur = _table[i];
					while (cur)
					{
						Node* next = cur->_next;  // 保存链表的下一个结点

						// 头插到新表当中
						size_t hashi = hf(kot(cur->_data)) % newTable.size();
						cur->_next = newTable[hashi];
						newTable[hashi] = cur;

						// 向链表后迭代
						cur = next;
					}
				}

				// 交换 两个表在 对象当中的指向,让编译器 帮我们释放旧表的空间
				_table.swap(newTable); 
			}

			// 计算hash值
			size_t hashi = hf(data) % _table.size();

			Node* newnode = new Node(data);
			newnode->_next = _table[hashi];
			_table[hashi] = newnode;
			++_n;

			return true;
		}

		Node* find(const K& key)
		{
			HashFunc hf;
			KeyOfT kot;

			size_t hash = hf(kot(key)) % _table.size();
			Node* cur = _table[hash];
			while (cur)
			{
				if (kot(cur->_data) == key)
					return cur;
				
				cur = cur->_next;
			}
			return nullptr;
		}

		bool erase(const K& key)
		{
			HashFunc hf;
			KeyOfT kot;

			size_t hashi = hf(kot(key)) % _table.size();
			Node* cur = _table[hashi];
			Node* prev = nullptr;
			while (cur)
			{
				if (hf(kot(cur->_data)) == key)
				{
					if (!prev)
					{
						_table[hashi] = cur->_next;
					}
					else
					{
						prev->_next = cur->_next;
					}
					delete cur;
					return true;
				}
				prev = cur;
				cur = cur->_next;
			}
			return false;
		}

		void Print()
		{
			for (size_t i = 0; i < _table.size(); i++)
			{
				printf("[%zd]->", i);
				Node* cur = _table[i];
				while (cur)
				{
					cout << kot(cur) << "->";
					cur = cur->_next;
				}

				printf("NULL\n");
			}
			cout << endl;
			cout << endl;
		}

		~hash()
		{
			for (size_t i = 0; i < _table.size(); i++)
			{
				Node* cur = _table[i];
				while (cur)
				{
					Node* next = cur->_next;
					delete cur;
					cur = next;
				}
				_table[i] = nullptr;
			}
		}

	private:
		vector<Node*> _table;  // 指针数组
		int _n = 0;   // 用于记录哈希桶当中的有效数据个数
	};
}

 结言

 上述实现的 哈希桶,还有很多的封装工作没有做,因为上述的哈希桶准备用来实现 unordered_set 和  unordered_map 的底层实现,所以关于 key 值不能修改,或者是迭代器在  unordered_set 和  unordered_map 的 封装博客的当中还要对 哈希桶进行 改进。

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

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

使用sqlmap获取数据步骤

文章目录 1.使用sqlmap获取所有数据库2.使用sqlmap获取当前连接数据库3.使用sqlmap获取当前数据库下所有表名4.使用sqlmap获取当前数据库下某个表下所有列名5.使用sqlmap获取当前数据库下某个表下指定字段的数据6.测试当前用户是否是管理员7.使用burpsqlmap批量检测8.脱库命令9…

【接口技术】总线课堂习题

1&#xff1a;CPU在执行OUT DX, AL指令时&#xff0c;&#xff08;&#xff09;寄存器的内容送到地址总线上 A&#xff0c;DL B&#xff0c;DX C&#xff0c;AX D&#xff0c;DL 解答&#xff1a;B out指令是把AL的数据输出到DX的端口&#xff0c;因此AL寄存器的内容送到…

多线程学习笔记(一)

文章目录 1 线程基础知识复习2 CompletableFuture1、Future和Callable接口2、FutureTask3、对Future的改进4、案例精讲——电商5、常用方法6、CompetableFutureWithThreadPool【重要】 3 锁1、乐观锁和悲观锁2、synchronized 8锁案例3、公平锁和非公平锁4、可重入锁5、死锁及排…

正点原子嵌入式linux驱动开发——TF-A初探

上一篇笔记中&#xff0c;正点原子的文档简单讲解了一下什么是TF-A&#xff0c;并且也学习了如何编译TF-A。但是TF-A是如何运行的&#xff0c;它的一个运行流程并未涉及。TF-A的详细运行过程是很复杂的&#xff0c;涉及到很多ARM处理器底层知识&#xff0c;所以这一篇笔记的内容…

剖析华为云Astro Platform技术价值与使用体验

面对企业IT专业人员紧缺的挑战&#xff0c;华为云Astro低代码平台提供创新解决方案。让非编程专业人士能快速构建、部署应用&#xff0c;缓解开发资源压力&#xff0c;并高度整合技术架构&#xff0c;强化项目沟通协作。使每个参与其中的角色都找到自己的位置&#xff0c;携手全…

初级篇—第三章多表查询

文章目录 为什么需要多表查询一个案例引发的多表连接初代查询笛卡尔积&#xff08;或交叉连接&#xff09;的理解 多表查询分类等值连接 vs 非等值连接自连接 vs 非自连接内连接VS外连接 SQL99语法实现多表查询内连接的实现外连接的实现左外连接右外连接满外连接 UNION的使用7种…

leetCode 455.分发饼干 贪心算法

455. 分发饼干 - 力扣&#xff08;LeetCode&#xff09; 假设你是一位很棒的家长&#xff0c;想要给你的孩子们一些小饼干。但是&#xff0c;每个孩子最多只能给一块饼干。 对每个孩子 i&#xff0c;都有一个胃口值 g[i]&#xff0c;这是能让孩子们满足胃口的饼干的最小尺寸&…

全网最全Python系列教程(非常详细)---集合讲解(学Python入门必收藏)

&#x1f9e1;&#x1f9e1;&#x1f9e1;这篇是关于Python中集合的讲解&#xff0c;涉及到以下内容&#xff0c;欢迎点赞和收藏&#xff0c;你点赞和收藏是我更新的动力&#x1f9e1;&#x1f9e1;&#x1f9e1; 1、集合是什么&#xff1f; 2、集合应该怎么去定义&#xff1f…

从零手搓一个【消息队列】实现数据的硬盘管理和内存管理(线程安全)

文章目录 一、硬盘管理1, 创建 DiskDataCenter 类2, init() 初始化3, 封装交换机4, 封装队列5, 关于绑定6, 关于消息 二、内存管理1, 数据结构的设计2, 创建 MemoryDataCenter 类3, 关于交换机4, 关于队列5, 关于绑定6, 关于消息7, 恢复数据 三、小结 创建 Spring Boot 项目, S…

Xcode 超简单实用小秘技让撸码进入新境界

概览 Xcode 是开发  应用不可或缺的王牌主力军&#xff0c;虽然 Xcode 中一些常用使用姿势想必大家都已驾轻就熟&#xff0c;但其中仍有一些隐藏宝藏小技巧不为人知。 充分挖掘它们可以极大加速和方便秃头码农们日常的撸码行为。 一般的&#xff0c;它们分为两类&#xff…

机器人制作开源方案 | 四轴飞行器

1. 概述 基于探索者搭建的模块化四轴飞行器研究平台&#xff0c;采用独特的设计方式&#xff0c;可实现在室内完成对四轴飞行器、无人机等运动控制的原理研究&#xff0c;以及学习飞行控制的原理知识。 2. 组装 请按照下图进行机架的组装。 整体图 请解压文末资料中的 /软件/Mi…

前端JavaScript入门到精通,javascript核心进阶ES6语法、API、js高级等基础知识和实战 —— Web APIs(一)

思维导图 学习目标 变量声明 一、Web API 基本认知 作用和分类 什么是DOM DOM树 DOM对象 二、获取DOM对象 三、操作元素内容 <!DOCTYPE html> <html lang"en"><head><meta charset"UTF-8" /><meta http-equiv"X-UA-Compa…

基于Java的实验室预约管理系统设计与实现(源码+lw+部署文档+讲解等)

文章目录 前言具体实现截图论文参考详细视频演示为什么选择我自己的网站自己的小程序&#xff08;小蔡coding&#xff09;有保障的售后福利 代码参考源码获取 前言 &#x1f497;博主介绍&#xff1a;✌全网粉丝10W,CSDN特邀作者、博客专家、CSDN新星计划导师、全栈领域优质创作…

【数据结构】选择排序 堆排序(二)

目录 一&#xff0c;选择排序 1&#xff0c;基本思想 2&#xff0c; 基本思路 3&#xff0c;思路实现 二&#xff0c;堆排序 1&#xff0c;直接选择排序的特性总结&#xff1a; 2&#xff0c;思路实现 3&#xff0c;源代码 最后祝大家国庆快乐&#xff01; 一&#xf…

设计模式8、装饰者模式 Decorator

解释说明&#xff1a;动态地给一个对象增加一些额外的职责。就扩展功能而言&#xff0c;装饰模式提供了一种比使用子类更加灵活的替代方案 抽象构件&#xff08;Component&#xff09;&#xff1a;定义一个抽象接口以规范准备收附加责任的对象 具体构件&#xff08;ConcreteCom…

10OpenMP

OpenMP概述 通过线程实现并行化&#xff0c;与Pthread一样&#xff0c;是基于线程的共享内存库 与Pthread的不同 简而言之&#xff1a; Pthread更加底层&#xff0c;需要用户自己定义每一个线程的行为&#xff0c;OpenMP虽然更加简单&#xff0c;但是底层的线程交互实现很难 …

【单片机】13-实时时钟DS1302

1.RTC的简介 1.什么是实时时钟&#xff08;RTC&#xff09; &#xff08;rtc for real time clock) &#xff08;1&#xff09;时间点和时间段的概念区分 &#xff08;2&#xff09;单片机为什么需要时间点【一定的时间点干什么事情】 &#xff08;3&#xff09;RTC如何存在于…

【Git】Git 原理和使用

Git 一、Git 本地仓库1. 本地仓库的创建2. 配置 Git3. 工作区、暂存区、版本库4. 添加文件5. 查看 .git 文件6. 修改文件7. 版本回退8. 撤销修改9. 删除文件 二、分支管理1. 理解分支2. 创建分支3. 切换分支4. 合并分支5. 删除分支6. 合并冲突7. 分支管理策略8. bug 分支9. 强制…

基于Java的厨艺交流平台设计与实现(源码+lw+部署文档+讲解等)

文章目录 前言具体实现截图论文参考详细视频演示为什么选择我自己的网站自己的小程序&#xff08;小蔡coding&#xff09;有保障的售后福利 代码参考源码获取 前言 &#x1f497;博主介绍&#xff1a;✌全网粉丝10W,CSDN特邀作者、博客专家、CSDN新星计划导师、全栈领域优质创作…

JavaScript Web APIs第三天笔记

Web APIs - 第3天 进一步学习 事件进阶&#xff0c;实现更多交互的网页特效&#xff0c;结合事件流的特征优化事件执行的效率 掌握阻止事件冒泡的方法理解事件委托的实现原理 事件流 事件流是对事件执行过程的描述&#xff0c;了解事件的执行过程有助于加深对事件的理解&…