IT数码 购物 网址 头条 软件 日历 阅读 图书馆
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
图片批量下载器
↓批量下载图片,美女图库↓
图片自动播放器
↓图片自动播放器↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁
 
   -> 数据结构与算法 -> 【C++数据结构】一个简单的unordered_set/unordered_map实现(适合初学者) -> 正文阅读

[数据结构与算法]【C++数据结构】一个简单的unordered_set/unordered_map实现(适合初学者)


前言

Hash,一般翻译做散列、杂凑,或音译为哈希,是把任意长度的输入(又叫做预映射pre-image)通过散列算法变换成固定长度的输出,该输出就是散列值。这种转换是一种压缩映射,也就是,散列值的空间通常远小于输入的空间,不同的输入可能会散列成相同的输出,所以不可能从散列值来确定唯一的输入值。简单的说就是一种将任意长度的消息压缩到某一固定长度的消息摘要的函数。


哈希的优点:

  • 最理想的情况下可以不经过任何比较,一次直接从表中得到要搜索的元素。 如果构造一种存储结构,通过某种函数(hashFunc)使元素的存储位置与它的关键码之间能够建立一一映射的关系,那么在查找时通过该函数可以很快找到该元素

哈希最大的问题就是缓解哈希碰撞,即对不同的关键字可能得到同一散列地址,即key1≠key2,而f(key1)=f(key2),这种现象称为碰撞。

常用的哈希算法:

  • 直接定制法–(常用)
  • 除留余数法–(常用)
  • 平方取中法–(了解)
  • 折叠法–(了解)
  • 随机数法–(了解)
  • 数学分析法–(了解)

常用的解决哈希冲突的方法:

  • 闭散列(开放定址法),又分为线性探测和二次探测
  • 开散列(链地址法)

注意:

  • 开散列和闭散列的增容,都需要将原来插入的数据进行重新的排放。

一、闭散列(开放定址法)


比较重要的一点,封装了unordered_map后,若想将K设置为const K,那么初始化节点的时候需要在初始化列表初始化,不能后面再赋值。

1.1 线性探测


散列表的负载因子定义:负载因子 = 表中的有效元素/散列表的长度。

线性探测的就是在哈希冲突之后他就会在后面找一个空的位置放入。
缺点:

  • 这样的做法就是比较容易造成一片冲突严重的地方。产生数据的堆积
  • 并且闭散列需要控制负载因子,线性探测的负载因子需要控制在0.7~0.8,超过0.8之后由于查表的时候CPU缓存不命中按照指数曲线上升,这就导致了需要通过更多的页表置换等等措施,导致效率上下降。所以例如Java的系统的hash库设置为0.75。

节点的结构


	enum State
	{
		EMPTY,//空的
		EXIST,//存在
		DELETE//删除
	};

	//节点默认是空
	template<class K,class V>
	struct CloseHashNode
	{
	public:
		CloseHashNode(const std::pair<K,V>& data = std::pair<K,V>(),State state = EMPTY)
			: _data(data)
			,_state(state)
		{}

		std::pair<K, V> _data;//节点的值
		State _state;//节点的状态
	};

CloseHash的结构


CloseHash也就是闭散列,实际上就是开辟一块数组,数组的每个节点的状态设置成空,并且因为我们删除元素不方便,我们需要关注如下几点:

  • 删除不能挪动后面的元素,因为每个元素的位置在哈希算法与数组长度固定的情况下是不变的;
  • 但是由于也不能置成特殊值-1等,因为可能我们存放的值就是这些。
    所以我们引入state状态标识。也因为如此我们需要_size来标识有多少格子是已经被占用的。
template<class K,class V,class Hash = MyHash<K,V>>
	class CloseHash
	{
		typedef CloseHashNode<K, V> Node;
	private:
		vector<Node> _hashtable;//哈希表
		size_t _size; // 有效元素,扩容要用
	};

插入函数


由于每次插入前需要检测负载因子以及表的空间是否足够,然后判断是否键值冗余,然后通过传来的data进行散列算法,然后模上表长得到固定的位置。

插入时要注意两种情况:

  • 空间不足够
  • 键值冗余

