动态规划(DP)经典模型 - 提升篇

在这里插入图片描述


如果觉得本文对您有所帮助,点个赞和关注吧,谢谢!!!你的支持就是我持续更新的最大动力


引言

动态规划 (Dynamic Programming, DP) 是算法设计中的一种极其强大的思想,它将一个复杂问题分解为一系列更小、可管理的、且相互重叠的子问题。通过系统地求解这些子问题并存储其结果,DP 能够避免冗余计算,从而在处理具有特定结构性质(最优子结构和重叠子问题)的问题时,展现出惊人的效率。

对于许多开发者而言,DP 似乎是一座难以逾越的高山。其核心难点在于如何将一个具体问题抽象成数学模型。然而,一旦你掌握了其内在的思维范式——定义状态推导状态转移方程——你会发现 DP 是一门充满逻辑之美的艺术。


第一章:动态规划的精髓

在投身于具体问题之前,我们必须深刻理解 DP 的两大支柱及其解决问题的通用方法论。

1.1 DP 的适用前提

一个问题若能用动态规划求解,通常必须满足两个核心性质:

  1. 最优子结构 (Optimal Substructure)

    • 定义:一个问题的最优解包含其子问题的最优解。这意味着我们可以放心地从子问题的最优解出发,构建出原问题的最优解。
    • 直观理解:假设你找到了到达目的地的最短路径,那么这条路径上的任意一段子路径,也必然是其两个端点之间的最短路径。如果不是,我们就可以用更短的子路径替换它,从而得到一条比原“最短路径”更短的路径,这与前提矛盾。0-1 背包和 LCS 问题都具备此性质。
  2. 重叠子问题 (Overlapping Subproblems)

    • 定义:在递归求解的过程中,许多相同的子问题会被反复计算。
    • 直观理解:以斐波那契数列为例,计算 fib(5) 需要 fib(4)fib(3);而计算 fib(4) 又需要 fib(3)fib(2)。可以看到,fib(3) 被计算了多次。DP 的核心优势正是利用“备忘录”(通常是一个数组或哈希表)来存储这些子问题的解,当再次遇到时直接查表,将递归树的指数级时间复杂度“剪枝”为多项式级别。

1.2 解决 DP 问题的通用五步法

面对一个看似复杂的 DP 问题,遵循一个结构化的方法论至关重要。这五个步骤将成为你分析问题的得力工具:

  1. 定义状态 (State Definition):这是最关键、最具创造性的一步。你需要明确 dp 数组(或函数)的含义。dp[i]dp[i][j] 究竟代表了什么?一个好的状态定义应该能够清晰地描述子问题的解,并且无后效性(即当前状态的决策不受未来状态的影响)。
  2. 推导状态转移方程 (State Transition Equation):这是算法的核心。它定义了问题状态之间如何演进。你需要思考 dp[i] 是如何由 dp[i-1], dp[i-2], … 等一个或多个已知的子问题状态推导出来的。
  3. 初始化 (Initialization):确定 dp 数组的初始值或边界条件。这些初始值是状态转移方程的起点。错误的初始化会导致整个递推过程的失败。
  4. 确定遍历顺序 (Traversal Order):根据状态转移方程,确定 dp 数组的填充顺序。必须保证在计算 dp[i] 时,其所依赖的所有子问题状态(如 dp[i-1])都已经计算完毕。
  5. 返回结果 (Return Result):根据 dp 数组的定义,确定问题的最终解在 dp 数组的哪个位置。

接下来,我们将运用这套方法论,彻底征服两大经典模型。


第二章:经典模型一:0-1 背包问题

2.1. 问题描述

给定 n 个物品,每个物品有其重量 weight[i] 和价值 value[i]。现在有一个容量为 W 的背包。你需要决定如何选择物品放入背包,才能在不超过背包总容量的前提下,使得所选物品的总价值最大。注意:每个物品只有一件,要么选,要么不选,不可分割。

2.2. DP 分析与求解 (二维数组)

步骤 1: 状态定义

问题的限制条件有两个:可供选择的物品范围背包的剩余容量。为了唯一确定一个子问题的解,我们的状态定义必须包含这两个维度。

dp[i][j]:一个精确的、无歧义的定义是:在从i 个物品(即物品 0到物品 i-1)中进行选择时,对于一个容量为 j 的背包,所能获得的最大总价值。

  • i 的范围: 0n (物品数量)
  • j 的范围: 0W (背包容量)
  • dp 数组大小为 (n+1) x (W+1)dp[0][...]dp[...][0] 作为边界,简化了逻辑。
