数据结构与算法学习⑦(动态规划之买卖股票 字典树和并查集 高级搜索)

本文详细介绍了多种股票交易问题的动态规划解决方案,包括买卖股票的最佳时机、最佳时机II、最佳时机含冷冻期、最佳时机III和最佳时机IV,分析了状态转移方程和初始化条件。此外,还探讨了字典树(Trie树)的应用,如在单词搜索II问题中的实现,以及并查集在朋友圈高级搜索中的应用。
摘要由CSDN通过智能技术生成

数据结构与算法学习⑦

买卖股票系列

算法分析

确定状态和选择,其实对于选择我们很容易去确定,在第 i 天我们可以选择下列三种之一:买入,卖出,不交易;但这6道题中基本都限制了,买之前必须得先卖出(第一次除外),卖出前必须买入,且不能同时参与多笔交易。
在这里插入图片描述
1、确定状态参数和选择
此处状态参数有第 i 天,交易次数 k ,是否持有股票 j ,
其中 i 的选择范围 {0,1,2,…n-1} , k 的选择范围: {0,1,2,…maxK} , j 的选择范围:{0,1}
选择也很简单:不交易,买入,卖出,但每天的选择是受限制的。
2、定义dp数组
dp[i][k][j] 代表了在第 i 天,交易次数不超过 k 次,持有股票或不持有股票下的最大收益。
3、状态转移逻辑
通过之前的分析,状态转移方程可列举如下:

dp[i][k][0] = MAX ( dp[i-1][k][0] , dp[i-1][k][1] + prices[i]); 
dp[i][k][1] = MAX ( dp[i-1][k][1] , dp[i-1][k-1][0] - prices[i])

而我们最终要返回的是就是: dp[n-1][maxK][0] ,
为什么不是 dp[n-1][maxK][1] ,因为 j=1 代表手里持有股票, j=0 代表手里不持有股票,很明细
j=0 收益会更大。
4、初始化状态

dp[-1][k][0] = dp[i][0][0] = 0; 
dp[-1][k][1] = dp[i][0][1] = -INFINITY

注意:
因为状态转移方程中有一个 i-1 ,而 i 是从 0 开始的,故会产生 -1 的情况,至于怎么来表达 -1 的情
况,方式有很多种
-INFINITY 代表了负无穷,也就是一种不可能的情况。
有了这些相关思路有,我们可以依次去解决股票买卖的几个问题了

121. 买卖股票的最佳时机

买卖股票的最佳时机
该题中 maxK=1 ,拿状态方程看一下

dp[i][1][0]=MAX(dp[i-1][1][0],dp[i-1][1][1] + prices[i]); 
dp[i][1][1]=MAX(dp[i-1][1][1],dp[i-1][0][0] - prices[i]) =MAX( dp[i-1][1][1] , - prices[i])

解释一下: k=0由初始条件dp[i-1][0][0]=0
而且此时会发现,k都是为1,即k对状态转移没有影响了,因此可以简化掉k,变成如下

dp[i][0]=MAX(dp[i-1][0], dp[i-1][1] + prices[i]); 
dp[i][1]=MAX( dp[i-1][1] , - prices[i]) 

代码实现如下:

class Solution {
    public int maxProfit(int[] prices) {
    //特殊
      if(prices==null||prices.length==0){
          return 0;
      }
      //定义dp
      int n=prices.length;
      //第i天持有或不持有股票的情况下的最大收益
      int [][]dp=new int[n][2];
      //转移
      for(int i=0;i<n;i++){
          if(i==0){
          //针对边界初始情况处理一下
          // 状态转移方程中需要计算dp[-1],我们单独计算即可
          /* Math.max(dp[-1][0], dp[-1][1] + prices[i])
             Math.max(0, -INFINITY + prices[i]) = 0;  */
              dp[0][0]=0;   //第一天不持有股票,前面也没什么操作
              /* Math.max(dp[-1][1], dp[-1][0] - prices[i]) 
                 Math.max(-INFINITY, 0 - prices[i]) = - prices[i] */
              dp[0][1]=-prices[i]; //第一天就买入股票,前面什么也没操作
              continue;
          }
          dp[i][0]=Math.max(dp[i-1][0],dp[i-1][1]+prices[i]);
          dp[i][1]=Math.max(dp[i-1][1],-prices[i]);
      }
      return dp[n-1][0];
    }
}

但是这样处理 初始条件 很麻烦,而且注意一下状态转移方程,新状态只和相邻的一个状态有关,其实不用整个 dp 数组,只需要两个变量储存所需的状态就足够了,这样可以把空间复杂度降到 O(1)

class Solution {
    public int maxProfit(int[] prices) {
      if(prices==null||prices.length==0){
          return 0;
      }
      int n=prices.length;
     int dp_i_0 = 0;//初始值,对应dp[-1][0] = 0 
     int dp_i_1 = Integer.MIN_VALUE; //初始值,对应dp[-1][1] = -INFINITY


      for(int i=0;i<n;i++){
          dp_i_0 = Math.max(dp_i_0, dp_i_1 + prices[i]); 
          dp_i_1 = Math.max(dp_i_1, - prices[i]);
      }
      return dp_i_0;
    }
}

122. 买卖股票的最佳时机 II

买卖股票的最佳时机 II
该题目中,买卖次数不受限制,可以认为 maxK=+INFINITY ,也可以认为 maxK=-INFINITY ,故也就证明 状态k 对状态转移不会产生影响, k 和 k - 1 是一样的。

dp[i][k][0]=MAX(dp[i-1][k][0],dp[i-1][k][1] + prices[i]); 
dp[i][k][1]=MAX(dp[i-1][k][1],dp[i-1][k-1][0]-prices[i])=MAX(dp[i-1][k] [1],dp[i-1][k][0]- prices[i])

既然 k 对状态转移不会产生影响,那么可以去掉状态 k 编程如下:

dp[i][0]=MAX(dp[i-1][0],dp[i-1][1] + prices[i]); 
dp[i][1]=MAX(dp[i-1][1],dp[i-1][0]- prices[i])

同样,对于初始化条件

dp[-1][0] = 0;
dp[-1][1] = -INFINITY

直接代码实现:

class Solution {
    public int maxProfit(int[] prices) {
        int n=prices.length;
        int dp_i_0=0;
        int dp_i_1=Integer.MIN_VALUE;

        for(int i=0;i<n;i++){
            dp_i_0=Math.max(dp_i_0,dp_i_1+prices[i]);
            dp_i_1=Math.max(dp_i_1,dp_i_0-prices[i]);
        }
        return dp_i_0;
    }
}

309. 最佳买卖股票时机含冷冻期

309. 最佳买卖股票时机含冷冻期
该题目中,买卖次数不受限制,可以认为 maxK=+INFINITY ,也可以认为 maxK=-INFINITY ,故也就证明 状态k 对状态转移不会产生影响, k 和 k - 1,k-2 等等 是一样的。
另外,卖出股票后,你无法在第二天买入股票 (即冷冻期为 1 天)。此时我们的状态转移方程需要做一个变更

//通用的状态转移方程 
dp[i][k][0] = MAX ( dp[i-1][k][0] , dp[i-1][k][1] + prices[i]); 
dp[i][k][1] = MAX ( dp[i-1][k][1] , dp[i-1][k-1][0] - prices[i]); 
//结合该题改造后的方程,该题的要求是第i天准备买的时候,前一天(i-1)不能卖,即前一天不能做任何 操作,因此收益继承自前两天,也就是说第i天的状态只能从(i-2)天转移过来 
dp[i][k][0] = MAX ( dp[i-1][k][0] , dp[i-1][k][1] + prices[i]); 
dp[i][k][1] = MAX ( dp[i-1][k][1] , dp[i-2][k-1][0] - prices[i]); 
//又由于k对状态转移没有影响故去掉状态k 
dp[i][0] = MAX ( dp[i-1][0] , dp[i-1][1] + prices[i]); 
dp[i][1] = MAX ( dp[i-1][1] , dp[i-2][0] - prices[i]);

初始条件:
//原始

dp[-1][k][0] = dp[i][0][0] = 0; 
dp[-1][k][1] = dp[i][0][1] = -INFINITY 
//本题的初始化状态
 dp[-1][0] = 0;
  dp[-2][0] = 0; 
  dp[-1][1] = -INFINITY

直接代码实现为:

class Solution {
    public int maxProfit(int[] prices) {
           int n=prices.length;
           if(n<1){
               return 0;
           }
            int dp_i_0 = 0; 
            int dp_i_2_0 = 0; 
            int dp_i_1 = Integer.MIN_VALUE;
           
           for(int i=0;i<n;i++){
           /* dp[i][0] = MAX ( dp[i-1][0] , dp[i-1][1] + prices[i]);
              dp[i][1] = MAX ( dp[i-1][1] , dp[i-2][0] - prices[i]); */
               int temp=dp_i_0;
               dp_i_0=Math.max(dp_i_0,dp_i_1+prices[i]);
               dp_i_1=Math.max(dp_i_1,dp_i_2_0-prices[i]);
               dp_i_2_0=temp;
           }
         return dp_i_0;
    }
}

714. 买卖股票的最佳时机含手续费

714. 买卖股票的最佳时机含手续费
该题目中,买卖次数不受限制,可以认为 maxK=+INFINITY ,也可以认为 maxK=-INFINITY ,故也就证明 状态k 对状态转移不会产生影响, k 和 k - 1,k-2 等等 是一样的,可从状态参数中去掉 k
另外,每次交易要支付手续费,只要把手续费从利润中减去即可,故我们定义在买入的时候将手续费扣除。
状态转移方程改造如下:

//原始状态转移方程 
dp[i][k][0] = MAX ( dp[i-1][k][0] , dp[i-1][k][1] + prices[i]); 
dp[i][k][1] = MAX ( dp[i-1][k][1] , dp[i-1][k-1][0] - prices[i]) 
//改造后的状态转移方程 
dp[i][0] = MAX ( dp[i-1][0] , dp[i-1][1] + prices[i] ); 
dp[i][1] = MAX ( dp[i-1][1] , dp[i-1][0] - prices[i] - fee)

初始化状态:

dp[-1][0] = 0; 
dp[-1][1] = -INFINITY

直接写出代码

class Solution {
    public int maxProfit(int[] prices, int fee) {
            int n=prices.length;
            /* dp[i][0] = MAX ( dp[i-1][0] , dp[i-1][1] + prices[i] ); 
               dp[i][1] = MAX ( dp[i-1][1] , dp[i-1][0] - prices[i] - fee) */
            int dp_i_0=0;
            int dp_i_1=Integer.MIN_VALUE;

            for(int i=0;i<n;i++){
                int temp=dp_i_0;
                dp_i_0=Math.max(dp_i_0,dp_i_1+prices[i]);
                dp_i_1=Math.max(dp_i_1,temp-prices[i]-fee);
            }
            return dp_i_0;
    }
}

注意:手续费的扣除也可以选择在卖出的时候扣除,即

dp[i][0] = MAX ( dp[i-1][0] , dp[i-1][1] + prices[i] - fee ); 
dp[i][1] = MAX ( dp[i-1][1] , dp[i-1][0] - prices[i])

