蓝桥杯第十五届抱佛脚(九)动态规划

蓝桥杯第十五届抱佛脚(九)动态规划

基本概念

动态规划(Dynamic Programming, DP)是一种用于解决复杂问题的优化算法设计技术。它将原问题分解为若干相互重叠的子问题,通过记录子问题的解,避免重复计算,从而大大减少了计算量。

动态规划典型的应用场景包括:

  1. 最优化问题:如求最短路径、最小编辑距离等。

  2. 计数问题:如有多少种方式走到终点、排列组合数量等。

  3. 取值问题:如背包问题、切钢条问题等。

动态规划的关键特征

最优子结构

  • 问题的最优解包含其子问题的最优解。这意味着问题可以通过组合子问题的解来解决。

  • 动态规划问题的最优子结构性质是指原问题的最优解可以由其子问题的最优解推导出来。也就是说,原问题的解是以子问题的最优解为基础,经过一定的组合或运算得到的。

  • 这个性质反映了原问题与子问题之间的关系:子问题的最优解构成了原问题最优解的一部分。因此,我们可以先求解子问题,然后根据子问题的最优解推导出原问题的最优解。

  • 通常我们使用状态转移方程来刻画这种子问题与原问题之间的关系。状态转移方程定义了如何由小规模子问题的最优解,组合得到大规模问题的最优解。

  • 所以最优子结构性质是动态规划的基础,它保证了我们能够通过解决子问题,逐步推导出原问题的最优解,从而达到将大问题分解为小问题解决的目的。满足这个性质是采用动态规划算法的前提条件。

重叠子问题

  • 在求解过程中,相同的子问题会多次出现。动态规划通过记忆化(存储子问题的解)来避免重复计算。
  • 重复子问题性质指的是在求解一个动态规划问题时,每个子问题都会被重复计算多次。换句话说,不同的较大规模的原问题,存在着相同的较小规模的子问题。
  • 比如在计算斐波那契数列时,f(n)需要先计算f(n-1)和f(n-2),而计算f(n-1)又需要计算f(n-2),这里f(n-2)就是一个重复计算的子问题。
  • 重复子问题的存在,使得相同的子问题被重复计算多次,造成了大量的计算冗余。而动态规划的思想就是:每个子问题只解决一次,将其结果保存在一个表格中,下次需要相同的子问题时,可以直接查表获得结果。
  • 满足重复子问题性质意味着我们可以用动态规划来有效地解决问题,减少重复计算。而如果一个问题在求解时没有出现重复子问题,那我们可以直接使用recursion递归或者更高效的方法来解决,不需要动态规划。
  • 所以重复子问题性质是动态规划相比其他方法优越性的体现,但不是使用动态规划的必要条件。只要满足最优子结构性质,就可以用动态规划求解。

动态规划的基本步骤

动态规划的基本步骤是一套系统的方法论,用于将复杂问题分解为更小、更易于管理的子问题。这些步骤有助于高效解决具有重叠子问题和最优子结构特征的问题。以下是动态规划的五个基本步骤:

1. 识别问题类型

确认问题是否适合用动态规划解决。关键是检查问题是否具有以下两个特性:

  • 最优子结构:问题的最优解包含其子问题的最优解。
  • 重叠子问题:问题可以分解为重复出现的子问题。

2. 定义状态

  • 状态的选择:准确定义出解决问题所需的状态。这通常涉及到找出一个或多个变量来描述一个问题的方方面面。
  • 状态表示:通常使用数组或矩阵来表示状态,例如dp[i]dp[i][j]。其中dp是一个常用的命名约定,代表“动态规划”。

3. 确定状态转移方程

  • 状态转移方程是动态规划的核心,它描述了如何从一个或多个已知状态得到另一个状态。对于每个状态,你需要考虑所有可能的转移,并选择能够达到问题要求的最优或最小化/最大化结果的转移。
  • 这一步需要对问题进行深入分析,确保方程正确地反映了问题的所有方面。

4. 确定边界条件

  • 定义初始状态或基本情况,这是计算过程的起点。例如,在递归实现中,这通常是递归的基准情况。
  • 这些条件必须足够简单,以便可以直接求解,从而为更复杂的状态提供起始点。

