当前位置:   article > 正文

Hash 哈希表

哈希表

一 <<什么是哈希表>>

我们学习了数据结构,其实都在做一件事情那就是   数据的结构    无论是学了啥?目的都是为了 存储数据  数据排序  查找数据

  • 线性结构有 顺序表 链表
  • 非线性的有  图       树       哈希

1.1 哈希表的定义

散列表(Hash table,也叫哈希表),是根据键(Key)而直接访问在内存存储位置的数据结构。也就是说,它通过计算一个关于键值的函数,将所需查询的数据映射到表中一个位置来访问记录,这加快了查找速度。这个映射函数称做散列函数,存放记录的数组称做散列表。

 remark: 通过某种映射关系  比方将你映射为 阿兰图灵  那么在这个名人散列表查找到图灵时就等于找到了你

通常无论是在  顺序表,链表,树,图,均是 迭代访问数据,即遍历(挨个挨个访问),效率通常比较慢,但是当来到了hash它就会变得非常快(一映射,根据映射的地方查找,很快就能找到)

 哈希表的几个概念

散列函数

比如说,我现在给你个电话本,上面记录的有姓名和对应的手机号,我想让你帮我找王二的手机号是多少,那么你会怎么做呢?

你可能会说,那挨个找呗。

确实可以,那么你有没有想过,如果这个王二是在最后几页,那你去岂不是前面几页都白找了,有没有更快的方式呢?

是不是可以按照人名给分个类,比如按照首字母来排序,就abcd那样的顺序,这样根据王二我就知道去找w这些,这样不久快很多了

我们可以按照人名的首字母去弄一个表格,比如像这样:
 

我们取姓名的首字母作为一个标志,就可以很快的找到以这个字母开头的人名了,那么王二也就能更快的被我们找到,我们也不用再费力气去找什么张二和李二的,因为人家的名字首字母都不是w。

这里我们用到了一种方法:那就是取姓名的首字母做一个排序,那么这是不是就是通过一些特定的方法去得到一个特定的值,比如这里取人名的首字母,那么如果是放到数学中,是不是就是类似一个函数似的,给你一个值,经过某些加工得到另外一个值,就像这里的给你个人名,经过些许加工我们拿到首字母,那么这个函数或者是这个方法在哈希表中就叫做散列函数

这种映射关系我们可能称之为  做白日梦 

remark:其实 十辈子 都不会成为阿兰这种天才,我们普通人所能做的是尽量去理解天才 

关键值key

就像画的这个图,阿兰  是怎么得出来得,是不是由我映射而来,这个映射过程其实就是个散列函数,而我就是整个散列体系的关键值

哈希表

所以说:哈希表就是通过将关键值也就是key通过一个散列函数加工处理之后得到一个值,这个值就是数据存放的位置,我们就可以根据这个值快速的找到我们想要的数据

1.2 哈希表的存储方式

之前我们已经知道了哈希表的本质其实是个数组,数组有啥特点?
——下表从0开始,连续的,直接通过下标访问

  •  键值对:有一个key和一个value对应着,比如图中的101011是键值key,对应value张三,学生的学号和姓名就是一个键值对
  • Entry:在java jdk里把键值对叫做Entry,C++则为pair
  • 在散列表中存储的是键值对

1.3 哈希表如何存数据

看上面的图,我们已经知道了哈希表本质是个数组,所以这里有个数组,长度是8,现在我们要做的是把这个学生信息存放到哈希表中,也就是这个数组中去,那我们需要考虑怎么去存放呢?

这里的学号是个key,我们之前也知道了,哈希表就是根据key值来通过哈希函数计算得到一个值,这个值就是用来确定这个pair要存放在哈希表中的位置的,实际上这个值就是一个下标值,来确定放在数组的哪个位置上。

比如这里的学号是101011,那么经过哈希函数的计算之后得到了1,这个1就是告诉我们应该把这个pair放到哪个位置,这个1就是数组的确切位置的下标,也就是需要放在数组中下表为1的位置,如图中所示。

我们之前已经介绍过什么是pair了,所以这里你要知道,数组中1的位置存放的是一个pair,它不是一个简单的单个数值,而是一个键值对,也就是存放了key和value,key就是学号101011,value就是张三,我们经过哈希函数计算得出的1只是为了确定这个pair该放在哪个位置而已。

