并查集(Union-Find)算法介绍及例题讲解

part 1:

先来点带理论的哈!


本文主要介绍解决动态连通性一类问题的一种算法,使用到了一种叫做并查集的数据结构,称为Union-Find

更多的信息可以参考Algorithms 一书的Section 1.5,实际上本文也就是基于它的一篇读后感吧。

原文中更多的是给出一些结论,我尝试给出一些思路上的过程,即为什么要使用这个方法,而不是别的什么方法。我觉得这个可能更加有意义一些,相比于记下一些结论。


关于动态连通性

我们看一张图来了解一下什么是动态连通性:


假设我们输入了一组整数对,即上图中的(4, 3) (3, 8)等等,每对整数代表这两个points/sites是连通的。那么随着数据的不断输入,整个图的连通性也会发生变化,从上图中可以很清晰的发现这一点。同时,对于已经处于连通状态的points/sites,直接忽略,比如上图中的(8, 9)


动态连通性的应用场景:

  • 网络连接判断:

如果每个pair中的两个整数分别代表一个网络节点,那么该pair就是用来表示这两个节点是需要连通的。那么为所有的pairs建立了动态连通图后,就能够尽可能少的减少布线的需要,因为已经连通的两个节点会被直接忽略掉。

  • 变量名等同性(类似于指针的概念)

在程序中,可以声明多个引用来指向同一对象,这个时候就可以通过为程序中声明的引用和实际对象建立动态连通图来判断哪些引用实际上是指向同一对象。

 

对问题建模:

在对问题进行建模的时候,我们应该尽量想清楚需要解决的问题是什么。因为模型中选择的数据结构和算法显然会根据问题的不同而不同,就动态连通性这个场景而言,我们需要解决的问题可能是:

  • 给出两个节点,判断它们是否连通,如果连通,不需要给出具体的路径
  • 给出两个节点,判断它们是否连通,如果连通,需要给出具体的路径

 

就上面两种问题而言,虽然只有是否能够给出具体路径的区别,但是这个区别导致了选择算法的不同,本文主要介绍的是第一种情况,即不需要给出具体路径的Union-Find算法,而第二种情况可以使用基于DFS的算法。

 

建模思路:

最简单而直观的假设是,对于连通的所有节点,我们可以认为它们属于一个组,因此不连通的节点必然就属于不同的组。随着Pair的输入,我们需要首先判断输入的两个节点是否连通。如何判断呢?按照上面的假设,我们可以通过判断它们属于的组,然后看看这两个组是否相同,如果相同,那么这两个节点连通,反之不连通。为简单起见,我们将所有的节点以整数表示,即对N个节点使用0N-1的整数表示。而在处理输入的Pair之前,每个节点必然都是孤立的,即他们分属于不同的组,可以使用数组来表示这一层关系,数组的index是节点的整数表示,而相应的值就是该节点的组号了。该数组可以初始化为:

[java]  view plain  copy
  1. for(int i = 0; i < size; i++)  
  2.     id[i] = i;    


即对于节点i,它的组号也是i

 

初始化完毕之后,对该动态连通图有几种可能的操作:

  • 查询节点属于的组

数组对应位置的值即为组号

  • 判断两个节点是否属于同一个组

分别得到两个节点的组号,然后判断组号是否相等

  • 连接两个节点,使之属于同一个组

分别得到两个节点的组号,组号相同时操作结束,不同时,将其中的一个节点的组号换成另一个节点的组号

  • 获取组的数目

初始化为节点的数目,然后每次成功连接两个节点之后,递减1


API

我们可以设计相应的API




注意其中使用整数来表示节点,如果需要使用其他的数据类型表示节点,比如使用字符串,那么可以用哈希表来进行映射,即将String映射成这里需要的Integer类型。

 

分析以上的API,方法connectedunion都依赖于findconnected对两个参数调用两次find方法,而union在真正执行union之前也需要判断是否连通,这又是两次调用find方法。因此我们需要把find方法的实现设计的尽可能的高效。所以就有了下面的Quick-Find实现。


Quick-Find 算法:

[java]  view plain  copy
  1. public class UF  
  2. {  
  3.     private int[] id; // access to component id (site indexed)  
  4.     private int count; // number of components  
  5.     public UF(int N)  
  6.     {  
  7.         // Initialize component id array.  
  8.         count = N;  
  9.         id = new int[N];  
  10.         for (int i = 0; i < N; i++)  
  11.             id[i] = i;  
  12.     }  
  13.     public int count()  
  14.     { return count; }  
  15.     public boolean connected(int p, int q)  
  16.     { return find(p) == find(q); }  
  17.     public int find(int p)  
  18.     { return id[p]; }  
  19.     public void union(int p, int q)  
  20.     {   
  21.         // 获得p和q的组号  
  22.         int pID = find(p);  
  23.         int qID = find(q);  
  24.         // 如果两个组号相等,直接返回  
  25.         if (pID == qID) return;  
  26.         // 遍历一次,改变组号使他们属于一个组  
  27.         for (int i = 0; i < id.length; i++)  
  28.             if (id[i] == pID) id[i] = qID;  
  29.         count--;  
  30.     }  
  31. }  

举个例子,比如输入的Pair(5 9),那么首先通过find方法发现它们的组号并不相同,然后在union的时候通过一次遍历,将组号1都改成8。当然,由8改成1也是可以的,保证操作时都使用一种规则就行。



上述代码的find方法十分高效,因为仅仅需要一次数组读取操作就能够找到该节点的组号,但是问题随之而来,对于需要添加新路径的情况,就涉及到对于组号的修改,因为并不能确定哪些节点的组号需要被修改,因此就必须对整个数组进行遍历,找到需要修改的节点,逐一修改,这一下每次添加新路径带来的复杂度就是线性关系了,如果要添加的新路径的数量是M,节点数量是N,那么最后的时间复杂度就是MN,显然是一个平方阶的复杂度,对于大规模的数据而言,平方阶的算法是存在问题的,这种情况下,每次添加新路径就是“牵一发而动全身”,想要解决这个问题,关键就是要提高union方法的效率,让它不再需要遍历整个数组。

 

Quick-Union 算法:

考虑一下,为什么以上的解法会造成“牵一发而动全身”?因为每个节点所属的组号都是单独记录,各自为政的,没有将它们以更好的方式组织起来,当涉及到修改的时候,除了逐一通知、修改,别无他法。所以现在的问题就变成了,如何将节点以更好的方式组织起来,组织的方式有很多种,但是最直观的还是将组号相同的节点组织在一起,想想所学的数据结构,什么样子的数据结构能够将一些节点给组织起来?常见的就是链表,图,树,什么的了。但是哪种结构对于查找和修改的效率最高?毫无疑问是树,因此考虑如何将节点和组的关系以树的形式表现出来。

 

如果不改变底层数据结构,即不改变使用数组的表示方法的话。可以采用parent-link的方式将节点组织起来,举例而言,id[p]的值就是p节点的父节点的序号,如果p是树根的话,id[p]的值就是p,因此最后经过若干次查找,一个节点总是能够找到它的根节点,即满足id[root] = root的节点也就是组的根节点了,然后就可以使用根节点的序号来表示组号。所以在处理一个pair的时候,将首先找到pair中每一个节点的组号(即它们所在树的根节点的序号),如果属于不同的组的话,就将其中一个根节点的父节点设置为另外一个根节点,相当于将一颗独立的树编程另一颗独立的树的子树。直观的过程如下图所示。但是这个时候又引入了问题。



在实现上,和之前的Quick-Find只有findunion两个方法有所不同:

[java]  view plain  copy
  1. private int find(int p)  
  2. {   
  3.     // 寻找p节点所在组的根节点,根节点具有性质id[root] = root  
  4.     while (p != id[p]) p = id[p];  
  5.     return p;  
  6. }  
  7. public void union(int p, int q)  
  8. {   
  9.     // Give p and q the same root.  
  10.     int pRoot = find(p);  
  11.     int qRoot = find(q);  
  12.     if (pRoot == qRoot)   
  13.         return;  
  14.     id[pRoot] = qRoot;    // 将一颗树(即一个组)变成另外一课树(即一个组)的子树  
  15.     count--;  
  16. }  

树这种数据结构容易出现极端情况,因为在建树的过程中,树的最终形态严重依赖于输入数据本身的性质,比如数据是否排序,是否随机分布等等。比如在输入数据是有序的情况下,构造的BST会退化成一个链表。在我们这个问题中,也是会出现的极端情况的,如下图所示。