5. 计算最终结果

  • 根据上述步骤定义的状态和转移方程,你可以计算出问题的解。
  • 解的计算可以是自顶向下(从大问题到小问题,通常使用递归和记忆化),也可以是自底向上(从基础情况开始逐步构建,通常使用迭代)。

6. (可选)路径重构

  • 在某些情况下,仅仅知道最优解的值是不够的,你可能还需要知道如何达到这个最优解。这可能涉及到回溯状态转移过程,来找出导致最终解的选择序列或路径。

动态规划的分类

自顶向下的动态规划(Top-Down)

  • 这种方法使用递归来解决问题,从最大的问题开始并逐步分解为更小的子问题。
  • 通常结合“记忆化”(Memorization)使用,即存储已解决的子问题的结果,以避免重复计算。
  • 更符合问题的自然形态,但可能会因过多的递归调用而导致性能问题。

自底向上的动态规划(Bottom-Up)

  • 从最小的子问题开始,逐步合成更大的问题的解。
  • 通常使用迭代方法,通过填充表格(一般是数组或矩阵)的方式来记录子问题的解。
  • 通常更高效,因为它避免了递归的开销,并且可以更容易地进行状态转移。

动态规划例题

自顶向下的动态规划(Top-Down)

斐波那契数列
问题描述

斐波那契数 (通常用 F(n) 表示)形成的序列称为 斐波那契数列 。该数列由 01 开始,后面的每一项数字都是前面两项数字的和。也就是:

F(0) = 0,F(1) = 1
F(n) = F(n - 1) + F(n - 2),其中 n > 1

给定 n ,请计算 F(n)

常规递归解题
  • 重复计算:在计算如斐波那契数列这样的问题时,递归方法可能会多次计算相同的子问题。例如,在计算 F(n) 的过程中,F(n-2) 会在计算 F(n-1)F(n) 时被重复计算。

  • 效率问题:由于重复计算,递归方法的时间复杂度可能会非常高。对于斐波那契数列的递归实现,时间复杂度是指数级的(大约是 O(2^n)),这对于较大的 n 是非常低效的。

class Solution {
    public int fib(int n) {
        if (n <= 1) {
            return n;
        }
        return fib(n-2) + fib(n-1);
    }
}
动态规划解题
  1. 定义状态

    • 创建一个数组 dp,其中 dp[i] 表示斐波那契数列的第 ( i ) 项。
  2. 初始状态

    • 因为斐波那契数列是由 0 和 1 开始的,所以 dp[0] = 0dp[1] = 1
  3. 状态转移方程

    • 根据斐波那契数列的定义,第 ( n ) 项是其前两项的和,即 dp[i] = dp[i - 1] + dp[i - 2]
  4. 计算顺序

    • 从第 2 项开始,直到第 ( n ) 项。
  5. 答案

    • dp[n] 就是问题的答案。
class Solution {
    public int fib(int n) {
        // Step 1 & 2: 初始化dp数组和初始状态
        if (n <= 1) {
            return n;
        }
        int[] dp = new int[n + 1];
        dp[0] = 0;
        dp[1] = 1;

        // Step 3 & 4: 使用状态转移方程计算dp数组中的每一项
        for (int i = 2; i <= n; i++) {
            dp[i] = dp[i - 1] + dp[i - 2];
        }

        // Step 5: 返回答案
        return dp[n];
    }
}

在这个实现中,我们使用了一个大小为 n + 1 的数组 dp 来存储斐波那契数列的每个值,避免了重复计算相同项的问题,这是动态规划的核心优势。对于大值的 n,这种方法比递归方法要高效得多。

最长递增子序列
问题描述

给你一个整数数组 nums ,找到其中最长严格递增子序列的长度。

子序列 是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。例如,[3,6,2,7] 是数组 [0,3,1,6,2,2,7] 的子序列。

示例:

输入:nums = [10,9,2,5,3,7,101,18]
输出:4
解释:最长递增子序列是 [2,3,7,101],因此长度为 4 。
动态规划的步骤
  1. 定义状态

    • 创建一个数组 dp,其中 dp[i] 表示以 nums[i] 结尾的最长递增子序列(LIS)的长度。
  2. 初始状态

    • 初始化 dp 的每个元素为 1,因为每个元素自身可以看作是长度为 1 的递增子序列。
  3. 状态转移方程

    • 对于每个 i(从 1 到 nums.length - 1),对于每个 j(从 0 到 i - 1),如果 nums[i] > nums[j],则 dp[i] = Math.max(dp[i], dp[j] + 1)
  4. 计算顺序

    • 依次计算 dp[1], dp[2], ..., dp[nums.length - 1]
  5. 答案

    • 最长递增子序列的长度为 dp 数组中的最大值。
