图文结合、利于理解的数据结构学习笔记(5)——映射、集合、哈希表和跳跃表

机械工业出版社《数据结构与算法——Python语言实现》学习笔记。
画图工具:draw.io(见附件)

1 映射和字典

在映射中,每一个唯一的 关键字 都被映射到对应的值上。键的值是唯一的,但是值不需要唯一。在 python 中定义了 dict 类来表示一种称作 字典 的抽象。
在这里插入图片描述

1.1 映射的 ADT

引入映射 ADT ,并且定义其行为以使其与 Python 内建类 dict 一致。
映射 M 最重要的五类行为:

  • M[k]:如果存在,返回在映射 M 中与键 k 相对应的值,否则返回 KeyError 错误。
  • M[k] = v:将映射 M 中的键 k 与值 v 建立关联,如果映射中的键 k 已经有对应的值存在,则替换该值。
  • del M[k]:从映射 M 中删除键为 k 的元组,如果 M 中不存在这样的元组,则返回 KeyError 错误。
  • len(M):返回在映射 M 中元组的数量。
  • iter(m):默认的对一个映射迭代生成其中所包含的 所有键的序列

为了实现其他的方便功能,映射 M 也应该支持如下行为:

  • K in M:如果映射中包含键为 k 的元组则返回 True。
  • M.get(k, d=None):如果在映射中存在键 k 则返回 M[k],否则返回缺省值 d。这种方法提供了一种避免返回 KeyError 风险的 M[k] 查询方法。
  • M.setdefault(k, d):如果在映射 M 中存在键 k ,则简单返回 M[k],如果键 k 不存在,则设置 M[k] = d,并返回这个值。
  • M.pop(k, d=None):从映射 M 中删除键为 k 的元组,并且返回与其对应的值 v 。如果键 k 不在映射中,则返回缺省值 d (或者如果参数 d 为 None,则抛出 KeyError)
  • M.clear():从映射中删除所有的 key-value 键值对。
  • M.keys():返回一个含有映射 M 中所有键的集合的视图。
  • M.values():返回一个含有映射 M 中所有值的集合的视图。
  • M.items():返回一个含有映射 M 中所有键值对元组的集合。
  • M.update(M2):对于 M2 中每一个 (k, v) 对进行赋值,设置 M[k]=v。
  • M == M2:如果映射 M 和 M2 中所有的 key-value 键值对完全相同,则返回 True。
  • M != M2:如果映射 M 和 M2 中包含有不同的 key-value 键值对,则返回 True。

2 哈希表

最实用的实现 map 的数据结构,Python用它来实现 dict 类。
首先考虑一种有限制的情况:映射中含有 n 个元组,使用范围在 0 到 N-1 的整数值作为键(N >= n)。在这种情况下我们可以使用长度为 N 的 查找表 来表示这个映射。
在这里插入图片描述
将这个框架扩展到更一般的映射设置有两个 挑战首先 ,如果在 N >> n 的情况下,长度为 N 的数组过长,空间占用将会非常稀疏。 第二 ,我们一般不会要求一个映射的键必须是整数。
在实践中可能有两个或者更多的不同键被映射到同一个索引上,因此,我们把表概念化为 桶数组 其中每个桶都管理一个元组集合,而这些元组则通过哈希函数发送到具体的索引。(为了节约空间,空桶可以用 None 代替。)
在这里插入图片描述

2.1 哈希函数

哈希函数 h ** 的目标就是把每个键 k 映射到 [0, N-1] 区间内的整数,其中 N 是哈希表的桶数组的容量。主要思想是使用哈希函数值 h(k) 作为哈希桶数组 A 内部的索引,而不用键 k 做索引。也就是说,我们在桶 A[h(k)] 中存储元组 (k, v) ***(产生冲突时,桶 A[h(k)]中可能会存储多个元组)*。

如果有两个或者多个键具有相同的哈希值,那么两个不同的元组将被映射到相同的桶 A 中。在这种情况下,我们说发生了一次 冲突 。如果一个哈希函数能在映射 map 中的键时最小化冲突的发生,我们就说该哈希函数是 “好的”

评价哈希函数 h(k) 最常见的方法由两部分组成:一个 哈希码,将一个键映射到一个整数;一个 压缩函数 ,将哈希码映射到一个桶数组的索引( 区间为 [0, N-1] 的一个整数)。