在这里插入图片描述

bool Insert(const std::pair<K, V>& data)
{
	//先检测一下容量
	CheckCapacity();
	//然后找到固定的位置进行操作
	size_t index = Hash()(data.first) % _hashtable.size();
	//键值不冗余才能插入
	while (_hashtable[index]._data.first != data.first && _hashtable[index]._state != EMPTY)
	{
		index++;
		index %= _hashtable.size();
	}
	if (_hashtable[index]._state == EMPTY)
	{
		//可以正常插入
		_hashtable[index] = Node(data, EXIST);
		_size++;
		return true;
	}
	else
	{
		return false;
	}
}

删除函数


找到了设置状态,没找到返回false.

bool Delete(const K& key)
{
	size_t index = Hash()(key) % _hashtable.size();
	while (!(_hashtable[index]._state != EMPTY && _hashtable[index]._data.first == key)
		&& _hashtable[index]._state != EMPTY)
	{
		//找到才能删除
		index++;
		index %= _hashtable.size();
	}
	//找到了或者没找到
	if (_hashtable[index]._state == EMPTY|| _hashtable[index]._state == DELETE)
	{
		return false;
	}
	else
	{
		_hashtable[index]._state = DELETE;
		_size--;
	}
	return true;
}

查找函数


查找失败就是查找的时候找到了EMPTY的节点,若到了EMPTY的格子之前没找到就说明是被删除了或者是没有插入过。

//找到了返回下标
//失败了返回 整形最大值 

size_t Find(const K& key)
{
	size_t index = Hash()(key) % _hashtable.size();
	while (_hashtable[index]._state != EMPTY
		&& _hashtable[index]._data.first != key)
	{
		index++;
		index %= _hashtable.size();
	}
	if (_hashtable[index]._state == EMPTY)
	{
		return -1;//找不到
	}
	else
	{
		return index;
	}
}

闭散列代码


/// <summary>
/// 1.线性探测,闭散列,线性探测
/// </summary>
namespace ljh0
{
	enum State
	{
		EMPTY,//空的
		EXIST,//存在
		DELETE//删除
	};

	//节点默认是空
	template<class K, class V>
	struct CloseHashNode
	{
	public:
		CloseHashNode(const std::pair<K, V>& data = std::pair<K, V>(), State state = EMPTY)
			: _data(data)
			, _state(state)
		{}

		std::pair<K, V> _data;
		State _state;
	};

	template<class K>
	struct MyHash
	{
		size_t operator()(const K& key)
		{
			return key;
		}
	};
	//采用的是线性探测,即往后依次寻找的方式
	template<class K, class V, class Hash = MyHash<K>>
	class CloseHash
	{
		typedef CloseHashNode<K, V> Node;
	private:
		vector<Node> _hashtable;
		size_t _size; // 有效元素,扩容要用
	public:
		CloseHash(size_t capacity = SIZE)
		{
			//一开始先开一点空间,这个空间在闭散列这里没有什么讲究,只需要将负载因子控制在0.7左右
			_hashtable.resize(capacity);
		}
		void CheckCapacity()
		{
			//需要将之前元素全部重新映射
			if (_size * 10 >= _hashtable.size() * 7)
			{
				//扩容
				size_t newCapacity = _hashtable.size() * 2;
				CloseHash tmp(newCapacity);
				for (size_t i = 0; i < _hashtable.size(); ++i)
				{
					if (_hashtable[i]._state == EXIST)
					{
						tmp.Insert(_hashtable[i]._data);
					}
				}
				std::swap(tmp._hashtable, _hashtable);
			}
		}
		bool Insert(const std::pair<K, V>& data)
		{
			//先检测一下容量
			CheckCapacity();
			//然后找到固定的位置进行操作
			size_t index = Hash()(data.first) % _hashtable.size();
			//键值不冗余才能插入
			while (_hashtable[index]._data.first != data.first && _hashtable[index]._state != EMPTY)
			{
				index++;
				index %= _hashtable.size();
			}
			if (_hashtable[index]._state == EMPTY)
			{
				//可以正常插入
				_hashtable[index] = Node(data, EXIST);
				_size++;
				return true;
			}
			else
			{
				return false;
			}
		}
		bool Delete(const K& key)
		{
			size_t index = Hash()(key) % _hashtable.size();
			while (!(_hashtable[index]._state != EMPTY && _hashtable[index]._data.first == key)
				&& _hashtable[index]._state != EMPTY)
			{
				//找到才能删除
				index++;
				index %= _hashtable.size();
			}
			//找到了或者没找到
			if (_hashtable[index]._state == EMPTY|| _hashtable[index]._state == DELETE)
			{
				return false;
			}
			else
			{
				_hashtable[index]._state = DELETE;
				_size--;
			}
			return true;
		}
		//找到了返回下标
		//失败了返回 整形最大值 
		size_t Find(const K& key)
		{
			size_t index = Hash()(key) % _hashtable.size();
			while (_hashtable[index]._state != EMPTY
				&& _hashtable[index]._data.first != key)
			{
				index++;
				index %= _hashtable.size();
			}
			if (_hashtable[index]._state == EMPTY)
			{
				return -1;//找不到
			}
			else
			{
				return index;
			}
		}
	};
}