class Solution {
    public int lengthOfLIS(int[] nums) {
        if (nums == null || nums.length == 0) {
            return 0;
        }
        int n = nums.length;
        int[] dp = new int[n];
        int maxLength = 1;

        // Step 2: 初始化dp数组
        for (int i = 0; i < n; i++) {
            dp[i] = 1;
        }

        // Step 3 & 4: 动态规划计算
        for (int i = 1; i < n; i++) {
            for (int j = 0; j < i; j++) {
                if (nums[i] > nums[j]) {
                    dp[i] = Math.max(dp[i], dp[j] + 1);
                }
            }
            // 更新最长长度
            maxLength = Math.max(maxLength, dp[i]);
        }

        // Step 5: 返回答案
        return maxLength;
    }
}

我们首先初始化一个 dp 数组,用来存储以每个元素结尾的最长递增子序列的长度。通过双层循环,我们不断更新 dp 数组中的值,从而找到最长递增子序列。最后,从 dp 数组中找到最大值,这就是我们要求的最长递增子序列的长度。

硬币兑换
问题描述

给你一个整数数组 coins ,表示不同面额的硬币;以及一个整数 amount ,表示总金额。

计算并返回可以凑成总金额所需的 最少的硬币个数 。如果没有任何一种硬币组合能组成总金额,返回 -1

你可以认为每种硬币的数量是无限的。

示例:

输入:coins = [1, 2, 5], amount = 11
输出:3 
解释:11 = 5 + 5 + 1

这个问题可以通过动态规划来解决。我们将问题分解为较小的子问题,然后找出每个子问题的解,从而找到最终问题的解。以下是使用动态规划求解最少硬币个数问题的详细步骤:

动态规划的步骤
  1. 定义状态

    • 创建一个数组 dp,其中 dp[i] 表示组成金额 i 所需的最少硬币个数。
  2. 初始状态

    • 初始化 dp[0] = 0,因为组成金额 0 不需要任何硬币。
    • 对于所有其他 i(1 到 amount),初始化为一个大数(例如 amount + 1),表示初始时无法达到该金额。
  3. 状态转移方程

    • 对于每个金额 i,遍历所有硬币面额。如果 i 大于等于当前硬币面额 coin,则 dp[i] = Math.min(dp[i], dp[i - coin] + 1)

    状态转移方程的核心详解:

    1. 遍历每个金额 i

      • 我们考虑从 1amount 的每个金额。对于每个这样的金额 i,我们要找出组成这个金额所需的最少硬币个数。
    2. 遍历所有硬币面额

      • 对于金额 i,我们检查每种硬币面额 coin。这包括数组 coins 中的每个元素。
    3. 检查条件 i >= coin

      • 我们只有在金额 i 大于等于硬币面额 coin 时才考虑这枚硬币。如果 i < coin,这枚硬币太大,不能用来组成金额 i
    4. 更新 dp[i]

      • 现在,如果金额 i 大于等于硬币面额 coin,我们尝试用这枚硬币来组成金额 i。如果用这枚硬币,那么我们还需要组成金额 i - coin。为此,我们查看 dp[i - coin],它表示组成金额 i - coin 所需的最少硬币个数。
      • 然后,我们使用 dp[i - coin] + 1 来更新 dp[i]。这里加 1 是因为我们使用了一枚面额为 coin 的硬币。这实际上是说:“如果我使用这枚硬币,那么组成金额 i 所需的总硬币数是组成 i - coin 所需的硬币数加上这一枚硬币”。
    5. 取最小值

      • 我们用 Math.min(dp[i], dp[i - coin] + 1) 来更新 dp[i]。这意味着我们对于每种硬币,都检查是否使用它会得到更少的硬币总数。我们选择能组成金额 i 的最少硬币数量。

    通过这种方式,dp[i] 最终存储的是组成金额 i 所需的最少硬币个数。这个过程会对所有金额和所有硬币组合进行尝试,以找出最优解。

  4. 计算顺序

    • 从小到大计算 dp[1], dp[2], ..., dp[amount]
  5. 答案

    • 如果 dp[amount] 大于 amount,意味着无法组成该金额,返回 -1;否则返回 dp[amount]
