三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

分组背包问题详解:从动态规划原理到C++代码实现与优化

分组背包问题详解:从动态规划原理到C++代码实现与优化

1. 项目概述:从“选哪个组”到“组内选哪个”的思维跃迁

在算法竞赛和面试准备中,背包问题家族是绕不开的经典。从最基础的01背包,到物品无限取用的完全背包,再到有数量限制的多重背包,我们一步步掌握了如何将一堆物品塞进有限容量的背包以获取最大价值。但现实中的决策往往更复杂:物品不是散乱堆放的,而是有组织的。比如,你要为一次出差准备行李,衣服(衬衫、裤子、外套)来自一个品牌店,电子产品(笔记本、充电宝、耳机)来自另一个数码商城。你不能把整个店都搬走,但可以在每个店里挑选至多一件商品。这种“分组选择,组内互斥”的场景,就是分组背包问题要解决的核心。

分组背包问题,顾名思义,就是在01背包的基础上,给物品加上了“分组”的约束。每个组就像一个资源池,你只能从每个池子里捞最多一个物品出来。这听起来简单,但思维上需要一次关键的转换:从“对于每个物品,选或不选”的线性思维,升级为“对于每个组,先决定选哪个物品(或不选)”的两层决策思维。很多朋友在初次接触时,容易把它和多重背包混淆,或者试图用简单的多重循环暴力破解,结果要么逻辑错误,要么时间复杂度爆炸。

我最初在刷题时也在这里卡过壳,直到把状态转移方程背后的“决策过程”想明白,才豁然开朗。今天,我们就来彻底拆解分组背包问题。我会用最直白的C++代码,结合生活化的类比,不仅让你看懂标准解法,更让你理解为什么状态转移要这样设计,以及在实际编码中如何避免那些教科书上不提、但新手必踩的坑。无论你是正在备战蓝桥杯、ACM,还是准备秋招面试,这篇文章都能帮你把这块硬骨头啃下来。

2. 问题定义与核心思路拆解:理解“组”的约束

2.1 问题形式化描述

让我们先把问题用严谨的数学语言描述清楚,这是写出正确代码的第一步。

假设我们有一个容量为V的背包。有N组物品,第i组物品包含S_i个物品。对于第i组中的第j个物品,我们知道它的体积(或重量)v[i][j]和价值w[i][j]

分组背包问题的核心约束是:对于每一组物品,你最多只能选择其中的一个物品放入背包,也可以一个都不选。

我们的目标是:在不超过背包容量的前提下,选择物品(遵守上述分组约束),使得装入背包中物品的总价值最大。

注意:这里“最多选一个”是分组背包的精髓,也是它区别于其他背包问题的关键。在01背包中,每个物品独立决策;在多重背包中,每个物品有数量限制;而在分组背包中,决策单位从“单个物品”变成了“整组物品”。

2.2 核心思路:三层循环的决策逻辑

理解了约束,我们来看如何用动态规划(DP)来求解。动态规划的核心是定义状态和状态转移方程。

  1. 状态定义: 我们定义一个二维数组dp[i][j]。它的含义是:只考虑前i组物品,在背包容量恰好为j时,所能获得的最大价值。这里采用“恰好”的定义是为了逻辑清晰,初始化时dp[0][0]=0,其他dp[0][j]为负无穷(表示不可能达到)。在实际编码中,更常用的是“不超过”容量j的定义,这样初始化更简单,我们稍后会详细对比。

  2. 状态转移: 这是最需要理解的部分。对于当前状态dp[i][j],我们如何从dp[i-1][...]转移过来? 我们需要对第i组物品做出决策。根据规则,我们有S_i + 1种选择(S_i个物品中选一个,或者不选)。

    • 选择不拿第 i 组的任何物品:那么最大价值就是只考虑前i-1组物品,容量为j时的最大价值,即dp[i-1][j]
    • 选择拿第 i 组的第 k 个物品:那么我们需要为这个物品腾出空间。在考虑前i-1组物品时,背包容量需要预留出v[i][k]。所以,最大价值是dp[i-1][j - v[i][k]] + w[i][k]。当然,前提是j >= v[i][k]

    因此,状态转移方程可以写成:dp[i][j] = max(dp[i-1][j], max_{k=1...S_i且 j>=v[i][k]} { dp[i-1][j - v[i][k]] + w[i][k] })

    这个方程清晰地体现了两层决策:外层max是在“不选这组”和“选这组某个物品”之间做抉择;内层max是在这组的所有物品中,挑选出能使总价值最大的那一个。

  3. 循环设计: 根据状态转移方程,我们需要三层循环:

    • 第一层:遍历所有组i(从 1 到 N)。
    • 第二层:遍历所有背包容量j(从 0 到 V)。这里有一个至关重要的优化点:为了防止同一组的物品被重复选择(即违反了“最多选一个”的规则),这一层容量j必须从大到小遍历。这和01背包的优化原理一致,是为了保证在计算dp[i][j]时,用到的dp[i-1][j - v[i][k]]是未被当前组物品更新过的、纯粹的前一组状态。如果从小到大遍历,就变成了完全背包(组内物品无限选)。
    • 第三层:遍历第i组内的所有物品k(从 1 到S_i),尝试将其放入背包。