步骤 2: 状态转移方程

考虑 dp[i][j] 的值如何计算。这等价于思考对于i-1 号物品(因为 dp[i] 对应前 i 个物品),我们有什么选择:

  1. 不放入第 i-1 号物品

    • 决策:我们决定不将这个物品放入背包。
    • 结果:问题转化为:从前 i-1 个物品中选择,放入容量为 j 的背包,所能获得的最大价值。根据我们的状态定义,这正是 dp[i-1][j]
  2. 放入第 i-1 号物品

    • 前提:当前背包容量 j 必须大于或等于第 i-1 号物品的重量 weights[i-1]
    • 决策:我们决定将这个物品放入背包。
    • 结果:放入后,我们获得了 values[i-1] 的价值,但背包的可用容量减少了 weights[i-1]。剩余的容量 j - weights[i-1] 需要用来从i-1 个物品中选择,以获得最大价值。这个价值就是 dp[i-1][j - weights[i-1]]。因此,此决策的总价值为 dp[i-1][j - weights[i-1]] + values[i-1]

为了获得最大价值,我们在这两种决策中取最优者。于是,状态转移方程诞生了:

// 如果当前背包容量 j 不足以放下第 i-1 号物品
if (j < weights[i-1]) {
    dp[i][j] = dp[i-1][j]; // 只能选择不放
} else {
    // 否则,在“不放”和“放”之间选择更优的
    dp[i][j] = max(
        dp[i-1][j],                                  // 不放第 i-1 号物品
        dp[i-1][j - weights[i-1]] + values[i-1]      // 放第 i-1 号物品
    );
}
步骤 3: 初始化
  • dp[0][j] for j from 0 to W: 表示从前 0 个物品(即没有物品)中选择,无论背包容量多大,最大价值都是 0
  • dp[i][0] for i from 0 to n: 表示背包容量为 0,无论有多少物品可选,都无法装入,最大价值是 0
  • dp 数组用 std::vector<std::vector<int>>(n + 1, std::vector<int>(W + 1, 0)) 创建时,这些初始值已自动设定为 0,无需额外处理。
步骤 4: 遍历顺序

观察状态转移方程,dp[i][j] 依赖于 dp[i-1] 行的数值。因此,我们必须先计算完第 i-1 行,才能计算第 i 行。这决定了我们的遍历顺序是:外层循环遍历物品 i(从 1n),内层循环遍历背包容量 j(从 1W)。

步骤 5: 返回结果

根据定义,dp[n][W] 代表从前 n 个物品中选择,放入容量为 W 的背包中的最大价值,这正是我们要求的最终解。

C++ 实现模板 (二维数组)
#include <vector>
#include <algorithm>

/**
 * @brief 使用动态规划解决0-1背包问题(二维DP数组实现)。
 * @param weights 包含 n 个物品重量的向量,下标从 0 开始。
 * @param values 包含 n 个物品价值的向量,下标从 0 开始。
 * @param capacity 背包的总容量 (W)。
 * @return int 可以获得的最大总价值。
 */
int zeroOneKnapsack_2D(const std::vector<int>& weights, const std::vector<int>& values, int capacity) {
    int n = weights.size();
    if (n == 0 || capacity <= 0) {
        return 0;
    }

    // dp[i][j]: 从前 i 个物品中选择,放入容量为 j 的背包的最大价值。
    // 尺寸为 (n+1) x (capacity+1) 以便处理边界。
    std::vector<std::vector<int>> dp(n + 1, std::vector<int>(capacity + 1, 0));

    // 外层遍历物品 (i 从 1 到 n)
    for (int i = 1; i <= n; ++i) {
        // 内层遍历背包容量 (j 从 1 到 capacity)
        for (int j = 1; j <= capacity; ++j) {
            // 注意:第 i 个物品的索引是 i-1
            int current_weight = weights[i - 1];
            int current_value = values[i - 1];

            if (j < current_weight) {
                // 容量不足,无法装入当前物品
                dp[i][j] = dp[i - 1][j];
            } else {
                // 在“不装入”和“装入”两种决策中取最大值
                dp[i][j] = std::max(dp[i - 1][j], dp[i - 1][j - current_weight] + current_value);
            }
        }
    }

    return dp[n][capacity];
}
  • 时间复杂度: O(n * W),因为我们需要填充一个 n x W 大小的表格。
  • 空间复杂度: O(n * W),因为我们使用了一个二维 dp 数组。

2.3. 空间优化 (滚动数组/一维数组)