class Solution {
    public int coinChange(int[] coins, int amount) {
        int max = amount + 1;
        int[] dp = new int[amount + 1];
        Arrays.fill(dp, max);
        dp[0] = 0;

        for (int i = 1; i <= amount; i++) {
            for (int coin : coins) {
                if (i >= coin) {
                    dp[i] = Math.min(dp[i], dp[i - coin] + 1);
                }
            }
        }

        return dp[amount] > amount ? -1 : dp[amount];
    }
}

dp 数组存储了组成每个金额所需的最少硬币个数。通过遍历所有的硬币面额和所有可能的金额,我们能够填充 dp 数组,找到组成特定金额所需的最少硬币个数。如果最终的 dp[amount] 值仍然大于 amount,这意味着没有合适的硬币组合可以组成这个金额,因此返回 -1

编辑距离
问题描述

给你两个单词 word1word2请返回将 word1 转换成 word2 所使用的最少操作数

你可以对一个单词进行如下三种操作:

  • 插入一个字符
  • 删除一个字符
  • 替换一个字符
动态规划的步骤
  1. 定义状态

    • 创建一个二维数组 dp,其中 dp[i][j] 表示 word1 的前 i 个字符转换成 word2 的前 j 个字符所需的最少操作数。
  2. 初始状态

    • 初始化 dp[0][j] = j,表示将空字符串转换为 word2 的前 j 个字符需要 j 步(全部插入操作)。
    • 初始化 dp[i][0] = i,表示将 word1 的前 i 个字符转换为空字符串需要 i 步(全部删除操作)。
  3. 状态转移方程

    • 对于每个 i > 0j > 0,我们考虑以下情况:
      • 如果 word1[i - 1] == word2[j - 1],则无需操作,dp[i][j] = dp[i - 1][j - 1]
      • 否则,我们可以进行插入、删除或替换操作,取这三种操作中最小的一个:
        • 插入:dp[i][j - 1] + 1
        • 删除:dp[i - 1][j] + 1
        • 替换:dp[i - 1][j - 1] + 1

    状态转移方程解析

    状态转移方程考虑的是将 word1 的前 i 个字符转换成 word2 的前 j 个字符所需的最少操作数。设 dp[i][j] 为这个最少操作数。转换方法有三种:插入、删除、替换。我们需要找到最优的操作序列,使得操作次数最少。状态转移方程如下:

    • word1[i - 1] == word2[j - 1]

      • 当前字符相等,不需要任何操作。因此,我们只需考虑 word1 的前 i-1 个字符和 word2 的前 j-1 个字符。所以,dp[i][j] = dp[i - 1][j - 1]
    • word1[i - 1] != word2[j - 1]

      • 当前字符不相等,我们有三种操作方式,选择其中操作数最少的一种:
        1. 插入:在 word1i 位置插入一个字符,使其与 word2[j] 相等。然后,我们需要将 word1 的前 i 个字符变成 word2 的前 j-1 个字符。因此,操作数为 dp[i][j-1] + 1
        2. 删除:删除 word1[i],然后将 word1 的前 i-1 个字符变成 word2 的前 j 个字符。操作数是 dp[i-1][j] + 1
        3. 替换:将 word1[i] 替换成 word2[j],接下来只需将 word1 的前 i-1 个字符变成 word2 的前 j-1 个字符。操作数为 dp[i-1][j-1] + 1

    在每一步,我们选择这三种操作中最小的操作数作为 dp[i][j] 的值。

    简单举例

    例如,考虑 word1 = "abc"word2 = "yabd"。如果我们要计算 dp[3][4](即将 "abc" 变成 "yabd" 的最少操作数):

    1. word1[3-1] != word2[4-1]c != d),所以我们考虑三种操作:
      • 插入:将 d 插入到 word1 的末尾,然后我们只需要考虑将 "abc" 变为 "yab",即 dp[3][3] + 1
      • 删除:删除 word1 的最后一个字符 c,然后我们只需将 "ab" 变为 "yabd",即 dp[2][4] + 1
      • 替换:将 word1 的最后一个字符 c 替换为 d,然后我们只需将 "ab" 变为 "yab",即 dp[2][3] + 1

    我们从这三个操作中选择最小的一个来更新 dp[3][4]

    通过这种方式,我们可以构建整个 dp 数组,最终 dp[word1.length()][word2.length()] 就是将 word1 转换成 word2 所需的最少操作数。

  4. 计算顺序

    • 按行和列依次计算 dp[i][j]
  5. 答案

    • dp[word1.length()][word2.length()] 是最终答案。
