• 数据结构——哈希


    目录

    1.什么是哈希?

    2.哈希冲突

    3.哈希冲突解决方法

    ①闭散列

    1.原理说明

    2.代码实现

    3.优缺点分析

    4.二次探测

    ②开散列

    1.原理说明

    2.代码实现

    ③闭散列与开散列的比较

    4.哈希的应用

    ①位图

    ②布隆过滤器

    1.布隆过滤器概念

    2.布隆过滤器的模拟实现

    3.布隆过滤器的优缺点

    ③海量数据处理

    1.哈希切割

    2.位图应用

    3.布隆过滤器


    1.什么是哈希?

    在顺序结构以及平衡树中,元素关键码与其存储位置之间没有对应的关系,因此在查找一个元素时,必须要经过关键码的多次比较。顺序查找时间复杂度为O(N),平衡树中为树的高度,即O(logN),搜索的效率取决于搜索过程中元素的比较次数。
    那么我们理想的搜索方法是:可以不经过任何比较,一次直接从表中得到要搜索的元素。
    如果构造一种存储结构,通过某种函数(hashFunc)使元素的存储位置与它的关键码之间能够建立一一映射的关系,那么在查找时通过该函数可以很快找到该元素。

    当向该结构中:

    1. 插入元素
    根据待插入元素的关键码,以此函数计算出该元素的存储位置并按此位置进行存放
    2. 搜索元素
    对元素的关键码进行同样的计算,把求得的函数值当做元素的存储位置,在结构中按此位置取元素比较,若关键码相等,则搜索成功

    这种方式即为哈希(散列)方法,哈希方法中使用的转换函数称为哈希(散列)函数,构造出来的结构称为哈希表(Hash Table)(或者称散列表

    举个例子

    2.哈希冲突

    虽然上面的方法确实很快,但是当新插入的元素为44时就会出现一个问题:新的数据放哪?

    对于两个数据元素的关键字Ki和Kj(i != j),有Ki != Kj,但有:Hash(Ki) == Hash(Kj),即:不同关键字通过相同哈希函数计算出相同的哈希地址,该种现象称为哈希冲突或哈希碰撞。
    我们把具有不同关键码而具有相同哈希地址的数据元素称为“同义词”。

    3.哈希冲突解决方法

    那既然有了哈希冲突,我们如何来解决它呢?

    一般来说,解决哈希冲突的方法有两种,分别是闭散列开散列

    ①闭散列

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

    这里我们以上面的图片为例,这里有两种寻找方法
    第一种是线性探测,即从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止。

    1.原理说明

    现在需要插入元素44,先通过哈希函数计算哈希地址为4,因此44理论上应该插在该位置,但是该位置已经放了值为4的元素,即发生哈希冲突。

    2.代码实现

    1. // 与开散列作区别(闭散列又叫开放寻址法)
    2. namespace open_address
    3. {
    4. // 在这里使用枚举来给哈希表每个空间标记
    5. enum State
    6. {
    7. EMPTY, // EMPTY此位置空,
    8. EXIST, // EXIST此位置已经有元素
    9. DELETE // DELETE元素已经删除
    10. };
    11. template<class K, class V>
    12. struct HashData
    13. {
    14. pair _kv;
    15. State _state = EMPTY;
    16. };
    17. template<class K>
    18. struct DefaultHashFunc
    19. {
    20. size_t operator()(const K& key)
    21. {
    22. return (size_t)key;
    23. }
    24. };
    25. // 这里可以使用偏特化对string的hashi进行处理
    26. template<>
    27. // BKDRHashFunc
    28. struct DefaultHashFunc
    29. {
    30. size_t operator()(const string& str)
    31. {
    32. size_t hash = 0;
    33. for (auto ch : str)
    34. {
    35. hash *= 131;
    36. hash += ch;
    37. }
    38. return hash;
    39. }
    40. };
    41. // 这里的HashFunc是当传入的K不为数字类型时,将其转换为对应的数字
    42. template<class K, class V, class HashFunc = DefaultHashFunc>
    43. class HashTable
    44. {
    45. public:
    46. HashTable()
    47. {
    48. _table.resize(10);
    49. }
    50. bool Insert(const pair& kv)
    51. {
    52. HashFunc hf;
    53. // 当空间占有率大小>0.7时就扩容
    54. if (_n*10 / _table.size() >= 7)
    55. {
    56. size_t newsize = _table.size() * 2;
    57. // 在扩容之后需要将哈希表中的元素对应关系重新映射
    58. HashTable newHT;
    59. newHT._table.resize(newsize);
    60. for (size_t i = 0; i < _table.size(); i++)
    61. {
    62. if (_table[i]._state == EXIST)
    63. {
    64. newHT.Insert(_table[i]._kv);
    65. }
    66. }
    67. _table.swap(newHT._table);
    68. }
    69. size_t hashi = hf(kv.first) % _table.size();
    70. while (_table[hashi]._state == EXIST)
    71. {
    72. ++hashi;
    73. hashi %= _table.size();
    74. }
    75. // 到这时,状态要么是DELETE要么是EMPTY
    76. // 可以直接插入
    77. _table[hashi]._kv = kv;
    78. _table[hashi]._state = EXIST;
    79. ++_n;
    80. return true;
    81. }
    82. HashData<const K, V>* Find(const K& key)
    83. {
    84. HashFunc hf;
    85. size_t hashi = hf(key) % _table.size();
    86. // 当对应位置不为空时才进入查找
    87. while (_table[hashi]._state != EMPTY)
    88. {
    89. if (_table[hashi]._state == EXIST
    90. && _table[hashi]._kv.first == key)
    91. {
    92. // 需要手动强转类型,因为不支持默认的自动类型转换
    93. return (HashData<const K, V>*) & _table[hashi];
    94. }
    95. ++hashi;
    96. hashi %= _table.size();
    97. }
    98. return nullptr;
    99. }
    100. // 这里的删除应该只是改变元素状态
    101. bool Erase(const K& key)
    102. {
    103. HashData<const K, V>* ret = Find(key);
    104. if (ret != nullptr)
    105. {
    106. ret->_state = DELETE;
    107. --_n;
    108. return true;
    109. }
    110. return false;
    111. }
    112. private:
    113. vector> _table;
    114. size_t _n = 0; // 存储有效数据的个数
    115. };
    116. }

    3.优缺点分析

    线性探测优点:实现非常简单,
    线性探测缺点:一旦发生哈希冲突,所有的冲突连在一起,容易产生数据“堆积”,即:不同关键码占据了可利用的空位置,使得寻找某关键码的位置需要许多次比较,导致搜索效率降低。如何缓解呢?

    4.二次探测

    线性探测的缺点是会导致冲突的数据集中在一起,这与它寻找下一个空位置的方式有关,因为它是按照顺序逐一查找的。为了避免这个问题,二次探测采用了一种不同的方法来找到下一个空位置,即:Hi​ = (H0​ + i^2 )% m 或 Hi​ = (H0​ - i^2 )% m。其中,i = 1,2,3…,H0​ 是通过散列函数 Hash(x) 对元素的关键码 key 进行计算得到的位置,m 是表的大小。

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

    因此,我们可以知道闭散列最大的缺陷就是空间利用率比较低,这也是哈希的缺陷。 

    ②开散列

    1.原理说明

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

    从上图可以看出,开散列中每个桶中放的都是发生哈希冲突的元素。

    2.代码实现

    1. // 开散列
    2. namespace hash_bucket
    3. {
    4. template<class K, class V>
    5. struct HashNode
    6. {
    7. pair _kv;
    8. HashNode* _next;
    9. HashNode(const pair& kv)
    10. :_kv(kv)
    11. ,_next(nullptr)
    12. {}
    13. };
    14. template<class K>
    15. struct DefaultHashFunc
    16. {
    17. size_t operator()(const K& key)
    18. {
    19. return (size_t)key;
    20. }
    21. };
    22. // 这里可以使用偏特化对string的hashi进行处理
    23. template<>
    24. // BKDRHashFunc
    25. struct DefaultHashFunc
    26. {
    27. size_t operator()(const string& str)
    28. {
    29. size_t hash = 0;
    30. for (auto ch : str)
    31. {
    32. hash *= 131;
    33. hash += ch;
    34. }
    35. return hash;
    36. }
    37. };
    38. // 这里的HashFunc是当传入的K不为数字类型时,将其转换为对应的数字
    39. template<class K, class V, class HashFunc = DefaultHashFunc>
    40. class HashTable
    41. {
    42. typedef HashNode Node;
    43. public:
    44. HashTable()
    45. {
    46. _table.resize(10, nullptr);
    47. }
    48. ~HashTable()
    49. {
    50. for (size_t i = 0; i < _table.size(); i++)
    51. {
    52. Node* cur = _table[i];
    53. while (cur)
    54. {
    55. Node* next = cur->_next;
    56. delete cur;
    57. cur = next;
    58. }
    59. _table[i] = nullptr;
    60. }
    61. }
    62. bool Insert(const pair& kv)
    63. {
    64. // 如果已经存在相同值就不再插入
    65. if (Find(kv.first))
    66. {
    67. return false;
    68. }
    69. HashFunc hf;
    70. // 桶的个数是一定的,随着元素的不断插入,每个桶中元素的个数不断增多,
    71. // 极端情况下,可能会导致一个桶中链表节点非常多,会影响的哈希表的性能
    72. // 因此在一定条件下需要对哈希表进行增容,最好的情况是:
    73. // 每个哈希桶中刚好挂一个节点,再继续插入元素时,每一次都会发生哈希冲突,
    74. // 因此,在元素个数刚好等于桶的个数时,可以给哈希表增容。
    75. if (_n == _table.size())
    76. {
    77. size_t newsize = _table.size() * 2;
    78. vector newtable(newsize, nullptr);
    79. size_t i = 0;
    80. for (i = 0; i < _table.size(); i++)
    81. {
    82. Node* cur = _table[i];
    83. // 将一个节点的全部桶重新挂到新的哈希表中
    84. while (cur)
    85. {
    86. Node* next = cur->_next;
    87. size_t hashi = hf(cur->_kv.first) % newsize;
    88. // 在hashi处头插
    89. cur->_next = newtable[hashi];
    90. newtable[hashi] = cur;
    91. cur = next;
    92. }
    93. }
    94. }
    95. size_t hashi = hf(kv.first) % _table.size();
    96. Node* newnode = new Node(kv);
    97. newnode->_next = _table[hashi];
    98. _table[hashi] = newnode;
    99. ++_n;
    100. return false;
    101. }
    102. Node* Find(const K& key)
    103. {
    104. HashFunc hf;
    105. size_t hashi = hf(key) % _table.size();
    106. Node* cur = _table[hashi];
    107. while (cur)
    108. {
    109. if (cur->_kv.first == key)
    110. {
    111. return cur;
    112. }
    113. cur = cur->_next;
    114. }
    115. return nullptr;
    116. }
    117. bool Erase(const K& key)
    118. {
    119. HashFunc hf;
    120. // 删除某一个节点时需要上一个节点的信息
    121. Node* prev = nullptr;
    122. size_t hashi = hf(key) % _table.size();
    123. Node* cur = _table[hashi];
    124. while (cur)
    125. {
    126. if (cur->_kv.first == key)
    127. {
    128. if (cur == _table[hashi])
    129. {
    130. _table[hashi] = cur->_next;
    131. }
    132. else
    133. {
    134. prev->_next = cur->_next;
    135. }
    136. delete cur;
    137. --_n;
    138. return true;
    139. }
    140. prev = cur;
    141. cur = cur->_next;
    142. }
    143. return false;
    144. }
    145. void Print()
    146. {
    147. for (size_t i = 0; i < _table.size(); i++)
    148. {
    149. printf("[%d]->", i);
    150. Node* cur = _table[i];
    151. while (cur)
    152. {
    153. cout << cur->_kv.first << ":" << cur->_kv.second << "->";
    154. cur = cur->_next;
    155. }
    156. printf("NULL\n");
    157. }
    158. cout << endl;
    159. }
    160. private:
    161. vector _table; // 指针数组
    162. size_t _n = 0; // 存储了多少个有效数据
    163. };
    164. }

    ③闭散列与开散列的比较

    闭散列和开散列在处理哈希冲突时各有优缺点。

    在存储效率上,闭散列采用顺序表存储,存储效率较高。而开散列采用单链表存储方式,因为附加了指针域,空间开销相对较大;在冲突解决方式上,闭散列方法是在哈希表中寻找下一个空闲位置来解决冲突,因此容易产生堆积,查找不易实现,可能需要二次再查找。而开散列方法则是将冲突的关键码存储在另一个数据结构中,避免了堆积现象,查找相对容易。

    综上所述,闭散列和开散列在存储效率和冲突解决方式上存在差异,具体选择哪种方案需要根据实际情况来决定。

    4.哈希的应用

    ①位图

    对于下面这个问题

    给40亿个不重复的无符号整数,没排过序。给一个无符号整数,如何快速判断一个数是否在这40亿个数中。

    对此,我们有三种解决办法

    1. 遍历,时间复杂度O(N)
    2. 排序(O(NlogN)),利用二分查找: logN
    3. 位图解决
    数据是否在给定的整形数据中,结果是在或者不在,刚好是两种状态,那么可以使用一个二进制比特位来代表数据是否存在的信息,如果二进制比特位为1,代表存在,为0代表不存在。 

    对于前面两种方法,在数据量达到40亿时,若要将其储存起来差不多会消耗 40亿*4byte = 1600万kb =  16000 mb = 16gb 的内存空间,这显然是做不到的,此时我们就需要第三种方法来解决,即位图,举例如下

    在这里让我们模拟实现其关键接口,即set, reset, test

    1. template<size_t N>
    2. class my_bitset
    3. {
    4. public:
    5. my_bitset()
    6. {
    7. // 一个size_t是4个字节即32个比特位,在这里即以32个比特为一个单元
    8. _a.resize(N / 32 + 1);
    9. }
    10. // 将x位置的值映射为1
    11. void set(size_t x)
    12. {
    13. size_t i = x / 32;
    14. size_t j = x % 32;
    15. // 只将该位置的值映射为1,其他位置维持不变
    16. _a[i] |= (1 << j);
    17. }
    18. // 将x位置的值映射为0
    19. void reset(size_t x)
    20. {
    21. size_t i = x / 32;
    22. size_t j = x % 32;
    23. // 只将该位置的值映射为0,其他位置维持不变
    24. _a[i] &= (~(1 << j));
    25. }
    26. // 判断x位置的值是否为1
    27. bool test(size_t x)
    28. {
    29. size_t i = x / 32;
    30. size_t j = x % 32;
    31. return _a[i] & (1 << j);
    32. }
    33. private:
    34. vector<int> _a;
    35. };

    ②布隆过滤器

    1.布隆过滤器概念

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

    举个例子

    当向位图中插入一个数据时,会先根据不同的哈希函数计算出不同的对应下标,然后将对应的值标记成1,再插入一个值时,有

    在查找时,可以按照以下方式进行查找:分别计算每个哈希值对应的比特位置存储的是否为零,只要有一个为零,代表该元素一定不在哈希表中,否则可能在哈希表中。
    注意:布隆过滤器如果说某个元素不存在时,该元素一定不存在,如果该元素存在时,该元素可能存在,因为有些哈希函数存在一定的误判。举个例子,如在布隆过滤器中查找某个元素是否存在时,假设3个哈希函数计算的哈希值刚好和其他元素的比特位重叠,此时布隆过滤器告诉该元素存在,但实该元素是不存在的。

    那么如何删除元素呢?其实布隆过滤器不能直接支持删除工作,因为在删除一个元素时,可能会影响其他元素。举个例子,在删除上图中"apple"元素时,如果直接将该元素所对应的二进制比特位置0,“banana”元素也被删除了,因为这两个元素在多个哈希函数计算出的比特位上刚好有重叠。那我们该如何进行删除操作呢?在此,可以给出一种支持删除的方法:将布隆过滤器中的每个比特位扩展成一个小的计数器,插入元素时给k个计数器(k个哈希函数计算出的哈希地址)加一,删除元素时,给k个计数器减一,通过多占用几倍存储空间的代价来增加删除操作。但是这种方法也有缺陷,即存在计数回绕,当计数器的值达到其最大值(例如32位整数的最大值)时,再次增加计数器的值会导致其回到最小值(0)。这在布隆过滤器中可能会导致问题,因为如果一个元素被删除了(计数器减一),然后再次被插入(计数器加一),那么这个元素的计数器可能会回绕到最初的0,即使这个元素实际上仍然存在于布隆过滤器中。

    2.布隆过滤器的模拟实现

    1. // 三种计算字符串转换为数值的不同计算方法
    2. struct BKDRHash
    3. {
    4. size_t operator()(const string& str)
    5. {
    6. size_t hash = 0;
    7. for (auto ch : str)
    8. {
    9. hash = hash * 131 + ch;
    10. }
    11. return hash;
    12. }
    13. };
    14. struct APHash
    15. {
    16. size_t operator()(const string& str)
    17. {
    18. size_t hash = 0;
    19. for (size_t i = 0; i < str.size(); i++)
    20. {
    21. size_t ch = str[i];
    22. if ((i & 1) == 0)
    23. {
    24. hash ^= ((hash << 7) ^ ch ^ (hash >> 3));
    25. }
    26. else
    27. {
    28. hash ^= (~((hash << 11) ^ ch ^ (hash >> 5)));
    29. }
    30. }
    31. return hash;
    32. }
    33. };
    34. struct DJBHash
    35. {
    36. size_t operator()(const string& str)
    37. {
    38. size_t hash = 5381;
    39. for (auto ch : str)
    40. {
    41. hash += (hash << 5) + ch;
    42. }
    43. return hash;
    44. }
    45. };
    46. template<size_t N,
    47. class K = string,
    48. class Hash1 = BKDRHash,
    49. class Hash2 = APHash,
    50. class Hash3 = DJBHash>
    51. class BloomFilter
    52. {
    53. public:
    54. void Set(const K& key)
    55. {
    56. size_t hashi1 = Hash1()(key) % N;
    57. _bs.set(hashi1);
    58. size_t hashi2 = Hash2()(key) % N;
    59. _bs.set(hashi2);
    60. size_t hashi3 = Hash3()(key) % N;
    61. _bs.set(hashi3);
    62. }
    63. bool Test(const K& key)
    64. {
    65. size_t hashi1 = Hash1()(key) % N;
    66. if (_bs.test(hashi1) == false)
    67. return false;
    68. size_t hashi2 = Hash2()(key) % N;
    69. if (_bs.test(hashi1) == false)
    70. return false;
    71. size_t hashi3 = Hash3()(key) % N;
    72. if (_bs.test(hashi1) == false)
    73. return false;
    74. return ture;
    75. }
    76. private:
    77. bitset _bs;
    78. };

    3.布隆过滤器的优缺点

    优点

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

     缺点

    1. 有误判率,即存在假阳性(False Position),即不能准确判断元素是否在集合中(补救方法:再建立一个白名单,存储可能会误判的数据)
    2. 不能获取元素本身
    3. 一般情况下不能从布隆过滤器中删除元素
    4. 如果采用计数方式删除,可能会存在计数回绕问题

    ③海量数据处理

    1.哈希切割

    给一个超过 100G 大小的 log file, log 中存着 IP 地址 , 设计算法找到出现次数最多的 IP 地址?如何找到top K IP

    在这里我们需要用到哈希切割,在这之前我们要先了解一下什么是哈希切割

    哈希切割是一种将大文件分割成多个小文件的方法,其本质是将小文件当做哈希桶,将大文件中的query通过哈希函数映射到这些哈希桶中,如果是相同的query,则会产生哈希冲突进入到同一个小文件中。

    举个例子 这样经过切分后,不同的ip地址就存入了不同的小文件中,此时再用map去统计各个小文件中ip出现次数即可

    2.位图应用

    给定 100 亿个整数,设计算法找到只出现一次的整数?

    这里可以设计用两个位来标记一个数的算法,如图所示

    这里可以用两个位图来标记,算法具体实现如下

    1. template <size_t N>
    2. class TwoBit
    3. {
    4. void set(size_t x)
    5. {
    6. // 对于没有出现过的元素——00要将其变为01
    7. if (!bs1.test(x) && !bs1.test(x))
    8. {
    9. bs2.set(x);
    10. }
    11. // 对于出现过一次的元素——01要将其变为10
    12. else if (!bs1.test(x) && bs2.test(x))
    13. {
    14. bs1.set(x);
    15. bs2.reset(x);
    16. }
    17. // 对于出现过一次以上的元素——10不变即可
    18. }
    19. bool is_once(size_t x)
    20. {
    21. // 判断是否为01即可
    22. return !bs1.test(x) && bs2.test(x);
    23. }
    24. private:
    25. bitset bs1;
    26. bitset bs2;
    27. };

    给两个文件,分别有100亿个整数,我们只有1G内存,如何找到两个文件交集? 

    对此,我们可以用两个位图来分别标识两个文件中的数据,第一个文件遇见一个数就将第一个位图的对应位置set为1,第二个文件遇见就将第二个位图的对应位置set为1,在标识完所有数据后,将两个位图&一下,这样得到的位图中所有为1的数据即为交集

    1 个文件有 100 亿个 int 1G 内存,设计算法找到出现次数不超过 2 次的所有整数

    对此,我们采取与第一个方式差不多的方法,即用两个位图标识一个数,标识完所有的数后,找到所有为01或者10的数据,即为出现次数不超过两次的整数。

    3.布隆过滤器

    给两个文件,分别有 100 亿个 query ,我们只有 1G 内存,如何找到两个文件交集?分别给出 精确算法和近似算法

    对此,我们可以用哈希切分来解决问题,具体解决如下

    将两个文件分别哈希切分到若干个小文件中,第一个文件切分到A_1, A_2, ... A_n,第二个文件切分到B_1, B_2, ...B_n,这样对应的query会被切分到对应编号的小文件中,然后我们先将A_i的数据读入到一个set中,然后在对应的B_i中去判断,如果存在就是交集,反之。

    但是这种解决办法存在一些问题:哈希切分并不是均匀的切分,当哈希冲突过多时,某一个文件会超出预计的1G内存,此时又该如何解决呢?

    此时这个文件可以被分为两种情况:一种是大部分query相同少部分相冲突,另一种是大部分的query都是相冲突的。对此我们的解决方案如下
    1.将A_i的数据全部插入到一个set中,如果set抛异常(bad_alloc)说明申请内存过多,即此时大部分的query都是互相冲突的,如果插入成功说明此时大部分的query都是相同的;

    2.如果结果是抛异常的话需要更换一个哈希函数进行二次切分,即将这个小文件进行再次的哈希切分。

  • 相关阅读:
    链接装载与库:第十二章——系统调用与API
    Node.js 应用高 CPU 占用率的分析方法
    一个网络空间安全的小游戏
    深入理解计算机系统:运行helloworld程序发生了什么?
    PHP基础语法(下)
    C语言实现输入一行字符统计其中有多少个单词,单词之间用空格分隔开
    【C++】GoogleTest进阶之gMock
    Java if VS switch
    动态TopicModel BERTopic 中文 长文本 SentenceTransformer BERT 均值特征向量 整体特征分词关键词
    数字钱包红海角逐,小程序生态快速引入可助力占领智慧设备入口
  • 原文地址:https://blog.csdn.net/qq_73201597/article/details/133621285