仔细观察状态转移方程 dp[i][j] = max(dp[i-1][j], dp[i-1][j - w] + v),我们发现计算第 i 行的状态只依赖于第 i-1 行。这意味着之前的所有行 (i-2, i-3, …) 都是不必要的。这启发我们可以用一个一维数组来优化空间。

  • 新的状态定义: dp[j] 表示容量为 j 的背包所能承载的最大价值。这个定义是“滚动”的,在遍历第 i 个物品时,它存储的是考虑了前 i-1 个物品的结果。

  • 状态转移与遍历顺序的关键
    dp[j] = max(dp[j], dp[j - weights[i]] + values[i])
    这里 dp[j] (等号左边) 是我们要计算的、考虑了物品 i 之后的状态。等号右边的 dp[j] 是未考虑物品 i 时的状态,而 dp[j - weights[i]] 也必须是未考虑物品 i 时的状态。

    为了保证这一点,内层循环必须逆序遍历容量 j(从 capacityweights[i])。

    • 为什么必须逆序? 假设我们正序遍历。当计算 dp[j] 时,我们需要 dp[j - weights[i]] 的旧值(i-1 轮的值)。但如果正序,dp[j - weights[i]] 在计算 dp[j] 之前就已经被更新为本轮(i 轮)的值了。这意味着在同一轮物品 i 的计算中,我们可能多次使用了物品 i 的价值,这就把问题变成了完全背包问题。
    • 逆序的好处:当计算 dp[j] 时,dp[j - weights[i]] 尚未被本轮更新,它仍然保留着上一轮(i-1 轮)的状态。这完美地模拟了二维 dp 数组中 dp[i-1][...] 的效果。
C++ 实现模板 (一维数组)
/**
 * @brief 使用动态规划解决0-1背包问题(空间优化后的一维DP数组)。
 * @param weights ...
 * @param values ...
 * @param capacity ...
 * @return int 最大总价值。
 */
int zeroOneKnapsack_1D(const std::vector<int>& weights, const std::vector<int>& values, int capacity) {
    int n = weights.size();
    if (n == 0 || capacity <= 0) {
        return 0;
    }

    // dp[j]: 容量为 j 的背包能获得的最大价值。
    std::vector<int> dp(capacity + 1, 0);

    // 外层遍历物品
    for (int i = 0; i < n; ++i) {
        // 内层逆序遍历背包容量
        for (int j = capacity; j >= weights[i]; --j) {
            dp[j] = std::max(dp[j], dp[j - weights[i]] + values[i]);
        }
    }

    return dp[capacity];
}
  • 时间复杂度: O(n * W),没有变化。
  • 空间复杂度: O(W),显著优化。

第三章:经典模型二:最长公共子序列 (LCS)

3.1. 问题描述

给定两个字符串 text1text2,返回这两个字符串的最长公共子序列 (Longest Common Subsequence) 的长度。

子序列:从原字符串中删除零个或多个字符,而不改变其余字符的相对顺序得到的新字符串。(例如,“ace” 是 “abcde” 的子序列)。
公共子序列:两个字符串所共有的子序列。

3.2. DP 分析与求解

步骤 1: 状态定义

LCS 问题涉及比较两个字符串的子问题。一个自然的子问题是比较两个字符串的前缀。

dp[i][j]:表示 text1 的前 i 个字符(即子串 text1[0...i-1])与 text2 的前 j 个字符(即子串 text2[0...j-1])的最长公共子序列的长度。

  • i 的范围: 0 to m (text1 的长度)
  • j 的范围: 0 to n (text2 的长度)
  • dp 数组大小为 (m+1) x (n+1)
步骤 2: 状态转移方程

我们考虑 dp[i][j] 如何从其子问题推导而来。关键在于比较 text1[i-1]text2[j-1] 这两个字符:

  1. 如果 text1[i-1] == text2[j-1]:

    • 发现:我们找到了一个新的公共字符。这个字符可以被添加到 text1[0...i-2]text2[0...j-2] 的 LCS 的末尾,从而形成一个新的、更长的 LCS。
    • 推论:因此,text1[0...i-1]text2[0...j-1] 的 LCS 长度,就是 text1[0...i-2]text2[0...j-2] 的 LCS 长度加一。
    • 方程dp[i][j] = dp[i-1][j-1] + 1
  2. 如果 text1[i-1] != text2[j-1]:

    • 发现:这两个末尾字符不匹配,它们不能同时成为 LCS 的一部分。
    • 推论:那么,最长的公共子序列必须在以下两种情况中产生:
      • text1 的前 i-1 个字符 (text1[0...i-2]) 与 text2 的前 j 个字符 (text2[0...j-1]) 的 LCS。其长度为 dp[i-1][j]
      • text1 的前 i 个字符 (text1[0...i-1]) 与 text2 的前 j-1 个字符 (text2[0...j-2]) 的 LCS。其长度为 dp[i][j-1]
    • 方程dp[i][j] 应取这两者中的较大值,即 dp[i][j] = max(dp[i-1][j], dp[i][j-1])