class Solution {
    public int minDistance(String word1, String word2) {
        int m = word1.length();
        int n = word2.length();
        int[][] dp = new int[m + 1][n + 1];

        // Step 2: 初始化dp数组
        for (int i = 0; i <= m; i++) {
            dp[i][0] = i;
        }
        for (int j = 0; j <= n; j++) {
            dp[0][j] = j;
        }

        // Step 3 & 4: 计算dp数组
        for (int i = 1; i <= m; i++) {
            for (int j = 1; j <= n; j++) {
                if (word1.charAt(i - 1) == word2.charAt(j - 1)) {
                    dp[i][j] = dp[i - 1][j - 1];
                } else {
                    dp[i][j] = Math.min(dp[i - 1][j - 1], Math.min(dp[i][j - 1], dp[i - 1][j])) + 1;
                }
            }
        }

        // Step 5: 返回答案
        return dp[m][n];
    }
}

自底向上的动态规划(Bottom-Up)

爬楼梯
问题描述

假设你正在爬楼梯。需要 n 阶你才能到达楼顶。

每次你可以爬 12 个台阶。你有多少种不同的方法可以爬到楼顶呢?

动态规划的步骤
  1. 定义状态

    • 创建一个数组 dp,其中 dp[i] 表示到达第 i 阶楼梯有多少种不同的方法。
  2. 初始状态

    • dp[0] = 1dp[1] = 1。到达第0阶(起点)只有1种方法(即不爬),到达第1阶也只有1种方法(爬1阶)。
  3. 状态转移方程

    • 对于 i >= 2,每一阶楼梯都可以从前一阶爬上来(一步),或者从前两阶跨两步爬上来。因此,dp[i] = dp[i - 1] + dp[i - 2]
  4. 计算顺序

    • dp[2] 开始计算,一直到 dp[n]
  5. 答案

    • dp[n] 就是到达第 n 阶楼梯的不同方法数量。
class Solution {
    public int climbStairs(int n) {
        if (n <= 1) {
            return 1;
        }
        int[] dp = new int[n + 1];
        dp[0] = 1;
        dp[1] = 1;

        for (int i = 2; i <= n; i++) {
            dp[i] = dp[i - 1] + dp[i - 2];
        }

        return dp[n];
    }
}

我们首先处理了两个初始状态 dp[0]dp[1],然后利用状态转移方程计算出每一阶楼梯的爬法数。最后,dp[n] 就给出了爬到第 n 阶楼梯的不同方法数量。这个问题本质上和斐波那契数列是相同的,因为每一阶的方法数都是前两阶方法数的和。

最大子数组和
问题描述

给你一个整数数组 nums ,请你找出一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。

示例:

输入:nums = [-2,1,-3,4,-1,2,1,-5,4]
输出:6
解释:连续子数组 [4,-1,2,1] 的和最大,为 6 。
动态规划的步骤
  1. 定义状态

    • 创建一个数组 dp,其中 dp[i] 表示以 nums[i] 结尾的最大子数组和。
  2. 初始状态

    • dp[0] = nums[0],因为最开始的最大子数组和只能是数组的第一个元素。
  3. 状态转移方程

    • 对于每个 i(从 1nums.length - 1),dp[i]dp[i-1] + nums[i]nums[i] 之间的较大值。这表示我们可以选择继续累加前面的子数组或者从当前位置重新开始一个新的子数组。
    • dp[i] = Math.max(dp[i - 1] + nums[i], nums[i])
  4. 计算顺序

    • dp[1] 开始计算,直到 dp[nums.length - 1]
  5. 答案

    • 最大子数组和是 dp 数组中的最大值。