2.3 与01背包、多重背包的对比

为了加深理解,我们把这几个“兄弟”问题放在一起对比:

问题类型物品特性决策核心状态转移关键
01背包每个物品唯一,选或不选。“对于这个物品,我要不要?”一维DP时,容量j逆序遍历。
完全背包每个物品无限供应。“对于这个物品,我要拿几个?”一维DP时,容量j顺序遍历。
多重背包每个物品有固定数量上限。“对于这个物品,我最多能拿几个,实际拿几个?”可转化为01背包,或用二进制优化/单调队列优化。
分组背包物品分组,每组内最多选一个。“对于这组物品,我选哪个(或不选)?”在组内遍历物品k,但组间容量j必须逆序遍历。

这个对比可以清晰地看到,分组背包在“组”的层面上,决策模式类似于01背包(每组选0或1次),但在组内,它需要进行一次额外的挑选。这种“组间01,组内完全”的复合结构,是理解其代码实现的关键。

3. 核心细节解析与一维DP优化

理解了思路,我们来看代码实现。我将给出两种最常见的实现方式:直观的二维DP和空间优化后的一维DP。我会重点解释一维DP的实现,因为这是面试和竞赛中最常写的,也是最容易出错的地方。

3.1 数据存储与初始化

首先,我们如何存储分组数据?通常有两种方式:

  1. 二维数组v[N][S],w[N][S]。但每组物品数量S_i可能不同,需要额外一个数组s[N]记录每组物品数,并且会浪费空间。
  2. vector嵌套vector<vector<int>> v(N), w(N)。这是更灵活、更推荐的方式。v[i]w[i]分别存储第i组所有物品的体积和价值。

我们采用第二种方式。初始化一维DP数组dp,长度为V+1,所有元素初始化为0。这里我们采用“不超过容量j的最大价值”定义,因此dp[j]初始为0是合理的,表示在没有任何物品时,任何容量下的最大价值都是0。

3.2 一维DP的代码实现与逐行解析

下面是分组背包一维DP的标准模板代码,我将逐行加上详细注释:

#include <iostream> #include <vector> using namespace std; int main() { int N, V; // N-组数, V-背包容量 cin >> N >> V; // 使用vector嵌套存储每组物品信息 vector<vector<int>> v(N+1), w(N+1); // 下标从1开始,符合习惯 vector<int> s(N+1); // s[i]记录第i组的物品数量 // 读入数据 for (int i = 1; i <= N; i++) { cin >> s[i]; // 第i组有多少个物品 v[i].resize(s[i] + 1); // 多开一位,物品下标也从1开始 w[i].resize(s[i] + 1); for (int j = 1; j <= s[i]; j++) { cin >> v[i][j] >> w[i][j]; } } // 一维DP数组,dp[j]表示容量不超过j时的最大价值 vector<int> dp(V + 1, 0); // 核心:三层循环 for (int i = 1; i <= N; i++) { // 第一层:遍历所有组 for (int j = V; j >= 0; j--) { // 第二层:遍历背包容量,**必须逆序!** // 第三层:遍历第i组内的所有物品 for (int k = 1; k <= s[i]; k++) { // 只有当前背包容量能装下这个物品时,才考虑选择它 if (j >= v[i][k]) { // 状态转移:比较“不选”和“选第i组第k个物品”哪个更优 // dp[j] 本身代表不选这组任何物品(继承上一轮状态) // dp[j - v[i][k]] + w[i][k] 代表选择这个物品 dp[j] = max(dp[j], dp[j - v[i][k]] + w[i][k]); } } // 注意:上面的循环已经隐含了“不选这组”的情况(dp[j]的初始值就是上一轮的结果) } } cout << dp[V] << endl; // 输出容量不超过V时的最大价值 return 0; }

