【蓝桥杯】算法提高 金陵十三钗(动态规划dp、深度优先搜索dfs两种解法)

算法提高 金陵十三钗

问题描述

在电影《金陵十三钗》中有十二个秦淮河的女人要自我牺牲代替十二个女学生去赴日本人的死亡宴会。为了不让日本人发现,自然需要一番乔装打扮。但由于天生材质的原因,每个人和每个人之间的相似度是不同的。由于我们这是编程题,因此情况就变成了金陵 n n n 钗。给出 n n n 个女人和 n n n 个学生的相似度矩阵,求她们之间的匹配所能获得的最大相似度。
所谓相似度矩阵是一个 n × n n\times n n×n 的二维数组 l i k e [ i ] [ j ] like[i][j] like[i][j]。其中 i , j i,j i,j 分别为女人的编号和学生的编号,皆从 0 到 n − 1 n-1 n1 编号。 l i k e [ i ] [ j ] like[i][j] like[i][j] 是一个 0 到 100 的整数值,表示第 i i i 个女人和第 j j j 个学生的相似度,值越大相似度越大,比如 0 表示完全不相似,100 表示百分之百一样。每个女人都需要找一个自己代替的女学生。
最终要使两边一一配对,形成一个匹配。请编程找到一种匹配方案,使各对女人和女学生之间的相似度之和最大。

输入格式

第一行一个正整数 n n n 表示有 n n n 个秦淮河女人和 n n n 个女学生
接下来 n n n 行给出相似度,每行 n n n 个 0 到 100 的整数,依次对应二维矩阵的 n n n n n n 列。

输出格式

仅一行,一个整数,表示可获得的最大相似度。

样例输入

4
97 91 68 14
8 33 27 92
36 32 98 53
73 7 17 82

样例输出

354

样例说明

最大相似度为 91+92+93+73=354

数据规模和约定

对于 70% 的数据, n ≤ 10 n \le 10 n10
对于 100% 的数据, n ≤ 13 n \le 13 n13




分析:
本题的意思抽象出来就是在一个 n × n n\times n n×n 的数字矩阵中选出 n n n 个数,要求这 n n n 个数的行和列均不相同,问你能取出的 n n n 个数的最大和是多少?其实这就是一个全排列问题,每当你选了某个数后,那个数所在的哪一行和列就都不能选了,于是可选的行和列就只剩下 ( n − 1 ) (n-1) (n1) 个;接着你选第二个数,选了之后第二个数所在的行和列就也被淘汰了,于是可选的行和列就只剩下 ( n − 2 ) (n-2) (n2) 个……

所以对于某个输入的 n n n,其可能的组合就有 n ! n! n! 个。



—— 分割线之DFS求解 ——


我们拿到这类题很容易想到的方向是搜索,毕竟搜索算法是最容易理解和实现的,于是我也不顾数据范围是多少,先来一手深搜。

dfs算法的思想很简单,对于某个 n × n n\times n n×n 的数字矩阵而言,我们直接遍历它的每一行,然后在每一行中,我们都选择其在列方向上未被选中的那个数继续往下 dfs。比如对于 4 × 4 4\times4 4×4 的矩阵,假设我第一次从第 1 行的第 1 个数开始搜索,那么接下来到了第 2 行后我就可以选第 2、3、4 列上的数继续 dfs 下去……

根据这样的思路我写出了以下代码:

#include<iostream>
using namespace std;

const int N=15;
int n,ans;
int like[N][N];
bool flag[N];						//标志每个列上是否已被选 
void create(int n)					//录入数据(注意like数组横纵坐标均从1开始编号)
{
	for(int i=1;i<=n;i++)
		for(int j=1;j<=n;j++)
			cin>>like[i][j];
}
void dfs(int x,int y,int sum)		//x表示当前所处数字矩阵的行(从1开始编号),y表示当前已经选了多少的数,sum表示当前已选数字的总和 
{
	if(y==n){
		ans=sum>ans?sum:ans;		//当前选中的序列和如果更大则更新ans,否则不做改变 
		return;
	}
	for(int i=1;i<=n;i++){			//遍历当前行的每一列 
		if(!flag[i]){				//如果当前列上还未选任何数字则可以选择 
			flag[i]=true;			//标记当前列已被选 
			sum+=like[x][i];		//更新sum 
			dfs(x+1,y+1,sum);		//继续往下搜索 
			sum-=like[x][i];		//回退时还原sum 
			flag[i]=false;			//回退时还原标志 
		}
	}
}