为了克服这个问题,BST可以演变成为红黑树或者AVL树等等。

 

然而,在我们考虑的这个应用场景中,每对节点之间是不具备可比性的。因此需要想其它的办法。在没有什么思路的时候,多看看相应的代码可能会有一些启发,考虑一下Quick-Union算法中的union方法实现:

[java]  view plain  copy
  1. public void union(int p, int q)  
  2. {   
  3.     // Give p and q the same root.  
  4.     int pRoot = find(p);  
  5.     int qRoot = find(q);  
  6.     if (pRoot == qRoot)   
  7.         return;  
  8.     id[pRoot] = qRoot;  // 将一颗树(即一个组)变成另外一课树(即一个组)的子树  
  9.     count--;  
  10. }  

上面 id[pRoot] = qRoot 这行代码看上去似乎不太对劲。因为这也属于一种“硬编码”,这样实现是基于一个约定,即p所在的树总是会被作为q所在树的子树,从而实现两颗独立的树的融合。那么这样的约定是不是总是合理的呢?显然不是,比如p所在的树的规模比q所在的树的规模大的多时,pq结合之后形成的树就是十分不和谐的一头轻一头重的”畸形树“了。


所以我们应该考虑树的大小,然后再来决定到底是调用:

id[pRoot] = qRoot 或者是 id[qRoot] = pRoot



即总是size小的树作为子树和size大的树进行合并。这样就能够尽量的保持整棵树的平衡。

 

所以现在的问题就变成了:树的大小该如何确定?

我们回到最初的情形,即每个节点最一开始都是属于一个独立的组,通过下面的代码进行初始化:

[java]  view plain  copy
  1. for (int i = 0; i < N; i++)  
  2.     id[i] = i;    // 每个节点的组号就是该节点的序号  


以此类推,在初始情况下,每个组的大小都是1,因为只含有一个节点,所以我们可以使用额外的一个数组来维护每个组的大小,对该数组的初始化也很直观:

[java]  view plain  copy
  1. for (int i = 0; i < N; i++)  
  2.     sz[i] = 1;    // 初始情况下,每个组的大小都是1  


而在进行合并的时候,会首先判断待合并的两棵树的大小,然后按照上面图中的思想进行合并,实现代码:

[java]  view plain  copy
  1. public void union(int p, int q)  
  2. {  
  3.     int i = find(p);  
  4.     int j = find(q);  
  5.     if (i == j) return;  
  6.     // 将小树作为大树的子树  
  7.     if (sz[i] < sz[j]) { id[i] = j; sz[j] += sz[i]; }  
  8.     else { id[j] = i; sz[i] += sz[j]; }  
  9.     count--;  
  10. }  

Quick-Union  Weighted Quick-Union 的比较:



可以发现,通过sz数组决定如何对两棵树进行合并之后,最后得到的树的高度大幅度减小了。这是十分有意义的,因为在Quick-Union算法中的任何操作,都不可避免的需要调用find方法,而该方法的执行效率依赖于树的高度。树的高度减小了,find方法的效率就增加了,从而也就增加了整个Quick-Union算法的效率。

 

上图其实还可以给我们一些启示,即对于Quick-Union算法而言,节点组织的理想情况应该是一颗十分扁平的树,所有的孩子节点应该都在height1的地方,即所有的孩子都直接连接到根节点。这样的组织结构能够保证find操作的最高效率。

 

那么如何构造这种理想结构呢?

find方法的执行过程中,不是需要进行一个while循环找到根节点嘛?如果保存所有路过的中间节点到一个数组中,然后在while循环结束之后,将这些中间节点的父节点指向根节点,不就行了么?但是这个方法也有问题,因为find操作的频繁性,会造成频繁生成中间节点数组,相应的分配销毁的时间自然就上升了。那么有没有更好的方法呢?还是有的,即将节点的父节点指向该节点的爷爷节点,这一点很巧妙,十分方便且有效,相当于在寻找根节点的同时,对路径进行了压缩,使整个树结构扁平化。相应的实现如下,实际上只需要添加一行代码:

[java]  view plain  copy
  1. private int find(int p)  
  2. {  
  3.     while (p != id[p])  
  4.     {  
  5.         // 将p节点的父节点设置为它的爷爷节点  
  6.         id[p] = id[id[p]];  
  7.         p = id[p];  
  8.     }  
  9.     return p;  
  10. }  