现在我们就成功把这个pair放到了哈希表中了

二 <<常见的哈希函数>>  

//有好多方法

除留余数法

散列表中允许的地址数为m,取一个不大于m,但最接近或者等于m的质数p作为除数, 按照哈希函数:Hash(key) = key%p(p<=m),将关键码转换成哈希地址

哈希冲突 

不同Key经过哈希函数映射,得到相同的散列地址,这时便产生了冲突,你说你是阿兰,那我还说我是图灵呢!

通常我们有  两种方法解决哈希冲突

                             1.链地址法  2.线性探测法 

 开散列

开散列法又叫链地址法(开链法),首先对关键码集合用散列函数计算散列地址,具有相同地 址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。

 为啥要进行头插,因为若是尾插我们先要通过遍历找到尾部节点,  O(n)浪费时间,     头插O(1) 

  1. #include<iostream>
  2. #include<vector>
  3. using namespace std;
  4. template<class Type>
  5. class HashTable;
  6. template<class Type>
  7. class HashNode
  8. {
  9. friend class HashTable<Type>;
  10. public:
  11. HashNode(Type d = Type(), HashNode<Type>* n = nullptr) :data(d), next(n)
  12. {}
  13. ~HashNode()
  14. {}
  15. private:
  16. Type data;
  17. HashNode* next;
  18. };
  19. template<class Type>
  20. class HashTable
  21. {
  22. public:
  23. HashTable()
  24. {
  25. memset(m_ht, 0, sizeof(m_ht));
  26. }
  27. HashNode<Type>* Find(const Type& Key)
  28. {
  29. size_t idx = Hash(Key);//idx就是在顺序表的下表位置
  30. HashNode<Type>* p = m_ht[idx];//对数组所悬挂的链表进行遍历
  31. while (p != nullptr && p->data != Key)//两种逻辑找了一圈没找到返回的p就是nullptr, 找到了就把p这个节点返回了
  32. p = p->next;
  33. return p;
  34. }
  35. void Insert(const Type& Key)
  36. {
  37. size_t idx = Hash(Key);
  38. HashNode<Type>* Node = new HashNode<Type>(Key);//产生节点
  39. Node->next = m_ht[idx];//对节点进行头插
  40. m_ht[idx] = Node;
  41. }
  42. void Remove(const Type& Key)
  43. {
  44. size_t idx = Hash(Key);
  45. HashNode<Type>* p = m_ht[idx];
  46. /* 第一种情况 : 删除的节点 不存在 即哈希表上啥也没挂 Key也不存在
  47. * 第二种情况 : 删除的节点 存在 是哈希表的头节点
  48. * 第三种情况 : 哈希表不空 遍历链表要删除的节点不存在
  49. * 第四种情况 : 删除的节点 存在
  50. *
  51. */
  52. if (p == nullptr)
  53. return ;
  54. if (p->data == Key)
  55. m_ht[idx] = m_ht[idx]->next;
  56. else{
  57. while (p != nullptr && p->next->data != Key)//找的是删除节点的pre节点
  58. p = p->next;
  59. if (p == nullptr)
  60. return;
  61. HashNode<Type>* pre = p;
  62. p = p->next;
  63. pre->next = p->next;
  64. }
  65. delete p;
  66. }
  67. void Show()const
  68. {
  69. int i;
  70. cout << "Hash" << endl;
  71. for (i = 0; i < HASH_TABLE_SIZE; ++i)
  72. {
  73. cout << i << " :";
  74. HashNode<Type>* p = m_ht[i];
  75. while (p!= nullptr)
  76. {
  77. cout << p->data << "->";
  78. p = p->next;
  79. }
  80. cout << "NIL."<<endl;
  81. }
  82. }
  83. protected:
  84. size_t Hash(const Type& Key)
  85. {
  86. return Key % HASH_TABLE_SIZE;//除留余数法
  87. }
  88. enum {HASH_TABLE_SIZE=7};
  89. private:
  90. HashNode<Type>* m_ht[HASH_TABLE_SIZE];//这是个指针数组里面存储的元素是HashNode*类型
  91. };
  92. void main()
  93. {
  94. int ar[] = { 1, 9, 10, 8, 22, 20 };
  95. int n = sizeof(ar) / sizeof(ar[0]);
  96. HashTable<int> ht;
  97. for (int i = 0; i < n; ++i)
  98. {
  99. ht.Insert(ar[i]);
  100. }
  101. ht.Remove(8);
  102. ht.Show();
  103. }

