”动态规划“核心&&经典案例🤡

224 阅读9分钟

一、动态规划的核心概念

动态规划(Dynamic Programming,DP)是一种用于求解优化问题的算法策略,其核心基于两个关键性质:最优子结构重叠子问题。最优子结构指的是问题的最优解可以通过其子问题的最优解来构建,这意味着如果我们已经知道子问题的最优解,就能够通过某种方式组合这些解,得到原问题的最优解。重叠子问题则表明在求解问题的过程中,会重复地遇到相同的子问题。动态规划通过记录子问题的解,避免重复计算,从而提升算法效率。

动态规划通常采用两种实现方式:自顶向下(Top - Down)的备忘录(Memoization)方法和自底向上(Bottom - Up)的迭代方法。自顶向下方法使用递归求解问题,并将已解决的子问题的解存储起来,避免重复计算;自底向上方法则从最小的子问题开始,逐步求解更大的子问题,直到得到原问题的解。

二、动态规划的解题步骤

  1. 定义状态:明确问题的状态表示,通常用数组或二维数组来存储不同状态下的解。状态定义需要能够完整地描述问题的子问题,并且满足最优子结构。
  1. 推导状态转移方程:确定不同状态之间的关系,即如何通过已知状态的解推导出未知状态的解。状态转移方程是动态规划的核心,它体现了问题的求解逻辑。
  1. 确定初始条件:定义最小子问题的解,这些解是后续推导更大子问题解的基础。
  1. 计算顺序:根据状态转移方程确定计算状态的顺序,确保在计算某个状态时,其所依赖的状态已经被求解。
  1. 返回结果:根据问题的要求,从计算得到的状态中提取最终的解。

三、经典案例分析

3.1 斐波那契数列

斐波那契数列是一个经典的动态规划入门案例,其定义为:(F(n) = F(n - 1) + F(n - 2)),其中(F(0) = 0),(F(1) = 1)。

Python 实现(自底向上)

def fibonacci_bottom_up(n):
    if n == 0:
        return 0
    elif n == 1:
        return 1
    dp = [0] * (n + 1)
    dp[1] = 1
    for i in range(2, n + 1):
        dp[i] = dp[i - 1] + dp[i - 2]
    return dp[n]

Java 实现(自底向上)

public class Fibonacci {
    public static int fibonacciBottomUp(int n) {
        if (n == 0) {
            return 0;
        }
        if (n == 1) {
            return 1;
        }
        int[] dp = new int[n + 1];
        dp[1] = 1;
        for (int i = 2; i <= n; i++) {
            dp[i] = dp[i - 1] + dp[i - 2];
        }
        return dp[n];
    }
}

3.2 0 - 1 背包问题

0 - 1 背包问题描述为:给定(n)个物品,每个物品有重量(w_i)和价值(v_i),背包容量为(W),每个物品只能选择放入或不放入背包,求在不超过背包容量的情况下,能装入背包的物品的最大价值。

设(dp[i][j])表示考虑前(i)个物品,背包容量为(j)时的最大价值。状态转移方程为:( dp[i][j] = \begin{cases} dp[i - 1][j] & \text{if } w_i > j \ \max(dp[i - 1][j], dp[i - 1][j - w_i] + v_i) & \text{if } w_i \leq j \end{cases} )

Python 实现(自底向上)

def knapsack_01(w, v, W):
    n = len(w)
    dp = [[0 for _ in range(W + 1)] for _ in range(n + 1)]
    for i in range(1, n + 1):
        for j in range(1, W + 1):
            if w[i - 1] > j:
                dp[i][j] = dp[i - 1][j]
            else:
                dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - w[i - 1]] + v[i - 1])
    return dp[n][W]

Java 实现(自底向上)