只不过代码编写的时候需要注意一下,初始条件当 i 等于0时,会产生 dp[-1][1] 的值,我们当时定的 dp[-1[1]=Integer.MIN_VALUE 就不能这样设置了,这个地方 -fee 后会导致 int 存储范围越界,不过我们可以改一下初始条件 dp[-1][1]=Integer.MIN_VALUE+fee ,代码如下

class Solution {
    public int maxProfit(int[] prices, int fee) {
            int n=prices.length;
            int dp_i_0=0;
            int dp_i_1=Integer.MIN_VALUE+fee;

            for(int i=0;i<n;i++){
                int temp=dp_i_0;
                dp_i_0=Math.max(dp_i_0,dp_i_1+prices[i]-fee);
                dp_i_1=Math.max(dp_i_1,temp-prices[i]);
            }
            return dp_i_0;
    }
}

不过更好的做法是,我们遍历的时候从 i=1 开始遍历

class Solution {
    public int maxProfit(int[] prices, int fee) {
            int n=prices.length;
            int dp_i_0=0;
            int dp_i_1=-prices[0];

            for(int i=1;i<n;i++){
                int temp=dp_i_0;
                dp_i_0=Math.max(dp_i_0,dp_i_1+prices[i]-fee);
                dp_i_1=Math.max(dp_i_1,temp-prices[i]);
            }
            return dp_i_0;
    }
}

123. 买卖股票的最佳时机 III

123. 买卖股票的最佳时机 III
该题中, maxK=2

//原始状态转移方程 
dp[i][k][0] = MAX ( dp[i-1][k][0] , dp[i-1][k][1] + prices[i]); 
dp[i][k][1] = MAX ( dp[i-1][k][1] , dp[i-1][k-1][0] - prices[i])

根据我们之前所给出的状态转移的通用模板来看

//进行状态转移 
for 状态1 in 状态1的所有取值: 
  for 状态2 in 状态2的所有取值: 
    for ... dp[状态1][状态2][...] = 求最值(选择1,选择2...)

我们现在需要遍历这三个状态: i 代表的第几天, k 代表的是最多交易次数, j 代表是否持有股票
但由于本题k取值的特殊性 k=2 因此可以像状态 j 一样进行枚举,反正 k 的取值要么是 1 要么是2 。
所以状态转移方程可改造为:

//k=1 
dp[i][1][0] = MAX ( dp[i-1][1][0] , dp[i-1][1][1] + prices[i]); 
dp[i][1][1] = MAX ( dp[i-1][1][1] , dp[i-1][1-1][0] - prices[i]) 
//k=2 
dp[i][2][0] = MAX ( dp[i-1][2][0] , dp[i-1][2][1] + prices[i]); 
dp[i][2][1] = MAX ( dp[i-1][2][1] , dp[i-1][2-1][0] - prices[i]) 

简化后为如下形式:

dp[i][1][0] = MAX ( dp[i-1][1][0] , dp[i-1][1][1] + prices[i]); 
dp[i][1][1] = MAX ( dp[i-1][1][1] , - prices[i]) //dp[i-1][1-1][0] = 0 
dp[i][2][0] = MAX ( dp[i-1][2][0] , dp[i-1][2][1] + prices[i]); 
dp[i][2][1] = MAX ( dp[i-1][2][1] , dp[i-1][1][0] - prices[i]);

初始化状态:

dp[-1][1][0] = 0; 
dp[-1][1][1] = -INFINITY; 
dp[-1][2][0] = 0; 
dp[-1][2][1] = -INFINITY;

注意:最后返回的是 dp[n-1][k][0] 的值

class Solution {
    public int maxProfit(int[] prices) {
         int n=prices.length;
         int[][][]dp=new int[n][3][2];

         dp[0][1][0]=0;
         dp[0][2][0]=0;
         dp[0][1][1]=-prices[0];
         dp[0][2][1]=-prices[0];
        for(int i=1;i<prices.length;i++){
            for(int j=2;j>0;j--){
               dp[i][j][0]=Math.max(dp[i-1][j][0],dp[i-1][j][1]+prices[i]);
               dp[i][j][1]=Math.max(dp[i-1][j][1],dp[i-1][j-1][0]-prices[i]);
            }
        }
        return dp[n-1][2][0];
    }
}

故直接用状态压缩写出O(1)空间复杂度的代码来:

class Solution {
    public int maxProfit(int[] prices) {
         int n=prices.length;
         /* 状态转移方程:
          dp[i][1][0] = MAX ( dp[i-1][1][0] , dp[i-1][1][1] + prices[i]); 
          dp[i][1][1] = MAX ( dp[i-1][1][1] , - prices[i]) 
          //dp[i- 1][1-1][0] = 0 dp[i][2][0] = MAX ( dp[i-1][2][0] , dp[i-1][2][1] + prices[i]); 
            dp[i][2][1] = MAX ( dp[i-1][2][1] , dp[i-1][1][0] - prices[i])
            //由于这里依赖dp[i-1][1][0],故先计算k=2的情况 初始条件: 
            dp[-1][1][0] = 0; 
            dp[-1][1][1] = -INFINITY; 
            dp[-1][2][0] = 0; 
            dp[-1][2][1] = -INFINITY; */

         int i_1_0=0;
         int i_1_1=Integer.MIN_VALUE;
         int i_2_0=0;
         int i_2_1=Integer.MIN_VALUE;

        
        for(int i=0;i<prices.length;i++){
             i_2_0=Math.max(i_2_0,i_2_1+prices[i]);
             i_2_1=Math.max(i_2_1,i_1_0-prices[i]);

             i_1_0=Math.max(i_1_0,i_1_1+prices[i]);
             i_1_1=Math.max(i_1_1,-prices[i]);
        }
        return i_2_0;
    }
}

188. 买卖股票的最佳时机 IV

188. 买卖股票的最佳时机 IV
该题中,状态参数 k 可以是任意值,就不可能再像上一题那样枚举了,只能通过 for 循环的方式穷举。
状态方程:

//原始状态转移方程 
dp[i][k][0] = MAX ( dp[i-1][k][0] , dp[i-1][k][1] + prices[i]); 
dp[i][k][1] = MAX ( dp[i-1][k][1] , dp[i-1][k-1][0] - prices[i])

初始化状态

dp[-1][k][0] = dp[i][0][0] = 0; 
dp[-1][k][1] = dp[i][0][1] = -INFINITY

根据状态转移方程和初始化状态,写出代码为:

class Solution {
    public int maxProfit(int k, int[] prices) {
        if(prices==null||prices.length==0){
            return 0;
        }
        //定义k的有效范围
          k=Math.min(k,prices.length/2);
          int n=prices.length;
          int[][][]dp=new int[n][k+1][2];
;
//第二维的长度是K+1的原因是: dp[i][k]代表的是第i天交易次数不超过k次,下标为k的数组长度是k+1 
/* 状态转移方程: 
dp[i][k][0] = MAX ( dp[i-1][k][0] , dp[i-1][k][1] + prices[i]); 
dp[i][k][1] = MAX ( dp[i-1][k][1] , dp[i-1][k-1][0] - prices[i]) 
初始化状态: 
dp[-1][k][0] = dp[i][0][0] = 0;
dp[-1][k][1] = dp[i][0][1] = -INFINITY */
          for(int i=0;i<n;i++){
              for(int j=1;j<=k;j++){
                  if(i==0){
                      dp[0][j][0]=0;
                      dp[0][j][1]=-prices[0];
                      continue;
                  }
                  dp[i][j][0]=Math.max(dp[i-1][j][0],dp[i-1][j][1]+prices[i]);
                  dp[i][j][1]=Math.max(dp[i-1][j][1],dp[i-1][j-1][0]-prices[i]);
              }
          }
         return dp[n-1][k][0];
    }
}

Trie树

在这里插入图片描述
解决方案分析:如何设计存储的数据结构,能满足快速高效的前缀匹配?
问题:你有什么样的思路?
1、二叉搜索树?
2、散列表?
3、堆?
接下来所讲的 Trie 树就是基于这种情况而产生的

1.1、Trie树的定义及特性

字典树,即 Trie 树,又称单词查找树或键树,也是一种树形结构,典型应用是统计和排序大量的字符串(但也不仅限于字符串),因此经常被搜索引擎系统用于文本词频统计。
在这里插入图片描述

字典树的特性有如下几点:
1、节点本身不存完整单词字符串,(跟以前的树不太一样,以前树中的节点存储的都是完整的数据)
2、根节点不存任何数据,除根节点外,其他节点每个节点可存单词字符串中的一个字符(也可不存),当然也可根据需要存储其他的信息,比如词条出现频率等等。
3、从根节点到某一节点,路径上经过的字符连接起来,才为该节点对应的单词字符串。
4、每个节点的子节点路径代表的字符都不一样。
5、在 Trie 树中查找字符串的时间复杂度是 O(k),k 表示要查找的字符串的长度

总结:
1、trie树是一个多叉树
2、Trie 树的本质,就是利用字符串之间的公共前缀,将重复的前缀合并在一起
3、节点内部的结构如下图

在这里插入图片描述
应用场景:
添加链接描述

1.2、面试实战

  1. 实现 Trie (前缀树)
    208. 实现 Trie (前缀树)

1、节点的定义

 class TrieNode{
 /* 当前节点所代表的字母,当然也可以不存 当前节点还可以存储其他所需要的信息,比如从访问频率等 */
 // private char w;
 //指向子节点的指针
     private TrieNode[]links;
     /* 最多R个指向子结点的链接,其中每个链接对应字母表数据集中的一个字母。 
        本题R定位26,为小写拉丁字母的数量 */
     private final int R=26;
     //指定节点是对应单词的结尾,还是说只是单词前缀
     private boolean isEnd;

     public TrieNode(){
         links=new TrieNode[R];
     }
/* 判断当前节点后是否有节点ch 即当前字母后是否有字母`ch` */
     public boolean containsKey(char ch){
         return links[ch-'a']!=null;
     }
     /* 从当前节点获取存储`ch`的节点 */
     public TrieNode get(char ch){
         return links[ch-'a'];
     }
     /* 存储`ch`的映射关系,存储后表明当前字母后面多了一个字母`ch` */
     public void put(char ch,TrieNode node){
         links[ch-'a']=node;
     }
     /* 设置当前节点为单词结尾,即这个单词到此节点就结束了 */
     public void setEnd(){
         isEnd=true;
     }
     /* 获取是否结束的标识 */
     public boolean isEnd(){
         return isEnd;
     }
 }

2、完成根节点的创建

//Trie树根节点 
TrieNode root; 

/** Initialize your data structure here. */
public Trie() { 
    root = new TrieNode(); 
}

3、向 Trie 树中插入单词,
操作过程如下图所示:
在这里插入图片描述

判断当前节点后面是否有当前字母的节点:
如果没有,则在当前节点后创建代表当前字母的节点,然后进入下一节点继续判断
如果有,进入下一节点继续判断

  /** Inserts a word into the trie. */
    public void insert(String word) {
          if(word==null||word.length()==0){
              return;
          }
          char[]words=word.toCharArray();
          TrieNode node=root;

          for(int i=0;i<words.length;i++){
              char currentchar=words[i];
              if(!node.containsKey(currentchar)){
                  node.put(currentchar,new TrieNode());
              }
              node=node.get(currentchar);
          }
          //尾节点要设置标识
          node.setEnd();
    }

时间复杂度:O(m),其中 m 为单词长度。在算法的每次迭代中,我们要么检查要么创建一个节点,直到到达键尾。只需要 m次操作。
空间复杂度:O(m)。最坏的情况下,新插入的键和 Trie 树中已有的键没有公共前缀。此时需要添加 m 个结点,使用O(m)空间

4、在 Trie 树中查找单词
查找的思路跟插入的思路类似

   public TrieNode searchPrefix(String word){
        char[]words=word.toCharArray();
          TrieNode node=root;
          for(int i=0;i<words.length;i++){
              char currentchar=words[i];
              if(!node.containsKey(currentchar)){
                 return null;
              }
              node=node.get(currentchar);
          }
          return node;
    }
  /** Returns if the word is in the trie. */
    public boolean search(String word) {
        if(word==null||word.length()==0){
              return false;
        }
       TrieNode node=searchPrefix(word);
       //查找word是否在trie树中
       //isEnd=true才表明是一个完整的单词,否则表 明只是前缀
        return node!=null&&node.isEnd();
    }
    
    /** Returns if there is any word in the trie that starts with the given prefix. */
    public boolean startsWith(String prefix) {
            if(prefix==null||prefix.length()==0){
              return false;
        }
        TrieNode node=searchPrefix(prefix);
        return node!=null;
    }
}