class Solution {
    public int maxSubArray(int[] nums) {
        if (nums == null || nums.length == 0) {
            return 0;
        }
        
        int n = nums.length;
        int[] dp = new int[n];
        dp[0] = nums[0];
        int maxSum = dp[0];

        for (int i = 1; i < n; i++) {
            dp[i] = Math.max(dp[i - 1] + nums[i], nums[i]);
            maxSum = Math.max(maxSum, dp[i]);
        }

        return maxSum;
    }
}

我们首先处理了初始状态 dp[0],然后使用状态转移方程计算出每个位置的最大子数组和。同时,我们追踪 dp 数组中的最大值,这就是最大子数组和。这种方法比直接遍历所有可能的子数组要高效得多。

不同路径
问题描述

一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为 “Start” )。

机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为 “Finish” )。

问总共有多少条不同的路径?

示例:

img

输入:m = 3, n = 7
输出:28
动态规划的步骤
  1. 定义状态

    • 创建一个二维数组 dp,其中 dp[i][j] 表示到达网格中的位置 (i, j) 的不同路径数。
  2. 初始状态

    • dp[0][j] = 1 对于所有 j(从左上角到第一行的任何位置都只有一条路径,即全部向右移动)。
    • dp[i][0] = 1 对于所有 i(从左上角到第一列的任何位置都只有一条路径,即全部向下移动)。
  3. 状态转移方程

    • 对于每个 i > 0j > 0,机器人只能从上方或左方到达 (i, j),所以 dp[i][j] = dp[i - 1][j] + dp[i][j - 1]
  4. 计算顺序

    • 按行(或按列)逐个计算 dp[i][j],从 dp[1][1] 开始,直到 dp[m - 1][n - 1]
  5. 答案

    • dp[m - 1][n - 1] 就是到达右下角的不同路径总数。
class Solution {
    public int uniquePaths(int m, int n) {
        int[][] dp = new int[m][n];

        // 初始化第一列和第一行
        for (int i = 0; i < m; i++) {
            dp[i][0] = 1;
        }
        for (int j = 0; j < n; j++) {
            dp[0][j] = 1;
        }

        // 动态规划填充其余的网格
        for (int i = 1; i < m; i++) {
            for (int j = 1; j < n; j++) {
                dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
            }
        }

        return dp[m - 1][n - 1];
    }
}

我们首先初始化了网格的第一行和第一列,因为这些位置的路径数是已知的。然后我们使用状态转移方程逐格计算其他位置的路径数。最后,dp[m - 1][n - 1] 给出了到达右下角的不同路径总数。

两类动态规划的解题思路对比

自顶向下(Top-Down)

也称为记忆化递归(Memoization)。

  1. 思路

    • 从原始问题开始,递归地解决所有子问题。
    • 利用记忆化存储(通常是数组或散列表),来记录已经解决的子问题的答案,避免重复计算。
  2. 实现细节

    • 通常使用递归方法实现。
    • 在函数调用时,先检查解是否已经在记忆化存储中。如果是,直接返回该解;如果不是,计算解并存储在记忆化存储中。
  3. 优缺点

    • 优点:递归实现更直观,更接近问题的实际定义。
    • 缺点:可能导致大量的递归调用,从而引起栈溢出;实现通常比自底向上慢。

自底向上(Bottom-Up)

也称为表格化(Tabulation)。

  1. 思路

    • 从最简单的子问题开始,逐步解决更复杂的子问题,直到解决最终问题。
    • 利用表(通常是数组)来按顺序存储每个子问题的解。
  2. 实现细节

    • 通常使用迭代方法实现。
    • 填充一个表格,表中的每个条目对应一个子问题的解。条目的填充顺序确保每个子问题在被解决之前,所有需要的信息都已经被计算并存储。
  3. 优缺点

    • 优点:通常比自顶向下快;避免了递归调用,降低了栈溢出的风险。
    • 缺点:可能较难理解和实现,特别是当问题的状态转移不是很直观时。

版权声明:本文为博主作者:全栈ing小甘原创文章,版权归属原作者,如果侵权,请联系我们删除!

原文链接:https://blog.csdn.net/weixin_58808338/article/details/137226959

共计人评分,平均

到目前为止还没有投票!成为第一位评论此文章。

(0)
扎眼的阳光的头像扎眼的阳光普通用户
上一篇 2024年4月22日
下一篇 2024年4月22日

相关推荐