步骤 3 & 4: 初始化与遍历顺序
  • 初始化: dp[0][j]dp[i][0] 均表示一个空串与另一个字符串的 LCS,长度显然为 0。同样,使用 std::vector 的默认初始化即可。
  • 遍历顺序: dp[i][j] 依赖于其左方 dp[i][j-1]、上方 dp[i-1][j] 和左上方 dp[i-1][j-1] 的值。因此,从上到下、从左到右填充 dp 数组是唯一的正确顺序。
步骤 5: 返回结果

最终解是比较完整的 text1 (长度 m) 和 text2 (长度 n),即 dp[m][n]

C++ 实现模板
#include <string>
#include <vector>
#include <algorithm>

/**
 * @brief 计算两个字符串的最长公共子序列 (LCS) 的长度。
 * @param text1 第一个字符串。
 * @param text2 第二个字符串。
 * @return int LCS 的长度。
 */
int longestCommonSubsequence(const std::string& text1, const std::string& text2) {
    int m = text1.length();
    int n = text2.length();

    // dp[i][j]: text1 的前 i 个字符和 text2 的前 j 个字符的 LCS 长度。
    std::vector<std::vector<int>> dp(m + 1, std::vector<int>(n + 1, 0));

    for (int i = 1; i <= m; ++i) {
        for (int j = 1; j <= n; ++j) {
            // 字符串索引是 i-1 和 j-1
            if (text1[i - 1] == text2[j - 1]) {
                dp[i][j] = dp[i - 1][j - 1] + 1;
            } else {
                dp[i][j] = std::max(dp[i - 1][j], dp[i][j - 1]);
            }
        }
    }

    return dp[m][n];
}
  • 时间复杂度: O(m * n)
  • 空间复杂度: O(m * n)。 (同样可以优化到 O(min(m, n)),但会增加实现的复杂性,此处不展开)。

3.3. 拓展:重构 LCS 字符串

通常我们不仅想知道长度,还想得到具体的 LCS 字符串。这可以通过回溯 dp 数组来实现。

dp[m][n] 开始,我们反向推导路径:

  • 如果 text1[i-1] == text2[j-1],说明这个字符是 LCS 的一部分。我们将其加入结果,并移动到 dp[i-1][j-1]
  • 如果 text1[i-1] != text2[j-1],我们比较 dp[i-1][j]dp[i][j-1],移动到值较大的那个格子,这代表了 LCS 的来源方向。
  • 如此反复,直到 ij0
// 伪代码
string lcs_string;
i = m, j = n;
while (i > 0 && j > 0) {
    if (text1[i-1] == text2[j-1]) {
        lcs_string += text1[i-1];
        i--;
        j--;
    } else if (dp[i-1][j] > dp[i][j-1]) {
        i--;
    } else {
        j--;
    }
}
reverse(lcs_string.begin(), lcs_string.end());

第四章:结论与展望

通过对 0-1 背包和最长公共子序列这两个经典模型的深度剖析,我们反复实践了动态规划的五步法。这套方法论是解决 DP 问题的通用钥匙。

  • 核心在于抽象:将实际问题转化为状态和状态转移的数学模型。
  • 状态定义是基石:一个好的状态定义能让状态转移方程的推导水到渠成。
  • 可视化是利器:在纸上画出 dp 表格并手动填充几个值,能极大地帮助理解状态转移的过程。
  • 优化是进阶:在掌握基本解法后,思考状态依赖关系,从而进行空间优化,是衡量开发者能力的重要标准。

动态规划的世界远不止于此,还包括区间 DP、树形 DP、状态压缩 DP 等更高级的模型。但万变不离其宗,掌握了本文所阐述的核心思想和分析方法,就拥有了探索更广阔 DP 世界的坚实基础。

如果觉得本文对您有所帮助,点个赞和关注吧,谢谢!!!你的支持就是我持续更新的最大动力

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

饭碗的彼岸one

感谢鼓励,谢谢

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值