将哈希函数分为这样两部分的优势是: 哈希码计算部分独立于具体的哈希表的大小 。这样就可以为每个对象开发一个通用的哈希码,并且可以用于任何大小的哈希表,只有压缩函数与表的大小有关。
在这里插入图片描述

2.2 哈希码

哈希函数执行的第一步:
取出映射中的任意一个键 k ,并且计算得到一个整数作为键 k 的哈希码;这个整数不需要在 [0, N-1] 范围内,甚至可以是负数。我们希望分配给键的哈希码集合尽可能避免冲突。

2.2.1 将位作为整数处理

键 314 可以简单地用 314 作为哈希码,浮点数的哈希码(如 3.14)可以由该浮点数各个位上的数所构成的整数来表示(314)。
如果按位表示长于所需的哈希码长度,比如Python中的哈希码的长度是 32 位,而一个浮点数采用 64 位表示,此时可以将 64 位键的高阶 32 位和低阶 32 位采用一定的方式进行合并:
对象 x 的二进制表示可以视为 32 位整数的 n 元组 ( x 0 , x 1 , ⋯   , x n − 1 ) (x_{0},x_{1},\cdots,x_{n-1}) (x0,x1,,xn1)则可以用 ∑ i = 0 n − 1 x i \sum_{i=0}^{n-1}x_{i} i=0n1xi或者 x 0 ⊕ x 1 ⊕ ⋯ ⊕ x n − 1 x_{0} \oplus x_{1} \oplus \cdots \oplus x_{n-1} x0x1xn1来生成 x 的哈希码,这里符号 ⊕ \oplus 表示按位异或操作。

2.2.2 多项式哈希码

上面所描述的方法没有考虑元组中 x i x_{i} xi 的顺序,为解决这一问题,我们可以选择一个非零常数 a 且 a != 1,并这样计算哈希码:
x 0 a n − 1 + x 1 a n − 2 + ⋯ + x n − 2 a + x n − 1 x_{0}a^{n-1} + x_{1}a^{n-2} + \cdots + x_{n-2}a + x_{n-1} x0an1+x1an2++xn2a+xn1
这种哈希码被称为 多项式哈希码 ,直观地说,一个多项式的哈希码通过乘以不同权值的方式来分散每一部分对哈希码结果的影响。

2.2.3 循环移位哈希码

一个多项式哈希码的变种,是用一定数量的位循环位移得到的部分和来替代乘以 a。例如,一个 32 位数 00111 101100101101010100010101000 的5位循环位移值,是取其最左边5位,并且将它们放置到数据的最右边,得到结果 101100101101010100010101000 00111

2.2.4 Python中的哈希码
  • 在Python中,只有 不可变 的数据类型是可哈希的。
  • 在默认情况下,用户定义的类的实例被视为是不可哈希的。然而,计算哈希码的函数可以在类中的一个名为 _ _hash_ _ 的特殊方法实现。返回的哈希码应该反映一个实例的 不可变属性。通过计算组合属性的哈希码来返回哈希值较为常见。比如,一个 Color 类维护着红、黄、蓝三种颜色的数字组件,可以用如下方法实现:
def __hash__(self):
    return hash((self._red, self._green, self._blue)) #hash combined tuple
  • 一个需要遵守的规则是,如果通过 _ _eq_ _ 定义一个类的等价类,则 _ _hash_ _ 的任何实现必须是一致的,即如果 x == y,则 hash(x) == hash(y)。

2.3 压缩函数

哈希函数处理的第二步:压缩函数
通常,键 k 的哈希码不适合立即用于桶数组,因为整数哈希码可能是负的或可能超过桶数组的容量。因此我们需要吧整数映射到 [0, N-1] 区间上。一个很好的压缩函数会使给定的一组哈希码的冲突数达到最小。

2.3.1 划分方法

一个简单的压缩函数是这样划分的,它将一个整数 i 映射到 N:
i    m o d    N i \; mod \; N imodN

2.3.2 MAD方法

[ ( a i + b )    m o d    p ]    m o d    N [(ai+b) \; mod \; p] \; mod \; N [(ai+b)modp]modN

2.4 冲突解决方案

