数据结构-哈希-哈希表实现

news2024/11/13 23:09:02

哈希表实现

  • 一,哈希概念
    • 哈希概念
    • 常见哈希函数
    • 哈希冲突
    • 哈希冲突的解决
  • 二,闭散列实现
    • 闭散列的结构
    • 插入
    • 查找
    • 删除
    • 闭散列总结
  • 三,哈希桶实现
    • 哈希桶的结构
    • 插入
    • 查找
    • 删除
    • 析构
    • 拷贝构造
    • 赋值运算符重载
  • 四,哈希表总结
    • 开散列与闭散列的比较
    • 哈希表的增删查改时间复杂度

一,哈希概念

哈希概念

🚀理想的搜索方法:不经过任何的比较,一次直接从表中得到要搜索的元素。如果构造一种存储结构,通过某种函数使元素的存储位置与其关键码之间能够建立起一一映射的关系,那么在查找的时候就能通过此函数快速的找到该元素。
🚀向该结构中插入元素:根据该元素的关键码,以及哈希函数,计算出该元素的存储位置并按该存储位置存放即可。
🚀在该结构中查询元素:根据元素的关键码以及哈希函数计算出该元素的存储位置,然后将元素的值与该存储位置的值进行比较,如果相等则查询成功。

🚀上面的方式就是一种哈希方法,哈希方法中使用的函数叫做哈希函数,构造出来的结构叫做哈希表。
🚀哈希本质就是一种映射,建立元素与存储位置的映射关系。

常见哈希函数

1,直接定址法
取关键字的某个线性函数作为哈希地址:Hash(key) = A * key + B;
其优点就是简单均匀,缺点是要实现知道数据的分布。
使用场景就是数据连续紧凑的情况。
2,除留余数法
设哈希表的长度为m,通常m素数,因为数据模一个素数后得到的余数更加均匀。关键字key模m得到的余数就是关键字对应的哈希地址。Hash(key) = key % m;
除留余数法是最常用的哈希函数。
3,平方取中法
假设关键字为1234,对它平方就是1522756,抽取中间的3位227作为哈希地址;
再比如关键字为4321,对它平方就是18671041,抽取中间的3位671(或710)作为哈希地址。
平方取中法比较适合:不知道关键字的分布,而位数又不是很大的情况
4, 折叠法
折叠法是将关键字从左到右分割成位数相等的几部分(最后一部分位数可以短些),然后将这几部分叠加求和,并按散列表表长,取后几位作为散列地址。
折叠法适合事先不需要知道关键字的分布,适合关键字位数比较多的情况。
5,随机数法
选择一个随机函数,取关键字的随机函数值为它的哈希地址,即H(key) =random(key),其中random为随机数函数。
通常应用于关键字长度不等时采用此法。
6,数学分析法
数学分析法是比较灵活多变的,例如使用手机号作为关键字对其散列,尝试告诉我们电话号码的前几位的相似度是很高的,所以我们可以取后四位,但是后四位依然可能出现冲突,所以可以对后四位进行反转(3276->6723),右环移位(3276->6327),等等的方法。

哈希冲突

🚀无论选取哪种哈希函数,将不同关键字通过哈希函数映射出的哈希地址有可能相同,这种现象称之为哈希冲突。
🚀设计更加精妙的哈希函数只等降低哈希冲突出现的概率,但是不能避免哈希冲突,哈希冲突是必然存在的。

哈希冲突的解决

1,闭散列
也叫开放定址法,当发生哈希冲突的时候,如果哈希表未被填满,说明在哈希表中必然还有空位置,那么就把key存在冲突位置的下一个位置去。
那么如何去寻找下一个位置呢?
(1)线性探测:从冲突的位置,一次向后探测,直到寻找到一个空位置。
(2)二次探测:从冲突的位置,新的hashi = 老的hashi + i*i,每次探测后i++,直到寻找到一个空位置。
2,开散列
也叫拉链法,哈希桶,首先对关键字通过哈希函数计算出哈希地址,具有相同地址的关键字归属于同一集合,每一个集合叫做一个桶,每个桶的元素通常通过一个单链表连接起来,各个链表的头节点存放在哈希表中。
对于开散列,发生哈希冲突时,不会去占用下一个数据的位置,相比于闭散列降低了冲突的概率。