1.2 二次探测


研究表明:当表的长度为质数且表装载因子a不超过0.5时,新的表项一定能够插入,而且任何一个位置都不会被探查两次。因此只要表中有一半的空位置,就不会存在表满的问题。在搜索时可以不考虑表装满的情况,但在插入时必须确保表的装载因子a不超过0.5,如果超出必须考虑增容。

所以二次探测的效率其实上是能比线性探测高上不少,但是他的浪费需要比线性探测更多,达到了50%。

实际上就是每次往后走i*i (i = 1,2,3....),或者往前走i*i(i=1,2,3...),也可以左右反复横跳。

从实现上,他的插入和删除,查找只需要更改的地方实际上也很少。

插入


在这里插入图片描述

bool Insert(const std::pair<K, V>& data)
{
	//先检测一下容量
	CheckCapacity();
	//然后找到固定的位置进行操作
	size_t index = Hash()(data.first) % _hashtable.size();
	int i = 1;
	while (_hashtable[index]._state != EMPTY)
	{
		index = ( index + i*i) % _hashtable.size();
		i++;
	}
	if (_hashtable[index]._state == EMPTY)
	{
		//可以正常插入
		_hashtable[index] = Node(data, EXIST);
		_size++;
		return true;
	}
	else
	{
		return false;
	}
}

删除


bool Delete(const K& key)
{
	size_t index = Hash()(key) % _hashtable.size();
	int i = 1;
	while (!(_hashtable[index]._state != EMPTY && _hashtable[index]._data.first == key)
		&& _hashtable[index]._state != EMPTY)
	{
		//找到才能删除
		index += i*i;
		i++;
		index %= _hashtable.size();
	}
	//找到了或者没找到
	if (_hashtable[index]._state == EMPTY)
	{
		return false;
	}
	else
	{
		_hashtable[index]._state = DELETE;
		_size--;
	}
	return true;
}

查找


//找到了返回下标
//失败了返回 整形最大值 
size_t Find(const K& key)
{
	size_t index = Hash()(key) %_hashtable.size();
	int i = 1;
	while (!(_hashtable[index]._state != EMPTY && _hashtable[index]._data.first == key) 
		&& _hashtable[index]._state != EMPTY)
	{
		index += i * i;
		i++;
		index %= _hashtable.size();
	}
	if (_hashtable[index]._state == EMPTY)
	{
		return -1;//找不到
	}
	else
	{
		return index;
	}
}



闭散列结束了,剩下的就是开散列啦!!!

二、(1)开散列


根据下图,我们可以大致知道T就是pair<const K,V>,随后的就是哈希方法,然后还有两个key值的比较函数,以及内存分配器(这个我们不关心)。

注意Hash函数针对的是key值,因为我们也是通过key值去定位对应桶的位置,而Pred也是关于key值的比较函数,因为key有可能是自定义类型,这个时候比较函数就需要我们自己写,或者重载operator=。
在这里插入图片描述
在这里插入图片描述