至此,动态连通性相关的Union-Find算法基本上就介绍完了,从容易想到的Quick-Find到相对复杂但是更加高效的Quick-Union,然后到对Quick-Union的几项改进,让我们的算法的效率不断的提高。

这几种算法的时间复杂度如下所示:

Algorithm

Constructor

Union

Find

Quick-Find

N

N

1

Quick-Union

N

Tree height

Tree height

Weighted Quick-Union

N

lgN

lgN

Weighted Quick-Union With Path Compression

N

Very near to 1 (amortized)

Very near to 1 (amortized)

 

对大规模数据进行处理,使用平方阶的算法是不合适的,比如简单直观的Quick-Find算法,通过发现问题的更多特点,找到合适的数据结构,然后有针对性的进行改进,得到了Quick-Union算法及其多种改进算法,最终使得算法的复杂度降低到了近乎线性复杂度。

 

如果需要的功能不仅仅是检测两个节点是否连通,还需要在连通时得到具体的路径,那么就需要用到别的算法了,比如DFS或者BFS。


并查集的应用,可以参考另外一篇文章 并查集应用举例




part 2:
举个栗子 哇咔咔咔。。。。。。

畅通工程

Time Limit: 4000/2000 MS (Java/Others)    Memory Limit: 65536/32768 K (Java/Others)
Total Submission(s): 59837    Accepted Submission(s): 31993


Problem Description
某省调查城镇交通状况,得到现有城镇道路统计表,表中列出了每条道路直接连通的城镇。省政府“畅通工程”的目标是使全省任何两个城镇间都可以实现交通(但不一定有直接的道路相连,只要互相间接通过道路可达即可)。问最少还需要建设多少条道路? 
 

Input
测试输入包含若干测试用例。每个测试用例的第1行给出两个正整数,分别是城镇数目N ( < 1000 )和道路数目M;随后的M行对应M条道路,每行给出一对正整数,分别是该条道路直接连通的两个城镇的编号。为简单起见,城镇从1到N编号。 
注意:两个城市之间可以有多条道路相通,也就是说
3 3
1 2
1 2
2 1
这种输入也是合法的
当N为0时,输入结束,该用例不被处理。 
 

Output
对每个测试用例,在1行里输出最少还需要建设的道路数目。 
 

Sample Input
  
  
4 2 1 3 4 3 3 3 1 2 1 3 2 3 5 2 1 2 3 5 999 0 0
 

Sample Output
  
  
1 0 2 998
Hint
Hint
Huge input, scanf is recommended.
 

Source
 

Recommend
JGShining   |   We have carefully selected several similar problems for you:   1233  1272  1875  1879  1213 



代码如下:
#include<stdio.h>
#include<string.h>
#include<math.h>
#include<algorithm>
#include<stdlib.h>
#include<iostream>
#include<string>
int num,road,pre[1001];
int unionsearch(int root)
{
    int tmp,son;
    son=root;
    while(root!=pre[root])
        root=pre[root];//求出root所在的root(祖宗)
    while(son!=pre[root])
    {
        tmp=pre[son];
        pre[son]=root;
        son=tmp;//从最后的开始往上辈一代一代地直接归于祖宗的直接后代
    }
    return root;
}
int main()
{
   while(scanf("%d%d",&num,&road)&&num)
   {
       int total=num-1,start,end,root1,root2;
       for(int i=1;i<=num;i++)
        pre[i]=i;
       while(road--)
       {
           scanf("%d%d",&start,&end);
           root1=unionsearch(start);
           root2=unionsearch(end);
           if(root1!=root2)
           {
               pre[root2]=root1;//或pre[root1]=root2;万宗归一  (~ ̄▽ ̄)~
               total--;
           }
       }
       printf("%d\n",total);
   }
   return 0;
}



来点有趣的解说吧 

 话说江湖上散落着各式各样的大侠,有上千个之多。他们没有什么正当职业,整天背着剑在外面走来走去,碰到和自己不是一路人的,就免不了要打一架。但大侠们有一个优点就是讲义气,绝对不打自己的朋友。而且他们信奉“朋友的朋友就是我的朋友”,只要是能通过朋友关系串联起来的,不管拐了多少个弯,都认为是自己人。这样一来,江湖上就形成了一个一个的帮派,通过两两之间的朋友关系串联起来。而不在同一个帮派的人,无论如何都无法通过朋友关系连起来,于是就可以放心往死了打。但是两个原本互不相识的人,如何判断是否属于一个朋友圈呢?