时间复杂度 : O(m)。
空间复杂度 : O(1)。

212. 单词搜索 II

单词搜索 II
算法分析:
暴力解法,在二维网格board中搜索出所有可能的单词,然后跟 words 中的单词去匹配,复杂度高,不可取。
使用 Trie 树的回溯搜索,配合剪枝
1、用所给的单词 words 构建 Trie 树,并且在树节点中,如果有一个节点代表了一个完整单词的结束,则将该完整的单词存储起来。
2、在二维 board 中按照每个坐标顶点去 Trie 树回溯搜索,如果搜索路径能在 Trie 中找到匹配的单词前缀或完整单词,则继续往下搜索,如果无匹配的前缀则直接返回;然后继续从下一个坐标顶点开始搜索。

这部分中,需要对 Trie 树加以改造,代码实现如下:

class WordTrie{
    public TrieNode root=new TrieNode();
   //根据单词构建字典树
    public void insert(String word){
        TrieNode node=root;
        for(char c:word.toCharArray()){
            if(!node.containsKey(c)){
                node.put(c,new TrieNode());
            }
            node=node.get(c);
        }
        node.setEnd();
        //当前节点如果代表了一个完整单词,则将该单词存储起来
        node.setword(word);
    }
}
//字典树节点对象
class TrieNode{
//如果该节点代表了一个单词的结尾,则将该完整单词存储到该节点的word属性中
    public String word;
    public TrieNode[] children;
    public final int R=26;
    public boolean isEnd;