开散列概念
开散列法又叫链地址法(开链法),首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码,归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。
在这里插入图片描述
这种方式是如今最常用的,也是unordered_set/unordered_map的底层容器。

比起闭散列,开散列的优势:

  • 开散列的负载因子控制在1左右,相当于理想情况下每一个桶都只挂有一个节点。
  • 空间浪费小,由于哈希桶的每个元素只是单链表加值,比起闭散列每一个表项的值反而更加小了,空间的浪费小了,所以只要能让哈希冲突尽可能小,这种方式是很优秀的。

哈希桶的节点结构:


template<class T>
	struct LinkList
	{
		LinkList(struct LinkList* next = nullptr,const T& data = T())
			:_next(next)
			,_data(data)
		{}
		struct LinkList* _next;//单链表的next指针
		T _data;//他的一个元素
	};

插入


插入操作涉及到是否需要扩容,为防止键值冗余有key值的比较,而模板类型T可能是pair,可能是K。

  • 所以需要KOfT(获取pair中的Key)和Pred仿函数(两个key值的比较方式)进行两个key值的比较。
  • 同时插入的时候需要通过Hash函数获取到Key值并且散列到对应的位置(即或获取到一个整形)。

注意,虽然模板参数没有什么KeyOfT,但是我们实际编写代码的时候需要用到,所以我们可以将unordered_map的模板参数最后一个添加KeyOfT,并且可以给定一个固定的方法,不暴露给上层的同时又可以供给HashTanble使用。

std::pair<iterator, bool> Insert(const T& data)
{
	CheckCapacity();

	//哈希采用除留余数法
	size_t index = Hash()(KOfT()(data)) % _openhash.size();
	//先便利一遍单链表查找是否存在
	LinkList<T>* first = _openhash[index];
	LinkList<T>* tmp = first;
	while (tmp)
	{
		if (Pred()(KOfT()(tmp->_data), KOfT()(data)))
		{
			return std::make_pair(iterator(tmp, this), false);
		}
		tmp = tmp->_next;
	}
	LinkList<T>* newList = new LinkList<T>(first, data);
	/*	newList->_next = first;
		newList->_data = data;*/
	_openhash[index] = newList;
	_size++;

	return std::make_pair(iterator(newList, this), true);
}

查找


查找函数涉及key值的比较,但是我们实际上不知道模板参数T是什么类型,所以也不能直接进行比较,所以我们是通过KOfT和Pred,Hash函数进行两个的比较。

iterator Find(const K& key)
{
	size_t index = Hash()(key) % _openhash.size();
	if (!_openhash[index])
	{
		//end就表示没找到
		return end();
	}
	LinkList<T>* head = _openhash[index];
	const K& k = KOfT()(head->_data);
	/// <summary>
	/// 这里的Pred是进行key值的比较,也就是需要先从T获取key值,所以KOfT必不可少
	/// </summary>
	while (head && !Pred()(k, key))
	{
		head = head->_next;
	}
	if (!head)
		return end();

	return iterator(head, this);
}

删除


删除的时候要注意删除的位置可能是桶的头节点的位置,这个时候需要特判一下,把头结点的位置做转化。

size_t Erase(const K& key)
{
	size_t index = Hash()(key) % _openhash.size();
	if (!_openhash[index])
		return 0;
	LinkList<T>* cur = _openhash[index];
	LinkList<T>* pre = nullptr;
	while (cur)
	{
		if (Pred()(key, KOfT()(cur->_data)))
		{
			if (pre)
			{
				//删掉的不是桶的第一个位置
				pre->_next = cur->_next;
			}
			else
			{
				//删除的就是桶
				_openhash[index] = cur->_next;
			}
			delete cur;
			return 1;
		}
		else
		{
			pre = cur;
			cur = cur->_next;
		}
	}
	return 0;
}

扩容函数


注意:

  • 扩容之后要注意每个元素都需要重新进行散列。
  • 并且由于这里我们采用的是除留余数法,所以若对于每次开的数组的大小做限制,能够让每次散列的值尽可能的分散。

以下是 stl3.0 当中的定义的选取每次扩容过后的数组长度。