public class Knapsack01 {
    public static int knapsack01(int[] w, int[] v, int W) {
        int n = w.length;
        int[][] dp = new int[n + 1][W + 1];
        for (int i = 1; i <= n; i++) {
            for (int j = 1; j <= W; j++) {
                if (w[i - 1] > j) {
                    dp[i][j] = dp[i - 1][j];
                } else {
                    dp[i][j] = Math.max(dp[i - 1][j], dp[i - 1][j - w[i - 1]] + v[i - 1]);
                }
            }
        }
        return dp[n][W];
    }
}

3.3 最长公共子序列(Longest Common Subsequence, LCS)

最长公共子序列问题是指在两个给定序列中,找出一个最长的子序列,该子序列在两个原序列中都按顺序出现,但不一定是连续的。例如,序列"AGGTAB"和"GXTXAYB"的最长公共子序列是"GTAB"。

设dp[i][j]表示序列X的前i个元素和序列Y的前j个元素的最长公共子序列的长度。状态转移方程为:

屏幕截图 2025-07-06 215030.png

Python 实现(自底向上)

def longest_common_subsequence(X, Y):
    m, n = len(X), len(Y)
    dp = [[0 for _ in range(n + 1)] for _ in range(m + 1)]
    for i in range(1, m + 1):
        for j in range(1, n + 1):
            if X[i - 1] == Y[j - 1]:
                dp[i][j] = dp[i - 1][j - 1] + 1
            else:
                dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])
    return dp[m][n]

Java 实现(自底向上)

public class LongestCommonSubsequence {
    public static int longestCommonSubsequence(String X, String Y) {
        int m = X.length();
        int n = Y.length();
        int[][] dp = new int[m + 1][n + 1];
        for (int i = 1; i <= m; i++) {
            for (int j = 1; j <= n; j++) {
                if (X.charAt(i - 1) == Y.charAt(j - 1)) {
                    dp[i][j] = dp[i - 1][j - 1] + 1;
                } else {
                    dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
                }
            }
        }
        return dp[m][n];
    }
}

最长公共子序列问题在生物信息学中用于 DNA 序列比对,通过比对 DNA 序列的相似性,能够帮助科学家推断物种之间的进化关系,了解基因的功能和疾病的遗传机制等。在文本处理领域,它可用于检测抄袭,通过比较两篇文章的最长公共子序列长度,判断它们的相似程度。

5.2 编辑距离(Edit Distance,又称 Levenshtein 距离)

编辑距离是指两个字符串之间,由一个转成另一个所需的最少编辑操作次数。许可的编辑操作包括将一个字符替换成另一个字符、插入一个字符、删除一个字符。例如,将"kitten"转成"sitting"需要 3 次编辑操作:"sitten"(k→s)、"sittin"(e→i)、"sitting"(→g)。

设dp[i][j]表示字符串str1的前i个字符转换为字符串str2的前j个字符所需的最少编辑操作次数。状态转移方程为:( dp[i][j] = \begin{cases} i & \text{if } j = 0 \ j & \text{if } i = 0 \ \min( dp[i - 1][j] + 1, \ dp[i][j - 1] + 1, \ dp[i - 1][j - 1] + (str1[i - 1] == str2[j - 1]? 0 : 1) ) & \text{otherwise} \end{cases} )

Python 实现(自底向上)

def edit_distance(str1, str2):
    m, n = len(str1), len(str2)
    dp = [[0 for _ in range(n + 1)] for _ in range(m + 1)]
    for i in range(m + 1):
        dp[i][0] = i
    for j in range(n + 1):
        dp[0][j] = j
    for i in range(1, m + 1):
        for j in range(1, n + 1):
            if str1[i - 1] == str2[j - 1]:
                cost = 0
            else:
                cost = 1
            dp[i][j] = min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + cost)
    return dp[m][n]

Java 实现(自底向上)