关键点解析与避坑指南:

  1. 逆序遍历容量j是灵魂: 代码中for (int j = V; j >= 0; j--)这一行至关重要。为什么必须逆序?

    • 正序的灾难:如果j0遍历到V。假设第i组有一个物品体积为2,价值为5。当j=2时,我们计算dp[2] = max(dp[2], dp[0]+5),此时dp[2]被更新为5。接着,当j=4时,我们又会计算dp[4] = max(dp[4], dp[2]+5)。注意,这里的dp[2]已经是本轮更新过的值(等于5),因此dp[4]可能变成10。这意味着什么?意味着我们在容量为4时,似乎把同一个组的物品选了两次(价值5+5),这完全违反了“每组最多选一个”的规则!
    • 逆序的保障:逆序遍历时,计算dp[4]用到的dp[2]上一轮(i-1组)的状态值,还没有被本组的物品污染过。这样就保证了对于第i组,我们在每个容量j下做出的决策,都是基于“前i-1组”的结果,从而确保了组内物品的互斥性。
  2. 第三层循环的位置: 第三层循环(遍历组内物品k)被放在了第二层循环(容量j)的内部。这意味着,对于每一个确定的容量j,我们都把第i组的所有物品尝试了一遍,从中选出能使dp[j]最大的那个物品(或不选)。这个顺序不能颠倒。你不能先遍历物品再遍历容量,那样就变成了对每个物品做01背包,组内物品就可能被重复选取。

  3. “不选”情况的隐含处理: 细心的你可能发现,代码里似乎没有显式地处理“不选第i组”的情况。其实,它被巧妙地包含了。在进入第三层k循环之前,dp[j]的值就是上一轮(i-1组)计算好的结果,这正好对应了“不选第i组”的决策。在k循环中,我们是用max(dp[j], ...)来更新,dp[j]自己就是候选值之一。如果组内所有物品都因为体积太大装不下,或者即使能装下但价值不如不选,那么dp[j]将保持不变,这就等价于选择了“不选”。

3.3 一个具体的计算例子

假设背包容量V=5,有两组物品:

  • 组1:物品A(体积2,价值4), 物品B(体积3,价值5)
  • 组2:物品C(体积1,价值2), 物品D(体积4,价值7)

我们用手推一下一维DP的过程,来验证逻辑: 初始化:dp = [0, 0, 0, 0, 0, 0](容量0~5)

处理第1组 (i=1)

  • j=5:尝试物品A(2,4),dp[5] = max(dp[5], dp[3]+4)=max(0,0+4)=4;尝试物品B(3,5),dp[5] = max(4, dp[2]+5)=max(4,0+5)=5。最终dp[5]=5
  • j=4:尝试A,dp[4]=max(0, dp[2]+4)=4;尝试B,dp[4]=max(4, dp[1]+5)=max(4,0+5)=5
  • j=3:尝试A,dp[3]=max(0, dp[1]+4)=4;尝试B,dp[3]=max(4, dp[0]+5)=5
  • j=2:尝试A,dp[2]=max(0, dp[0]+4)=4;B装不下。
  • j=1,0:两个物品都装不下,dp值保持为0。 第一轮结束后,dp = [0, 0, 4, 5, 5, 5]。这表示只考虑第一组,容量为2时最大价值4(选A),容量3/4/5时最大价值5(选B)。