闭散列 

闭散列:也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有 空位置,那么可以把key存放到冲突位置中的“下一个” 空位置中去。那如何寻找下一个空位置呢?

  1. #include<iostream>
  2. #include<vector>
  3. using namespace std;
  4. enum State { EMPTY, EXIST, DELETE };
  5. template<class K, class V>
  6. class HashTable
  7. {
  8. struct Elem
  9. {
  10. pair<K, V> _val;
  11. State _state;
  12. };
  13. public:
  14. HashTable(size_t sz):m_ht(sz), m_size(0)
  15. {
  16. for(int i=0; i<sz; ++i)
  17. {
  18. m_ht[i]._state = EMPTY;
  19. }
  20. }
  21. public:
  22. void Insert(const pair<K,V> &val)
  23. {
  24. size_t hash_idx = Hash(val);
  25. size_t origin_idx = hash_idx;
  26. CheckCapacity();
  27. while(m_ht[hash_idx]._state == EXIST)
  28. {
  29. hash_idx = (hash_idx+1) % m_ht.capacity(); //空间循环
  30. if(hash_idx == origin_idx)
  31. return;
  32. }
  33. Elem e = {val, EXIST};
  34. m_ht[hash_idx] = e;
  35. m_size++;
  36. }
  37. int Find(const pair<K,V> &key)
  38. {
  39. size_t hash_idx = Hash(key);
  40. size_t origin_idx = hash_idx;
  41. while(m_ht[hash_idx]._state==EXIST && key!=m_ht[hash_idx]._val)
  42. {
  43. hash_idx = (hash_idx+1) % m_ht.capacity(); //空间循环
  44. if(hash_idx == origin_idx)
  45. return -1;
  46. }
  47. if(m_ht[hash_idx]._state == EXIST)
  48. return hash_idx;
  49. return -1;
  50. }
  51. void Remove(const pair<K,V> &key)
  52. {
  53. int hash_idx = Find(key);
  54. if(hash_idx != -1)
  55. {
  56. m_ht[hash_idx]._state = DELETE; //标记删除法
  57. m_size--;
  58. }
  59. }
  60. int GetNextPrime(int cur_prime)
  61. {
  62. static int prime_table[] = {7, 13, 19, 23, 29, 43, 53, 93, 103};
  63. int n = sizeof(prime_table) / sizeof(prime_table[0]);
  64. int i;
  65. for(i=0; i<n; ++i)
  66. {
  67. if(cur_prime == prime_table[i])
  68. break;
  69. }
  70. return i<n ? prime_table[i+1] : prime_table[n-1];
  71. }
  72. void CheckCapacity()
  73. {
  74. if (m_size * 10 / m_ht.capacity() >= 7) // 0.7
  75. {
  76. HashTable<K, V> new_ht(GetNextPrime(m_ht.capacity()));
  77. for (size_t i = 0; i < m_ht.capacity(); ++i)
  78. {
  79. if (m_ht[i]._state == EXIST)
  80. new_ht.Insert(m_ht[i]._val);
  81. }
  82. m_ht.swap(new_ht.m_ht);
  83. }
  84. }
  85. protected:
  86. size_t Hash(const pair<K,V> &val)
  87. {
  88. return val.first % m_ht.capacity();
  89. }
  90. private:
  91. vector<Elem> m_ht;
  92. size_t m_size;
  93. };
  94. void main()
  95. {
  96. int ar[] = {1, 9, 4, 10, 8, 22, 20, 15};
  97. int n = sizeof(ar) / sizeof(ar[0]);
  98. HashTable<int,int> ht(7);
  99. for(int i=0; i<n; ++i)
  100. {
  101. pair<int,int> v = make_pair(ar[i],ar[i]);
  102. ht.Insert(v);
  103. }
  104. int idx = ht.Find(make_pair(22,22));
  105. ht.Remove(make_pair(22,22));
  106. idx = ht.Find(make_pair(22,22));
  107. }