当有两个不同的关键字 k 1 k_{1} k1 k 2 k_{2} k2 h ( k 1 ) = h ( k 2 ) h(k_{1}) = h(k_{2}) h(k1)=h(k2) 时,会导致冲突。

2.4.1 分离链表

处理冲突的一个简单有效的方式是使每个桶 A[j] 存储其自身的二级容器,我们可以用一个很小的 list 来实现二级容器,这种解决冲突的方法称为 分离链表
在这里插入图片描述
假设我们使用一个比较合适的哈希函数来在容量为 N 的哈希桶(其实就是长度为 N 的哈希桶数组)中索引 map 中的 n 个元组,则桶的理想大小为 n/N。比值 λ=n/N 被称为哈希表的 负载因子,这一系数应该选择一个较小常数,最好不大于 1。

2.4.2 开放寻址

开放寻址模式是几种方法的统称,开放寻址需要负载因子总是最大不超过 1,并且元组 直接存储在桶数组自身的单元中

2.4.2.1 线性探测

是使用开放寻址处理冲突的一种方法。使用这种方法时,如果我们想要将一个元组 (k, v) 插入桶 A[j] 处,在这里 j=h(k) ,但 A[j] 已经被占用,那么,我们将尝试插入 A[j+1 mod N];若 A[j+1 mod N] 也已经被占用,则我们尝试使用 A[j+2 mod N] ,如此重复操作,直到找到一个可以接受新元组的空桶。
相应地,查找已存在键的方法也要改变。特别是在我们试图查找键等于 k 的元组时,必须从 A[h(k)] 开始 检测连续的空间
在这里插入图片描述
相应的,为了实现删除操作,我们不能把找到的元组简单地从插槽中移除,这样会破坏存储哈希函数值相同元素的空间的连续性。解决这一问题的典型方法是 用一个带标记的特殊对象来替换被删除的对象 。这种方法会占用哈希表中的空间;同时,在查找键为 k 的元组时,搜索将跳过所有包含可用标记的单元;此外,可用标记单元是可插入新元组的有效位置。
线性探测还存在其他问题:可能造成重叠(尤其是哈希表中的一半以上的单元已经被占用时)。这种使用连续的哈希单元的运行方式会导致搜索速度大大降低。

2.4.2.2 二次探测

反复探测桶 A[h(k)+f(i) mod N], i-0, 1, 2, ···, 其中 f(i)=i^2 ,直到发现一个空桶。当 N 是素数并且桶数组填充了不到一半时,保证可以找到一个空闲位置。然而,一旦哈希表元组填充了超过一半或者 N 不是素数时,就无法保证能找到空闲位置。

2.4.2.3 双哈希策略