处理第2组 (i=2): 注意,此时dp数组代表的是“只考虑前1组”的状态。我们开始逆序遍历j

  • j=5:尝试C(1,2),dp[5] = max(5, dp[4]+2)=max(5,5+2)=7;尝试D(4,7),dp[5] = max(7, dp[1]+7)=max(7,0+7)=7。这里dp[4]是上一轮的值5,代表“只选第一组的B”,加上C后总价值7。选D的话,需要容量4,dp[1]是0,总价值7。所以最终dp[5]=7(方案:第一组选B,第二组选C;或者第一组不选,第二组选D)。
  • j=4:尝试C,dp[4]=max(5, dp[3]+2)=max(5,5+2)=7;尝试D,dp[4]=max(7, dp[0]+7)=7
  • j=3:尝试C,dp[3]=max(5, dp[2]+2)=max(5,4+2)=6;D装不下。
  • j=2:尝试C,dp[2]=max(4, dp[1]+2)=4;D装不下。
  • j=1:尝试C,dp[1]=max(0, dp[0]+2)=2;D装不下。
  • j=0:不变。 最终dp[5]=7,就是全局最优解。

通过这个手算过程,你可以清晰地看到,在计算第二组时,用于转移的dp[4]dp[3]等都是第一轮结束后的值,确保了组与组之间的决策独立性。

4. 典型应用场景与变种问题分析

分组背包不是纯粹的学术问题,它的模型可以映射到很多实际场景。

4.1 实际应用场景举例

  1. 课程选修问题:每个学期,学校开设多门课程。每个课程属于一个专业方向(如“算法组”、“系统组”、“理论组”)。由于时间冲突或知识体系要求,每个方向你最多只能选一门课。每门课有它的学习耗时(体积)和技能提升价值(价值)。你有一个总的学习时间预算(背包容量),如何选课使总技能提升最大?这就是典型的分组背包。

  2. 投资组合优化(简化版):你有一定资金,可以投资到不同领域的多个项目,比如科技领域有A、B公司,消费领域有C、D公司。出于风险分散考虑,你决定每个领域最多投资一个项目。每个项目需要投资额(体积)和预期收益(价值)。如何分配资金使总收益最大?

  3. 游戏装备选择:在角色扮演游戏中,装备栏位是有限的(如武器、头盔、护甲等,每个栏位就是一个“组”)。每个栏位可能有多种装备可选(武器组:剑、斧、法杖),但你只能装备其中一个。每件装备有它的属性加成(价值)和等级要求或重量(体积)。你有一个总的等级或负重上限(背包容量),如何搭配装备使总属性最强?

4.2 常见变种与应对策略

掌握了标准模型,我们来看看它的一些变种,这能检验你是否真正理解了其本质。

  1. 每组至少选一个物品: 这是最常见的变种。约束从“最多选一个”变成了“必须选一个”。如何修改?

    • 思路:状态定义需要稍作调整。我们可以定义dp[i][j]为考虑前i组,容量为j,且每组都至少选了一个物品的最大价值。初始化会变得麻烦,因为第一组就必须选。
    • 更巧妙的转化:对于每组,我们先强制选一个物品(作为“基础”),然后对于这个组剩下的物品,就变成了标准的“最多选一个”(因为已经选过一个了)或者“不能再选”(如果规则是恰好一个)。更通用的方法是,在第三层循环中,不再将“不选”作为初始候选。我们可以先初始化一个临时变量temp = -INF,然后只用组内物品更新它,最后再与dp[j]比较。但需要注意容量遍历顺序和初始化值。
    // 伪代码思路:每组必须选一个 for (int i = 1; i <= N; i++) { for (int j = V; j >= 0; j--) { int temp = -0x3f3f3f3f; // 用一个很小的数表示“必须从这组选一个”的初始状态 for (int k = 1; k <= s[i]; k++) { if (j >= v[i][k]) { // 注意,这里用上一组的状态 dp_prev[j - v[i][k]] 来更新temp temp = max(temp, dp_prev[j - v[i][k]] + w[i][k]); } } // 如果这组一个都选不了(所有物品体积都大于j),那么temp可能还是-INF // 这种情况下,dp[j]也应该是一个无效值(比如-INF),表示无法满足“前i组每组必选” dp[j] = temp; } // 更新 dp_prev 为当前 dp,用于下一组计算 }

    这种变种在初始化dp[0]时也需要小心处理,通常dp[0][0]在没物品时是0,但有了“每组必选”后,dp[0][0]可能应该是 -INF(不可能状态),因为0组物品无法满足“每组必选”的条件。

  2. 每组可以选多个物品,但有上限: 这其实是分组背包+多重背包的混合问题。例如,每组最多可以选m_i个物品。一种思路是将“选k个来自同一组的物品”看作一个新的“复合物品”,然后对这个组进行多重背包处理。但更清晰的方法是使用二维费用背包的思路,增加一维状态来表示当前组已经选取的物品数量。

  3. 依赖分组背包: 有时,选择某一组的某个物品,可能会解锁或禁用另一组的某些物品。这就引入了物品之间的依赖关系,通常需要用状态压缩DP树形DP来配合分组背包解决,复杂度会大大提高。

