动态规划

一、动态规划概述

  动态规划(Dynamic Programming)通常是用来解决最优化问题的。最初是由数学家在研究多阶段决策过程的优化问题时,提出的优化原理,把多阶段过程转化为一系列单阶段问题,利用各阶段之间的关系,逐个求解,创立了解决优化问题的方法。值得注意的是动态规划的英文名dynamic programming ,这个名字看上去似乎和程序设计有关,然而这么理解就错了。这里的programming和程序设计没有任何关系,而是指表格查询法(tabular method),即将每一步计算的结果存储在表格里,供随后的计算使用。[举例说明的话太长,大致理解一下即可]

  一般的组合优化问题都有对应的 目标函数 约束条件。组合优化问题的 解分布在搜索空间中,其中满足约束条件的解称为 可行解,而在可行解中使得目标函数达到最小(或最大)的解称为 最优解(the optional solution),最优解可能存在多个。所谓求解组合优化问题就是找到该问题的最优解。

  如今动态规划技术已经被广泛应用于许多组合优化问题的算法设计中,如图的 多起点与多终点的最短路径问题、矩阵链的乘法问题、最大效益投资问题、背包问题、最长公共子序列问题、图像压缩问题、最大子段和问题、最优二分检索树问题、RNA的最优二级结构问题等

  需要注意的是:在口语交流时,大家常常说“动态规划算法”,但动态规划是求解最优化问题的一种途径、一种方法,而不是一种特定的算法。

  在学习《算法导论》书中的动态规划之前,一般都学习过分治法。

  分治法中的各个子问题是独立的(不包含公共的子问题),因此只要递归地求出各个子问题的解后,便可自下而上地将子问题的解合并成原问题的解。

partition the problem into disjoint(不相交) subproblems, solve the subproblems recursively, and then combine their solutions to solve the original problem.————《divide-and-conquer》

  但分治后, 如果各个子问题是不独立的,则分治法要做很多不必要的工作(即重复地解公共的子问题),对时间的消耗很大。例如在Fibonacci数列中递归的求解F(4)F(4),重复计算子问题F(2)F(2),影响求解效率(如下图所示)。在求解F(n)F(n)时,不仅时间复杂度是指数级的,而且反复递归调用时可能导致栈溢出。


斐波那契

  在上面的例子中,由于经分解得到的各个子问题不独立,就适合用动态规划求解问题。在求解的过程中,将已解决的子问题的解保存起来,在需要的时候可以轻松找出。通常采用表(table)的形式保存中间子问题的结果。这样就可以避免大量无意义的重复计算,从而降低算法的时间复杂度。在此要强调的是:解决问题、提高效率是动态规划的任务,但却不是动态规划的全部。因为我们不只是要解决一个问题,而是要以最优的方式解决这个问题。

  小结:动态规划的实质就是分治思想和解决冗余。将原来具有指数级复杂性的算法改进成具有多项式时间的算法,这是动态规划算法的目的。由于在实现的过程中,需要存储各种状态,所以它的空间复杂性要大于其他算法,这是一种以空间换取时间的技术。


二、动态规划三要素

  所有的算法都有局限性,超出特定条件,它就失去了作用。同样采用该算法要满足三个基本要素:最优子结构性质、子问题重叠性、自底向上的求解方法。在这三个要素的指导下,可以对某问题是否适合采用动态规划算法进行求解进行预判。

1.最优子结构性质 (optimal sub-structure)

  最优子结构性质,通俗的说法就是问题的最优解包含其子问题的最优解。任何问题,如果不具备该性质,就不能用动态规划来解决。常用反证法分析论证问题是否具备最优子结构性质。 
  有时对某个子问题的解不一定达到最优,但是当把它延伸成整个问题的解时反而成了最优解,这种问题不满足最优子结构性质,无法使用动态规划。

