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

【C++闯关笔记】unordered_map与unordered_set的底层:哈希表(哈希桶)

系列文章目录

上篇笔记:【C++闯关笔记】使用红黑树简单模拟实现map与set-CSDN博客


文章目录

目录

系列文章目录

文章目录

一、了解哈希

1.什么是哈希?

2.哈希表

HashData:哈希表数据

3.哈希函数

除留余数法

将关键字转为整数

        仿函数

4.哈希冲突

负载因子

开放定址法

        线性探测规则

二次线性探测规则

链地址法

哈希桶扩容

二、模拟实现

1.开放地址法哈希表模拟实现

类主体与仿函数

Find查询函数

插入Insert函数

Erase删除函数

综合上述:较完整的哈希表代码

2.链地址法哈希表:哈希桶的模拟实现

实现默认构造函数

综上述:较完整的哈希桶模拟实现代码

本文总结



一、了解哈希

1.什么是哈希?

        哈希(hash)又称散列,是一种组织数据的方式,哈希的本质就是通过哈希函数把关键字Key跟存储位置建立一个映射关系。

        哈希最有用的地方在于查找,查找时通过哈希函数计算出Key存储的位置,进行快速查找,于是一种相关的数据结构就随之诞生了——哈希表。

2.哈希表

        上面说到“哈希的本质就是通过哈希函数把关键字Key跟存储位置建立一个映射关系。”,也就是说哈希表中存在着一种Key/Value键值对,其中key决定了val数据存在哪。

        实际上,常用的哈希表底层都是复用的vector容器:通过申请一片可动态增长的空间,结合相关算法(哈希函数),将具有键值对关系的数据存入其中,并通过key与存储位置建立映射关系。你可能会问,什么样的数据具有键值对关系呢?举个例子,比如英语单词与汉语词语间具有键值关系,或者身份证与个人具有键值关系,等等。

        于是一个哈希表的大概结构就可以搭建起来了:

template<class K,class V>
struct HashDate
{}template<class K,class V>
class HashTable
{
private:vector<HashData<K,V>> _tables;size_t _n=0;//表中存储数据个数
};

HashData:哈希表数据

        具有键值对关系的数据,应该如何设计类来描述它们呢?首先映入脑海中的是 pair 容器,与其费劲的定义两个成员变量并且时刻维护它们之间的关系,不如直接使用 C++中现成的pair容器,pair 中存储的两个数据天然的具有映射关系。

        存储数据的问题解决了,那么如何定义哈希表中数据的状态呢?我们可以设计一个枚举enum State 来表示,一共三种状态来表示当前节点中HashData的状态:EXIST(存在)、EMPTY(空,未存储实际数据)、DELETE(已删除)。

        综上,哈希表中的数据我们可以如下设计HashData类描述它们:

	enum  State{EXIST,DELETE,EMPTY};template<class K, class V>struct HashData{std::pair<K, V> _kv;State _state;HashData(const std::pair<K, V>& kv = std::pair<K, V>()):_kv(kv), _state(EMPTY){}};

3.哈希函数

        在哈希表中,哈希函数决定了关键字Key跟存储位置之间的映射关系。一个好的哈希函数应该让N个关键字被等均匀的散列分布到哈希表的M个空间中,但实际中其实是很难做到这一点的,但是我们要尽量往这个方向去考量设计。

        下面介绍一种经典又常用的哈希函数::除留余数法。

除留余数法

        什么是除留余数法呢?顾名思义:

假设哈希表的大小为M,那么通过key除以M的余数(即下标)作为key与存储位置的的映射关系,也就是说除留余数法哈希函数为:h(key)=key%M

        上面说到数据在存储的过程中,实际上是很难做到均分在整个哈希表中的,接下来将会具体分析为什么会这样。

先说结论:当使用除法散列法时,要尽量避免哈希表的大小M为某些值,如2的幂,10的幂等。

        为什么?假设M的大小10的幂,如10^2,那么key%M时本质相当于保留key的后X位,那么后x位相同的值,计算出的哈希值都是一样的,就冲突了。举个例子,我们要挑选出电话尾号为76的手机号,那么手机尾号1076与2076在经过除留余数法算出的位置就是相同的,这就导致了冲突(2同理)。

        所以当使用除法散列法时,建议M取不太接近2的整数次幂的一个质数(素数)。但是别忘了哈希表的底层是vector ,而vector的增长正是按二倍来扩容的,这可怎么办呢?C++标准库中通过下面的next_prime函数精准控制每次增长的M大小。