二,闭散列实现

闭散列的结构

🚀闭散列在实现前要考虑一个问题,闭散列数据的删除不同于顺序表(移动覆盖),而是给每个位置都设置一个标记位,标记的状态有EMPTY,EXIST,DELETE。

typedef enum
{
	EMPTY,
	EXIST,
	DELETE
}state;
template<typename K,typename V>
struct HashNode
{
	pair<K, V> _kv;
	state _state = EMPTY;
};
template<typename K,typename V,typename Hash = BKDRhash<K>>
class HashTable
{
public:
	bool insert(const pair<K, V>& kv)
	{}
	HashNode<K, V>* find(const K& key)
	{}
	bool erase(const K& key)
	{}
private:
	vector<HashNode<K,V>> _table;
	size_t _n = 0;//记录哈希表中数据个数
};

🚀实现哈希表时常用的哈希函数就是除留余数法,但并不是所有的数据类型都能够直接通过哈希表的大小来获取映射的位置。所以要通过一个仿函数来控制,仿函数的作用就是将各种类型的数据经过特定的算法返回一个整数。在实现哈希表的时候仿函数默认支持的类型有整型和字符串类型。

template<typename T>
struct BKDRhash
{
	size_t operator()(const T& t)
	{
		return t;
	}
};
template<>
struct BKDRhash<string>
{
	size_t sum = 0;
	size_t operator()(const string& t)
	{
		for (auto e : t) { sum += e; sum *= 13; }
		return sum;
	}
};

🚀将字符串转化为整型的方法有很多,本文采用的BKDRhash的方法。如果需要更多方法请参考字符串哈希算法

插入

🚀首先要计算插入数据在hash表中的位置(hashi),因为可能存在哈希冲突,当发生冲突时要继续向后迭代,直到某个位置的状态标志位为EMPTY或DELETE时就插入数据,并且将标记位改为EXIST,_n++。
🚀为了更好的维持哈希表的效率,引入了负载因子这一概念,负载因子是指插入到哈希表中的数据占哈希表总体大小的百分比,可见这个负载因子不宜过大,过大意味着在插入新数据的时候发生哈希冲突的概率变大。同样负载因子的值也不宜过小,虽然负载因子较小的时候,发生哈希冲突的概率低,但是空间利用率也低。综合考虑,负载因子的值控制在0.7-0.8之间是比较合适的。
🚀插入数据之前,要先判断是否需要扩容,扩容的情况分为两种:
第一种是,刚开始时哈希表的size为0要进行扩容。
第二种是,当负载因子超过设定的值的时候要扩容。

bool insert(const pair<K, V>& kv)
{
	if (find(kv.first) != nullptr) { return false; }
	if (_table.size() == 0 || _n * 10 / _table.size() >= 7)
	{
		//扩容...
		size_t newsize = _table.size() == 0 ? 10 : _table.size() * 2;
		HashTable<K, V,Hash>* newhash = new HashTable<K, V, Hash>;
		newhash->_table.resize(newsize);
		for (auto& e : _table)
		{
			if(e._state == EXIST)
			{
				newhash->insert(e._kv);
			}
		}
		_table.swap(newhash->_table);
	}
	size_t hashi = Hash()(kv.first) % _table.size();
	size_t i = 1;
	size_t index = hashi;
	while (_table[index]._state == EXIST)
	{
		index = hashi + i;
		index %= _table.size();
		i++;
	}
	_table[index]._kv = kv;
	_table[index]._state = EXIST;
	_n++;
	return true;
}

查找

HashNode<K, V>* find(const K& key)
{
	if (_table.size() == 0) { return nullptr; }
	size_t hashi = Hash()(key) % _table.size();
	size_t i = 1;
	size_t index = hashi;
	while (_table[index]._state != EMPTY)
	{
		if (_table[index]._state == EXIST && _table[index]._kv.first == key) { return &_table[index]; }
		index = hashi + i;
		index %= _table.size();
		i++;
		if (index == hashi) { return nullptr; }//找了一圈还没找到
	}

	return nullptr;
}