    public TrieNode(){
         this.children = new TrieNode[R];
    }
    
    public boolean containsKey(char key){
        return this.children[key-'a']!=null;
    }
    public void put(char key,TrieNode node){
        this.children[key-'a']=node;
    }
    public TrieNode get(char key){
        return this.children[key-'a'];
    }
    public void setEnd(){
        this.isEnd=true;
    }
    public boolean isEnd(){
        return this.isEnd;
    }
    public void setword(String word){
        this.word=word;
    }
    public String getword(){
        return this.word;
    }
}

实现逻辑如下:

class Solution {
    int rows;
    int cols;
    public List<String> findWords(char[][] board, String[] words) {
    //根据words构建字典树
        WordTrie wordTrie=new WordTrie();
        for(String word:words){
            wordTrie.insert(word);
        }
        //准备进行搜索board
        rows=board.length;
        cols=board[0].length;
        //定义board相关顶点是否被访问过
        boolean[][]visted=new boolean[rows][cols];
        //用来存储中间结果,用Set去重,因为从不同坐标顶点开始可能会得到相同的单词
        Set<String>result=new HashSet();
        //搜索二维数组
        TrieNode root=wordTrie.root;
        for(int i=0;i<rows;i++){
            for(int j=0;j<cols;j++){
                if(root.containsKey(board[i][j])){
                    dfs(board,i,j,visted,root,result);
                }
            }
        }
       return new ArrayList(result);
    }

    public void dfs(char[][]board,int i,int j,boolean[][]visted,TrieNode currentNode,Set<String>result){
         //如果(row,col)不在二维board内 或者 该顶点已被访问过则返回
        if(!inBoard(i,j)||visted[i][j]){
            return;
        }
        /* 查找currentNode后是否存在字母board[row][col] 我们不能先在board中去搜索所有的单词后再去trie树中查找前缀匹配的 正确的做法是在dfs搜索board的过程中trie树也跟着逐层搜索,看currentNode节 点后是否存在当前(row,col)这个字母*/
        //如果当前节点后不存在该(i,j)字母,无需下探了直接返回
        if(!currentNode.containsKey(board[i][j])){
            return;
        }
        /* 如果当前节点后存在该(i,j)字母, 1、判断到当(i,j)为止是否是一个完整的单词,如果是则表明我们已经找到了在board 和单词列表中同时存在的一个了,加入到结果集中 2、继续下探且标注该(i,j)已访问过了 找到单词后不能回退,因为可能是“ad” “addd”这样的单词得继续回溯 */
        visted[i][j]=true;
        currentNode=currentNode.get(board[i][j]);

        if(currentNode.isEnd()){
            result.add(currentNode.getword());
        }
//继续下探有4个方向可以走 上下左右
 // int[] rowOffset = {-1, 0, 1, 0}; 
 // int[] colOffset = {0, 1, 0, -1}; 
 // for (int i=0;i<4;i++) { 
 // int newI = row + rowOffset[i]; 
 // int newJ = col + colOffset[i]; 
 // if (inBoard(newI,newJ) && !visited[newI][newJ]) { 
 // dfs(board,newI,newJ,visited,currentNode,result); 
 // } 
 // }
        dfs(board,i+1,j,visted,currentNode,result);
        dfs(board,i,j+1,visted,currentNode,result);
        dfs(board,i-1,j,visted,currentNode,result);
        dfs(board,i,j-1,visted,currentNode,result);
        //最后要回退,因为下一个起点可能会用到上一个起点的字符
        visted[i][j]=false;
    }
//判断坐标(i,j)是否在二维board中
    public boolean inBoard(int i,int j){
        return i>=0&&i<rows&&j>=0&&j<cols;
    }
}
class WordTrie{
    public TrieNode root=new TrieNode();

    public void insert(String word){
        TrieNode node=root;
        for(char c:word.toCharArray()){
            if(!node.containsKey(c)){
                node.put(c,new TrieNode());
            }
            node=node.get(c);
        }
        node.setEnd();
        node.setword(word);
    }
}
class TrieNode{
    public String word;
    public TrieNode[] children;
    public final int R=26;
    public boolean isEnd;

    public TrieNode(){
         this.children = new TrieNode[R];
    }
    
    public boolean containsKey(char key){
        return this.children[key-'a']!=null;
    }
    public void put(char key,TrieNode node){
        this.children[key-'a']=node;
    }
    public TrieNode get(char key){
        return this.children[key-'a'];
    }
    public void setEnd(){
        this.isEnd=true;
    }
    public boolean isEnd(){
        return this.isEnd;
    }
    public void setword(String word){
        this.word=word;
    }
    public String getword(){
        return this.word;
    }
}

2、并查集

2.1、并查集的基本实现和特性

并查集(Union Find/Disjoint Set),简单理解它体现出了两个操作:合并,查找。即可以将两个元素所在的集合合并以及查找元素所在的集合。
并查集一般包含以下三个操作:
新建:make :给定 num 个元素,建立包含 num 个元素的集合。
合并:union(x,y) :将元素 x 所在的集合与元素 y 所在的集合合并,当然如果 x 和 y 本身就在一个集合的话无需操作
查找:find(x) :查找元素 x 所在的集合,返回的是能代表该集合的一个 代表 ,当然也可以用来判断两个元素是否在同一个集合,只需要判断两个元素所在集合的 代表 是否是同一个即可。
并查集具体的构建,合并,查找过程如下图
在这里插入图片描述
提供四个操作:
1、初始化构建
2、查找
3、合并
4、获取集合个数

class UnionFind{
    int count=0; //代表该并查集中集合的个数
    int []father;  //指向父节点的箭头

    public UnionFind(int num){// num代表元素的个数,即初始时有num个单独的集合
    //初始条件每个元素的parent都指向自己 parent[i] = i;
        father=new int[num];
        for(int i=0;i<num;i++){
            father[i]=i;
        }
        //初始集合个数即元素个数
        count=num;
    }
    public int find(int n){
    //查找n所在的集合
    //循环版本 
    /*while (father[n] != n) { 
          n = father[n]; 
    }
    return n;*/
    //递归版本
        if(father[n]==n){
            return n;
        }
        return find(father[n]);
    }