哈希桶

解决冲突的方法选了链地址法的哈希表

 

 伴随着冲突的不断加剧,虽然链地址法可以无限解决冲突,但是挂的节点越多,当目标节点在链表尾部时,我们寻找这个元素需要遍历整个链表,

寻找效率极其低下,因此我们可以对哈希表进行扩容,这样它所悬挂的节点会分散开,以达到高效查找的目的

  1. #include<iostream>
  2. using namespace std;
  3. template<class Type>
  4. class HashTable;
  5. const size_t primeList[] = { 7, 13, 19, 23 };
  6. size_t GetNextPrime(size_t prime)
  7. {
  8. for (int i = 0; i < 4; ++i)
  9. {
  10. if (primeList[i] > prime)
  11. return primeList[i];
  12. }
  13. return primeList[3];
  14. }
  15. template<class Type>
  16. class HashNode
  17. {
  18. friend class HashTable<Type>;
  19. public:
  20. HashNode(Type d = Type(), HashNode<Type>* n = NULL) :data(d), next(n)
  21. {}
  22. ~HashNode()
  23. {}
  24. private:
  25. Type data;
  26. HashNode* next;
  27. };
  28. template<class Type>
  29. class HashTable
  30. {
  31. public:
  32. HashTable() :m_size(0)
  33. {
  34. m_ht = new HashNode<Type>*[DEFAULT_HASH_TABLE_SIZE];
  35. memset(m_ht, 0, sizeof(HashNode<Type>*) * DEFAULT_HASH_TABLE_SIZE);
  36. m_bucket_count = DEFAULT_HASH_TABLE_SIZE;
  37. }
  38. public:
  39. HashNode<Type>* Find(const Type& key)
  40. {
  41. size_t idx = Hash(key);
  42. HashNode<Type>* p = m_ht[idx];
  43. while (p != NULL && key != p->data)
  44. p = p->next;
  45. return p;
  46. }
  47. void Insert(const Type& v)
  48. {
  49. m_size++;
  50. CheckCapacity();
  51. //hash地址
  52. size_t idx = Hash(v);
  53. //插入数据
  54. HashNode<Type>* node = new HashNode<Type>(v);
  55. node->next = m_ht[idx];
  56. m_ht[idx] = node;
  57. }
  58. void Remove(const Type& key)
  59. {
  60. //查找
  61. size_t idx = Hash(key);
  62. HashNode<Type>* p = m_ht[idx];
  63. if (p == NULL)
  64. return;
  65. if (key == m_ht[idx]->data)
  66. {
  67. m_ht[idx] = p->next;
  68. }
  69. else
  70. {
  71. while (p != NULL && p->next->data != key)
  72. p = p->next;
  73. if (p == NULL)
  74. return;
  75. HashNode<Type>* prev = p;
  76. p = p->next;
  77. prev->next = p->next;
  78. }
  79. //删除
  80. delete p;
  81. }
  82. void Show()const
  83. {
  84. for (int i = 0; i < m_bucket_count; ++i)
  85. {
  86. cout << i << " : ";
  87. HashNode<Type>* p = m_ht[i];
  88. while (p != NULL)
  89. {
  90. cout << p->data << "-->";
  91. p = p->next;
  92. }
  93. cout << "Nil." << endl;
  94. }
  95. }
  96. protected:
  97. size_t Hash(const Type& key)
  98. {
  99. //除留余数法
  100. return key % m_bucket_count;
  101. }
  102. protected:
  103. void CheckCapacity()
  104. {
  105. if (m_size > m_bucket_count)
  106. {
  107. //扩容
  108. size_t new_bucket_count = GetNextPrime(m_bucket_count);
  109. HashNode<Type>** new_ht = new HashNode<Type>*[new_bucket_count];
  110. memset(new_ht, 0, sizeof(HashNode<Type>*) * new_bucket_count);
  111. for (int i = 0; i < m_bucket_count; ++i)
  112. {
  113. HashNode<Type>* p = m_ht[i];
  114. while (p != NULL)
  115. {
  116. m_ht[i] = p->next;
  117. size_t hash_index = p->data % new_bucket_count;
  118. p->next = new_ht[hash_index];
  119. new_ht[hash_index] = p;
  120. p = m_ht[i];
  121. }
  122. }
  123. delete[]m_ht;
  124. m_ht = new_ht;
  125. m_bucket_count = new_bucket_count;
  126. }
  127. }
  128. protected:
  129. enum { DEFAULT_HASH_TABLE_SIZE = 7 };
  130. private:
  131. HashNode<Type>** m_ht;
  132. size_t m_bucket_count;//哈希表长
  133. size_t m_size;//已经挂载的节点个数
  134. };
  135. void main()
  136. {
  137. int ar[] = { 1, 9, 10, 8, 22, 20, 43, 32, 21, 4, 6, 76, 8, 9, 56, 54, 48, 25, 5 };
  138. //int ar[] = {1, 9, 10, 8, 22, 20, 43, 32};
  139. int n = sizeof(ar) / sizeof(ar[0]);
  140. HashTable<int> ht;
  141. for (int i = 0; i < n; ++i)
  142. {
  143. ht.Insert(ar[i]);
  144. }
  145. //ht.Remove(8);
  146. ht.Show();
  147. //HashNode<int> *p = ht.Find(80);
  148. }