size_t next_prime(size_t n)
{// Note: assumes long is at least 32 bits.static const int nums = 28;static const unsigned long _prime_list[nums] ={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};const unsigned long* first = _prime_list;const unsigned long* last = _prime_list + nums;//  [first,last)const unsigned long* pos = std::lower_bound(first, last, n);return pos == last ? *(last - 1) : *pos;
}

        next_prime,即下一个质数的意思,当vector扩容时,调用next_prime函数通过传入当前哈希表的容量n,返回第一个不小于n的质数,vector便按返回的质数大小扩容。

        当然,除了除留余数法之外,还有其他哈希函数,但日常生活中最常用到的就是除留余数法,本文这里就不对其他哈希函数进行说明。其他类似的哈希函数包括:乘法散列法、全域散列法等。

将关键字转为整数

        上面说到:“哈希函数决定了关键字Key跟存储位置之间的映射关系,通过key除以哈希表大小M的余数(即下标)作为key与存储位置的的映射关系

        可是,如果待存放到哈希表中的数据是string等非整型类型的数据呢?比如成语字典,key是成语string类型;val是语句解释,它们都是string类型的数据。

        将关键字映射到数组中位置,利用的是key%M,如果key不是整数,我们要想办法将key转换成整数。这里的办法就是使用仿函数。

        仿函数

        简单描述一下仿函数的感念:就是设计一个类,类中只有一个成员函数——重载的operator( ),当通过该类的对象使用operator( )函数时,调用方式十分像函数调用,所以被曾为仿函数。

        说回将关键字转为整数,如果key的类型是char 、double等非整数数据类型,直接强制转换就行;如果key的类型是string 类型,那么我们可以统计该string的字典序之和(ASCII码之和)作为key。

实现代码如下:

template<class K>
struct HashFunc
{size_t operator()(const K& key){return size_t(key);}
};//针对string特化
template<>
struct HashFunc<string>
{size_t ret = 0;size_t operator()(const string& key){for (auto& ch : key)ret += ch;return ret;}
};

4.哈希冲突

        尽管对vector 的扩容逻辑进行了优化,但实际数据的存储过程中依旧会产生不少的冲突,导致很难做到将数据均分在整个哈希表中,下面引入的诸多概念与方法旨在努力消除这种弊端,尽量使得整个哈希表数据分布均匀,并解决数据key键冲突时的问题。

负载因子

        为了确定vector 的扩容时机,哈希表中引入了负载因子的概念:

        假设哈希表中已经映射存储了N个值,哈希表的大小为M,那么负载因子 = N / M

        每插入一个数据时便需要判断一下负载因子的大小,因为负载因子越大,哈希冲突的概率越高,空间利用率越高;负载因子越小,哈希冲突的概率越低,空间利用率越低。

        一般当负载因子 ≥ 0.7时便进行底层扩容,以减少哈希冲突的概率。

        实践中哈希表一般还是选择除法散列法作为哈希函数,当然哈希表无论选择什么哈希函数也避免不了冲突,那么插入数据时,如何解决冲突呢?主要有两种两种方法,开放定址法和链地址法。