    public void union(int p,int q){
    //先查找p,q各自所在的集合
        int p_root=find(p);
        int q_root=find(q);
        //如果p,q在同一个集合无需合并
        if(p_root==q_root){
            return;
        }
        //让p_root的父节点为q_root;
        father[p_root]=q_root;
        //集合个数减一
        count--;
    }
    //获取集合个数
    public int getCount(){
        return this.count;
    }
}

当然在这个过程中,还可以进行路径压缩:
在这里插入图片描述
路径压缩,主要影响的是查找操作,代码实现如下:

/** 
* 查找 
* @param n 
* * @return */ 
* public int find (int n) { 
* //查找n所在的集合 
* //循环版本 
/*while (father[n] != n) { 
  father[n] = father[father[n]]; 
  n = father[n]; 
  }
 return n;*/ 
 //递归版本 
 if (father[n] == n) {
  return n; 
  }
  // find(father[n])查找就是当前节 父亲的父亲即爷爷 
  father[n] = find(father[n]);//路径压缩版本 
  return father[n]; 
  }

2.2、面试实战

547. 朋友圈

547. 朋友圈
算法分析:
直接使用DFS,BFS,类似岛屿
使用并查集
可以直接套用并查集的模板代码

class Solution {
    public int findCircleNum(int[][] M) {
        int N=M.length;
        UnionFind uf=new UnionFind(N);

        for(int i=0;i<N;i++){
            for(int j=i+1;j<N;j++){
                if(M[i][j]==1){
                    uf.union(i,j);
                }
            }
        }
        return uf.getCount();
    }
}
class UnionFind{
    int count=0;
    int []father;

    public UnionFind(int num){
        father=new int[num];
        for(int i=0;i<num;i++){
            father[i]=i;
        }
        count=num;
    }
    public int find(int n){
        if(father[n]==n){
            return n;
        }
        return find(father[n]);
    }

    public void union(int p,int q){
        int p_root=find(p);
        int q_root=find(q);
        if(p_root==q_root){
            return;
        }
        father[p_root]=q_root;
        count--;
    }

    public int getCount(){
        return this.count;
    }
}

高级搜索

1、搜索回顾

1、朴素搜索
即暴力搜索,时间复杂度高,多数情况下为指数级的。需要优化。
优化方向:重复计算(斐波拉契),剪枝(括号生成问题)
去重:在搜索过程中如果该分支已计算过则直接从缓存(数组,哈希)返回,否则计算并将计算结果缓存
剑指 Offer 10- I. 斐波那契数列

class Solution { 
   public int fib(int n) {
     return fib(n,new HashMap()); 
    }
    public int fib(int n,Map<Integer,Integer> hash){ 
    //终止条件 
    if (n < 2 ) { 
    hash.put(n,n); 
    return n ; 
    }else { 
    if (hash.containsKey(n)) { 
    return hash.get(n); 
    }
    int x = fib(n-1,hash) % 1000000007; 
    hash.put(n-1,x); 
    int y = fib(n-2,hash) % 1000000007;
    hash.put(n-2,y); 
    int z = (x+y) % 1000000007;
    hash.put(n,z); 
    return z; 
    } 
   } 
 }

在这里插入图片描述

当然也可以先从子问题开始解决,进而推导大问题的解决(动态规划)

class Solution {
    public int fib(int n) {
        if(n==0){
            return 0;
        }
        int []dp=new int[n+1];
        dp[0]=0;
        dp[1]=1;
        for(int i=2;i<=n;i++){
            dp[i]=dp[i-1]+dp[i-2];
            dp[i]=dp[i]%1000000007;
        }
        return dp[n];
    }
}

压缩状态

class Solution {
    public int fib(int n) {
        if(n==0){
            return 0;
        }
        int a=0;int b=1;int sum;
        for(int i=0;i<n;i++){
            sum=(a+b)%1000000007;
            a=b;
            b=sum;
        }
         return a;
    }
}

剪枝:搜索过程中将不符合需要的分支,不符合最终结果的分支,无法让最终结果达到最优的分支都给拿掉。减少搜索过程,降低时间复杂度。

22. 括号生成

class Solution {
    public List<String> generateParenthesis(int n) {
          List<String>list=new ArrayList();
          recurGenerate(0,0,n,"",list);
          return list;
    }

    public void recurGenerate(int left,int right,int n,String s,List<String>list){
        if(left==n&&right==n){
            System.out.println(s);
            list.add(s);
            return ;
        }
        if(left<n){
            recurGenerate(left+1,right,n,s+"(",list);
        }
        if(right<left){
            recurGenerate(left,right+1,n,s+")",list); 
        }
    }
}

51. N 皇后

class Solution {
    public List<List<String>> solveNQueens(int n) {
         List<List<String>>res=new ArrayList();
         if((n<1)||(n>1&&n<4)){
             return res;
         }
        int []queens=new int[n];
        Arrays.fill(queens,-1);

        Set<Integer>lie=new HashSet();
        Set<Integer>pie=new HashSet();
        Set<Integer>na=new HashSet();
        backTrack(n,0,queens,lie,pie,na,res);
        return res;
    }

    public void backTrack(int n,int row,int[]queens,Set<Integer>lie,Set<Integer>pie,Set<Integer>na,List<List<String>>res){
        if(row==n){
            List<String>result=genResult(queens,n);
            res.add(result);
            return;
        }
        for(int i=0;i<n;i++){
            if(lie.contains(i)){
                continue;
            }
            int rowAddColumn=i+row;
            if(pie.contains(rowAddColumn)){
                continue;
            }
            int rowSubColumn=row-i;
            if(na.contains(rowSubColumn)){
                continue;
            }
            
            queens[row]=i;
            lie.add(i);
            pie.add(rowAddColumn);
            na.add(rowSubColumn);

            backTrack(n,row+1,queens,lie,pie,na,res);
            queens[row]=-1;
            lie.remove(i);
            pie.remove(rowAddColumn);
            na.remove(rowSubColumn);
        }
    }

    public List<String>genResult(int[]queens,int n){
        List<String>temp=new ArrayList();
        for(int i=0;i<queens.length;i++){
            char[]row=new char[n];
            Arrays.fill(row,'.');
            row[queens[i]]='Q';
            temp.add(new String(row));
        }
        return temp;
    }

}