🚀注意: 查找的过程类似于插入,但也有不同之处,在插入时计算出hashi之后,因为可能发生哈希冲突,所以要将hashi位置向后迭代直到某位置的标记位为EMPTY或者DELETE时即可插入。但是,查找的时候算出hashi之后,要迭代到某位置的标记位为EMPTY的时候停止。这种情况下可能会出现的bug就是查找的数据不在哈希表中,并且哈希表的所有位置的标记位不存在EMPTY的情况,会造成死循环。解决的办法就是如果查找了一圈还没找到就直接返回nullptr。

特殊情况图示:
在这里插入图片描述
🚀在上图中的哈希表中每个位置的标记位不是EXIST就是DELETE,不存在EMPTY的情况。

删除

🚀删除的逻辑十分简单,先进行查找,如果返回值为nullptr,证明哈希表中不存在此数据直接返回flase,否则进行数据的删除(就是将此位置的标记位修改为DELETE)。

bool erase(const K& key)
{
	HashNode<K, V>* ret = find(key);
	if (ret == nullptr) { return false; }
	//不是空那么直接修改标志位
	ret->_state = DELETE;
	--_n;
	return true;
}

闭散列总结

🚀当插入的某组数据中存在局部集中一些数据时,哈希冲突是很严重的,一个数据占据了另一个数据的位置,另一个位置就要去占据一个其他数据的位置,这样一些数据较为集中的时候,哈希冲突是很严重的导致效率较低。
🚀下面实现这开散列的形式是更为优秀的,其哈希冲突的概率于闭散列相比是更低的。

三,哈希桶实现

哈希桶的结构

🚀哈希桶的实现是顺序表+单链表的结构,当发生哈希冲突的时候,不会去占用其他数据的位置,而是挂接到当前位置的单链表中。这样相比于闭散列的形式哈希冲突的概率降低。

template<typename K,typename V>
struct HashNode
{
	HashNode<K, V>* _next = nullptr;
	pair<K, V> _kv ;
	struct HashNode<K,V>(const pair<K,V>& kv)
		:_next(nullptr),_kv(kv)
	{}
};
template<typename T>
struct BKDRhash
{
	size_t operator()(const T& t)
	{
		return t;
	}
};
template<>
struct BKDRhash<string>
{
	size_t sum = 0;
	size_t operator()(const string& t)
	{
		for (auto e : t) { sum += e; sum *= 13; }
		return sum;
	}
};
template<typename K,typename V,typename Hash = BKDRhash<K>>
	struct HashTable
	{
	public:
		HashTable() = default;
		HashTable(const HashTable& ht)
		{}
		HashTable& operator=(HashTable ht)
		{}
		~HashTable()
		{}
		bool insert(const pair<K, V>& kv)
		{}
		HashNode<K, V>* find(const K& key)
		{}
		bool erase(const K& key)
		{}
		void swap(HashTable<K,V,Hash>& ht)
		{}
	private:
		size_t GetNextNum(size_t CurSize)
		{}
	private:
		vector<HashNode<K,V>*> _table;
		size_t _n = 0;
	};

在这里插入图片描述

插入

🚀首先计算出数据映射的位置(hashi),在hashi位置完成单链表的头插即可。
🚀开散列的形式实现哈希表的时候,负载因子的值是可以超过1的,因为哈希桶中的数据是存储在挂在哈希表每个位置的单链表中的。
负载因子越大,空间利用率越高,效率越低。
负载因子越小,空间利用率越低,效率越高。
综合考虑,当负载因子为1时扩容是比较合理的,理想情况下就是每个位置的单链表都挂着一个结点。

🚀插入之前,要先判断是否需要扩容:
1,哈希表的size为0
2,负载因子达到1
扩容时的处理方法与闭散列是不同的,对于哈希桶而言如果像闭散列那样处理的话效率是不够高的,因为完全可以重复利用单链表的结点,而不是创建新的节点插入,再将原结点释放。

bool insert(const pair<K, V>& kv)
{
	if (_n == _table.size()) //哈希表为空或者负载因子为1的时候扩容
	{
		if (find(kv.first) != nullptr) return false;
		//扩容...
		size_t newsize = GetNextNum(_table.size());
		vector<HashNode<K, V>*> newtable(newsize,nullptr);
		for (auto& cur : _table)
		{
			while (cur)
			{
				HashNode<K, V>* next = cur->_next;
				size_t hashi = Hash()(cur->_kv.first) % newtable.size();
				//头插
				cur->_next = _table[hashi];
				newtable[hashi] = cur;
				cur = next;
			}
		}
		_table.swap(newtable);
	}
	size_t hashi = Hash()(kv.first) % _table.size();
	//头插
	HashNode<K, V>* node = new HashNode<K, V>(kv);
	node->_next = _table[hashi];
	_table[hashi] = node;
	++_n;

	return true;
}