实操心得:遇到变种,不要慌。核心是回到动态规划的基本功:重新定义状态。问自己:现在的约束条件是什么?它如何影响“状态”的含义?如何影响状态之间的“转移”?把新的约束条件用状态维度或转移条件表达出来,问题就解决了一大半。分组背包的框架(组循环+容量逆序循环+组内物品循环)具有很强的扩展性。

5. 算法性能分析与优化技巧

5.1 时间复杂度与空间复杂度

标准的三层循环解法:

  • 时间复杂度:O(N * V * S_avg),其中N是组数,V是背包容量,S_avg是平均每组物品数。这是一个多项式时间复杂度,但对于N, V, S_avg都较大(比如达到1000)的情况,计算量可能达到10^9级别,需要优化。
  • 空间复杂度:使用一维DP数组是 O(V)。这是非常优秀的。

5.2 常数优化与剪枝

在无法改变算法阶数的情况下,我们可以通过一些技巧来减少实际运行时间:

  1. 容量遍历下界优化: 在第二层循环for (int j = V; j >= 0; j--)中,我们可以不必每次都从V遍历到0。对于第i组,如果这组物品的最小体积是min_v,那么对于容量j < min_v的状态,无论如何也选不了这组的任何物品,dp[j]将直接继承上一轮的值。因此,我们可以将下界设为min_v

    int min_v_of_group_i = *min_element(v[i].begin()+1, v[i].end()); // 求本组最小体积 for (int j = V; j >= min_v_of_group_i; j--) { // ... 内部循环 } // 对于 j < min_v_of_group_i 的部分,dp[j] 保持不变即可

    这个优化在每组物品体积都较大时效果明显。

  2. 组内物品排序与提前终止: 在第三层循环遍历组内物品时,如果我们将物品按“单位价值”(价值/体积)降序排序,那么当我们顺序遍历时,更容易较早地找到较优解。在某些情况下,结合贪心思路,甚至可以在找到某个足够好的解后提前跳出内层循环。但要注意,分组背包不能直接用贪心得到全局最优,排序主要是为了优化常数。

  3. 无效状态跳过: 如果dp[j]在上一轮就是一个无效状态(比如在“恰好装满”问题中初始化为-INF且未被更新),那么在第三层循环中尝试更新它也是徒劳的。可以在更新前加一个判断if (dp[j] != -INF),但通常收益不大,因为判断本身也有开销。

5.3 从分组背包到树形DP:一种更深刻的联系

分组背包的思想可以延伸到树形动态规划上,这是算法学习中的一个重要飞跃。考虑这样一个问题:一棵树,每个节点有一个价值和一个体积(代价),选择节点时需要满足父子节点之间的依赖关系(比如选了子节点才能选父节点,或者选了父节点才能选子节点)。求在总代价限制下的最大价值。

我们可以把每个节点及其子节点看作一个“组”。对于树形DP常用的“选或不选”模型,在某个节点上,我们需要考虑所有子节点的选择情况,这等价于对子节点们进行了一次分组背包:背包容量是当前剩余的代价,每个子节点作为一个“物品”(其体积和价值是处理完该子树后得到的某种状态),并且由于树的结构,这些“物品”之间通常是互斥的(比如在“上司舞会”问题中,选了父节点就不能选直接子节点,但子节点之间可以形成新的分组关系)。

理解这种联系,能让你在面对复杂的树形DP问题时,有一个清晰的“背包式”的思考框架:如何定义每个节点的“体积”和“价值”?如何将子节点的状态“打包”成可供父节点选择的“物品”?这大大降低了树形DP的设计难度。

6. 常见错误排查与调试技巧实录

即便理解了原理,亲手写代码时还是会遇到各种bug。下面是我和学生们在练习分组背包时最常遇到的几个错误,以及排查方法。

6.1 错误类型与解决方案速查表