2、搜索方向:
DFS:深度优先搜索(基于递归,采用栈这种数据结构)
BFS:广度优先搜索(基于遍历,采用了队列这种结构)
优化方向:双向搜索,启发式搜索
双向搜索:在起点和终点处分别做一个广度优先,在中间相遇,时间更快
启发式搜索:采用了一个优先级队列的数据结构,有些节点更符合我们的需要,更能让结果达到最优,则将他们先出队列进行搜索。

2、面试实战

37. 解数独

37. 解数独

本题的开胃菜:36. 有效的数独

class Solution {
    public boolean isValidSudoku(char[][] board) {
            int[][]row=new int[9][10];
            int[][]lie=new int[9][10];
            int[][]block=new int[9][10];
            
            for(int i=0;i<9;i++){
                for(int j=0;j<9;j++){
                    if(board[i][j]=='.'){
                        continue;
                    }else{
                    int num=board[i][j]-'0';
                    if(row[i][num]==1){
                        return false;
                    }
                    if(lie[j][num]==1){
                        return false;
                    }
                    if(block[i/3*3+j/3][num]==1){
                        return false;
                    }
                    row[i][num]=1;
                    lie[j][num]=1;
                    block[i/3*3+j/3][num]=1;
                    }
                }
            }
            return true;
    }
}

回溯搜索+剪枝
国际站点的几个高效题解:依次解读一下(需要能看懂,能写出来)
Straight Forward Java Solution Using Backtracking
Less than 30 line clean java solution using DFS
国内站点一个比较好的题解:
set+回溯超过95%

class Solution {
         Set<Character>[]rowSet;
         Set<Character>[]colSet;
         Set<Character>[]blockSet;
    public void solveSudoku(char[][] board) {
    //定义行,列,方块的set
         rowSet=new HashSet[9];
         colSet=new HashSet[9];
         blockSet=new HashSet[9];
        for(int i=0;i<9;i++){
            rowSet[i]=new HashSet<>();
            colSet[i]=new HashSet<>();
            blockSet[i]=new HashSet<>();
        }
//存储剩余还未填充的位置
        List<int[]>need=new ArrayList();
        //预扫描一遍board
        for(int i=0;i<9;i++){
            for(int j=0;j<9;j++){
                if(board[i][j]!='.'){
                    rowSet[i].add(board[i][j]);
                    colSet[j].add(board[i][j]);
                    blockSet[(i/3)*3+j/3].add(board[i][j]);
                }else{
                    need.add(new int[]{i,j});
                }
            }
        }
        int k=need.size();
        backtrack(board,0,k,need);
    }

    public boolean backtrack(char[][]board,int depth,int k,List<int[]>need){
    //开始回溯搜索
         if(depth==k){
             return true;
         }
         int[]cur=need.get(depth);
         int row=cur[0];
         int col=cur[1];
         int block=(row/3)*3+col/3;

         for(char i='1';i<='9';i++){
         //剪枝
             if(rowSet[row].contains(i)){
                 continue;
             }
             if(colSet[col].contains(i)){
                 continue;
             }
             if(blockSet[block].contains(i)){
                continue;
             }
             //可以选择
             board[row][col]=i;
             rowSet[row].add(i);
             colSet[col].add(i);
             blockSet[block].add(i);
//drill down
             boolean result=backtrack(board,depth+1,k,need);
             if(result){
                 return true;
             }
             //撤销
             board[row][col]='.';
             rowSet[row].remove(i);
             colSet[col].remove(i);
             blockSet[block].remove(i);
         }
         //1~9都选完了还没返回true,说明没有合适的
         return false;
    }
}

在这个题的该解法中,在对剩余每个位置进行搜索选择时,如果先从剩余可选择数量最少的位
置开始,则会加快整个搜索进程。

127. 单词接龙

算法分析:
参考优秀题解:
题解1
题解2
1、单词的转换是一个图的搜索,从 beginWord 到 endWord 之间可以经过字典 wordList 中的单词,构成一个图,且是无向图。
2、题目转换为在无向图中搜索从 beginWord 到 endWord 之间的最端路径。
解法一:在树/图中求最短路径,直接想到 BFS
需要注意的是:在图中使用 BFS ,除了需要借助一个队列外,还需要一个用于判断某顶点是否已经
被访问过的数据结构,可以是数组,哈希,set等等

class Solution {
    public int ladderLength(String beginWord, String endWord, List<String> wordList) {
        // 第 1 步:先将 wordList 放到哈希表里,便于判断某个单词是否在 wordList 里
        Set<String> wordSet = new HashSet<>(wordList);
        if (wordSet.size() == 0 || !wordSet.contains(endWord)) {
            return 0;
        }
        wordSet.remove(beginWord);
        
        // 第 2 步:图的广度优先遍历,必须使用队列和表示是否访问过的 visited 哈希表
        Queue<String> queue = new LinkedList<>();
        queue.offer(beginWord);
        Set<String> visited = new HashSet<>();
        visited.add(beginWord);
        
        // 第 3 步:开始广度优先遍历,包含起点,因此初始化的时候步数为 1
        int step = 1;
        while (!queue.isEmpty()) {
            int currentSize = queue.size();
            for (int i = 0; i < currentSize; i++) {
                // 依次遍历当前队列中的单词
                String currentWord = queue.poll();
                // 如果 currentWord 能够修改 1 个字符与 endWord 相同,则返回 step + 1
                if (changeWordEveryOneLetter(currentWord, endWord, queue, visited, wordSet)) {
                    return step + 1;
                }
            }
            step++;
        }
        return 0;
    }

    /**
     * 尝试对 currentWord 修改每一个字符,看看是不是能与 endWord 匹配
     *
     * @param currentWord
     * @param endWord
     * @param queue
     * @param visited
     * @param wordSet
     * @return
     */
    private boolean changeWordEveryOneLetter(String currentWord, String endWord,
                                             Queue<String> queue, Set<String> visited, Set<String> wordSet) {
        char[] charArray = currentWord.toCharArray();
        for (int i = 0; i < endWord.length(); i++) {
            // 先保存,然后恢复
            char originChar = charArray[i];
            for (char k = 'a'; k <= 'z'; k++) {
                if (k == originChar) {
                    continue;
                }
                charArray[i] = k;
                String nextWord = String.valueOf(charArray);
                if (wordSet.contains(nextWord)) {
                    if (nextWord.equals(endWord)) {
                        return true;
                    }
                    if (!visited.contains(nextWord)) {
                        queue.add(nextWord);
                        // 注意:添加到队列以后,必须马上标记为已经访问
                        visited.add(nextWord);
                    }
                }
            }
            // 恢复
            charArray[i] = originChar;
        }
        return false;
    }
}