🚀通常哈希表的长度最好为素数,因为大量数据模一个素数所得到的余数会较为均匀的分布。

static const int __stl_num_primes = 28;
static const unsigned long __stl_prime_list[__stl_num_primes] =
{
  53,         97,         193,       389,       769,
  1543,       3079,       6151,      12289,     24593,
  49157,      98317,      196613,    393241,    786433,
  1572869,    3145739,    6291469,   12582917,  25165843,
  50331653,   100663319,  201326611, 402653189, 805306457,
  1610612741, 3221225473, 4294967291
};
size_t GetNextNum(size_t CurSize)
{
	if (CurSize == 0) return __stl_prime_list[0];
	size_t i = 0;
	for (; i < __stl_num_primes; i++)
	{
		if (__stl_prime_list[i] == CurSize) break;
	}
	return __stl_prime_list[i + 1];
}

查找

🚀对于哈希桶的查找也十分简单,直接遍历哈希表,找到相应数据返回其地址即可,如果没有找到返回nullptr。

HashNode<K, V>* find(const K& key)
{
	if (_table.size() == 0) return nullptr;
	for (auto& cur : _table)
	{
		while (cur)
		{
			if (cur->_kv.first == key) return cur;
		}
	}
	return nullptr;
}

删除

🚀首先计算出hashi,然后在hashi位置处的单链表中找到要删除的结点和其前置结点。对于一般情况,将其前置结点的next指针指向要删除结点的next指针然后delete掉删除结点即可。当前前置结点为空的时候,将要删除结点的下一个结点的指针存储在哈希表的hashi处,delete掉要删除结点即可。如果整个链表中没有要删除的数据,那么就返回false;

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

析构

🚀由于底层使用的是vector,所以vector的空间不用手动释放,但是对于每个链表的空间是要手动释放的,否则会发生内存泄漏。

~HashTable()
{
	for (auto& cur : _table)
	{
		while (cur)
		{
			HashNode<K, V>* next = cur->_next;
			delete cur;
			cur = next;
		}
	}
	_n = 0;
}

拷贝构造

🚀哈希桶的拷贝是深拷贝,值得注意的一点是在拷贝链表结点的时候应该采用尾插的形式,因为拷贝出来的哈希桶要和原哈希桶的结构保持相同。

HashTable(const HashTable& ht)
{
	_table.resize(ht._table.size());
	for (size_t i = 0; i < ht._table.size(); i++) // 拷贝每个单链表结点
	{
		HashNode<K,V>* cur = ht._table[i];
		HashNode<K, V>* tail = nullptr;
		while (cur)
		{
			HashNode<K,V>* newnode = new HashNode<K,V>(cur->_kv);
			if (_table[i] == nullptr)
			{
				_table[i] = newnode;
			}
			else
			{
				if (tail)
					tail->_next = newnode;
			}
			tail = newnode;
			cur = cur->_next;
		}
	}
}

赋值运算符重载

🚀对于赋值运算符重载可以直接复用拷贝构造。

HashTable& operator=(HashTable ht)
{
	swap(ht);
	return *this;
}

四,哈希表总结

开散列与闭散列的比较

🚀应用开散列,对于每个结点要增设链接的指针,似乎增加了空间的开销。事实上,由于开放定址法必须持有大量的空闲空间以确保搜索效率,对于线性探测要保证负载因子的大小要在0.7-0.8之间,而表项所占的空间要比指针大的多,所以开散列反而比闭散列更省空间。

哈希表的增删查改时间复杂度

🚀哈希表的时间复杂度为O(1),这里使用的是平均的时间复杂度,其最坏的时间复杂度为O(N),就是当N个元素都在一个桶内的情况,这种情况出现的概率是极低的,并且哈希表的扩容就会重新映射,改变这一状态。还有一种解决方法就是,当某个桶的长度超过某个值的时候,采用红黑树代替来链表。哈希表中存储的内容也要做改变。