size_t GetNextPrime(size_t prime)
		{
			const int PRIMECOUNT = 28;
			static const size_t primeList[PRIMECOUNT] =
			{
				53ul, 97ul, 193ul, 389ul, 769ul,
				1543ul, 3079ul, 6151ul, 12289ul, 24593ul,
				49157ul, 98317ul, 196613ul, 393241ul, 786433ul,
				1572869ul, 3145739ul, 6291469ul, 12582917ul, 25165843ul,
				50331653ul, 100663319ul, 201326611ul, 402653189ul, 805306457ul,
				1610612741ul, 3221225473ul, 4294967291ul
			};

			size_t i = 0;
			for (; i < PRIMECOUNT; ++i)
			{
				if (primeList[i] > prime)
					return primeList[i];
			}

			return primeList[i];
		}
void CheckCapacity()
		{
			if (_openhash.size() <= _size)
			{
				//获取下一个数字。
				size_t newCapacity = GetNextPrime(_openhash.size());
				OpenHash tmp(newCapacity);
				//需要将所有的东西重新设置
				for (size_t i = 0; i < _openhash.size(); ++i)
				{
					if (_openhash[i])
					{
						struct LinkList<T>* cur = _openhash[i];
						while (cur)
						{
							tmp.Insert(cur->_data);
							cur = cur->_next;
						}
					}
				}
				std::swap(tmp._openhash, _openhash);
			}
		}

二、(2)迭代器实现


  • 由于迭代器封装了节点指针,节点若遍历到nullptr的时候不知道下一个桶,所以我们在HashTable的begin,end函数时应该将对象的指针也传递过来,从而我们就可以使用重新Hash,然后除留余数法找到下一个桶的位置。
  • 并且由于需要访问到HashTable当中的私有成员变量,所以需要设置OpenHashIterator为HashTable的友元函数。
	template<class K,class T,class Hash,class Pred>
	class OpenHashIterator
	{
		typedef LinkList<T> Node;
		typedef OpenHashIterator<K,T,Hash, Pred> Self;
	private:
		Node* _node;
		OpenHash<K,T,Hash, Pred>* _oh;//需要有原先的对象,这里的模板参数待定
	public:
		OpenHashIterator(Node* node, OpenHash<K, T, Hash,Pred>* oh)
			:_node(node)
			,_oh(oh)
		{}
		Self& operator++()
		{
			if (_node->_next)
			{
				_node = _node->_next;
				return *this;
			}
			//如果不在则需要找到下一个桶
			size_t capacity = _oh->_openhash.size();

			size_t index = Hash()(_node->_data) % capacity;
			int f_index = -1;
			for (size_t i = index+1; i < capacity; ++i)
			{
				if (_oh->_openhash[i])
				{
					f_index = i;
					break;
				}
			}
			if (f_index != -1)
			{
				_node = _oh->_openhash[f_index];
			}
			else
			{
				_node = nullptr;
			}
			return *this;
		}
		bool operator!=(const Self& s)
		{
			return _node != s._node;
		}
		T* operator->()
		{
			return &_node->_data;
		}
		T& operator*()
		{
			return _node->_data;
		}
	};

二、(3)封装unordered_map/unordered_set


unordered_map

其中我们可以定义哈希算法,特化字符串哈希算法,以及KOfT的实现

#pragma once
#include<iostream>
#include"HashTable.h"
#include<string>
using std::string;
namespace ljh
{
	template<class K>
	struct hash1
	{
		const K& operator()(const K& k)
		{
			return k;
		}
	};

	template<>
	struct hash1<string>
	{
		size_t operator()(const string& k)
		{
			char* c_str = (char*)k.c_str();
			unsigned int hash = 0;

			while (*c_str)
			{
				// equivalent to: hash = 65599*hash + (*str++);
				hash = (*c_str++) + (hash << 6) + (hash << 16) - hash;
			}

			return (hash & 0x7FFFFFFF);
		}
	};

	template<class K>
	struct pred
	{
		bool operator()(const K& k1, const K& k2)
		{
			return k1 == k2;
		}
	};