哈希的应用 ---布隆过滤器

我们在使用新闻客户端看新闻时,它会给我们不停地推荐新的内容,它每次推荐时要去重,去掉 那些已经看过的内容。问题来了,新闻客户端推荐系统如何实现推送去重的? 用服务器记录了用 户看过的所有历史记录,当推荐系统推荐新闻时会从每个用户的历史记录里进行筛选,过滤掉那 些已经存在的记录。 如何快速查找呢?

  • 用哈希表存储用户记录,缺点:浪费空间

  • 用位图存储用户记录,缺点:位图一般只能处理整形,如果内容编号是字符串,就无法处理 了。

  • 将哈希与位图结合,即布隆过滤器

布隆过滤器概念

布隆过滤器是由布隆(Burton Howard Bloom)在1970年提出的 一种紧凑型的、比较巧妙的概 率型数据结构,特点是高效地插入和查询,可以用来告诉你 “某样东西一定不存在或者可能存 在”,它是用多个哈希函数,将一个数据映射到位图结构中。此种方式不仅可以提升查询效率,也 可以节省大量的内存空间。

布隆过滤器的插入

布隆过滤器通过多个哈希函数将我们期望“Key ”  映射到位图中,高效且节省空间

 布隆过滤器的查找

布隆过滤器的思想是将一个元素用多个哈希函数映射到一个位图中,因此被映射到的位置的比特 位一定为1。所以可以按照以下方式进行查找:分别计算每个哈希值对应的比特位置存储的是否为 零,只要有一个为零,代表该元素一定不在哈希表中,否则可能在哈希表中。

attention:布隆过滤器如果说某个元素不存在时,该元素一定不存在,如果该元素存在时,该元素可 能存在,因为有些哈希函数存在一定的误判。

比如:在布隆过滤器中查找"alibaba"时,假设3个哈希函数计算的哈希值为:1、3、7,刚好和其 他元素的比特位重叠,此时布隆过滤器告诉该元素存在,但实该元素是不存在的。

布隆过滤器删除

布隆过滤器不能直接支持删除工作,因为在删除一个元素时,可能会影响其他元素。【上图若你将baidu 删除 ,那么tencent也将无法找到(缺少7位置的1)】

一种支持删除的方法:将布隆过滤器中的每个比特位扩展成一个小的计数器,插入元素时给k个计 数器(k个哈希函数计算出的哈希地址)加一,删除元素时,给k个计数器减一,通过多占用几倍存储 空间的代价来增加删除操作。

  • 缺陷: 1.
  • 无法确认元素是否真正在布隆过滤器中
  •  存在计数回绕