danamic programming 
11最优子结构性质

  最优子结构性质见上面的示意图:如果节点A到节点E的最长路径是(A>B>C>D>E)(A−>B−>C−>D−>E);那么,节点C到节点E的最长路径必定在此路径(A>B>C>D>EA−>B−>C−>D−>E)上! 
  在知道什么是最优子结构性质后,怎么利用这个性质设计动态规划算法呢?首先,要明确动态规划是基于分治策略的,会将原问题分解为一个或多个子问题。我们要根据具体问题,考虑所有选择所产生的子问题,然后从中选择一个最优的选项。比如在最长公共子序列LCS问题中,选择是根据xi=yjxi=yj成立与否分为1个或2个选项。

  • 如果xm=ynxm=yn,则zk=xm=ynzk=xm=ynZk1Zk−1Xm1Xm−1Yn1Yn−1的一个LCS。
  • 如果xmynxm≠yn,那么zkxmzk≠xm,意味着ZZXm1Xm−1YY的一个LCS。
  • 如果xmynxm≠yn,那么zkynzk≠yn,意味着ZZXXYn1Yn−1的一个LCS。

  从上述的结论可以看出,两个序列的LCS问题包含两个序列的前缀的LCS,因此,LCS问题具有最优子结构性质。为设计递归算法,我们可以进一步利用最优子结构性质写出问题的递归方程。设C[i,j]C[i,j]表示XiXiYjYj的最长公共子序列LCS的长度。如果i=0i=0j=0j=0,即一个序列长度为00时,那么LCS的长度为0。根据LCS问题的最优子结构性质,可得如下公式:

C[i,j]=0C[i1,j1]+1MAX(C[i,j1],C[i1,j])i=0j=0i,j>0xi=yji,j>0xiyjC[i,j]={0,当i=0或j=0C[i−1,j−1]+1,当i,j>0且xi=yjMAX(C[i,j−1],C[i−1,j])当i,j>0且xi≠yj

从而编写基于动态规划的递归算法。

2.子问题重叠性质 (subproblems overlap)

Dynamic programming solves each subsubproblem just once and then saves its answer in a table, thereby avoiding the work of recomputing.

  子问题重叠性质并不是动态规划适用的必要条件,但是如果该性质无法满足,动态规划算法同其他算法相比就不具备优势。

3.自底向上的求解方法 (bottom-up method)

  由于动态规划解决的问题具有子问题重叠性质,求解时需要自底向上的方法,即:首先选择合适的表格(一维或二维),将递归的停止条件填入表格的相应位置;然后将问题的规模一级一级放大,求出每一级子问题的最优质,并将其填入表格的相应位置,直到问题所要求的规模,此时求出的便是原问题的最优值。

  除了自底向上法之外,还可以使用“ 带备忘录的自顶向下法”(top-down with memoization)。此方法仍按自然的递归形式编写过程,但过程会保存每个子问题的解(用数组或散列表保存)。当需要子问题的解时,过程首先检查是否已经保存过此解。如果是,直接返回保存的值,从而节约计算时间;否则,按通常方式计算这个子问题。这个递归过程是带备忘的,因为它“记住了”之前已经计算出的结果。


三、动态规划和静态规划的关系

  算法世界中确实还存在静态规划的概念,只不过不是被直接称呼为静态规划,而是有着更加动听的名字:线性规划和非线性规划。 
   与静态规划相比,动态规划具有许多优越性:

  1. 动态规划的核心是找到一个问题所包含的子问题及其表现形式。找子问题的表现形式需要创造力和实验,但也存在着一些常用的形式,如:子问题是原问题的前缀、中缀,子问题是原问题的子树。因此,动态规划常有迹可循。
  2. 动态规划比静态规划更容易得到最优解。静态规划可能由于约束条件确定的约束集合复杂而变得困难。而动态规划把原问题分解为一系列结构相似的子问题,每个子问题的变量个数大大减小,约束集合也简单地多,因此更易求解。
  3. 动态规划可以得到一族最优解(原问题和子问题的最优解),而非线性规划只能得到全过程的最优解。
  4. 动态规划的时间效率可以容易获得:子问题数量×子问题的时间效率。

   与静态规划相比,动态规划也存在缺点:

  1. 找出子问题的表现方式需要创造力和实验,经常需要对每类问题进行具体分析,非熟练的分析人员难以准确对原问题进行合理分解,导致应用上的局限。
  2. 状态空间可能呈指数增长。如果一维状态变量有m个取值,则n维问题的状态就有mnmn个值。对于n较大的实际问题,其计算成本上无法容忍。

  静态规划和动态规划是可以相互转化的。原理复杂,能力有限,不做介绍。


四、仅有动态规划是不够的—–贪婪策略的出现

  动态规划的一个关键特点是每次做选择之前,对所有选择的效果进行计算。在计算的结果上选择能够达到最优的选项,从而保证每次选择都是最优的。但是,这种策略在当选项的数量非常巨大的时候将不堪重负。例如在下围棋的时候,如果采用动态规划策略,则需要先对每步可能的行棋的影响进行计算,然后比较选择最优的走法。但每一步可进行的走法实在太多,如果再考虑到一盘棋有几乎不计其数的步骤,所以计算任务非常大几乎不可能完成。这种情况就是上面介绍的动态规划缺点。这个时候应该采用新的策略———贪婪策略。 

五、动态规划算法经典案例

转自:https://segmentfault.com/a/1190000004498566

解决动态规划类问题,分为两步:1.确定状态,2.根据状态列状态转移方程
确定该状态上可以执行的操作,然后是该状态和前一个状态或者前多个状态有什么关联,通常该状态下可执行的操作必定是关联到我们之前的几个状态。

数字三角形

问题描述
给定一个数字三角形,找到从顶部到底部的最小路径和。每一步可以移动到下面一行的相邻数字上。

[2],
[3,4],
[6,5,7],
[4,1,8,3]

从顶到底部的最小路径和为11 ( 2 + 3 + 5 + 1 = 11)。

如果采用朴素算法,我们需要记录每次的行走轨迹,然后对其大小进行比较,最终得出结果,行走轨迹的统计是呈现指数递增的,所以我们要采用动态规划的方法来解决。根据我们的解决方法,先确定状态,也就是每次向下走的一步即为一个状态,然后是状态转移方程,从上一个状态到下一个状态,如果确定最优,当前状态的结果,取决于上一个状态,找到上一个状态,然后确定上一个状态到当前状态转移的方程。记录下每一个状态,我们通过一个二维数组来实现。

public int digitalTriangle(int[][] triangle) {
		if(triangle == null || triangle.length == 0) return 0;
		
		int dis = Integer.MAX_VALUE;
		int m = triangle.length;
		int[][] dp = new int[m][m];
		dp[0][0] = triangle[0][0];
		
		for(int i = 1; i < m; i++) {
			for(int j = 0; j < triangle[i].length; j++) {
				int low = Math.max(0, j - 1);
				int high = Math.min(j + 1, triangle[i-1].length - 1);
				if(high - low > 1) {
					int a = Math.min(dp[i-1][low], dp[i-1][low+1]);
					dp[i][j] = Math.min(a, dp[i-1][low+1]) + triangle[i][j];
				}
				else
					dp[i][j] = Math.min(dp[i-1][low], dp[i-1][high]) + triangle[i][j];
			}
		}
		for(int i = 0; i < dp[m-1].length; i++) {
			dis = Math.min(dis, dp[m-1][i]);
		}
		return dis;
	}

背包问题两讲

这里解决了两张背包问题,一个是确定最多可以装的下多少的背包盛放物品问题,还有一个是背包中放置的物品具有价值,要来确定其价值为多少。解决方法都是通过动态规划来解决。

背包问题1
问题描述
在n个物品中挑选若干物品装入背包,最多能装多满?假设背包的大小为m,每个物品的大小为A[i]

首先寻找状态,确定将什么作为状态,记录状态,有背包和物品,物品有放和不放两种状态,放置的时候可能会对应各种容量,当前的容量下可以放置进的最多的物品取决于上一个物品放置时在该状态下所能够达到的最大状态和当前物品的的大小,这样我们在最后,就可以得到每种容量下,所能放置的物品的最大数量。

 public int backPack(int m, int[] A) {
        // write your code here
           if (A == null || 0 == A.length || m == 0)
               return 0;
          int len = A.length;
          //初始化了一个数组,
          int[][]  sum = new int[len][m+1];
          for(int i=0;i<len;i++){
               sum[i][0] = 0;
          }
          for(int j=0;j<m+1;j++){
               if(j>=A[0]){
                    sum[0][j] = A[0];
               }
          }         
          for(int i=1;i<len;i++){
               for(int j=1;j<m+1;j++){
                    if(j>=A[i]){
                         sum[i][j] = max(sum[i-1][j], sum[i-1][j-A[i]]+A[i]);
                    }else{
                         sum[i][j] = sum[i-1][j];
                    }
               }
          }
          return sum[len-1][m];
    }

背包问题2
问题描述
给出n个物品的体积A[i]和其价值V[i],将他们装入一个大小为m的背包,最多能装入的总价值有多大?

考虑到价值问题,状态不发生变化,只是对于状态我们所记录的内容方式变化,我们现在记录的是其价值,而不是其放置的物品的大小。

public int backPackII(int m, int[] A, int V[]) {
        // write your code here
        if(m==0||A==null||V==null||0==A.length)
            return 0;
        int len = A.length;
        int [][]val = new int[len][m+1];
        for(int i=0;i<len; i++){
            val[i][0]=0;
        }
        for(int i=0; i<m+1; i++){
            if(i>=A[0])
                val[0][i]=V[0];
        }
        for(int i=1; i<len; i++){
            for(int j=1;j<m+1; j++){
                if(j>=A[i]){
                    val[i][j] = max(val[i-1][j],val[i-1][j-A[i]]+V[i]);
                }else{
                    val[i][j]=val[i-1][j];
                }
            }
        }
        return val[len-1][m];
    }

公共子序列,公共子串问题

公共子串
给出两个字符串,找到最长公共子串,并返回其长度

状态,字符串的每一位对应另一个字符串的每一个位置,因此通过一个二维数组来表示这每一个状态位,然后是找状态转移方程,转移方程即为其前一个位置的前一个的比对的结果累计当前的结果,如果相同则加1,否则为0

 public int longestCommonSubstring(String A, String B) {
        // write your code here
          if(A==null||B==null||A.length()==0||B.length()==0)
            return 0;
        int lenOfA = A.length();
        int lenOfB = B.length();
        //状态记录结构
        int[][] longSubString = new int[lenOfB][lenOfA];
        int max = 0;
        for(int i=0; i<lenOfA; i++){
            if(B.charAt(0)==A.charAt(i)){
                longSubString[0][i] = 1;
                max = 1;
            }
        }
        for(int i=1; i<lenOfB; i++){
            for(int j=0; j<lenOfA; j++){
            //状态转移
                if(B.charAt(i)==A.charAt(j)){
                    if(j-1>=0)
                    longSubString[i][j] = longSubString[i-1][j-1]+1;
                    else 
                         longSubString[i][j]=1;
                    max = Max(longSubString[i][j],max);
                }
            }
        }
        return max;
    }

公共子序列
给出两个字符串,找到最长公共子序列(LCS),返回LCS的长度。

子序列和子串的区别在于,其值不是仅仅取决于其上一个位置的对应于比对的位置的状态,而是要寻找最大的前面的状态值中最大的一个。

 public int longestCommonSubsequence(String A, String B) {
        // write your code here
        if(A==null||B==null||A.length()==0||B.length()==0)
            return 0;
        int lenOfA = A.length();
        int lenOfB = B.length();
           int [][] subsLen = new int[lenOfB][lenOfA];
           int max=0;
           for(int i=0; i<lenOfA; i++){
               if(A.charAt(i)==B.charAt(0)){
                   subsLen[0][i]=1;
                   max = 1;
               }
           } 
           for(int i=1; i<lenOfB; i++){
               for(int j=0; j<lenOfA; j++){
                   if(A.charAt(j)==B.charAt(i)){
                       subsLen[i][j]=Max(subsLen,i-1,j-1)+1;
                       if(subsLen[i][j]>max)
                           max = subsLen[i][j];
                   }
               }
           }
           return max;

    }
    
     public int Max(int[][] array,int end1,int end2){
        if(end2<0)
            return 0;
        int max = array[0][0];
        for(int i=0; i<=end1; i++){
            for(int j=0; j<=end2; j++){
                if(array[i][j]>max)
                    max = array[i][j];
            }
        }
        return max;
    }

打劫房屋

问题描述
假设你是一个专业的窃贼,准备沿着一条街打劫房屋。每个房子都存放着特定金额的钱。你面临的唯一约束条件是:相邻的房子装着相互联系的防盗系统,且 当相邻的两个房子同一天被打劫时,该系统会自动报警。
给定一个非负整数列表,表示每个房子中存放的钱, 算一算,如果今晚去打劫,你最多可以得到多少钱 在不触动报警装置的情况下。

我们可以在通过一个数组来记录下来,我们在每个位置打劫,所能得到的钱,在求下一个状态的时候,遍历前面的与其相隔的所有状态,然后找到一个最大的,但是复杂度比较到达到了n2,空间复杂度为n,对于状态,我们需要记录的只有其前一个,还有与其相隔的所有状态的最大值,因此通过两个数字来表示即可。具体转化方式见代码实现。

public static long houseRobber(int[] A) {
		if(A.length == 0 || A == null)
			return 0;
		int n = A.length;
		
		int[] dp = new int[n + 1];
		dp[0] = 0;
		dp[1] = A[0];
		for(int i = 2; i < n + 1; i++) {
			dp[i] = Math.max(max(dp, i-1), max(dp, i-2) + A[i-1]);
		}
		return dp[n];
	}
	private static int max(int[] dp, int i) {
		int max = dp[0];
		for(int k = 0; k <= i; k++) {
			max  = max > dp[k] ? max : dp[k];
		}
		return max;
	}


编辑距离

题目描述
给出两个单词word1和word2,计算出将word1 转换为word2的最少操作次数。

你总共三种操作方法:

  • 插入一个字符

  • 删除一个字符

  • 替换一个字符

三种操作,因此我们在一个状态上面可以进行三种状态的变化,确定每一个状态,通过第二个字符串和第一个字符串的每一个位置的对应作为一个状态,处在该状态上,我们可以进行的操作,改,进行改操作,那么与之关联的前一个状态是其前一个字符对应另一个字符串的当前对应的前一个字符,增,则是说当前字符串的当前位对应到前一个字符串的前一个位置,删,则为当前字符串的当前位对应前一个字符串的前一个位置。为了增加一个增的位置,需要我们在其前面,所以我们在两个字符串的开始处设置一增加的位置。

public static int minDistance(String word1, String word2) {
		if(word1 == null || word2 == null)
			return 0;
		
		int m = word1.length();
		int n = word2.length();
		int[][] dp = new int[m+1][n+1];
		for(int i = 0; i < m + 1; i++) {
			dp[i][0] = i;
		}
		for(int j = 0; j < n + 1; j++) {
			dp[0][j] = j;
		}
		
		for(int i = 1; i < m + 1; i++) {
			for(int j = 1; j < n + 1; j++) {
				if(word1.charAt(i-1) == word2.charAt(j-1))
					dp[i][j] = Math.min(Math.min(dp[i-1][j-1], dp[i-1][j] + 1),dp[i][j-1] + 1);
				else
					dp[i][j] = Math.min(Math.min(dp[i-1][j-1], dp[i-1][j]),dp[i][j-1]) + 1;
			}
		}
		return dp[m][n];
	}

N皇后问题1

n皇后问题是将n个皇后放置在n*n的棋盘上,皇后彼此之间不能相互攻击。
给定一个整数n,返回所有不同的n皇后问题的解决方案。

对于n皇后的问题,下一个皇后的布局位置将与之前的所有王后布局有关,因此通过动态规划,没安置一个皇后就作为一个状态,然后判断之前的已经安放的所有皇后的状态,确定是否可以按这一个皇后,通过递归的方式实现。

public int totalNQueens(int n) {
		if(n<1)
	        return 0;
	    int[] record = new int[n];
	    return process(0,record,n);
    }
    
	public int process(int i,int[] record,int n){
	    if(i==n)
	        return 1;
	    int res = 0;
	    for(int j=0; j<n; j++){
	        if(isValid(record,i,j)){
	            record[i]=j;
	            res+=process(i+1,record,n);
	        }
	    }
	    return res;
	}

	public boolean isValid(int[] record,int i,int j){
	    for(int k=0; k<i; k++){
	        if(j==record[k]||Math.abs(record[k]-j)==Math.abs(i-k)){
	            return false;
	        }
	    }
	    return true;
	}

N皇后问题2

public List<List<String>> solveNQueens(int n) {
        char[][] board = new char[n][n];
        for(int i = 0; i < n; i++)
            for(int j = 0; j < n; j++)
                board[i][j] = '.';
        List<List<String>> res = new ArrayList<List<String>>();
        dfs(board, 0, res);
        return res;
    }
    
    private void dfs(char[][] board, int colIndex, List<List<String>> res) {
        if(colIndex == board.length) {
            res.add(construct(board));
            return;
        }
        
        for(int i = 0; i < board.length; i++) {
            if(validate(board, i, colIndex)) {
                board[i][colIndex] = 'Q';
                dfs(board, colIndex + 1, res);
                board[i][colIndex] = '.';
            }
        }
    }
    
    private boolean validate(char[][] board, int x, int y) {
        for(int i = 0; i < board.length; i++) {
            for(int j = 0; j < y; j++) {
                if(board[i][j] == 'Q' && (x + j == y + i || x + y == i + j || x == i))
                    return false;
            }
        }
        
        return true;
    }
    
    private List<String> construct(char[][] board) {
        List<String> res = new LinkedList<String>();
        for(int i = 0; i < board.length; i++) {
            String s = new String(board[i]);
            res.add(s);
        }
        return res;
    }

六、动态规划例题

        Maximal Rectangle:http://blog.youkuaiyun.com/github_35160620/article/details/52158604

  public int maximalRectangle(char[][] matrix) {
	    if(matrix.length == 0 || matrix == null) return 0;
	    int m = matrix.length;
	    int n = matrix[0].length;
	    int[] left = new int[n]; 
	    int [] right = new int[n];
	    int[] height = new int[n];
	    Arrays.fill(right,n);
	    int maxA = 0;
	    for(int i = 0; i < m; i++) {
	        int cur_left = 0, cur_right = n; 
	        for(int j = 0; j < n; j++) { // compute height (can do this from either side)
	            if(matrix[i][j] == '1') height[j]++; 
	            else height[j]=0;
	        }
	        for(int j = 0; j< n; j++) { // compute left (from left to right)
	            if(matrix[i][j] == '1') left[j] = Math.max(left[j], cur_left);
	            else {left[j]=0; cur_left=j+1;}
	        }
	        // compute right (from right to left)
	        for(int j = n-1; j >= 0; j--) {
	            if(matrix[i][j]=='1') right[j] = Math.min(right[j],cur_right);
	            else {right[j]=n; cur_right=j;}    
	        }
	        // compute the area of rectangle (can do this from either side)
	        for(int j=0; j<n; j++)
	            maxA = Math.max(maxA, (right[j]-left[j])*height[j]);
	    }
	    return maxA;
	  }

        Regular Expression Matching:https://leetcode.com/problems/regular-expression-matching/description/(经典)

public boolean isMatch(String s, String p) {
    if (s == null || p == null) {
        return false;
    }
    boolean[][] dp = new boolean[s.length()+1][p.length()+1];
    dp[0][0] = true;
    for (int i = 0; i < p.length(); i++) {
        if (p.charAt(i) == '*' && dp[0][i-1]) {
            dp[0][i+1] = true;
        }
    }
    for (int i = 0 ; i < s.length(); i++) {
        for (int j = 0; j < p.length(); j++) {
            if (p.charAt(j) == '.') {
                dp[i+1][j+1] = dp[i][j];
            }
            if (p.charAt(j) == s.charAt(i)) {
                dp[i+1][j+1] = dp[i][j];
            }
            if (p.charAt(j) == '*') {
                if (p.charAt(j-1) != s.charAt(i) && p.charAt(j-1) != '.') {
                    dp[i+1][j+1] = dp[i+1][j-1];
                } else {
                    dp[i+1][j+1] = (dp[i+1][j] || dp[i][j+1] || dp[i+1][j-1]);
                }
            }
        }
    }
    return dp[s.length()][p.length()];
}

击鼓传花

import java.util.Scanner;

public class Main {
	
	public static void main(String[] args) {
		Scanner s = new Scanner(System.in);

        int n = s.nextInt();
      	int m = s.nextInt();
      	
      	int[][] dp = new int[n + 1][m + 1];
      	dp[1][0] = 1;
      	dp[2][1] = 1;
      	dp[n][1] = 1;
      	for(int j = 2; j < m + 1; j++) {
      		for(int i = 1; i < n + 1; i++){
      			if(i == 1)
      				dp[i][j] = dp[2][j-1] + dp[n][j-1];
      			else if(i == n)
      				dp[i][j] = dp[1][j-1] + dp[n-1][j-1];
      			else
      				dp[i][j] = dp[i-1][j-1] + dp[i+1][j-1];
      		}
      	}
      	System.out.println(dp[1][m]);
	}
}

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值