	template<class K,class V>
	struct MyKOfT
	{
		const K& operator()(const std::pair<const K, V>& kv)
		{
			return kv.first;
		}
	};
	template<class K,class V,class Hash = hash1<K>,class Pred = pred<K>>
	class unordered_map
	{		
	public:
		//这个KOfT不对上公开接口
		typedef typename OpenHash<K, std::pair<const K, V>, Hash,Pred,MyKOfT<K,V>>::iterator  iterator;
	private:
		OpenHash<K, std::pair<const K, V>, Hash,Pred ,MyKOfT<K, V>> _umap;
	public:
		iterator begin()
		{
			return _umap.begin();
		}
		iterator end()
		{
			return _umap.end();
		}
		std::pair<iterator, bool> insert(const std::pair<const K,V>& kv)
		{
			return _umap.Insert(kv);
		}
		size_t erase(const K& key)
		{
			return _umap.Erase(key);
		}
		V& operator[](const K& key)
		{
			
			//insert(make_pair(key, V()) -- > pair<iterator, bool>
			//(insert(make_pair(key, V())).first iterator
			//(insert(make_pair(key, V())).first)->second value type(因为里面封装的是一个pair<K,V>& data)
			///
			return (insert(std::make_pair(key, V())).first)->second;
		}
		iterator find(const K& key)
		{
			return _umap.Find(key);
		}
	};
}

unordered_set

#pragma once

#include<iostream>

#include"HashTable.h"
#include<string>
#include<vector>
using std::vector;
using std::string;

namespace ljh
{
	template<class K>
	struct myhash
	{
		size_t operator()(const K& key)
		{
			return key;
		}
	};
	template<>
	struct myhash<string>
	{
		size_t operator()(const string& key)
		{
			char* c_str = (char*)key.c_str();
			unsigned int hash = 0;

			while (*c_str)
			{
				// equivalent to: hash = 65599*hash + (*str++);
				hash = (*c_str++) + (hash << 6) + (hash << 16) - hash;
			}

			return (hash & 0x7FFFFFFF);
		}
	};
	template<class K>
	struct Pred
	{
		bool operator()(const K& k1,const K& k2)
		{
			return k1 == k2;
		}
	};
	template<class K,class Hash = myhash<K>,class Pred = Pred<K>>
	class unordered_set
	{
	private:
		struct MyKOfT
		{
			const K& operator()(const K& key)
			{
				return key;
			}
		};
		OpenHash<K,const K, Hash, Pred,MyKOfT> _uset;
	public:
		typedef typename OpenHash<K,const K, Hash, Pred, MyKOfT>::iterator iterator;
	public:
		iterator begin()
		{
			return _uset.begin();
		}
		iterator end()
		{
			return _uset.end();
		}
		pair<iterator,bool> insert(const K& key)
		{
			return _uset.Insert(key);
		}

		iterator find(const K& key)
		{
			return _uset.Find(key);
		}
	};
}

总结

gitee链接:
https://gitee.com/wuyi-ljh/test-43—testing/tree/master/MyHashTable

常见的字符串哈希算法

  • 喜欢就收藏
  • 认同就点赞
  • 支持就关注
  • 疑问就评论
  数据结构与算法 最新文章
【力扣106】 从中序与后续遍历序列构造二叉
leetcode 322 零钱兑换
哈希的应用:海量数据处理
动态规划|最短Hamilton路径
华为机试_HJ41 称砝码【中等】【menset】【
【C与数据结构】——寒假提高每日练习Day1
基础算法——堆排序
2023王道数据结构线性表--单链表课后习题部
LeetCode 之 反转链表的一部分
【题解】lintcode必刷50题<有效的括号序列
上一篇文章      下一篇文章      查看所有文章
加:2022-07-20 19:09:52  更:2022-07-20 19:11:51 
 
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁

360图书馆 购物 三丰科技 阅读网 日历 万年历 2024年11日历 -2024/11/25 23:52:36-

图片自动播放器
↓图片自动播放器↓
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
图片批量下载器
↓批量下载图片,美女图库↓
  网站联系: qq:121756557 email:121756557@qq.com  IT数码