开放定址法

        简单描述开放地址法的概念:在开放定址法中所有的元素都放到哈希表里,当一个关键字key用哈希函数计算出的位置冲突了,则按照某种规则找到一个没有存储数据的位置进行存储,开放定址法中负载因子一定是小于1的。这里的规则包括:线性探测、双重探测等。

        线性探测规则

        从发生哈希冲突的位置开始,依次向后探测,直到寻找到下一个没有存储数据的位置为止,如果走到哈希表尾,则回绕到哈希表头的位置。因为负载因子小于1,则最多探测M-1次,一定能找到一个存储key的位置。

        h(key)=hash0=key%M,hash0位置冲突了,则线性探测公式为:hc(key,i)=hashi=(hash0+i)%M,i={1,2,3,...,M−1}

        哈希表与下面的哈希桶最常用的还是线性探测法,下面的示例帮助读者更好的理解线性探测规则。

        线性探测原理还是比较简单且容易实现的,但随之而来的一些问题也十分突出:线性探测的问题假设是hash0位置连续冲突,hash0,hash1,hash2位置已经存储数据了,后续映射到hash0,hash1,hash2,hash3的值都会争夺hash3位置,这种现象叫做群集/堆积

        正如上图中那样,下标8的位置被19占用,导致原本的key=8的数据被迫占取下标为9的位置,如果下标9的位置之后又来数据了呢?只能接着占下一个位置,哪怕是之后的二次循环探测也无法根治这样的问题,如此循环“内卷式”抢占将会拖慢整个哈希表的效率,所以开放定址法的哈希表在实际运用的过程中不多,而下面介绍的链地址法哈希表,即我们常说的哈希桶,才是最常使用的。

二次线性探测规则

        从发生冲突的位置开始,依次左右按二次方跳跃式探测,直到寻找到下一个没有存储数据的位置为止,如果往右走到哈希表尾,则回绕到哈希表头的位置;如果往左走到哈希表头,则回绕到哈希表尾的位置。

        h(key)=hash0=key%M,hash0位置冲突了,则二次探测公式为:

h(key) = hash0 = key%Mhc(key,i) = hash i= (hash0±i)%M,i={1,2,3,...,}

 二次探测ashi<0时,需要hashi+=M。

       二次线性探测法本质是线性探测法的一种变种,哈希表与下面的哈希桶最常用的还是线性探测法。

链地址法

        开放定址法中所有的元素放到哈希表里,链地址法中所有的数据不再直接存储在哈希表中,哈希表中每一个节点存储一个指针,没有数据映射这个位置时,这个指针为空,有多个数据映射到这个位置时,我们把这些冲突的数据链接成一个链表,挂在哈希表这个位置下面。就如下图所示。

        链地址法也叫做拉链法或者哈希桶。

哈希桶扩容

        开放定址法负载因子必须小于1,链地址法的负载因子就没有限制了,可以大于1。但依旧负载因子越大,哈希冲突的概率越高,空间利用率越高;负载因子越小,哈希冲突的概率越低,空间利用率越低;stl中unordered_xxx的最大负载因子基本控制在1,大于1就扩容。下面我们模拟实现哈希桶时也这样做。


二、模拟实现

1.开放地址法哈希表模拟实现

类主体与仿函数

        综合上述已有的代码,可以得到哈希表HashTable的大致结构。

