算法学习之动态规划,从 Fibonacci 到 01 背包与 LeetCode 实战
今天我学习的算法是动态规划。说来也好笑,其实这些内容在研一上算法课时,都是讲过的,当时学习的时候也没怎么放在心上,也觉得算法不是那么重要,一直学得囫囵吞枣的,只是为了考试而去学习算法,只看到它的表层,根本不会深究它的原理。
动态规划在我的理解里,当问题满足最优子结构且存在大量重叠子问题时,如果使用暴力计算的方式解决问题,极易导致重复计算,导致高复杂度,这时候,使用动态规划就是一个好选择。那么,什么是最优子结构和重叠子问题?说人话就是一个问题的最优解,可以从它的子问题的最优解中得出,且子问题被重复计算。
大致上,动态规划有两种:
自顶向下的
自底向上的
那么,如何判断需要使用动态规划?主要有下面几点:
可以把问题拆分成若干子问题
能否写出状态和状态转移(就是通用的表达式,后面会提)
能否定义边界并确定计算顺序
以下是我在完成今天的学习之后,总结的动态规划解题的步骤:
确定要优化的量(求最大值或者且最小值,就是要优化的量)
定义状态(这个状态一般是个数组,dp[],可能一维,也可能二维等等)
写状态转移方程(就是通用的表达式,即如何从一个小问题中,得到大的问题)
确定边界值(如 dp[0] 的值等等,这个边界代表的是什么时候结束或开始)
选择计算顺序(如自顶向下的,或自底向上的)
我们先从常见的基础入门。
1 斐波那契
在数学上,有一个斐波那契数列,他是长这样的:
0,1,1,2,3,5,8,13,21,34,55......斐波那契数列,就是从第三项开始,每一项都是前面两项的和。
而我们的题目,也随之而来,即求第 n 项斐波那契数列的值。
我们按照我的解题步骤来:
确定要优化的量
第 n 项就是我们要优化,即计算的量。
定义状态
我们创建一个静态数组,来保存状态,其实,这个状态数组就是我们的斐波那契数列:
private static long[] dp = new long[100];写状态转移方程
状态转移方程就是通用的表达式,其实很简单,就是:
f(n) = f(n-1) + f(n-2) (n>=3时)确定边界值
在本题中,边界值也很好确认,即第 1 项是 0,第 2 项是 1。也就是 f(1)=0,f(2)=1。
选择计算顺序
这个计算顺序我们在写完程序之后再说,看看我们的计算顺序是什么,同时,另一种计算顺序又该怎么做?
来看我们的程序,程序非常简单:
package dp;
import java.util.Arrays;
/**
* @Author: sangui
* @CreateTime: 2025-11-11
* @Description: 斐波那契 0、1、1、2、3、5、8、13、21、34
* @Version: 1.0
*/
public class Fibonacci {
private static long[] dp = new long[100];
static void main() {
// 默认全是 -1
Arrays.fill(dp, -1);
// 第一项是 0,第二项是 1
dp[0] = 0;
dp[1] = 1;
System.out.println(getFibonacci(10));
}
/**
* 获取某一项斐波那契数组的值
*
* @param n 第 n 项
* @return 第 n 项斐波那契数组的值
*/
public static long getFibonacci(int n) {
// 初始数组值都是 -1,若不是 -1,代表已经该项数已经计算并记录在对应数组了
if (dp[n] != -1) {
return dp[n];
}
// 运行到这里意味着还没用算过第 n 项,开始计算第 n 项
dp[n] = getFibonacci(n - 1) + getFibonacci(n - 2);
return dp[n];
}
}如果我们的 dp 数组没有赋值过,那么我们数组中元素就都是默认值 0 。为了方便,也同时是因为我们的斐波那契数列中的数都是非负的,所以我们将数组的所有值赋值为 -1,代表这一项没有被赋值计算过。根据我们的边界值的确定,我们直接写:dp[0] = 0;dp[1] = 1;
接下来看具体的方法。我们的想法是,一项一项计算数列的每一项,把所有已经计算过的值,存入 dp 数组里,若某一项被赋值计算过,则直接返回这个值。至于未计算的值,则使用我们想好的 状态转移方程来计算,并同时存储他的值。
程序就是这样,很简单。我们来分析一下,他是自顶向下的,还是自底向上的。明显这时自顶向下的,因为我们是最先计算第 n 项,再一步一步按照转移方程来计算前两项,直至到我们的边界值。
那要是自底向上的,程序应该是怎么样的?其实也差不多,自底向下就是它是最先计算下面的值,再依次往上计算,请看我新写的程序:
/**
* 获取某一项斐波那契数组的值(自底向上)
*
* @param n 第 n 项
* @return 第 n 项斐波那契数组的值
*/
public static long getFibonacci2(int n) {
// 初始数组值都是 -1,若不是 -1,代表已经该项数已经计算并记录在对应数组了
if (dp[n] != -1) {
return dp[n];
}
// 对第 2 项到第 n 项的值进行计算
for (int i = 2; i <= n; i++) {
// 若该值是 -1 ,则代表该值需要计算,反之则不用
if (dp[i] == -1) {
// 计算该值
dp[i] = dp[i - 1] + dp[i - 2];
}
}
return dp[n];
}我们看到,他里面用到了循环,而不是递归!这是一个关键点,其次,它在循环里,从小到大,计算斐波那契数列中的值,并同时记录下 dp 数组 具体的值。
可以得出结论:
自顶向下用了递归,逻辑简洁,但可能会方法栈溢出,自底向上稍复杂,但无溢出风险,因为用的是循环,次数可控。
2 爬楼梯
题目: 一共有 n 阶台阶,你每次可以爬 1 阶或 2 阶。请问有多少种不同的方式爬到顶?
爬楼梯这一题,这个题我之前遇到过,我知道解法和斐波那契的算法一模一样,一开始我还绕不过弯来,为什么他和斐波那契数列一样。
确定要优化的量
和上一题一样,第 n 项就是我们要优化,即计算的量。
定义状态
我们创建一个静态数组,来保存状态,其实,这个状态数组就是我们走到某一阶台阶时,存储的有多少种不同的方式:
static int[] arr = new int[46];写状态转移方程
这一步是解决题目的关键,就是要想清楚一件事,就是,假设有
n = 5级台阶,你要到达第 5 级台阶的最后一步,只有两种可能:从第 4 级台阶走 1 级上来
从第 3 级台阶走 2 级上来
不可能有第三种情况!也就是说,当我们走第 n 阶台阶,一定是从 n-1 阶台阶和 n-2 阶台阶走来的,那么走第 n 阶台阶的走法,就是 n-1 阶台阶的走法加上 n-2 阶台阶的走法!当然 n是要大于 2 的。所以我们归纳状态转移方程:
f(n) = f(n-1) + f(n-2) (n>=3时)可以看到,又是这个表达式!
确定边界值
我们确定了 n>=3时的状态转移方程,那么,很容易就想到,n=1 和 n=2 就是它的边界值,即第 1 项是 1,第 2 项是 2。也就是 f(1)=1,f(2)=2。代表的是1 阶台阶有一种走法,2 阶台阶有两种走法
选择计算顺序
计算顺序还是有两种。我们仔细看,究竟是自顶向下的好,还是自底向上的好。
来看我写的程序:
package dp;
import java.util.Arrays;
/**
* @Author: sangui
* @CreateTime: 2025-11-11
* @Description: 爬楼梯
* @Version: 1.0
*/
public class Q_70 {
// 状态数组,我自己写的,最高 45 阶台阶
static int[] arr = new int[46];
static void main() {
// 题目:一共有 n 阶台阶,你每次可以爬 1 阶或 2 阶。请问有多少种不同的方式爬到顶?
// n 是题目输入,代表有 n 阶台阶
int n = 10;
// 计算结果
int res = climbStairs(n);
// 输出结果
System.out.println(res);
}
/**
* 爬楼梯的方法
*
* @param n 代表 n 阶台阶
* @return 爬 n 阶台阶的有多少种不同的方式
*/
public static int climbStairs(int n) {
if (n == 1) {
// 1 阶台阶有一种走法
arr[n] = 1;
return arr[n];
}
if (n == 2) {
// 2 阶台阶有两种走法
arr[n] = 2;
return arr[n];
}
if (arr[n] != 0) {
return arr[n];
}
// 第 n 阶台阶(n>2),一定是从 n-1 阶台阶和 n-2 阶台阶走来的
// 那么走第 n 阶台阶的走法,就是 n-1 阶台阶的走法加上 n-2 阶台阶的走法
arr[n] = climbStairs(n - 1) + climbStairs(n - 2);
return arr[n];
}
}程序就不多说了,和斐波那契数列几乎一模一样,且这个是自顶向下的解法。
同时,我看到了一个天才的程序,我们目前的程序的空间复杂度是 O(n),也就是说随着我们楼梯的数量而增加,我看到了一个空间复杂度是 O(1)的程序,供大家参考:
/**
* 爬楼梯的方法(邪修,无需数组存储)
*
* @param n 代表 n 阶台阶
* @return 爬 n 阶台阶的有多少种不同的方式
*/
public static int climbStairs3(int n) {
if (n == 1) {
return 1;
}
if (n == 2) {
return 2;
}
int a = 1;
int b = 2;
int c = 0;
for (int i = 3; i <= n; i++) {
c = a + b;
a = b;
b = c;
}
return c;
}最后,我提出一个进阶版爬楼梯题:
每次可以爬 1、2 或 3 阶,问有多少种方法?
这个题就不再是单纯斐波那契,而是根据自己推状态方程的训练,我会在文末贴上我的解题方案(分自顶向下、自底向上、还有空间复杂度为 O(1) 的邪修)、
3 0/1 背包
0/1 背包是经典中的经典,理解它能把很多变形题都拆开来做。
有
n件物品,第i件重量wt[i],价值val[i],背包容量W。每件物品最多取 1 次(要么取,要么不取)。求在不超过容量W的前提下,能获得的最大总价值是多少?并演示如何恢复出被选择的物品集合。
下面直接给出我的题目数据,即总共有 4 件物品,背包的容量是 10 :
// 重量
int[] wt = {3, 4, 6, 5};
// 价值
int[] val = {2, 3, 1, 4};
// 容量
int W = 10;这题目看起来很难,但不要被题目吓到了。提示一点,要把问题拆分成选与不选的两种选择,我们还是根据之前的五个步骤来分析题目
确定要优化的量
要优化的量其实就是题目中的要求的值。
定义状态
要用一个数组来记录中间结果。常见定义是:
static int[][] dp = new int[4 + 1][10 + 1]
为方便和展示友好起见,题目中给的 4 和 10 我直接硬编码写入了,后面的我也会这么做。
注意这里的 dp 是个 二维数组,比如
dp[i][w]的含义是,只考虑前 i 件物品、背包容量为 w 时能取得的最大价值。而我们要求的,即是dp[4][10],即代表,前 4 件物品,背包容量为 10 时的最大价值。写状态转移方程
很关键的一步!要把问题拆分成选与不选的两种选择。
不选
如果选择不选的话,表达式很好写:
dp[i][w] = dp[i-1][w]
什么意思,我们依次循环 i ,i 是我们的物品,有 4 件,从 1-4 循环遍历 ,若不选,那么他的价值就和上一个存储的值一样。从这也可以看出,我们的计算顺序,是从小到大,就是自底向上的。
选
如果选择选的话,那么对应的
dp[i][w]又是什么呢?我们的想法是,若我们选了,那么该值应该是 没加这件物品之前的价值 + 这件物品的价值其实就是:
dp[i][w] = dp[i-1][w - wt[i-1]] + val[i-1]
注意
dp[i-1][w - wt[i-1]]就是 没加这件物品之前的价值val[i-1]就是 这件物品的价值,注意坐标偏移, i - 1 其实代表的就是我的第 i 件商品。
在做了选与不选的选择之后,我们就要比较,取两者之中最大者(不要忽视了这一步,以为选了一定比不选大,他们不能这么比)
确定边界值
想想
i = 0或w = 0时应该是什么值? 其实,没物品或者容量为 0,都没价值可取,即都是 0.选择计算顺序
是自底向上的,在之前上面的步骤中说过。根据状态定义,
dp[i][w]依赖dp[i-1][...],所以应该 外层遍历 i 从 1 到 n,内层遍历 w 从 0 到 W
最后,我给出我的程序:
package dp;
/**
* @Author: sangui
* @CreateTime: 2025-11-11
* @Description: 0-1 背包
* @Version: 1.0
*/
public class Knapsack01 {
static int[][] dp = new int[4 + 1][10 + 1];
static void main() {
// 有 n 件物品,第 i 件重量 wt[i],价值 val[i],背包容量 W。
// 每件物品最多取 1 次(要么取,要么不取)。
// 求在不超过容量 W 的前提下,能获得的最大总价值是多少?
// 并演示如何恢复出被选择的物品集合。
// 重量
int[] wt = {3, 4, 6, 5};
// 价值
int[] val = {2, 3, 1, 4};
// 容量
int W = 10;
// 计算最大价值
int res = knapsackWithChoice(wt, val, W);
// 输出
System.out.println("计算得出最大的背包价值:" + res);
}
public static int knapsackWithChoice(int[] wt, int[] val, int W) {
// i 代表第 i 件物品,i 从 1 开始。
for (int i = 1; i <= wt.length; i++) {
// 第 i 件物品的重量
int wt_i = wt[i - 1];
// 第 i 件物品的价值
int val_i = val[i - 1];
// w 代表背包容量,从 0 开始计算,一直到我们的真正的背包容量:W
for (int w = 0; w <= W; w++) {
// 若 背包容量 w 小于 第 i 件物品的重量,代表不能放
if (w < wt_i) {
// 不能放,该价值等于上一件物品这时的价值
dp[i][w] = dp[i - 1][w];
} else {
// 走到这里,说明背包有空闲重量,可放,可不放,需要比较这两个值
// 该价值等于 没加这件物品的价值(dp[i - 1][w - wt_i]) + 这件物品的价值(val_i)
dp[i][w] = Math.max(dp[i - 1][w - wt_i] + val_i, dp[i - 1][w]);
}
}
}
System.out.println("具体 dp 矩阵:------------------");
for (int i = 0; i <= 4; i++) {
for (int j = 0; j <= 10; j++) {
System.out.print(dp[i][j] + " ");
}
System.out.println();
}
System.out.println("------------------------------");
return dp[4][10];
}
}在程序中,我们依照自底向上循环,依次进行遍历,需要注意的是,影响我们选与不选的,还有一种情况,就是,若背包容量超了,我们必须不选。若没抄,我们再把之前分析的两个值取最大值分析。最后使出我们 dp 数组最最边界的值,就是我们所求。
至此,程序就解决了,但同时,0/1 背包问题往往还伴随着另外一个问题,就是,我们需要回溯出,到底选了什么物品。恢复被选择的物品的思路就是:
从最后一件物品往前走:
i = n,容量w = W如果
dp[i][w] != dp[i-1][w],说明第i件物品被选了然后把
w -= wt[i-1]
否则没选,直接
i--
我给出我的程序:
for (int i = 4; i >= 1; i--) {
if (dp[i][W] != dp[i - 1][W]) {
System.out.println("第 " + i + " 件物品被选!");
W -= wt[i - 1];
}
}这端代码写在生成矩阵之后,返回最优质之间即可。
0/1背包的变式:一维滚动数组版本
0/1背包问题,除了二维 DP 的解决方法,其实还有一维滚动数组版本,就是我们的 dp 数组,改为一维的。
我们看之前二维 DP 时的状态:
dp[i][w] = 前 i 件物品、背包容量为 w 的最大价值
观察递推格式:
dp[i][w] = max(dp[i-1][w], dp[i-1][w - wt[i-1]] + val[i-1])dp[i][w] 只依赖 上一行 dp[i-1][...],所以完全可以用一维数组 dp[w] 来替代。
所以二维的 i 这一维度,其实可以去掉。我们可以建立一个一维 dp 数组来解决,以减少内存复杂度。
而一维数组的递推方向则是关键,之前我们是自底向上的,一维数组中,则是要大到小。因为如果从小到大遍历,dp[w - wt[i-1]] 会被覆盖,导致把同一件物品选了多次(变成完全背包)。从大到小就保证了每件物品只被用一次。
来看我写的代码:
static int[] dp = new int[10 + 1];
public static int knapsackWithChoice(int[] wt, int[] val, int W) {
// i 代表第 i 件物品,i 从 1 开始。
for (int i = 1; i <= 4; i++) {
// 第 i 件物品的重量
int wt_i = wt[i - 1];
// 第 i 件物品的价值
int val_i = val[i - 1];
for (int w = W; w >= wt[i-1]; w--) {
dp[w] = Math.max(dp[w], dp[w - wt_i] + val_i); // 最关键一行
}
}
System.out.println("具体 dp 矩阵:------------------");
for (int i = 0; i <= 10; i++) {
System.out.print(dp[i] + " ");
}
System.out.println();
System.out.println("------------------------------");
return dp[10];
}其中,最关键的一行就是:
dp[w] = Math.max(dp[w], dp[w - wt_i] + val_i); // 最关键一行其实,想通了就很简单,这和之前的二维 dp 大差不差,就是把 i 给去掉了,但实质还是一样的。
最后是回溯选中物品,在二维 dp 中,我们是要根据二维 dp 中的 i 行来判断是否选中的。一维 DP 也可以回溯,但回溯稍微麻烦一些,必须在更新前保存“上一步状态”。一般不会要求一维数组的追溯,因为一维数组主要用于“求最大价值”,空间优化”。
4 一和零
这是,题目是这样的:
给你一个二进制字符串数组
strs和两个整数m和n。请你找出并返回
strs的最大子集的长度,该子集中 最多 有m个0和n个1。如果
x的所有元素也是y的元素,集合x是集合y的 子集 。示例 1:
输入:strs = ["10", "0001", "111001", "1", "0"], m = 5, n = 3
输出:4
解释:最多有 5 个 0 和 3 个 1 的最大子集是 {"10","0001","1","0"} ,因此答案是 4 。
其他满足题意但较小的子集包括 {"0001","1"} 和 {"10","1","0"} 。{"111001"} 不满足题意,因为它含 4 个 1 ,大于 n 的值 3 。示例 2:
输入:strs = ["10", "0", "1"], m = 1, n = 1
输出:2
解释:最大的子集是 {"0", "1"} ,所以答案是 2 。
这题就是典型的 0/1 背包问题的变种,但他有两个背包,分别是存 0 和 存 1的,容量由 m 和 n 来决定,这题还不需要回溯具体选了谁。还是按照之前的解题方法来做:
确定要优化的量
不必说,就是
strs的最大子集的长度。定义状态
我们创建一个静态三维数组,来保存状态,和我们 0/1 背包的创建类似:
static int[][][] dp = new int[strs.length + 1][m + 1][n + 1];
比如
dp[i][j][k]的值的意思是,截止到 str 中的第 i 个字符串,可以得到最多的的字符串数量。那么,题目所求的,就是dp[strs.length][m][n]。写状态转移方程
依旧十分关键!同样把把问题拆分成选与不选的两种选择,而若数量超过了限制,也只能不选!
不选
如果选择不选的话,表达式很好写:
dp[i][j][k] = dp[i-1][j][k]
和 0/1 背包的逻辑一模一样!而且也是自底向上的。
选
如果选择选的话,那么对应的
dp[i][j][k]又是什么呢?我们的想法依旧是,若我们选了,那么该值应该是 没加这选它之前的价值 + 此物的价值(此物的价值永远是1,因为就是选与不选)其实就是:
dp[i][j][k] = dp[i - 1][j - countZero][k - countOne] + 1
我们建立一个方法 f ,就是输入一个字符串,返回一个数组 arr,arr 第 0 个坐标为该字符串的 0 的数量,arr 第 1 个坐标为该字符串的 1 的数量。增加 0 的数量为:countZero = arr[0]。增加 1 的数量为:countOne = arr[1]。
注意
dp[i - 1][j - countZero][k - countOne]就是 没选之前的价值1就是 这件物品的价值
和之前一样,还要在做了选与不选的选择之后,我们就要比较,取两者之中最大者
dp[i][j][k] = Math.max(dp[i - 1][j - countZero][k - countOne] + 1, dp[i - 1][j][k]);
确定边界值
边界值其实都是 0 ,我们的数组都是会默认赋值 0 ,所以不需要额外去赋值。
选择计算顺序
也是是自底向上的,就不多说了。
最后看我程序:
package dp;
/**
* @Author: sangui
* @CreateTime: 2025-11-11
* @Description:
* @Version: 1.0
*/
public class Q_474 {
static int[][][] dp;
static void main() {
String[] strs = {"10", "0001", "111001", "1", "0"};
int m = 5;
int n = 3;
int res = findMaxForm(strs, m, n);
System.out.println(res);
}
public static int findMaxForm(String[] strs, int m, int n) {
dp = new int[strs.length + 1][m + 1][n + 1];
// 依次遍历每一个数组中的元素
for (int i = 1; i <= strs.length; i++) {
int[] arr = f(strs[i - 1]);
int countZero = arr[0];
int countOne = arr[1];
// m 代表 0 的容量,依次遍历
for (int j = 0; j <= m; j++) {
// n 代表 1 的容量,依次遍历
for (int k = 0; k <= n; k++) {
if (j < countZero || k < countOne) {
// 走到这里,说明背包没有空闲,不可放
dp[i][j][k] = dp[i - 1][j][k];
} else {
// 走到这里,说明背包有空闲,可放,可不放,需要比较这两个值
dp[i][j][k] = Math.max(dp[i - 1][j - countZero][k - countOne] + 1, dp[i - 1][j][k]);
}
}
}
}
return dp[strs.length][m][n];
}
public static int[] f(String str) {
int[] res = new int[2];
int length = str.length();
for (int i = 0; i < length; i++) {
res[str.charAt(i) - '0']++;
}
return res;
}
}基本想玩五个步骤之后,程序按照之前 0/1 背包的架构,就可以写出来了。接下来就可以实现更新优化了,就是进行二维迭代,现在的空间复杂度是 O(len×m×n)
,可以根据之前我 0/1 背包的思路,将这个三维复杂度,降至二维。哎,今天不知不觉就学算法学到了晚上快九点,有点太累了,以后有时间再优化吧。
最后,提供一下我提出的进阶版爬楼梯题,我的解法,解法如下:
/**
* 每次可以爬 1、2 或 3 阶的爬楼梯的方法(下到上)
*
* @param n 代表 n 阶台阶
* @return 爬 n 阶台阶的有多少种不同的方式
*/
public static int climbStairs4(int n) {
// 1 阶台阶有一种走法
arr[1] = 1;
// 2 阶台阶有两种走法
arr[2] = 2;
// 3 阶台阶有三种走法
arr[3] = 3;
// 第 n 阶台阶(n>3),一定是从 n-1 阶台阶和 n-2 阶台阶和 n-3 阶台阶走来的
// 那么走第 n 阶台阶的走法,就是 n-1 阶台阶的走法加上 n-2 阶台阶的走法
for (int i = 4; i <= n; i++) {
arr[i] = arr[i - 1] + arr[i - 2] + arr[i - 3];
}
return arr[n];
}
/**
* 每次可以爬 1、2 或 3 阶的爬楼梯的方法(上到下)
*
* @param n 代表 n 阶台阶
* @return 爬 n 阶台阶的有多少种不同的方式
*/
public static int climbStairs5(int n) {
if (n == 1) {
// 1 阶台阶有一种走法
arr[n] = 1;
return arr[n];
}
if (n == 2) {
// 2 阶台阶有两种走法
arr[n] = 2;
return arr[n];
}
if (n == 3) {
// 3 阶台阶有三种走法
arr[n] = 3;
return arr[n];
}
if (arr[n] != 0) {
return arr[n];
}
// 第 n 阶台阶(n>4),一定是从 n-1 阶台阶和 n-2 阶台阶和 n-3 阶台阶走来的
// 那么走第 n 阶台阶的走法,就是 n-1 阶台阶的走法加上 n-2 阶台阶,加上 n-3 阶台阶的走法
arr[n] = climbStairs5(n - 1) + climbStairs5(n - 2) + climbStairs5(n - 3);
return arr[n];
}
/**
* 每次可以爬 1、2 或 3 阶楼梯的方法(邪修,无需数组存储)
*
* @param n 代表 n 阶台阶
* @return 爬 n 阶台阶的有多少种不同的方式
*/
public static int climbStairs6(int n) {
// 第 n 阶台阶(n>2),一定是从 n-1 阶台阶和 n-2 阶台阶走来的
// 那么走第 n 阶台阶的走法,就是 n-1 阶台阶的走法加上 n-2 阶台阶的走法
int a = 1;
if (n == 1) {
return a;
}
int b = 2;
if (n == 2) {
return b;
}
int c = 3;
if (n == 3) {
return c;
}
int d = 0;
for (int i = 4; i <= n; i++) {
d = a + b + c;
a = b;
b = c;
c = d;
}
return c;
}
- 微信
- 赶快加我聊天吧

- 赶快加我聊天吧