int main()
{
	cin>>n;
	create(n);
	dfs(1,0,0);
	cout<<ans<<endl;
	return 0;
}

这个代码仅得了 70 分,剩下三组数据都因超时而过不了。

这样的结局很正常,在 O ( n ! ) O(n!) O(n!) 的时间复杂度下,当 n > 10 n>10 n>10 时总的枚举次数就已经相当大了,更何况 n n n 最大能取到 13,这简直是在为难 dfs。

通常搜索算法因超时而解决不了时,可以用记忆化搜索来代之解决。但是更多的时候,都意味着这道题是在考你动态规划!而本题恰恰是后者。



—— 分割线之DP求解 ——


我们考虑 DP 时往往需要分析题目中暗含的动态转移方程。在本题中,女人和学生的匹配关系就是这一关键点。但是这里有一个很严肃的问题是,我们怎么表达女人和学生的匹配关系?

二进制。比如对于某个数 ( 11010 ) 2 (11010)_2 (11010)2,我们可以给这个二进制数赋予如下意义:3 个 1 表示当前选出前三个女人,从右往左数 1 的位置分别表示第 2、4、5 个学生被选中。那么我们就可以用 d p [ 3 ] [ 11010 ] dp[3][11010] dp[3][11010] 来表达前三个女人和第 2、4、5 个学生相匹配,至于具体是谁和谁匹配,我们根本不用关心,我们只需要关心在这样的状态下,在 d p [ 3 ] [ 11010 ] dp[3][11010] dp[3][11010] 中存放的是目前所能取得的最大值即可。

接下来我们思考: d p [ 3 ] [ 11010 ] dp[3][11010] dp[3][11010] 能从哪些状态转移过来?
很明显其只能从 d p [ 2 ] [ 11000 ] 、 d p [ 2 ] [ 10010 ] 、 d p [ 2 ] [ 01010 ] dp[2][11000]、dp[2][10010]、dp[2][01010] dp[2][11000]dp[2][10010]dp[2][01010] 得来。

  • 当从 d p [ 2 ] [ 11000 ] dp[2][11000] dp[2][11000] 得来的时候, d p [ 3 ] [ 11010 ] = d p [ 2 ] [ 11000 ] + l i k e [ 3 ] [ 2 ] dp[3][11010]=dp[2][11000]+like[3][2] dp[3][11010]=dp[2][11000]+like[3][2],其意义是:当前两个女人选择学生的方式为 11000(即第 4、5 个学生被选中)时,再将第 3 个女人和第 2 个学生相匹配(即加上第 3 个女人和第 2 个学生的相似程度 l i k e [ 3 ] [ 2 ] like[3][2] like[3][2])就得到了 d p [ 3 ] [ 11010 ] dp[3][11010] dp[3][11010] 的一种可能的状态;

  • 当从 d p [ 2 ] [ 10010 ] dp[2][10010] dp[2][10010] 得来的时候, d p [ 3 ] [ 11010 ] = d p [ 2 ] [ 10010 ] + l i k e [ 3 ] [ 4 ] dp[3][11010]=dp[2][10010]+like[3][4] dp[3][11010]=dp[2][10010]+like[3][4],其意义是:当前两个女人选择学生的方式为 10010(即第 2、5 个学生被选中)时,再将第 3 个女人和第 4 个学生相匹配(即加上第 3 个女人和第 4 个学生的相似程度 l i k e [ 3 ] [ 4 ] like[3][4] like[3][4])就得到了 d p [ 3 ] [ 11010 ] dp[3][11010] dp[3][11010] 的一种可能的状态;

  • 当从 d p [ 2 ] [ 01010 ] dp[2][01010] dp[2][01010] 得来的时候, d p [ 3 ] [ 11010 ] = d p [ 2 ] [ 01010 ] + l i k e [ 3 ] [ 5 ] dp[3][11010]=dp[2][01010]+like[3][5] dp[3][11010]=dp[2][01010]+like[3][5],其意义是:当前两个女人选择学生的方式为 01010(即第 2、4 个学生被选中)时,再将第 3 个女人和第 5 个学生相匹配(即加上第 3 个女人和第 5 个学生的相似程度 l i k e [ 3 ] [ 5 ] like[3][5] like[3][5])就得到了 d p [ 3 ] [ 11010 ] dp[3][11010] dp[3][11010] 的一种可能的状态;