#include<vector>size_t next_prime(size_t n)
{// Note: assumes long is at least 32 bits.static const int nums = 28;static const unsigned long _prime_list[nums] ={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};const unsigned long* first = _prime_list;const unsigned long* last = _prime_list + nums;//  [first,last)const unsigned long* pos = std::lower_bound(first, last, n);return pos == last ? *(last - 1) : *pos;
}template<class K>
struct HashFunc
{size_t operator()(const K& key){return size_t(key);}
};//针对string特化
template<>
struct HashFunc<string>
{size_t ret = 0;size_t operator()(const string& key){for (auto& ch : key)ret += ch;return ret;}
};namespace karsen_open_address
{enum  State{EXIST,DELETE,EMPTY};template<class K, class V>struct HashData{std::pair<K, V> _kv;State _state;HashData(const std::pair<K, V>& kv = std::pair<K, V>()):_kv(kv), _state(EMPTY){}};template<class K, class V>class HashTable{typedef HashData<K,V> Node;public:HashTable():_table(next_prime(0)), _cnt(0){}size_t count(){return _cnt;}private:std::vector<HashData<K, V>> _table;size_t _cnt = 0;//记录有效数据个数};
}

Find查询函数

        Find函数,使用find函数时传入key值,然后遍历哈希表依次比对,若找到则返回节点地址,没找到或者节点状态为EMPTY,则返回nullptr。

		Node*<K, V>* Find(const K& key){HashFunc<K> hf;size_t hash0 = hf(key) % _table.size();size_t hashi = hash0;size_t i = 1;while (_table[hashi]._state != EMPTY){if (_table[hashi]._state == EXIST &&_table[hashi]._kv.first == key){return &_table[hashi];}hashi = (hash0 + i) % _table.size();i++;}return nullptr;}

插入Insert函数

        首先,插入之前先检查表中是否已有该类型的数据,若有返回false;在检查完确定表中没有相同的key后,还需要检查负债因子是否 ≥ 0.7,若大于则需要扩容并重新将旧数据插入,没有则执行正常插入逻辑。

        你可能会问:为什么在扩容时不利用旧数据原下标直接挪动到新空间?——原因是当哈希表的空间大小即M增大后,靠“h(key)=key%M”计算得到key与存储空间的映射就发生了改变,需要重新计算映射关系。

		bool Insert(const std::pair<K, V>& kv){if (Find(kv.first))return false;//扩容,现代写法if (_cnt * 10 / _table.size() >= 7){HashTable<K, V> newTable;//这里得+1,否则永远是原容量newTable._table.reserve(next_prime(_table.size()+1));for (auto& x : _table){if (x._state == EXIST)newTable.Insert(x._kv);}_table.swap(newTable._table);}HashFunc<K> hf;size_t hash0 = hf(kv.first) % _table.size();size_t hashi = hash0;size_t i = 1;//线性探测//while (_table[hashi]._state == EXIST)//{//	if (_table[hashi]._kv.first == kv.first)//	{//		return false;//	}//	hashi = (hash0 + i) % _table.size();//	i++;//}//二次线性探测int flag = 1;while (_table[hashi]._state == EXIST){if (_table[hashi]._kv.first == kv.first){return false; }hashi = (hash0 + (i * i * flag)) % _table.size();if (hashi < 0)hashi += _table.size();if (flag == 1){flag = -1;}else{flag = 1;i++;}}_table[hashi]._kv = kv;_table[hashi]._state = EXIST;_cnt++;return true;}

Erase删除函数

        Erase函数的实现逻辑很简单:找到要删除的值,然后将数据状态更改为DELETE即可,然后返回true;若没有在表中找到要删除的数据,则直接返回false;

		bool Erase(const K& key){HashData<K, V>* jug = Find(key);if (jug){jug->_state = DELETE;return true;}else{return false;}}

综合上述:较完整的哈希表代码

#pragma once
#include<vector>size_t next_prime(size_t n)
{// Note: assumes long is at least 32 bits.static const int nums = 28;static const unsigned long _prime_list[nums] ={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};const unsigned long* first = _prime_list;const unsigned long* last = _prime_list + nums;//  [first,last)const unsigned long* pos = std::lower_bound(first, last, n);return pos == last ? *(last - 1) : *pos;
}template<class K>
struct HashFunc
{size_t operator()(const K& key){return size_t(key);}
};//针对string特化
template<>
struct HashFunc<string>
{size_t ret = 0;size_t operator()(const string& key){for (auto& ch : key)ret += ch;return ret;}
};namespace karsen_open_address
{enum  State{EXIST,DELETE,EMPTY};template<class K, class V>struct HashData{std::pair<K, V> _kv;State _state;HashData(const std::pair<K, V>& kv = std::pair<K, V>()):_kv(kv), _state(EMPTY){}};template<class K, class V>class HashTable{public:HashTable():_table(next_prime(0)), _cnt(0){}bool Insert(const std::pair<K, V>& kv){if (Find(kv.first))return false;//扩容,现代写法if (_cnt * 10 / _table.size() >= 7){HashTable<K, V> newTable;//这里得+1,否则永远是原容量newTable._table.reserve(next_prime(_table.size()+1));for (auto& x : _table){if (x._state == EXIST)newTable.Insert(x._kv);}_table.swap(newTable._table);}HashFunc<K> hf;size_t hash0 = hf(kv.first) % _table.size();size_t hashi = hash0;size_t i = 1;//线性探测//while (_table[hashi]._state == EXIST)//{//	if (_table[hashi]._kv.first == kv.first)//	{//		return false;//	}//	hashi = (hash0 + i) % _table.size();//	i++;//}//二次线性探测int flag = 1;while (_table[hashi]._state == EXIST){if (_table[hashi]._kv.first == kv.first){return false; }hashi = (hash0 + (i * i * flag)) % _table.size();if (hashi < 0)hashi += _table.size();if (flag == 1){flag = -1;}else{flag = 1;i++;}}_table[hashi]._kv = kv;_table[hashi]._state = EXIST;_cnt++;return true;}HashData<K, V>* Find(const K& key){HashFunc<K> hf;size_t hash0 = hf(key) % _table.size();size_t hashi = hash0;size_t i = 1;while (_table[hashi]._state != EMPTY){if (_table[hashi]._state == EXIST &&_table[hashi]._kv.first == key){return &_table[hashi];}hashi = (hash0 + i) % _table.size();i++;}return nullptr;}bool Erase(const K& key){HashData<K, V>* jug = Find(key);if (jug){jug->_state = DELETE;return true;}else{return false;}}size_t count(){return _cnt;}private:std::vector<HashData<K, V>> _table;size_t _cnt = 0;};
}

2.链地址法哈希表:哈希桶的模拟实现

        哈希桶本质依旧是哈希表,所以诸如insert、find等函数的逻辑与上述开放地址法的哈希表中完全一致,而不同之处在于:链地址法实现的哈希桶由于底层还套用了list 的逻辑,所以编译器自动生成的默认构造函数与析构函数不再符合要求,需要自主实现。

实现默认构造函数

        总结下来需要我们自主实现的一共有四个默认成员函数:默认构造、默认拷贝构造、默认赋值运算符重载、析构函数。而仔细深究它们的逻辑,其实只需要实现默认构造、默认拷贝构造和析构函数即可,默认重载赋值运算符直接复用默认拷贝构造函数即可。

        其中默认拷贝构造函数的逻辑与Insert函数十分类似,析构函数则直接遍历释放即可。

		HashTable():_tables(next_prime(0)),_cnt(0){}HashTable(const HashTable<K, V>& kv){_tables.resize(kv._tables.size(),nullptr);_cnt = kv._cnt;for (size_t i = 0; i < kv._tables.size(); ++i){//头插if (kv._tables[i]){Node* cur = kv._tables[i];while (cur){Node* newNode = new Node(cur->_kv);newNode->_next = _tables[i];_tables[i] = newNode;cur = cur->_next;}}}}HashTable<K, V>& operator=(HashTable<K, V> kv){std::swap(kv._tables, _tables);std::swap(kv._cnt, _cnt);return *this;}~HashTable(){for (size_t i = 0; i < _tables.size(); ++i){if (_tables[i] != nullptr){Node* cur = _tables[i];Node* next = nullptr;while (cur){next = cur->_next;delete cur;cur = next;}_tables[i] = nullptr;}}}

综上述:较完整的哈希桶模拟实现代码

#pragma once
#include<vector>size_t next_prime(size_t n)
{// Note: assumes long is at least 32 bits.static const int nums = 28;static const unsigned long _prime_list[nums] ={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};const unsigned long* first = _prime_list;const unsigned long* last = _prime_list + nums;//  [first,last)const unsigned long* pos = std::lower_bound(first, last, n);return pos == last ? *(last - 1) : *pos;
}template<class K>
struct HashFunc
{size_t operator()(const K& key){return size_t(key);}
};//针对string特化
template<>
struct HashFunc<string>
{size_t ret = 0;size_t operator()(const string& key){for (auto& ch : key)ret += ch;return ret;}
};namespace karsen_hash_bucket
{template<class K,class V>struct HashNode{std::pair<K, V> _kv;HashNode* _next = nullptr;HashNode(const std::pair<K, V>& kv):_kv(kv),_next(nullptr){ }};template<class K, class V>class HashTable{typedef HashNode<K, V> Node;public:HashTable():_tables(next_prime(0)),_cnt(0){}HashTable(const HashTable<K, V>& kv){_tables.resize(kv._tables.size(),nullptr);_cnt = kv._cnt;for (size_t i = 0; i < kv._tables.size(); ++i){//头插if (kv._tables[i]){Node* cur = kv._tables[i];while (cur){Node* newNode = new Node(cur->_kv);newNode->_next = _tables[i];_tables[i] = newNode;cur = cur->_next;}}}}HashTable<K, V>& operator=(HashTable<K, V> kv){std::swap(kv._tables, _tables);std::swap(kv._cnt, _cnt);return *this;}~HashTable(){for (size_t i = 0; i < _tables.size(); ++i){if (_tables[i] != nullptr){Node* cur = _tables[i];Node* next = nullptr;while (cur){next = cur->_next;delete cur;cur = next;}_tables[i] = nullptr;}}}bool Insert(const std::pair<K, V>& kv){			if (Find(kv.first))return false;HashFunc<K> ht;//扩容if (_cnt == _tables.size()){std::vector<Node*> newTable(next_prime(_tables.size() + 1));for (size_t i = 0; i < _tables.size(); ++i){Node* cur = _tables[i];while (cur){Node* next = cur->_next;//头插size_t newPos = ht(cur->_kv.first) % newTable.size();//这里跳过第一次,就能明显看出头插逻辑了cur->_next = newTable[newPos];newTable[newPos] = cur;cur = next;}}_tables.swap(newTable);}size_t hash0 = ht(kv.first) % _tables.size();//头插Node* next = _tables[hash0];_tables[hash0] = new Node(kv);_tables[hash0]->_next = next;_cnt++;return true;}Node* Find(const K& key){HashFunc<K>ht;size_t pos = ht(key) % _tables.size();Node* cur = _tables[pos];while (cur){if (cur->_kv.first == key)return cur;cur = cur->_next;}return nullptr;}bool Erase(const K& key){HashFunc<K> ht;size_t goalPos = ht(key) % _tables.size();Node* cur = _tables[goalPos];Node* prev = nullptr;while (cur){if (cur->_kv.first == key){if (!prev){_tables[goalPos] = cur->_next;}else{prev->_next = cur->_next;}delete cur;--_cnt;return true;}prev = cur;cur = cur->_next;}return false;}private:std::vector<HashNode<K, V>*>_tables;size_t _cnt = 0;};}


本文总结

        本文分为前后两个部分:哈希表的核心概念与实现方法。

        前半部分阐述了哈希的本质是通过哈希函数建立键值与存储位置的映射关系,重点讲解了除留余数法的哈希函数及其缺陷。针对哈希冲突问题,详细分析了开放定址法(线性探测、二次探测)和链地址法的原理与实现。

        后半部分提供了完整的哈希表和哈希桶模拟实现代码,包括插入、查找、删除等核心功能,并解释了负载因子控制、扩容机制等关键技术点。通过pair容器存储键值对,结合枚举状态管理数据,最终实现了基于vector和链表的高效哈希结构。

http://www.dtcms.com/a/570521.html

相关文章:

  • 项目部署方法总结
  • 注册网站会员需要填写信息工程设计有限公司
  • 建设网站全部流程个人网站建设制作
  • 用php做网站的方法网站开发团队分工
  • 网站规划中的三种常用类型学习网
  • app企业网站模板贵阳网站制作专业
  • 提出网络营销思想的网站改版计划腰椎间盘突出压迫神经腿疼怎么治疗
  • ref 和 reactive的区别与用法
  • 网站整套模板做网站哪个平台
  • asp与sql做网站莱州网站建设多少钱
  • UE C++ 代码上构建反射
  • 360建筑网官方网站网站运营编辑
  • 网站点赞怎么做邮箱域名和网站域名
  • 企业采购如何管理部门预算?
  • 三、ILA逻辑分析仪抓取及查看波形
  • asp.net网站本机访问慢网络运维需要懂什么技术
  • 网站的相关性 实用性网站建设项目登记表
  • 深圳本地做网站wordpress 文章列表只显示标题
  • notion模版 | 小胡的第二大脑[特殊字符]-任务篇
  • harmonyos的鸿蒙的跳转页面的部署
  • Godaddy优惠码网站怎么做的红豆梧州论坛
  • 商户查询缓存、商户更新缓存(opsForHash、opsForList、ObjectMapper、@Transactional、@PutMapping、RequestParam、装箱拆箱、线程池)
  • 做网站如何推销网站建设论证方案
  • 济南企业网站推广网络销售的工作内容
  • 大神自己做的下载音乐的网站域名是什么意思举个例子
  • Python中常用内置函数下【含代码理解】
  • QuickDruid
  • Java 文件上传-阿里云OSS对象存储
  • 国外 网站源码西部建设公司官网
  • 深圳做h5网站设计济南冰河世纪网站建设