template<typename K,typename V,typename Hash>
struct HashNodePtr
{
	typedef union ptr
	{
		链表结点的指针
		红黑树结点指针
	}ptr;
	size_t _size;
};

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

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

相关文章

设备虚拟化基础 - PCI

目录 1. 配置空间概念和作用 2. 通过配置空间发现设备 3. Linux读取PCI配置空间接口 4. 内核中具体读取配置空间实例 5. Virtion设备自定义空间 6. Linux读取Capabilities List代码解析 1. 配置空间概念和作用 详细的定义可以参考PCI Spec的第六章《Configuration Space…

嵌入式操作系统(嵌入式学习)

嵌入式操作系统 嵌入式操作系统是什么&#xff1f;嵌入式操作系统有哪些&#xff1f;常用的嵌入式操作系统及其特点对初学者的建议 嵌入式操作系统是什么&#xff1f; 嵌入式操作系统是一种专门设计和优化用于嵌入式系统的操作系统。它是在资源受限的嵌入式设备上运行的操作系…

No CMAKE_Swift_COMPILER could be found问题解决

编译OpenCV的IOS平台包,出错: CMake Error at CMakeLists.txt:20 (enable_language): No CMAKE_Swift_COMPILER could be found. 出错定位,原因是启用Swift语言时没有找到CMAKE_Swift_COMPILER变量 CMAKE官方文档说明启用Swift语言方法 cmake 3.15开始支持swift 查找swift …

简单介绍html/javascript、ajax应用

文章目录 前言1、html1.1.、html实例1.2、HTML文档的后缀名1.3、HTML 编辑器1.4、HTML 标题1.5、HTML 段落1.6、HTML 链接1.7、HTML 图像1.8、HTML 表格1.8.1、HTML 表格实例1.8.2、HTML 表格和边框1.8.3、HTML 表格表头 1.9、HTML <input> 标签1.10、Bootstrap 教程1.10…

开发常用命令合集(Docker、K8s、Linux、Windows等)

开发常用命令合集&#xff08;Docker、K8s、Linux、Windows等&#xff09; 1 Docker 详情查看&#xff1a;https://blog.csdn.net/weixin_45565886/article/details/130130361 1.1 基础命令 ①拉取、查看、删除镜像 docker pull nginx #下载最新版镜像名:版本名&#xff08…

元数据驱动架构的官方数据空间设计

淘宝开放平台是阿里与外部生态互联互通的重要开放途径&#xff0c;通过开放的产品技术把阿里经济体一系列基础服务&#xff0c;像水、电、煤一样输送给我们的商家、开发者、社区媒体以及其他合作伙伴&#xff0c;推动行业的定制、创新、进化, 并最终促成新商业文明生态圈。 开放…

PHP简单入门

PHP是一种流行的服务器端编程语言&#xff0c;被广泛用于Web开发。许多著名的网站和应用程序都是使用PHP编写的&#xff0c;例如Facebook、Wikipedia和WordPress等。本篇文章将为您介绍如何入门PHP编程。 环境配置 在开始使用PHP之前&#xff0c;需要先配置开发环境。要在本…

3ds MAX绘制花坛

绘制一个八边形花坛&#xff1a; 首先绘制一个八边形&#xff0c;并将它转换为可编辑样条线&#xff1a; 在前视图中绘制一个长方形&#xff0c;参数如图&#xff0c;可以按照喜好变换弧度等 注意&#xff0c;长方形的宽要和八边形的边长对应。 接下来我们通过旋转创建完整的…

测试流程体系

目录&#xff1a; 软件测试基本概念软件测试模型软件测试工作流程测试左移和测试右移 1.软件测试基本概念 通过手工或者工具对"被测对象"进行测试验证实际结果与预期结果之间是否存在差异 软件测试作用 通过测试工作可以发现并修复软件当中存在的缺陷&#xff…

环状支撑佩戴,骨传导新体验:南卡OE骨传导开放式耳机

骨传导耳机究竟是“黑科技”还是“智商税”呢&#xff1f;我想近几年的市场反馈就能给出答案。尤其是在户外运动场景下&#xff0c;骨传导耳机的综合体验都要比入耳式耳机更好一些。最近国产品牌南卡推出了一款新形态的骨传导耳机&#xff0c;南卡OE&#xff0c;它通过改良耳机…