解法二:解法升级,采用双向 BFS ,能加大搜索效率。
注意:此处的双向并非严格的同时从两个方向进行搜索,只是每次都找最小的集合进行搜索。
每层最小的集合可能存在不同的方向。

class Solution {
    public int ladderLength(String beginWord, String endWord, List<String> wordList) {
         Set<String>wordSet=new HashSet(wordList);

         if(!wordList.contains(endWord)){
             return 0;
         }
     //用于从beginWord开始搜索
         Queue<String>queue1=new LinkedList();
         Set<String>visited1=new HashSet();
         queue1.offer(beginWord);
         visited1.add(beginWord);
//用于从endWord开始搜索
         Queue<String>queue2=new LinkedList();
         Set<String>visited2=new HashSet();
         queue2.offer(endWord);
         visited2.add(endWord);

//定义最短转换序列的长度
         int minCount=0;

         while(!queue1.isEmpty()&&!queue2.isEmpty()){
             minCount++;
             /* 比较一下queue1和queue2中元素个数,我们每次都选择从单词数量小的集合开始 扩散 
             相当于选择该顶点下一层中顶点个数少的进行搜索,这样搜索总次数是最少的 */
             //我们记queue1是数量少的集合
           if(queue1.size()>queue2.size()){//交换
               Queue<String>temp=queue1;
               queue1=queue2;
               queue2=temp;
//对应的visited也需要交换
               Set<String>vtemp=visited1;
               visited1=visited2;
               visited2=vtemp;
           }
           //开始进行搜索
           int size=queue1.size();
           for(int i=0;i<size;i++){
               String currentword=queue1.poll();
               char[]currentwords=currentword.toCharArray();
               for(int j=0;j<currentwords.length;j++){
                 char origin= currentwords[j];
                 for(char c='a';c<='z';c++){
                     currentwords[j]=c;
                     String newWord=new String(currentwords);
                     //访问过了,跳过
                     if(visited1.contains(newWord)){
                         continue;
                     }
                     // 两端遍历相遇,结束遍历,返回count
                     if(visited2.contains(newWord)){
                         return minCount+1;
                     }
                     // 如果单词在列表中存在,将其添加到队列,并标记为已访问
                     if(wordSet.contains(newWord)){
                         queue1.offer(newWord);
                         visited1.add(newWord);
                     }
                 }
                 //复位
                 currentwords[j]=origin;
               }
           }
    }
     return 0;
}
}
1091. 二进制矩阵中的最短路径

1091. 二进制矩阵中的最短路径
高级搜索:启发式搜索(Heuristic Search / A*
在图的搜索过程中添加智能化的思考,不断优化搜索的条件,每次选择下一次的搜索路径时都选择最优的,更快能获得最优结果的。
这个过程中需要引入优先级队列(PriorityQueue),每次在选择搜索下一层邻居的时候不在是使用先进先出的队列,而是按照优先级进行选择,选择一个优先级最高的进行搜索。
参考模板

void bfs (graph,start,end) { 
  Queue pq = PriorityQueue();
  //优先级队列->确定优先级:估价函数 
  pq.add(start); 
  visited.add(start); 
  while (pq) { 
  size = pq.size(); 
  for (i:0~size) { 
  node = pq.poll();
  //从队列获取元素时添加智能化/最优化条件判断。 
  visited.add(node); 
  //处理当前
  node process(node); 
  //获取node最近一层的邻居,且是未访问过的,加入到队列 
  unvisited = getUnvisitedNeighbor(); 
  pq.add(unvisited); 
  } 
 }
}

在启发式搜索中,最重要的就是如何确定选择的优先级,即所谓的估价函数:
它是一种告知搜索方法的方法,它提供了一种更为明智的方法来猜测哪个邻居节点会导向我们所要查找的终极目标

class Solution {
     int[][]direction=new int[][]{
            {-1, -1}, {-1, 0}, {-1, 1}, {0, -1}, {0, 1}, {1, -1}, {1, 0}, {1, 1} };
         int n;
    public int shortestPathBinaryMatrix(int[][] grid) {
    // BFS
        this.n=grid.length;
        // 特殊判断
         if(grid[0][0]==1||grid[n-1][n-1]==1){
                return -1;
         }
         //定义优先级队列
         Queue<Vertex>queue=new PriorityQueue<Vertex>((v1,v2)->{
             return v1.f-v2.f;
         });
/* grid有几重作用 
grid[x][y] ==0 可以走 grid[x][y] ==1 不可以走 
grid[x][y] == 2,代表走到(x,y)的最短路径为2,直接将走到当前点的路径步数存 储到该位置。不会和grid[x][y] ==1冲突的原因是路径最短步数为1 */
         queue.offer(new Vertex(0,0,grid[0][0]=1)); //起点也算一步
         while(!queue.isEmpty()){
             Vertex node=queue.poll();//拿出优先级最高的点走
             int step=grid[node.x][node.y];;//获取走到该点的步数
             if(node.x==n-1&&node.y==n-1){
             //已经走到终点了
                 return step;
             }
             for(int []ints:direction){
             //往8个方向上延申   // 延伸方向
                 int x=node.x+ints[0];
                 int y=node.y+ints[1];
                 // 边界条件,不能走出网格
                 if(x<0||y<0||x>=n||y>=n){
                     continue;
                 }
                 /* 能走的路只有grid[x][y]==0、或者之前走过但是步数过多的点(当前这条 路径从该点走的步数更少) 
                 另外如果当前点原本不可走即grid[x][y]==1,由于step+1>1所以肯定也 是不会走该点的。 */
                 if(grid[x][y]==0||grid[x][y]>step+1){
                     queue.offer(new Vertex(x,y,grid[x][y]=step+1));
                 }
             }
         }
         return -1;
    }
//定义顶点
    class Vertex{
        int x,y,f;
        public Vertex(int x,int y,int step){
            this.x=x;
            this.y=y;
            // 曼哈顿距离变体
            int diff=Math.max(n-x,n-y);
            // 离右下角近的点优先计算 使用曼哈顿距离 并且加上 当前的步数,因为有可能两个点的diff一样,但是走到这两个点的步数不一样
            this.f=diff+step;
        }
    }
}
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值