我们可以在每个朋友圈内推举出一个比较有名望的人,作为该圈子的代表人物。这样,每个圈子就可以这样命名“中国同胞队”美国同胞队”……两人只要互相对一下自己的队长是不是同一个人,就可以确定敌友关系了。

但是还有问题啊,大侠们只知道自己直接的朋友是谁,很多人压根就不认识队长 抓狂 要判断自己的队长是谁,只能漫无目的的通过朋友的朋友关系问下去:“你是不是队长?你是不是队长?”这样,想打一架得先问个几十年,饿都饿死了,受不了。这样一来,队长面子上也挂不住了,不仅效率太低,还有可能陷入无限循环中。于是队长下令,重新组队。队内所有人实行分等级制度,形成树状结构,我队长就是根节点,下面分别是二级队员、三级队员。每个人只要记住自己的上级是谁就行了。遇到判断敌友的时候,只要一层层向上问,直到最高层,就可以在短时间内确定队长是谁了。由于我们关心的只是两个人之间是否是一个帮派的,至于他们是如何通过朋友关系相关联的,以及每个圈子内部的结构是怎样的,甚至队长是谁,都不重要了。所以我们可以放任队长随意重新组队,只要不搞错敌友关系就好了。于是,门派产生了。
https://img-blog.csdn.net/20180108202659666?watermark/2/text/aHR0cDovL2Jsb2cuY3Nkbi5uZXQvSWFpcmVk/font/5a6L5L2T/fontsize/400/fill/I0JBQkFCMA==/dissolve/70/gravity/SouthEast

下面我们来看并查集的实现。 int pre[1000]; 这个数组,记录了每个大侠的上级是谁。大侠们从1或者0开始编号(依据题意而定),pre[15]=3就表示15号大侠的上级是3号大侠。如果一个人的上级就是他自己,那说明他就是掌门人了,查找到此为止。也有孤家寡人自成一派的,比如欧阳锋,那么他的上级就是他自己。每个人都只认自己的上级。比如胡青牛同学只知道自己的上级是杨左使。张无忌是谁?不认识!要想知道自己的掌门是谁,只能一级级查上去。 

find这个函数就是找掌门用的,意义再清楚不过了(路径压缩算法先不论,后面再说)。


  1. int unionsearch(int root) //查找根结点  
  2. {  
  3.     int son, tmp;  
  4.     son = root;  
  5.     while(root != pre[root]) //我的上级不是掌门  
  6.         root = pre[root];  
  7.     while(son != root) //我就找他的上级,直到掌门出现  
  8.     {  
  9.         tmp = pre[son];  
  10.         pre[son] = root;  
  11.         son = tmp;  
  12.     }  
  13.     return root; //掌门驾到~~  
  14. }  

 再来看看join函数,就是在两个点之间连一条线,这样一来,原先它们所在的两个板块的所有点就都可以互通了。这在图上很好办,画条线就行了。但我们现在是用并查集来描述武林中的状况的,一共只有一个pre[]数组,该如何实现呢? 还是举江湖的例子,假设现在武林中的形势如图所示。虚竹帅锅与周芷若MM是我非常喜欢的两个人物,他们的终极boss分别是玄慈方丈和灭绝师太,那明显就是两个阵营了。我不希望他们互相打架,就对他俩说:“你们两位拉拉勾,做好朋友吧。”他们看在我的面子上,同意了。这一同意可非同小可,整个少林和峨眉派的人就不能打架了。这么重大的变化,可如何实现呀,要改动多少地方?其实非常简单,我对玄慈方丈说:“大师,麻烦你把你的上级改为灭绝师太吧。这样一来,两派原先的所有人员的终极boss都是师太,那还打个球啊!大笑反正我们关心的只是连通性,门派内部的结构不要紧的。”玄慈一听肯定火大了:“我靠,凭什么是我变成她手下呀,怎么不反过来?我抗议!”于是,两人相约一战,杀的是天昏地暗,风云为之变色啊,但是啊,这场战争终究会有胜负,胜者为王。弱者就被吞并了。反正谁加入谁效果是一样的,门派就由两个变成一个了。这段函数的意思明白了吧?


  1. void join(int root1, int root2) //虚竹和周芷若做朋友  
  2. {  
  3.     int x, y;  
  4.     x = unionsearch(root1);//我老大是玄慈  
  5.     y = unionsearch(root2);//我老大是灭绝  
  6.     if(x != y)   
  7.         pre[x] = y; //打一仗,谁赢就当对方老大  
  8. }  