【二叉树part03】| 104.二叉树的最大深度、559.n叉树的最大深度、111.二叉树的最小深度、222.完全二叉树的节点个数

目录 ✿LeetCode104.二叉树的最大深度❀ ✿LeetCode559.n叉树的最大深度❀ ✿LeetCode111.二叉树的最小深度❀ ✿LeetCode222.完全二叉树的节点个数❀ ✿LeetCode104.二叉树的最大深度❀ 链接&#xff1a;104.二叉树的最大深度 给定一个二叉树&#xff0c;找出其最大深度…

[Leetcode] 0014. 最长公共前缀

14. 最长公共前缀 img { margin: auto; display: block } 点击上方&#xff0c;跳转至Leetcode 题目描述 编写一个函数来查找字符串数组中的最长公共前缀。 如果不存在公共前缀&#xff0c;返回空字符串 ""。 示例 1&#xff1a; 输入&#xff1a;strs ["flowe…

一道有点仙的数位dp 方伯伯的商场之旅

link 大意&#xff1a; 思路&#xff1a; 先来考虑单个数字的情况 其实首先可以将题意稍微转化一下&#xff0c;就是移动一个石子的代价是其移动的距离。这样的话&#xff0c;显然我们的策略就是对于每一个石子&#xff0c;一次性将其移动到正确的位置&#xff0c;毕竟能一步…

Axure教程—选择器

本文将教大家如何用AXURE中的动态面板制作选择器 一、效果 预览地址&#xff1a;https://zhihp9.axshare.com 二、功能 用户点击选择框&#xff0c;可以显示下拉选择器&#xff0c;点击后选择对应项。 三、制作 1、选择框 拖入一个矩形&#xff0c;设置大小为16438&#xff…

macOS Ventura 13.4.1 (22F82) Boot ISO 原版可引导镜像下载

macOS Ventura 13.4.1 (22F82|22F2083) Boot ISO 原版可引导镜像下载 本站下载的 macOS 软件包&#xff0c;既可以拖拽到 Applications&#xff08;应用程序&#xff09;下直接安装&#xff0c;也可以制作启动 U 盘安装&#xff0c;或者在虚拟机中启动安装。另外也支持在 Wind…

EMC学习笔记(六)优选布线层

EMC学习笔记&#xff08;六&#xff09;优选布线层 1. 表层与内层走线的比较1.1 微带线&#xff08;microsstrip&#xff09;1.2 带状线&#xff08;stripline&#xff09;1.3 微带线与带状线的比较 2.布线层的优先级别 对于时钟、高频、高速、小、弱信号而言&#xff0c;选择合…

React封装axios请求

1、前言 因为最近在进行老系统用新框架改造&#xff0c;正好用到了react&#xff0c;就顺便整理了一下react中对axios进行封装的相关知识点和步骤。 2、如何封装 可以参考一下chat gpt给出的回答。 我大概总结一下&#xff0c;其实就是使用axios.create创建一个axios的实例&…

基于座位预约管理系统的设计与实现

前言&#xff1a; 各位小伙伴儿们端午节快乐&#xff0c;本篇文章为大家带来一份基于ssm技术搭建的项目&#xff0c;正在学习这块技术的童靴们可以体验一下哦&#xff0c;在资源中可获取源码 ~ 以下正文开始&#xff1a; 文章目录 需求分析功能模块项目架构图首页部分Controll…

存储笔记 - 整理

文章目录 第一章 存储系统introlesson 1何为数据&#xff1f;类型与关系 lesson 2 存储系统环境lesson 1lesson 2 Disk Drive 数据保护 RAIDlesson 1 智能存储lesson 第二章 存储网络技术与虚拟化DAS 直连式存储与 SCSIlesson 直连存储lesson&#xff1a; SCSI简介summary SANl…

长尾关键词有什么作用?要怎么用?

长尾关键词很多的网站都会忽略其存在&#xff0c;其实你不要小看长尾关键词&#xff0c;他将带给网站的流量也是极其可观的&#xff0c;所说比不上那些重点关键词的流量&#xff0c;但是对提升网站的权重还是有着重要的作用。 长尾关键词有什么用&#xff1f;长尾关键词的3…