public class EditDistance {
    public static int editDistance(String str1, String str2) {
        int m = str1.length();
        int n = str2.length();
        int[][] dp = new int[m + 1][n + 1];
        for (int i = 0; i <= m; i++) {
            dp[i][0] = i;
        }
        for (int j = 0; j <= n; j++) {
            dp[0][j] = j;
        }
        for (int i = 1; i <= m; i++) {
            for (int j = 1; j <= n; j++) {
                int cost = (str1.charAt(i - 1) == str2.charAt(j - 1)? 0 : 1);
                dp[i][j] = Math.min(Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1), dp[i - 1][j - 1] + cost);
            }
        }
        return dp[m][n];
    }
}

编辑距离在拼写检查中,当用户输入一个可能拼写错误的单词时,系统可以通过计算该单词与字典中所有单词的编辑距离,找出距离最小的单词作为正确的拼写建议。在语音识别领域,它用于衡量识别结果与真实文本之间的差异,评估识别系统的准确性。

5.3 矩阵链乘法(Matrix Chain Multiplication)

给定一系列矩阵,确定矩阵相乘的最优顺序,使得总的乘法运算次数最少。矩阵乘法的运算次数取决于矩阵的维度,例如,一个m×n的矩阵与一个n×p的矩阵相乘,需要进行m×n×p次标量乘法运算。

设dp[i][j]表示从第i个矩阵到第j个矩阵相乘的最少乘法运算次数。状态转移方程为:( dp[i][j] = \begin{cases} 0 & \text{if } i = j \ \min_{i \leq k < j} (dp[i][k] + dp[k + 1][j] + p_{i - 1}p_{k}p_{j}) & \text{if } i < j \end{cases} )

其中p数组存储每个矩阵的维度,p[i]表示第i + 1个矩阵的行数,p[i - 1]表示第i个矩阵的列数。

Python 实现(自底向上)

def matrix_chain_order(p):
    n = len(p) - 1
    dp = [[0 for _ in range(n + 1)] for _ in range(n + 1)]
    for L in range(2, n + 1):
        for i in range(1, n - L + 2):
            j = i + L - 1
            dp[i][j] = float('inf')
            for k in range(i, j):
                q = dp[i][k] + dp[k + 1][j] + p[i - 1] * p[k] * p[j]
                if q < dp[i][j]:
                    dp[i][j] = q
    return dp[1][n]

Java 实现(自底向上)

public class MatrixChainMultiplication {
    public static int matrixChainOrder(int[] p) {
        int n = p.length - 1;
        int[][] dp = new int[n + 1][n + 1];
        for (int L = 2; L <= n; L++) {
            for (int i = 1; i <= n - L + 1; i++) {
                int j = i + L - 1;
                dp[i][j] = Integer.MAX_VALUE;
                for (int k = i; k < j; k++) {
                    int q = dp[i][k] + dp[k + 1][j] + p[i - 1] * p[k] * p[j];
                    if (q < dp[i][j]) {
                        dp[i][j] = q;
                    }
                }
            }
        }
        return dp[1][n];
    }
}

矩阵链乘法在数值计算、计算机图形学等领域有着广泛应用。在数值计算中,当需要进行大量矩阵运算时,合理安排矩阵乘法顺序可以显著减少计算量,提高计算效率。在计算机图形学中,矩阵变换用于描述物体的旋转、平移、缩放等操作,通过优化矩阵链乘法的顺序,可以加快图形渲染速度,提升用户体验。

四、动态规划的应用与拓展

动态规划在实际应用中有着广泛的场景,除了上述的斐波那契数列和 0 - 1 背包问题,还常用于求解最长公共子序列(LCS)、编辑距离、矩阵链乘法等问题。在实际编程中,我们可以根据问题的特点,灵活选择合适的状态定义和状态转移方程,运用动态规划高效地解决问题。

同时,动态规划还有一些优化技巧,如空间压缩,当状态转移只依赖于前几个状态时,可以通过滚动数组等方式减少空间复杂度。此外,对于一些复杂问题,可能需要结合其他算法策略,如贪心算法、图论算法等,来实现更优的解决方案。