对于哈希函数的取法,是数学领域所擅长的,哈希函数取得好,我们的Key便极具敏感性,Key稍微发生改变,Key的映射便产生极大的变化

 

  1. #include<iostream>
  2. #include<string>
  3. #include<bitset>
  4. #include<assert.h>
  5. using namespace std;
  6. #define _N 10 //误判率
  7. template<class T>
  8. struct Hashfunc1
  9. {
  10. size_t operator()(const T& key)
  11. {
  12. return BKDRHash(key.c_str());
  13. }
  14. size_t BKDRHash(const char* str)
  15. {
  16. register size_t hash = 0;
  17. while (size_t ch = (size_t)*str++)
  18. {
  19. hash = hash * 131 + ch;
  20. }
  21. return hash;
  22. }
  23. };
  24. template<class T>
  25. struct Hashfunc2
  26. {
  27. size_t operator()(const T& key)
  28. {
  29. return SDBMHash(key.c_str());
  30. }
  31. size_t SDBMHash(const char* str)
  32. {
  33. register size_t hash = 0;
  34. while (size_t ch = (size_t)*str++)
  35. {
  36. hash = 65599 * hash + ch;
  37. }
  38. return hash;
  39. }
  40. };
  41. template<class T>
  42. struct Hashfunc3
  43. {
  44. size_t operator()(const T& key)
  45. {
  46. return RSHash(key.c_str());
  47. }
  48. size_t RSHash(const char* str)
  49. {
  50. register size_t hash = 0;
  51. size_t magic = 63689;
  52. while (size_t ch = (size_t)*str++)
  53. {
  54. hash = hash * magic + ch;
  55. magic *= 378551;
  56. }
  57. return hash;
  58. }
  59. };
  60. template<class T, class KeyToInt1 = Hashfunc1<T>,class KeyToInt2 = Hashfunc2<T>,class KeyToInt3 = Hashfunc3<T>>
  61. class BloomFilter
  62. {
  63. public:
  64. BloomFilter() :_size(0)
  65. {}
  66. public:
  67. void Insert(const T& v)
  68. {
  69. size_t idx1 = KeyToInt1()(v) % _N;
  70. _bitmap.set(idx1);
  71. size_t idx2 = KeyToInt2()(v) % _N;
  72. _bitmap.set(idx2);
  73. size_t idx3 = KeyToInt3()(v) % _N;
  74. _bitmap.set(idx3);
  75. _size++;
  76. }
  77. bool Test(const T& key)const
  78. {
  79. size_t idx1 = KeyToInt1()(key) % _N;
  80. if (_bitmap.test(idx1) == 0)
  81. return false;
  82. size_t idx2 = KeyToInt2()(key) % _N;
  83. if (_bitmap.test(idx2) == 0)
  84. return false;
  85. size_t idx3 = KeyToInt3()(key) % _N;
  86. if (_bitmap.test(idx3) == 0)
  87. return false;
  88. return true; //可能存在,有可能存在误判
  89. }
  90. private:
  91. bitset<_N> _bitmap; //位图
  92. size_t _size;
  93. };
  94. void main()
  95. {
  96. const string url1 = "www.baidu.com";
  97. const string url2 = "www.taobao.com";
  98. const string url3 = "www.jingdong.com";
  99. const string url4 = "www.pinduoduo.com";
  100. const string url5 = "www.qq.com";
  101. const string url6 = "www.weixin.com";
  102. BloomFilter<string> bf;
  103. bf.Insert(url1);
  104. bf.Insert(url2);
  105. //bf.Insert(url3);
  106. //bf.Insert(url4);
  107. //bf.Insert(url5);
  108. cout << bf.Test(url2) << endl;
  109. }

 布隆过滤器优点

  •  增加和查询元素的时间复杂度为:O(K), (K为哈希函数的个数,一般比较小),与数据量大小无 关
  • 哈希函数相互之间没有关系,方便硬件并行运算
  • 布隆过滤器不需要存储元素本身,在某些对保密要求比较严格的场合有很大优势  
  • 在能够承受一定的误判时,布隆过滤器比其他数据结构有这很大的空间优势  
  • 数据量很大时,布隆过滤器可以表示全集,其他数据结构不能
  • 使用同一组散列函数的布隆过滤器可以进行交、并、差运算

布隆过滤器缺陷

  • 有误判率,即存在假阳性(False Position),即不能准确判断元素是否在集合中(补救方法:再建立一个白名单,存储可能会误判的数据)
  • 不能获取元素本身
  • 一般情况下不能从布隆过滤器中删除元素
  • 如果采用计数方式删除,可能会存在计数回绕问题
声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/不正经/article/detail/405188
推荐阅读
相关标签
  

闽ICP备14008679号