错误现象可能原因排查与修复方法
结果比预期大,似乎物品被重复选了容量j的循环没有逆序(写成了for(int j=0; j<=V; j++))。这是最经典的错误。立即检查第二层循环是否为逆序for(int j=V; j>=0; j--)
结果比预期小,或者为01.第三层循环(遍历组内物品)写在了第二层循环(容量)的外面
2.状态转移方程写错,比如写成了dp[j] = max(dp[j], dp[j] + w[i][k])
3.数据读入错误,比如组数、物品数、体积价值的对应关系乱了。
1. 检查循环嵌套顺序,必须是组i -> 容量j(逆序) -> 物品k
2. 仔细核对转移方程,应是dp[j] = max(dp[j], dp[j - v[i][k]] + w[i][k])
3. 使用调试器或打印中间变量(如每组的v[i],w[i]),检查读入的数据是否正确。
程序运行超时1.复杂度太高N*V*S太大。
2.使用了未经优化的二维DP,且数组开得很大。
1. 分析数据范围,确认是否必须用分组背包模型,或有更优的贪心策略。
2. 换用一维DP。检查是否有不必要的循环或计算。
3. 尝试上述的常数优化(下界优化)。
“恰好装满”问题出错初始化错误。标准“不超过”问题dp[0]=0, 其他为0。“恰好装满”问题dp[0]=0, 其他为-INF明确问题要求。如果是“恰好装满”,初始化dp[0]=0,dp[1..V]=-0x3f3f3f3f(一个很大的负数)。在转移时也要注意,只有dp[j-v[i][k]]不是-INF时,转移才有效。
多组测试数据时结果互相影响没有清空上一组测试数据使用的全局数组或vector在每组测试数据开始前,将dp数组重置为0(或-INF),并将存储每组物品的vector清空(v.clear(); w.clear();)或重新创建。

6.2 调试技巧:打印DP表

当逻辑复杂,肉眼难以看出错误时,最有效的方法就是打印出关键的DP表(数组)。对于分组背包,我建议在每组物品处理完后,打印出当前的dp数组。

// ... 在核心三层循环内部 ... for (int i = 1; i <= N; i++) { for (int j = V; j >= 0; j--) { for (int k = 1; k <= s[i]; k++) { if (j >= v[i][k]) { dp[j] = max(dp[j], dp[j - v[i][k]] + w[i][k]); } } } // 调试:打印处理完第i组后的dp数组 cout << "After group " << i << ": "; for (int j = 0; j <= V; j++) cout << dp[j] << ' '; cout << endl; }

通过观察每一轮之后dp数组的变化,你可以非常直观地看到决策是如何进行的:哪一组物品在哪个容量下更新了最大值。如果发现某一次更新不符合预期(比如值突然变得很大,可能是重复选取;或者该更新的没更新),就能很快定位到问题所在的那组数据甚至那个物品。

6.3 边界条件与初始化陷阱

  1. 下标从0还是1开始?为了思维和代码的一致性,强烈建议所有数组下标都从1开始dp数组长度为V+1dp[0]表示容量为0。物品和组的编号也从1开始。这能避免很多-1的调整,让代码更清晰,不易出错。

  2. 背包容量为0如果背包容量V为0,那么任何体积大于0的物品都无法选择。根据定义,最大价值应该是0。你的代码应该能正确处理这种情况。在一维DP初始化全为0的情况下,结果自然是0。

  3. 物品体积为0或价值为0如果存在体积为0但价值为正的物品,那么在任何容量下都可以无成本地拿它,这可能会导致问题。需要根据题目具体含义判断是否允许。如果允许,那么“恰好装满”的初始化就需要特别小心,因为通过体积为0的物品可以到达任何状态。价值为0的物品可以选择忽略,因为它不影响最终价值。

分组背包的代码模板性很强,一旦写对一次,以后基本可以套用。关键就在于理解“逆序”的原因,以及三层循环的顺序。把这些核心点内化,再结合打印调试等实践技巧,你就能稳稳地拿下这类问题。在算法竞赛中,它常常不是孤立的考点,而是作为更大问题的一个子模块,比如树形DP、状态压缩DP的一部分。因此,扎实掌握分组背包,是通向更高级动态规划问题的必经之路。

← 返回列表