不会引起如线性探测或二次探测所产生的聚集问题。在这种方法中,选择一个二次哈希函数 h ′ h^{'} h,如果函数 h h h 将一些键 k 映射到已经被占据的桶 A[h(k)] 中,则我们将迭代探测桶 A[h(k)+f(i) mod N], i-0, 1, 2, ···, 其中 f ( i ) = i ⋅ h ′ ( k ) f(i) = i · h^{'}(k) f(i)=ih(k)。二次哈希函数不为 0。 h ′ ( k ) = q − ( k m o d q ) h^{'}(k) = q - (k mod q) h(k)=q(kmodq) 是一个常被选用的函数,对于素数 q 满足 q < N,且 N 也应该是素数。

2.4.2.4 Python字典采用的策略

迭代地探测桶 A[h(k)+f(i) mod N] ,这里 f(i) 是一个基于伪随机数产生器的函数,它提供一个基于原始哈希码位的可重复的但是随机的、连续的地址探测序列。

2.5 负载因子、重新哈希和效率

保证负载因子 λ=n/N 总是小于 1 是非常重要的。使用分离链表,在 λ 的值非常接近 1 时,冲突将急剧增加。实验表明,使用分离链表时我们应该保持 λ < 0.9。
实验表明,当使用线性探测的开放寻址策略时,我们应该维持 λ<0.5 ,而对于其他开放地址策略这个值可能会高一点。
当一个哈希表的插入操作引起的负载因子超过了指定的阈值,就需要调整表的大小(重新获取指定的负载因子)并且将所有的对象重新插入新表中。不需要为每个对象定义一个新的哈希码,但需要基于新的哈希表大小重新设计一个压缩函数,每次重新哈希都会将元组分布到整个新桶数组中。可以将新数组大小设置为原来的 2 倍,这样将分期承担重新哈希表格中所有元组的开销(就像动态数组的摊销)。

2.5.1 哈希表的效率

如果哈希函数足够好,那么所有的元组应该均匀分布在桶数组的 N 个单元中。那么,为了存储 n 个元组,在一个桶中期望的键的数量应该是 ⌈ n / N ⌉ \lceil n/N \rceil n/N ,如果 n 是 O(N),那么这个键的数量就是 O(1)。
最坏的情况,所有的元组映射到同一个桶中。这将导致无论是使用分离链表还是使用任何开放式寻址策略的核心映射操作的性能是线性增长的,因为这些操作的二次序列探测仅仅与哈希码有关。

操作列表期望值(哈希表)最坏情况(哈希表)
_ _getitem_ _O(n)O(1)O(n)
_ _setitem_ _O(n)O(1)O(n)
_ _delitem_ _O(n)O(1)O(n)
_ _len_ _O(1)O(1)O(1)
_ _iter_ _O(n)O(n)O(n)

3 有序映射

有序映射是标准映射 ADT 的扩展,它包括标准映射的所有行为,还增加了以下行为:

  • M.find_min():用最小键返回 (key, value) 对(或 None,如果映射为空)。
  • M.find_max():用最大键返回 (key, value) 对(或 None,如果映射为空)。
  • M.find_lt(k):用严格小于 k 的最大键返回(key, value) 对(或 None,如果没有这样的项存在)。
  • M.find_le(k):用严格小于等于 k 的最大键返回(key, value) 对(或 None,如果没有这样的项存在)。
  • M.find_gt(k):用严格大于 k 的最小键返回(key, value) 对(或 None,如果没有这样的项存在)。
  • M.find_ge(k):用严格大于等于 k 的最小键返回(key, value) 对(或 None,如果没有这样的项存在)。
  • M.find_range(start, stop):用 start <= 键 < stop迭代遍历所有(key, value) 对。如果 start 指定为 None,从最小的键开始迭代;如果 stop 指定为 None,到最大键迭代结束。
  • iter(M):根据自然顺序从最小到最大迭代遍历映射中的所有键。
  • reversed(M):根据逆序迭代映射中的所有键 r,这在 Python 中是用 _ _reversed_ _ 来实现的。

3.1 排序检索表

本节先讨论一个简单有序映射的实现。我们将映射的元组存储在一个基于数组的序列 A 中,以键的升序排列,假定键是天然定义的顺序。我们将这个映射实现为 排序检索表
在这里插入图片描述
这种表示最主要的优势是,它支持使用 二分查找算法(运行时间为 O(log n)) 来做各种有效的操作。

3.1.2 实现

使用 _find_index(k, low, high) 功能函数,这个方法使用二分查找算法,返回搜索区间中键大于等于 k 的最左侧元组的索引。然而,如果是当前的键,它将返回键为该值的元组的索引。
在实现传统的映射操作和新的有序映射操作时,我们依赖这个实用方法。方法 _ _getitem_ _、_ _setitem_ _和 _ _delitem_ _ 中的每一个函数体都从调用 _find_index 函数开始,以决定候选索引来匹配要找的键。

3.1.3 效率
操作运行时间
len(M)O(1)
k in MO(log n)
M[k]=v最坏情况下为 O(n) 如果存在 k 则为 O(log n)
del M[k]最坏情况下为 O(n)
M.find_min(), M.find_max()O(1)
M.find_lt(k), M.find_gt(k)M.find_le(k), M.find_ge(k)O(log n)
M.find_range(start ,stop)O(s + log n),报告 s 项
iter(M), reversed(M)O(n)

二分查找算法运行时间为 O(log n),find_range()先二分查找再相继报告后续值,删除和替换操作需要调整位置。
由此可见,排序映射主要是用于含有查找较多但更新较少的情况。同时排序映射也支持模糊查找和范围查找的优势

4 跳跃表

跳跃表是实现排序映射 ADT 的一种数据结构,它提供一个折衷的方式以有效地支持查找和更新操作。
一个映射 M 的跳跃表 S 包含一列表序列 { S 0 , S 1 , ⋯   , S h } \lbrace S_{0},S_{1}, \cdots ,S_{h} \rbrace {S0,S1,,Sh}。每一个列表 S i S_{i} Si 依照键的升序存储着 M 的一个元组子集,用两个标注为 − ∞ - \infty + ∞ +\infty + 的哨兵键追加元组,其中 − ∞ - \infty 比每一个可能的插入 M 的键都要小, + ∞ +\infty + 比每一个可能插入 M 的键都大。此外,列表 S 还要满足下面的条件:

  • 列表 S 0 S_{0} S0 包含映射 M 中的每一项(包含 − ∞ - \infty + ∞ +\infty + )。
  • 对于 i = 1 , ⋯   , h − 1 i=1, \cdots ,h-1 i=1,,h1,列表 S i S_{i} Si 包含一个列表 S i − 1 S_{i}-1 Si1 随机生成的元组的子集(还有 − ∞ - \infty + ∞ +\infty + )。
  • 列表 S h S_{h} Sh 仅包含 − ∞ - \infty + ∞ +\infty +

我们称 h 为列表 S 的高度。
在这里插入图片描述
S i + 1 S_{i+1} Si+1 中的元组是从 S i S_{i} Si 中随机挑选出来的,概率为 1/2。因此,我们希望 S 1 S_{1} S1 含有 n/2 个元组, S 2 S_{2} S2 含有 n/4 个元组,一般地说就是 S i S_{i} Si 含有 n / 2 i n/2^{i} n/2i 个元组。换言之,我们希望 S 的高度为 log n。

跳跃表在组织它的结构时,通过平均时间为 O(log n) 的查找和更新方法做随机选择,其中 n 是映射中的元组项目数。注意:这里使用的平均时间复杂度取决于在实现用于帮助决定在哪安插新条目的插入函数中所使用的随机数生成器。

我们视跳跃表为一个水平组织成 层(level)、垂直组织成 塔(tower) 的二维位置集合。可以使用以下操作遍历跳跃表中的每个位置:

  • next(p):返回在同一水平层位置上紧接着 p 的位置。
  • prev(p):返回在同一水平层位置上在 p 之前的位置。
  • below(p):返回在同一垂直位置上在 p 下面的位置。
  • above(p):返回在同一垂直位置上在 p 上面的位置。

假设对于上述操作,如果要求的位置不存在则返回 None,则可以通过双链表结构简单地实现一个跳跃表,给定一个跳跃表的位置,每一个单独的遍历方法需要 O(1) 的时间。

4.1 跳跃表中的查找和更新操作

所有的跳跃表查找和更新算法都依赖于一个简洁的 SkipSearch 方法,其需要一个键 k 并发现 S 列表中具有小于等于键 k (可能为 − ∞ -\infty )的最大键的元组 p 的位置。

4.1.1 在跳跃表中查找

假设给出搜索键 k 。在跳跃表 S 中最顶层靠左的位置设立一个位置变量 p,称为 S 的开始位置(即 − ∞ -\infty )。则执行步骤如下, key§ 表示在位置 p 处的元组的键:

  1. 如果 S.below§ 为空,那么查找结束。否则,通过设置 p = S.below§ 从当前垂直塔位置下降到下一个水平层。
  2. 从位置 p 开始,如果 key(next§) <= k 则将 p 向前移动。(正向扫描步骤)
  3. 返回第一步。

在这里插入图片描述

在含有 n 个条目的跳跃表中执行算法 SkipSearch 时期望的运行时间是 O(log n)。

4.1.2 跳跃表中的插入操作

假设要插入一个键为 k 的元组。先在跳跃表中查找键大于 k 的最底层的位置,并在查找过程中标记需要进行链接的位置。在最底层插入新元组项后,通过 随机方式 决定新元组的垂直塔高度。
在这里插入图片描述

4.1.3 在跳跃表中移除

为了执行映射操作 del M[k],首先执行方法 SkipSearch(k)。如果位置 p 存储的条目与键 k 不同,则返回 KeyError。否则,移除 p 和 p 之上所有的位置。同时重新建立每一个移除位置与水平邻居之间的链接。
在这里插入图片描述

4.1.4 维护最高水平层

跳跃表必须有一个策略服务于任何期望继续越过 S 的顶层的插入操作。可采取两种方法:

  • 方法一。限制最高层 h 保持在某一个固定值,这是一个 n(当前 map 条目数) 的函数。(从分析中可知 h = m a x { 10 , 2 ⌈ l o g n ⌉ } h=max \lbrace 10,2\lceil log n \rceil \rbrace h=max{10,2logn} 是一个不错的选择,并且挑选 h = 3 ⌈ l o g n ⌉ h=3\lceil log n \rceil h=3logn 更安全)实现这个函数选择意味着我们必须修改插入算法,这样就可以在达到最高层时停止插入(除非 ⌈ l o g n ⌉ > ⌈ l o g ( n − 1 ) ⌉ \lceil log n \rceil > \lceil log(n-1) \rceil logn>log(n1),这种情况下高度的边界在增长,至少可以再多达到一个水平层)
  • 方法二。只要头部不断从随机数生成器获得返回值,就让插入操作持续插入新的位置。这种情况下,当插入高度大于当前跳跃表高度时,需要插入新的水平层,由于插入一个水平层的时间复杂度大于 O(log n) 的概率非常低,因此可以正常工作。

4.2 性能分析

对于常量 c > 1,跳跃表 S 的高度大于 c log n 的概率至多为 1 / n c − 1 1/n^{c-1} 1/nc1
搜索时间: O(log n)
空间使用: O(n)
跳跃表实现的排序表的性能:

操作运行时间
len(M)O(1)
k in M期望为O(log n)
M[k]=v期望为O(log n)
del M[k]期望为O(log n)
M.find_min(), M.find_max()O(1)
M.find_lt(k), M.find_gt(k)M.find_le(k), M.find_ge(k)期望为O(log n)
M.find_range(start ,stop)期望为O(s + log n),报告 s 项
iter(M), reversed(M)O(n)

5 集合、多集和多映射

  • 集合(set) 是无序元素的一个聚集,这些元素不重复并且通常支持高效的成员检测。从本质上说,集合中的元素像是映射中的键,但是它没有任何的附加值。
  • 多集(multiset) (也称为包(bag))是一个允许有重复元素的类集合(set-like)容器。
  • 多映射(multimap) 与传统的映射类似,在映射中它将键和值联系起来。然而,在多映射中多个值可以映射到同一个键上。

5.1 集合的 ADT

在集合 S 中最基本的五个行为:

  • S.add(e):向集合中添加元素 e,如果集合中已经包含元素 e,则行为无效。
  • S.discard(e):如果集合中包含元素 e,则从集合中删除该元素。如果集合中不包含元素 e,则行为无效。
  • e in S:如果集合中包含元素 e,则返回 True,该行为是通过特定的方法 _ _contains_ _ 来实现的。
  • len(S):返回集合 S 中的元素个数。
  • iter(S):生成集合中所有元素的迭代。

由以上五种方法可以派生出一个集合的其他所有行为,如集合之间的布尔比较、集合运算等。

在 Python中,抽象基类 collections.MutableSet 类似于具体的 set 类,抽象基类 collections.Set 匹配具体的 frozenset 类,frozenset 类是一个不可变的形式。这两个类都是使用 Python 中的哈希表来实现的。

5.2 集合、多集和多映射的实现

5.2.1 集合

集合和映射有完全不同的公共接口,但是它们之间非常相似。一个集合是一个简单的映射 ,这个映射中没有相关联的值。为了避免空间浪费,一个有效的集合应该放弃在 MapBase 类中使用的 _Item 组合模式,而在数据结构中直接存储集合元素。

5.2.2 多集

一种实现多集的方法是使用映射,该映射的键是多集中的元素(互不相同的),而键所关联的值是这个元素在多集中出现的次数。
Python 的标准 collections 模块包括一个名为 Counter 类 的定义,它本质上是一个多集。形式上,Counter 类是 dict 类的一个子类,它所包含的值最好都是整数(代表元素出现的次数),并且包含一些附加函数,比如 most common(n) 方法返回前 n 个最常见元素的列表。标准 _ _iter_ _ 方法对每个元素只报告一次(它们形式上是字典的键)。elements() 方法从头到尾地按元素的计数来重复遍历多集的每个元素。

5.2.3 多映射

Python 的标准库中没有多映射 ,一个常见的实现方法是使用一个标准映射,该映射与键相关联的值是一个本身存储任意数量的关联值的容器类。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值