而最终 d p [ 3 ] [ 11010 ] dp[3][11010] dp[3][11010] 的取值应该是这上面的最大值。根据这样的思路,我们便可得到本题的状态转移方程为(设学生被选中的方式为 s t a t e state state,比如上面的 s t a t e state state 有 11010、11000、10010、01010):

d p [ i ] [ s t a t e ] = max { d p [ i − 1 ] [ 单独剔除 1 的 s t a t e ] + l i k e [ i ] [ 被剔除的那个 1 所在的位置 ] } dp[i][state] = \text{max}\{ dp[i-1][单独剔除1的state] + like[i][被剔除的那个1所在的位置] \} dp[i][state]=max{dp[i1][单独剔除1state]+like[i][被剔除的那个1所在的位置]}

注: s t a t e state state 的二进制中,1 的数量必然为 i i i

比如对于上面举的例子而言,该方程可实例化为:

d p [ 3 ] [ 11010 ] = m a x { d p [ 2 ] [ 11000 ] + l i k e [ 3 ] [ 2 ] , d p [ 2 ] [ 10010 ] + l i k e [ 3 ] [ 4 ] , d p [ 2 ] [ 01010 ] + l i k e [ 3 ] [ 5 ] , } \begin{aligned} dp[3][11010] &= max \{ \\ & dp[2][11000] + like[3][2],\\ & dp[2][10010] + like[3][4],\\ & dp[2][01010] + like[3][5],\\ \} \end{aligned} dp[3][11010]}=max{dp[2][11000]+like[3][2]dp[2][10010]+like[3][4]dp[2][01010]+like[3][5]

根据上面的思路,我们就能直接按图索骥了。不过在给出完整代码之前,我先讲一些关于处理二进制数的部分操作,这些操作在本题的代码中至关重要。


操作一:lowbit 函数与求二进制表达式中 1 的个数

首先是lowbit技术,这个函数在前面讲 树状数组 的时候就提到过,其主要作用是取出某个数中的最低位 1 所表示的数。比如对于十进制数 6,其对应的二进制数为 110,那么其最低位 1 构成的二进制数就是 10,转换成十进制数就是 2,即 lowbit(6)=2。再比如对于十进制数 4,其对应的二进制数为 100,那么其最低位 1 构成的二进制数就是 100,转换成十进制数就是 4,即 lowbit(4)=4。

对于本题而言,我们有一个任务是统计每个数其对应的二进制表达式中有多少个 1,这里就需要用到我们的 lowbit 技术了。我们可以通过一个循环来对某个数进行迭代处理,从而得到该数的二进制表达式中 1 的个数。该函数以及 lowbit 函数的的完整代码如下:

int lowbit(int n){ return n&(-n); }	//取出最低位1所表示的数 
int getOneCount(int n)				//得到数n的二进制表达式中1的个数 
{
	int num=0;
	while(n){
		num++;
		n -= lowbit(n);
	}
	return num;
}

操作二:求单独剔除 1 的 s t a t e state state

我们的状态转移方程是: d p [ i ] [ s t a t e ] = max { d p [ i − 1 ] [ 单独剔除 1 的 s t a t e ] + l i k e [ i ] [ 被剔除的那个 1 所在的位置 ] } dp[i][state] = \text{max}\{ dp[i-1][单独剔除1的state] + like[i][被剔除的那个1所在的位置] \} dp[i][state]=max{dp[i1][单独剔除1state]+like[i][被剔除的那个1所在的位置]}。那要怎么求“单独剔除 1 的 s t a t e state state”呢?

利用一层循环,通过 lowbit 技术即可。比如对于数 11010,可以通过上面的 getOneCount() 函数来将其中 1 的个数(3 个)求出。然后我们先定义一个临时变量 lastOnePos=11010,接着进入一个循环:

  • 第一次循环:首先通过 lowbit(lastOnePos) 函数取出第一次需要被剔除的 1,此时 lowbit(11010)=10。然后再用 s t a t e − 10 = 11010 − 10 = 11000 state-10=11010-10=11000 state10=1101010=11000 即得到了第一次剔除 1 的状态,最后更新 lastOnePos=lastOnePos-lowbit(lastOnePos)=11010-10=11000;
  • 第二次循环:首先通过 lowbit(lastOnePos) 函数取出第二次需要被剔除的 1,此时 lowbit(11000)=1000。然后再用 s t a t e − 1000 = 11010 − 1000 = 10010 state-1000=11010-1000=10010 state1000=110101000=10010 即得到了第二次剔除 1 的状态,最后更新 lastOnePos=lastOnePos-lowbit(lastOnePos)=11000-1000=10000;
  • 第三次循环(最后一次):首先通过 lowbit(lastOnePos) 函数取出第三次需要被剔除的 1,此时 lowbit(10000)=10000。然后再用 s t a t e − 10000 = 11010 − 10000 = 01010 state-10000=11010-10000=01010 state10000=1101010000=01010 即得到了第三次剔除1的状态,最后更新 lastOnePos=lastOnePos-lowbit(lastOnePos)=10000-10000=0;

退出循环。

关于上述描述的代码为:

int lastOnePos=state;
for(int i=0;i<getOneCount(state);i++)
{
	int temp = lowbit(lastOnePos);			//被剔除的数
	%单独剔除1的state% = state-temp;
	lastOnePos = lastOnePos - temp;			//更新lastOnePos
}

操作三:求被剔除的数其 1 所在的位置

我们在求被剔除的1所在的位置时,处理的都是经过 lowbit 函数处理过的数。这些数的特征是:全部都是 2 k 2^k 2k,比如 (1)2、(10)2、(100)2、(1000)2……其实也就是上面的 t e m p temp temp。根据这一特征,我们可以利用移位运算来算出 k k k(当然你也可以直接调用库函数),进而得到被剔除的数中其 1 所在的位置。下面先放出代码:

int getOnePos(int n)				//返回某个二进制数从左至右数第一次出现1的位置 
{
	int num=0;
	while(n){
		num++;
		n >>= 1;
	}
	return num;
}

比如对于二进制数 100 而言(对应十进制为 4):

  • 第一次 while(4),进入循环,此时更新 num=1,然后 n 移位得到 n=10(二进制),对应十进制就为 2;
  • 第二次 while(2),进入循环,此时更新 num=2,然后 n 移位得到 n=1(二进制),对应十进制也是 1;
  • 第三次 while(1),进入循环,此时更新 num=3,然后 n 移位得到 n=0,对应十进制也为 0;
  • 第四次 while(0),退出循环,返回在状态 (100)2中,其 1 所在位置为 3。

将这个得到某个数的二进制表达式中 1 出现的位置的函数,与前面的操作二放在一起就得到了本题最关键的 DP 部分,代码如下:

int lastOnePos=state;
for(int i=0;i<getOneCount(state);i++)
{
	int temp = lowbit(lastOnePos);			//被剔除的数
	%单独剔除1的state% = state-temp;
	%被剔除的那个1所在的位置% = getOnePos(temp);
	lastOnePos = lastOnePos - temp;			//更新lastOnePos
}

下面给出基于以上思路得到的满分完整代码:

#include<iostream>
#include<iomanip>
#include<vector> 
using namespace std;

const int N=14;
int dp[N][1<<N];
int like[N][N];						//相似程度矩阵(两个维度上的索引均从1开始编号) 
vector<int> v[N];					//列表v[i]表示 : 二进制下1的数量为i的数的集合  

int lowbit(int n){ return n&(-n); }	//取出最低位1所表示的数 
int getOneCount(int n)				//得到数n的二进制表达式中1的个数 
{
	int num=0;
	while(n){
		num++;
		n -= lowbit(n);
	}
	return num;
}
void create(int n)					//录入like数组以及初始化向量v 
{
	for(int i=1;i<=n;i++)			//录入like数组(注:索引从1开始) 
		for(int j=1;j<=n;j++)
			cin>>like[i][j];
	for(int i=1;i<(1<<n);i++)		//将数i的二进制有多少个1统计进向量v中 
		v[getOneCount(i)].push_back(i); 
}
int getOnePos(int n)				//返回某个二进制数从左至右数第一次出现1的位置 
{
	int num=0;
	while(n){
		num++;
		n >>= 1;
	}
	return num;
}
void DP(int n)
{
	for(int i=1;i<=n;i++){
		for(int j=0;j<v[i].size();j++){
			int state = v[i][j],lastOneNum=state;
			for(int k=0;k<i;k++){
				int temp = lowbit(lastOneNum);		//被剔除的数 
				dp[i][state] = max(dp[i][state],dp[i-1][state-temp]+like[i][getOnePos(temp)]);
				lastOneNum -= temp;					//更新lastOneNum
			}
		}
	}
} 

int main()
{
	int n;cin>>n;
	create(n);
	DP(n);
	cout<<dp[n][(1<<n)-1]<<endl;
	return 0;
}


—— 分割线之登峰造极 ——


事情到这里其实就结束了,但是总有人喜欢追求极致,比如我,能不能将上面的的程序进行优化?

当然可以。我们在做 DP 的题时,为了能够更快的想出动态转移方程,都会用二维数组的形式来进行推理填表,因为在维度为二的数组下更易让人理解。但是大多数情况下,二维数组最终都能被简化为一维数组,从而从空间上进行优化,本题也正是这样。

前面我们说到,可以用二进制的方式来表示某个位置上的学生是否被选,比如用 (11010)2 来表示第 2、4、5 个学生被选中。对于本题而言,实际上就是要求得到最终状态:111……11(长度为 n 的序列)的值。若我们假设某个状态(比如序列00111、10010、11101等)为 s t a t e state state,则可以得到状态转移方程为:

d p [ i ] [ s t a t e ] = max { d p [ i − 1 ] [ 单独剔除 1 的 s t a t e ] + l i k e [ i ] [ 被剔除的那个 1 所在的位置 ] } dp[i][state]=\text{max}\{ dp[i-1][单独剔除1的state] + like[i][被剔除的那个1所在的位置] \} dp[i][state]=max{dp[i1][单独剔除1state]+like[i][被剔除的那个1所在的位置]}

上面之所以采用二维 d p dp dp,是因为相似矩阵是二维的,我们为了能更合理、自然地将相似矩阵与 d p dp dp 数组联系到一起,于是也采用二维数组,这样就能更贴合现有数据。基于这样的一种想法,我们在进行动态转移时,顺序是根据 s t a t e state state 里对应二进制数中 1 的数量来执行的。这样一来,我们就只得以二维的方式进行更新。

但是!在这样的思维定势下,我们似乎忽略了一个重要的事实: s t a t e state state 序列本身就是有序的!
什么意思?我们可以直接将 d p [ i ] [ s t a t e ] dp[i][state] dp[i][state] 数组替换为 d p [ s t a t e ] dp[state] dp[state],然后遍历 s t a t e state state 序列(二进制串)即可。即将循环体改写为:

for(int i=1; i<(1<<n); i++)

这样一来, s t a t e state state 序列本身的更新方向就被固定为自左向右(从小到大),因此我们就不需要再去考虑什么取 s t a t e state state 中 1 的个数、1 的个数组成的集合等复杂问题了。

在这样的思路下,解题过程也发生了改变。
首先,我们是进入上面所述的 i i i 循环,以遍历所有的 s t a t e state state
然后,对于每个状态,我们都去找其单独剔除 1 的子 s t a t e state state。比如对于某个 state=(11010)2,其子序列有:11000、10010、01010。这里同样可以用上面的那种方式,即通过 getOneNum() 函数求出当前 s t a t e state state 中 1 的个数(比如 getOneNum(11010)=3 ),然后再通过一层循环来求出每个具体的子序列。

但是在这里我换一种做法,我直接枚举在当前 n n n 的前提下,能产生多少的单 1 序列 s t a t e 1 state1 state1。比如当 n = 4 n=4 n=4 时,共有 4 个单 1 序列,分别是:0001,0010,0100,1000。这些可以通过移位操作得到,即:

for(int j=0;j<n;j++)
	%1序列state1% = 1<<j;

对于其枚举出的每一个 s t a t e 1 state1 state1,都让它和 i i i 进行与 (&) 运算,只要与操作结果非 0 就说明当前 s t a t e 1 state1 state1 是当前某个初始 s t a t e state state 的子序列。比如对于序列 1010,其子序列只有 10 和 1000,而:

1 & 1010 = 0001 & 1010 = 0;
10 & 1010 = 0010 & 1010 = 10(非 0 可以进行 dp);
100 & 1010 = 0100 & 1010 = 0;
1000 & 1010 = 1000(非 0 可以进行 dp);

得到的结果也是只有序列 10 和 1000 符合要求。

这一过程的代码如下:

for(int i=1;i<(1<<n);i++){		//枚举所有配对序列state(如11010、11000、10010、01010等) 
	for(int j=0;j<n;j++)	//枚举所有的单1序列state1(如00001、00010、00100等) 
		if(i & (1<<j))		//如果当前的单1序列state1是由原始state得来(如00010、01000、10000是由11010得来)才能进行下面的动态转移
			%执行动态转移的操作%
}

接下来我们的重点来了:动态转移方程。

其实这个方程较之二维的并未发生多大变化,只是进行了降维处理,此时其被简化为:

d p [ s t a t e ] = max { d p [ 单独剔除 1 的 s t a t e ] + l i k e [ 当前 s t a t e 中 1 的个数(二进制) ] [ 被剔除的那个 1 所在的位置 ] } dp[state]=\text{max}\{ dp[单独剔除1的state] + like[当前state中1的个数(二进制)][被剔除的那个1所在的位置] \} dp[state]=max{dp[单独剔除1state]+like[当前state1的个数(二进制)][被剔除的那个1所在的位置]}

下面我们一一分析这新的状态转移方程中的各个项:

由于在上面已经得到了单 1 序列 s t a t e 1 state1 state1,因此在这里,单独剔除 1 的 s t a t e = 初始 s t a t e − s t a t e 1 state=初始state - state1 state=初始statestate1;而 “当前 s t a t e state state 中 1 的个数(二进制)” 可以直接由函数 getOneCount() 得到;至于 “被剔除的那个 1 所在的位置”,其实就等于 j + 1 j+1 j+1,比如 j = 0 j=0 j=0 1 < < j = 1 1<<j=1 1<<j=1,此时 1 所处位置为 0+1=1;当 j = 1 j=1 j=1 1 < < j = 10 1<<j=10 1<<j=10,此时 1 所处位置为 1+1=2……这一部分的代码如下:

for(int i=1;i<(1<<n);i++){		//枚举所有配对序列state(如11010、11000、10010、01010等) 
	for(int j=0;j<n;j++)		//枚举所有的单1序列state1(如00001、00010、00100等) 
		if(i & (1<<j))			//如果当前的单1序列state1是由原始state得来(如01010、10010、11000能由11010得来)才能进行下面的动态转移 
			dp[i]=max(dp[i],dp[i-(1<<j)]+like[getOneCount(i)][j+1]); 
}



下面给利用一维dp求解本题的完整满分代码:

#include<iostream>
using namespace std;

const int N=14; 
int like[N][N],dp[1<<N];

void create(int n)					//录入like数组 
{
	for(int i=1;i<=n;i++)
		for(int j=1;j<=n;j++)
			cin>>like[i][j];
}
int lowbit(int n){ return n&(-n); }	//取出最低位1所表示的数 
int getOneCount(int n)				//得到数n的二进制表达式中1的个数 
{
	int num=0;
	while(n){
		num++;
		n -= lowbit(n);
	}
	return num;
}
void DP(int n)
{
	for(int i=1;i<(1<<n);i++){		//枚举所有配对序列state(如11010、11000、10010、01010等) 
		for(int j=0;j<n;j++)		//枚举所有的单1序列state1(如00001、00010、00100等) 
			if(i & (1<<j))			//如果当前的单1序列state1是由原始state得来(如01010、10010、11000能由11010得来)才能进行下面的动态转移 
				dp[i]=max(dp[i],dp[i-(1<<j)]+like[getOneCount(i)][j+1]); 
	}
}

int main()
{
	int n;cin>>n;
	create(n);
	DP(N);
	cout<<dp[(1<<n)-1]<<endl;
	return 0;
}

END


  • 7
    点赞
  • 19
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 4
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

theSerein

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值