再来看看路径压缩算法。建立门派的过程是用join函数两个人两个人地连接起来的,谁当谁的手下完全随机。最后的树状结构会变成什么样,我也无法预知,一字长蛇阵也有可能。这样查找的效率就会比较低下。最理想的情况就是所有人的直接上级都是掌门,一共就两级结构,只要找一次就找到掌门了。哪怕不能完全做到,也最好尽量接近。这样就产生了路径压缩算法。

 设想这样一个场景:两个互不相识的大侠碰面了,想知道能不能干一场。 于是赶紧打电话问自己的上级:“你是不是掌门?” 上级说:“我不是呀,我的上级是谁谁谁,你问问他看看。” 一路问下去,原来两人的最终boss都是东厂曹公公。 “哎呀呀,原来是自己人,有礼有礼,在下三营六组白面葫芦娃!” “幸会幸会,在下九营十八组仙子狗尾巴花!” 两人高高兴兴地手拉手喝酒去了。 “等等等等,两位大侠请留步,还有事情没完成呢!”我叫住他俩。 “哦,对了,还要做路径压缩。”两人醒悟。 白面葫芦娃打电话给他的上级六组长:“组长啊,我查过了,其实偶们的掌门是曹公公。不如偶们一起结拜在曹公公手下吧,省得级别太低,以后查找掌门麻烦。” “唔,有道理。” 白面葫芦娃接着打电话给刚才拜访过的三营长……仙子狗尾巴花也做了同样的事情。 这样,查询中所有涉及到的人物都聚集在曹公公的直接领导下。每次查询都做了优化处理,所以整个门派树的层数都会维持在比较低的水平上。路径压缩的代码,看得懂很好,看不懂可以自己模拟一下,很简单的一个递归而已。总之它所实现的功能就是这么个意思。

  1.  while(son != root)//将所有人全部变为掌门的直系下属 ,从unionsearch中抠的第二个while循环
  2.     {  
  3.         tmp = pre[son];  
  4.         pre[son] = root;  
  5.         son = tmp;  
  6.     }  


完整代码
  1. #include<iostream>  
  2. #include<cstdio>  
  3. #include<cstring>  
  4. #include<cmath>  
  5. #include<algorithm>  
  6. using namespace std;  
  7. int pre[1010]; //里面全是掌门  
  8.   
  9. int unionsearch(int root)  
  10. {  
  11.     int son, tmp;  
  12.     son = root;  
  13.     while(root != pre[root]) //寻找掌门ing……  
  14.         root = pre[root];  
  15.     while(son != root) //路径压缩  
  16.     {  
  17.         tmp = pre[son];  
  18.         pre[son] = root;  
  19.         son = tmp;  
  20.     }  
  21.     return root; //掌门驾到~  
  22. }  
  23.   
  24. int main()  
  25. {  
  26.     int num, road, total, i, start, end, root1, root2;  
  27.     while(scanf("%d%d", &num, &road) && num)  
  28.     {  
  29.         total = num - 1; //共num-1个门派  
  30.         for(i = 1; i <= num; ++i) //每条路都是掌门  
  31.             pre[i] = i;  
  32.         while(road--)  
  33.         {  
  34.             scanf("%d%d", &start, &end); //他俩要结拜  
  35.             root1 = unionsearch(start);  
  36.             root2 = unionsearch(end);  
  37.             if(root1 != root2) //掌门不同?踢馆!~  
  38.             {  
  39.                 pre[root1] = root2;  
  40.                 total--; //门派少一个,敌人(要建的路)就少一个  
  41.             }  
  42.         }  
  43.         printf("%d\n", total);//天下局势:还剩几个门派  
  44.     }  
  45.     return 0;  
  46. }  
emmm......


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值