C++信息学奥赛

动态规划(DP)详解

分解问题,存储中间结果,高效解决复杂问题

动态规划在信息学奥赛中的地位

动态规划(Dynamic Programming,简称DP)是一种通过将复杂问题分解为重叠子问题,并存储子问题的解(避免重复计算)来高效解决问题的算法思想。它不是一种具体的算法,而是一种解决问题的策略。

在信息学奥赛中,动态规划是解决优化问题的核心方法之一,尤其适用于具有重叠子问题和最优子结构性质的问题。掌握动态规划不仅能帮助你解决各类复杂问题,更能培养你从全局视角分析问题、分解问题的能力,是信息学奥赛选手必须掌握的关键算法思想。

动态规划基本概念

动态规划是一种通过将复杂问题分解为更小的子问题,并存储子问题的解来避免重复计算的算法设计技术。它主要适用于具有重叠子问题和最优子结构性质的问题。

动态规划的核心要素

  • 状态(State):描述问题在某一阶段的具体情况,通常用变量表示。状态的定义是动态规划的关键,直接影响问题的解决难度。
  • 状态转移方程(Transition):描述如何从一个状态转移到另一个状态,即如何利用子问题的解来构建当前问题的解。
  • 初始条件(Initialization):问题的最简单情况,也是递归的终止条件,通常是不需要再分解的基础状态。
  • 边界条件(Boundary):规定状态的取值范围和限制条件,确保状态转移的有效性。

动态规划的适用条件

最优子结构(Optimal Substructure)

问题的最优解包含子问题的最优解。即可以通过求解子问题的最优解来构建原问题的最优解。

例如:最短路径问题中,从A到C的最短路径如果经过B,则A到B和B到C的路径也分别是各自的最短路径。

重叠子问题(Overlapping Subproblems)

问题的递归求解过程中,会重复计算相同的子问题。动态规划通过存储这些子问题的解来避免重复计算。

例如:斐波那契数列的计算中,fib(5) = fib(4) + fib(3),而fib(4) = fib(3) + fib(2),其中fib(3)被重复计算。

动态规划与其他算法的对比

算法/思想 核心特点 适用场景
动态规划 存储子问题解,自底向上或自顶向下 优化问题,有重叠子问题和最优子结构
分治法 分解为独立子问题,不存储子问题解 子问题独立的问题,如归并排序
贪心算法 局部最优选择,不回溯 具有贪心选择性质的问题
递归 直接调用自身,可能重复计算 结构递归的问题,子问题不重叠时

动态规划的两种实现方式

自顶向下(Top-Down)

又称记忆化搜索,采用递归的方式,从原问题开始,将其分解为子问题,遇到子问题时先检查是否已经计算过,如果是则直接返回存储的结果,否则计算并存储。

优点:直观,易于理解和实现;缺点:可能有递归调用的开销

自底向上(Bottom-Up)

采用迭代的方式,从最基础的子问题开始计算,逐步构建出更大问题的解,直到得到原问题的解。通常使用数组或表格来存储子问题的解。

优点:没有递归开销,效率更高;缺点:需要设计合理的计算顺序

动态规划的核心思想

动态规划的核心思想可以概括为"分解问题、存储结果、避免重复"。它通过将复杂问题分解为规模较小的子问题,求解每个子问题并存储其结果,最终利用这些子问题的解来构建原问题的解。

动态规划的基本原理

1

分解问题(Divide)

将原问题分解为具有相同结构的子问题。这些子问题应该比原问题更容易解决,并且能够组合起来得到原问题的解。分解的关键是找到合适的状态表示,使得每个状态都能准确描述问题的一个子情况。

2

识别重叠子问题(Identify)

分析问题是否存在重叠子问题,即不同的问题分解路径会导致相同的子问题。如果子问题不重叠,动态规划可能不会比普通递归有优势。

3

存储子问题解(Store)

使用数组、哈希表等数据结构存储已经解决的子问题的解,这个存储结构通常被称为"DP表"或"记忆数组"。当再次遇到相同的子问题时,直接从存储结构中获取结果,而不是重新计算。

4

构建最优解(Combine)

根据子问题的解,按照一定的规则(状态转移方程)构建更大问题的解,最终得到原问题的解。对于优化问题,这个过程需要确保构建出的解是最优的。

动态规划的工作流程示例

以斐波那契数列问题为例,展示动态规划的工作流程:

问题:计算斐波那契数列的第n项

斐波那契数列定义:

  • fib(0) = 0
  • fib(1) = 1
  • fib(n) = fib(n-1) + fib(n-2) (n ≥ 2)
递归解法的问题

直接递归会导致大量重复计算。例如计算fib(5)时,需要计算fib(4)和fib(3),而计算fib(4)又需要计算fib(3)和fib(2),其中fib(3)被重复计算。

斐波那契数列递归计算树

动态规划解法

自顶向下(记忆化搜索)
// 记忆化数组,存储已计算的结果
int memo[100];

int fib(int n) {
    // 初始条件
    if (n <= 1) return n;
    
    // 如果已经计算过,直接返回
    if (memo[n] != 0) return memo[n];
    
    // 否则计算并存储结果
    memo[n] = fib(n-1) + fib(n-2);
    return memo[n];
}
自底向上(迭代)
int fib(int n) {
    // 处理特殊情况
    if (n <= 1) return n;
    
    // dp数组存储子问题解
    int dp[n+1];
    dp[0] = 0;  // 初始条件
    dp[1] = 1;  // 初始条件
    
    // 从子问题开始计算
    for (int i = 2; i <= n; i++) {
        dp[i] = dp[i-1] + dp[i-2];  // 状态转移方程
    }
    
    return dp[n];
}

动态规划解题步骤

解决动态规划问题需要遵循一定的步骤,其中最关键的是状态定义和状态转移方程的设计。以下是动态规划解题的一般步骤:

1

分析问题,确定是否适用DP

分析问题是否具有最优子结构和重叠子问题性质,判断是否适合用动态规划解决。

  • 问题是否可以分解为更小的子问题
  • 子问题的解是否可以组合成原问题的解
  • 是否存在大量重复的子问题
2

定义状态(State)

设计合适的状态来描述问题的子情况,状态的定义是DP问题的核心,直接影响问题的难易程度。

  • 用变量表示问题的关键特征
  • 确保状态能够涵盖问题的所有情况
  • 状态应尽可能简洁,避免冗余
3

确定初始条件

确定最简单情况下的状态值,这些是递归或迭代的起点,也是问题的边界情况。

  • 找到不需要再分解的基础情况
  • 为这些基础情况赋予正确的初始值
  • 确保初始条件覆盖所有必要的边界情况
4

推导状态转移方程

找到从已知状态(子问题的解)计算当前状态(当前问题的解)的规律,即状态之间的转移关系。

  • 分析当前状态与哪些子状态相关
  • 确定如何从子状态的值计算当前状态的值
  • 对于优化问题,通常需要取最大值或最小值
5

确定计算顺序

对于自底向上的实现方式,需要确定状态的计算顺序,确保在计算当前状态时,所有依赖的子状态都已计算完毕。

  • 通常按照状态规模从小到大的顺序计算
  • 对于多维状态,需要设计合理的嵌套循环顺序
  • 确保无后效性,即后续计算不影响先前的结果
6

实现并优化

根据设计的状态和转移方程实现算法,并根据需要进行优化,如空间优化、时间优化等。

  • 选择合适的实现方式(自顶向下或自底向上)
  • 考虑使用滚动数组等技术优化空间复杂度
  • 验证算法的正确性,处理边界情况

状态定义的艺术

状态定义是动态规划中最具挑战性也最关键的一步,良好的状态定义能够简化问题,反之则会使问题变得复杂甚至无法解决。以下是一些状态定义的技巧:

状态定义的一般原则

  • 明确状态表示的含义:状态应该清晰地表示问题的一个子情况,例如"dp[i]表示前i个元素的最大和"
  • 包含必要的信息:状态需要包含计算所需的所有信息,不多也不少
  • 避免冗余:状态中不应包含可以通过其他信息推导出来的内容
  • 易于转移:设计的状态应便于推导出状态转移方程

常见的状态定义方式

  • 一维状态:dp[i]表示处理前i个元素的结果
  • 二维状态:dp[i][j]可以表示处理前i个和前j个元素的结果,或在位置i处状态为j的结果
  • 多维状态:对于复杂问题,可能需要三维甚至更高维的状态,如dp[i][j][k]
  • 状态压缩:对于某些问题,可以通过状态压缩减少维度,如使用位运算表示状态

动态规划经典例题解析

1

爬楼梯(LeetCode 70)

问题描述:假设你正在爬楼梯。需要n阶你才能到达楼顶。每次你可以爬1或2个台阶。你有多少种不同的方法可以爬到楼顶?

解题思路:

这是一个经典的动态规划入门问题。我们可以通过分析问题找到状态转移规律:

  1. 设dp[i]表示爬到第i阶台阶的不同方法数
  2. 要爬到第i阶台阶,最后一步可以是从第i-1阶爬1个台阶,或者从第i-2阶爬2个台阶
  3. 因此,状态转移方程为:dp[i] = dp[i-1] + dp[i-2]
  4. 初始条件:dp[1] = 1(爬1阶只有1种方法),dp[2] = 2(爬2阶有2种方法)

代码实现

// 爬楼梯问题的动态规划解法
#include <iostream>
#include <vector>
using namespace std;

// 方法1:基础动态规划(O(n)时间,O(n)空间)
int climbStairs1(int n) {
    if (n <= 2) return n;
    
    // dp[i]表示爬到第i阶台阶的方法数
    vector<int> dp(n + 1);
    dp[1] = 1;  // 初始条件
    dp[2] = 2;  // 初始条件
    
    // 计算dp[3]到dp[n]
    for (int i = 3; i <= n; i++) {
        dp[i] = dp[i - 1] + dp[i - 2];  // 状态转移方程
    }
    
    return dp[n];
}

// 方法2:空间优化(O(n)时间,O(1)空间)
int climbStairs2(int n) {
    if (n <= 2) return n;
    
    // 只需要保存前两个状态
    int prev_prev = 1;  // dp[i-2]
    int prev = 2;       // dp[i-1]
    int current;        // dp[i]
    
    for (int i = 3; i <= n; i++) {
        current = prev + prev_prev;  // 状态转移
        prev_prev = prev;            // 更新前两个状态
        prev = current;
    }
    
    return prev;
}

int main() {
    int n = 5;
    cout << "爬" << n << "阶楼梯的方法数(方法1): " << climbStairs1(n) << endl;  // 8
    cout << "爬" << n << "阶楼梯的方法数(方法2): " << climbStairs2(n) << endl;  // 8
    
    return 0;
}

算法分析

  • 时间复杂度:O(n),需要计算从3到n的每个状态
  • 空间复杂度:基础方法为O(n),优化后为O(1),通过只保存必要的前两个状态实现
  • 扩展:如果每次可以爬1、2、...、k个台阶,状态转移方程变为dp[i] = dp[i-1] + dp[i-2] + ... + dp[i-k]
2

最长递增子序列(LeetCode 300)

问题描述:给你一个整数数组nums,找到其中最长严格递增子序列的长度。子序列是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。例如,[3,6,2,7]是数组[0,3,1,6,2,2,7]的子序列。

解题思路:

最长递增子序列(LIS)是动态规划的经典应用:

  1. 设dp[i]表示以nums[i]结尾的最长递增子序列的长度
  2. 对于每个i,我们需要查看所有j < i,如果nums[j] < nums[i],则dp[i]可以取dp[j] + 1和当前值中的较大者
  3. 状态转移方程:dp[i] = max(dp[j] + 1) 对于所有j < i且nums[j] < nums[i],如果没有这样的j,则dp[i] = 1
  4. 最终结果是dp数组中的最大值

代码实现

// 最长递增子序列的动态规划解法
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

// 动态规划解法(O(n²)时间,O(n)空间)
int lengthOfLIS(vector<int>& nums) {
    if (nums.empty()) return 0;
    
    int n = nums.size();
    // dp[i]表示以nums[i]结尾的最长递增子序列的长度
    vector<int> dp(n, 1);  // 初始化为1,每个元素自身是一个长度为1的子序列
    int max_len = 1;        // 记录最长长度
    
    // 计算dp[i]
    for (int i = 1; i < n; i++) {
        // 查看所有j < i
        for (int j = 0; j < i; j++) {
            // 如果nums[j] < nums[i],则可以形成更长的子序列
            if (nums[j] < nums[i]) {
                dp[i] = max(dp[i], dp[j] + 1);
            }
        }
        // 更新最长长度
        max_len = max(max_len, dp[i]);
    }
    
    return max_len;
}

int main() {
    vector<int> nums = {10, 9, 2, 5, 3, 7, 101, 18};
    cout << "最长递增子序列的长度: " << lengthOfLIS(nums) << endl;  // 4(序列:2,3,7,101)
    
    return 0;
}

算法分析

  • 时间复杂度:O(n²),需要两层循环,外层n次,内层平均n/2次
  • 空间复杂度:O(n),需要一个长度为n的dp数组
  • 优化:该问题还可以使用贪心算法结合二分查找优化到O(n log n)时间复杂度
  • 扩展:如果要求最长非递减子序列,只需将条件改为nums[j] <= nums[i]
3

0-1背包问题

问题描述:有n件物品和一个容量为V的背包。第i件物品的重量是w[i],价值是v[i]。每件物品只能使用一次,求解将哪些物品装入背包可使这些物品的重量总和不超过背包容量,且价值总和最大。

解题思路:

0-1背包是动态规划的经典问题,体现了二维动态规划的思想:

  1. 设dp[i][j]表示前i件物品中选择若干件放入容量为j的背包中所能获得的最大价值
  2. 对于第i件物品,有两种选择:放入背包或不放入背包
  3. 状态转移方程:
    • 不放入第i件物品:dp[i][j] = dp[i-1][j]
    • 放入第i件物品(当j >= w[i]时):dp[i][j] = dp[i-1][j-w[i]] + v[i]
    • 取两种情况的最大值:dp[i][j] = max(dp[i-1][j], (j >= w[i] ? dp[i-1][j-w[i]] + v[i] : 0))
  4. 初始条件:dp[0][j] = 0(前0件物品的价值为0),dp[i][0] = 0(容量为0时价值为0)
  5. 最终结果是dp[n][V]

代码实现

// 0-1背包问题的动态规划解法
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

// 方法1:基础二维DP(O(nV)时间,O(nV)空间)
int knapsack1(vector<int>& w, vector<int>& v, int V) {
    int n = w.size();
    if (n == 0 || V == 0) return 0;
    
    // dp[i][j]表示前i件物品放入容量为j的背包的最大价值
    vector<vector<int>> dp(n + 1, vector<int>(V + 1, 0));
    
    // 填充dp数组
    for (int i = 1; i <= n; i++) {
        for (int j = 1; j <= V; j++) {
            // 不选第i件物品
            dp[i][j] = dp[i - 1][j];
            
            // 如果背包容量足够,考虑选择第i件物品
            if (j >= w[i - 1]) {  // 注意物品索引从0开始
                dp[i][j] = max(dp[i][j], dp[i - 1][j - w[i - 1]] + v[i - 1]);
            }
        }
    }
    
    return dp[n][V];
}

// 方法2:空间优化(O(nV)时间,O(V)空间)
int knapsack2(vector<int>& w, vector<int>& v, int V) {
    int n = w.size();
    if (n == 0 || V == 0) return 0;
    
    // 只使用一维数组,从后往前更新
    vector<int> dp(V + 1, 0);
    
    for (int i = 0; i < n; i++) {
        // 从后往前更新,避免覆盖需要用到的前一轮结果
        for (int j = V; j >= w[i]; j--) {
            dp[j] = max(dp[j], dp[j - w[i]] + v[i]);
        }
    }
    
    return dp[V];
}

int main() {
    vector<int> w = {2, 3, 4, 5};  // 物品重量
    vector<int> v = {3, 4, 5, 6};  // 物品价值
    int V = 8;                      // 背包容量
    
    cout << "0-1背包的最大价值(方法1): " << knapsack1(w, v, V) << endl;  // 10
    cout << "0-1背包的最大价值(方法2): " << knapsack2(w, v, V) << endl;  // 10
    
    return 0;
}

算法分析

  • 时间复杂度:O(nV),其中n是物品数量,V是背包容量
  • 空间复杂度:基础方法为O(nV),优化后为O(V),通过一维数组和逆序更新实现
  • 变种:完全背包(物品可无限使用)、多重背包(物品有使用次数限制)等
  • 注意:当V很大时,该方法可能不适用,需要考虑其他优化或近似算法
4

最长公共子序列(LeetCode 1143)

问题描述:给定两个字符串text1和text2,返回这两个字符串的最长公共子序列的长度。如果不存在公共子序列,返回0。子序列是指这样一个新的字符串:它是由原字符串在不改变字符的相对顺序的情况下删除某些字符(也可以不删除任何字符)后组成的新字符串。例如,"ace"是"abcde"的子序列,但"aec"不是。两个字符串的公共子序列是这两个字符串所共同拥有的子序列。

解题思路:

最长公共子序列(LCS)是字符串处理中动态规划的经典应用:

  1. 设dp[i][j]表示text1的前i个字符和text2的前j个字符的最长公共子序列长度
  2. 状态转移方程:
    • 如果text1[i-1] == text2[j-1](当前字符相同):dp[i][j] = dp[i-1][j-1] + 1
    • 否则:dp[i][j] = max(dp[i-1][j], dp[i][j-1])
  3. 初始条件:dp[0][j] = 0(text1为空),dp[i][0] = 0(text2为空)
  4. 最终结果是dp[m][n],其中m和n分别是text1和text2的长度

代码实现

// 最长公共子序列的动态规划解法
#include <iostream>
#include <vector>
#include <string>
#include <algorithm>
using namespace std;

// 方法1:基础二维DP(O(mn)时间,O(mn)空间)
int longestCommonSubsequence1(string text1, string text2) {
    int m = text1.size();
    int n = text2.size();
    
    // dp[i][j]表示text1前i个字符和text2前j个字符的LCS长度
    vector<vector<int>> dp(m + 1, vector<int>(n + 1, 0));
    
    // 填充dp数组
    for (int i = 1; i <= m; i++) {
        for (int j = 1; j <= n; j++) {
            // 如果当前字符相同
            if (text1[i - 1] == text2[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];
}

// 方法2:空间优化(O(mn)时间,O(min(m,n))空间)
int longestCommonSubsequence2(string text1, string text2) {
    // 确保text1是较短的字符串,以节省空间
    if (text1.size() > text2.size()) {
        swap(text1, text2);
    }
    
    int m = text1.size();
    int n = text2.size();
    
    // 只使用一维数组和一个变量
    vector<int> dp(n + 1, 0);
    
    for (int i = 1; i <= m; i++) {
        int prev = 0;  // 保存dp[i-1][j-1]的值
        for (int j = 1; j <= n; j++) {
            int temp = dp[j];  // 保存更新前的dp[j],即dp[i-1][j]
            if (text1[i - 1] == text2[j - 1]) {
                dp[j] = prev + 1;
            } else {
                dp[j] = max(dp[j], dp[j - 1]);
            }
            prev = temp;  // 更新prev为下一轮的dp[i-1][j-1]
        }
    }
    
    return dp[n];
}

int main() {
    string text1 = "abcde";
    string text2 = "ace";
    
    cout << "最长公共子序列的长度(方法1): " << longestCommonSubsequence1(text1, text2) << endl;  // 3
    cout << "最长公共子序列的长度(方法2): " << longestCommonSubsequence2(text1, text2) << endl;  // 3
    
    return 0;
}

算法分析

  • 时间复杂度:O(mn),其中m和n分别是两个字符串的长度
  • 空间复杂度:基础方法为O(mn),优化后为O(min(m,n))
  • 扩展:可以通过回溯dp数组来重建最长公共子序列本身
  • 应用:该算法可用于计算两个序列的相似度,在生物信息学等领域有重要应用
5

编辑距离(LeetCode 72)

问题描述:给你两个单词word1和word2,请你计算出将word1转换成word2所使用的最少操作数。你可以对一个单词进行如下三种操作:插入一个字符、删除一个字符、替换一个字符。

解题思路:

编辑距离(Levenshtein距离)是字符串处理中另一个经典的动态规划问题:

  1. 设dp[i][j]表示将word1的前i个字符转换成word2的前j个字符所需的最少操作数
  2. 状态转移方程:
    • 如果word1[i-1] == word2[j-1](当前字符相同):dp[i][j] = dp[i-1][j-1]
    • 否则:dp[i][j] = min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]) + 1
      • dp[i-1][j] + 1:删除word1的第i个字符
      • dp[i][j-1] + 1:插入word2的第j个字符
      • dp[i-1][j-1] + 1:替换word1的第i个字符为word2的第j个字符
  3. 初始条件:
    • dp[0][j] = j(将空字符串转换为word2的前j个字符需要j次插入)
    • dp[i][0] = i(将word1的前i个字符转换为空字符串需要i次删除)
  4. 最终结果是dp[m][n],其中m和n分别是word1和word2的长度

代码实现

// 编辑距离的动态规划解法
#include <iostream>
#include <vector>
#include <string>
#include <algorithm>
using namespace std;

// 方法1:基础二维DP(O(mn)时间,O(mn)空间)
int minDistance1(string word1, string word2) {
    int m = word1.size();
    int n = word2.size();
    
    // dp[i][j]表示word1前i个字符转换成word2前j个字符的最少操作数
    vector<vector<int>> dp(m + 1, vector<int>(n + 1, 0));
    
    // 初始化
    for (int i = 0; i <= m; i++) {
        dp[i][0] = i;  // 删除i个字符
    }
    for (int j = 0; j <= n; j++) {
        dp[0][j] = j;  // 插入j个字符
    }
    
    // 填充dp数组
    for (int i = 1; i <= m; i++) {
        for (int j = 1; j <= n; j++) {
            if (word1[i - 1] == word2[j - 1]) {
                // 当前字符相同,不需要操作
                dp[i][j] = dp[i - 1][j - 1];
            } else {
                // 取三种操作的最小值加1
                dp[i][j] = min({dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]}) + 1;
            }
        }
    }
    
    return dp[m][n];
}

// 方法2:空间优化(O(mn)时间,O(n)空间)
int minDistance2(string word1, string word2) {
    int m = word1.size();
    int n = word2.size();
    
    // 只使用一维数组
    vector<int> dp(n + 1, 0);
    
    // 初始化第一行
    for (int j = 0; j <= n; j++) {
        dp[j] = j;
    }
    
    for (int i = 1; i <= m; i++) {
        int prev = dp[0];  // 保存dp[i-1][j-1]
        dp[0] = i;        // 第i行第0列的值
        
        for (int j = 1; j <= n; j++) {
            int temp = dp[j];  // 保存更新前的dp[j],即dp[i-1][j]
            
            if (word1[i - 1] == word2[j - 1]) {
                dp[j] = prev;
            } else {
                dp[j] = min({dp[j], dp[j - 1], prev}) + 1;
            }
            
            prev = temp;  // 更新prev为下一轮的dp[i-1][j-1]
        }
    }
    
    return dp[n];
}

int main() {
    string word1 = "horse";
    string word2 = "ros";
    
    cout << "最少编辑操作数(方法1): " << minDistance1(word1, word2) << endl;  // 3
    cout << "最少编辑操作数(方法2): " << minDistance2(word1, word2) << endl;  // 3
    
    return 0;
}

算法分析

  • 时间复杂度:O(mn),其中m和n分别是两个单词的长度
  • 空间复杂度:基础方法为O(mn),优化后为O(n)
  • 应用:编辑距离在拼写检查、自然语言处理、DNA序列比对等领域有广泛应用
  • 变种:可以为不同的操作设置不同的权重,如替换的代价可能高于插入和删除

动态规划的优化技巧

动态规划问题往往可以通过各种技巧进行优化,以提高时间或空间效率。掌握这些优化技巧对于解决复杂的DP问题至关重要。

1. 空间优化

动态规划的空间复杂度往往可以通过优化大幅降低,常见的空间优化技巧包括:

滚动数组(Rolling Array)

当状态转移只依赖于前几行(通常是前一行)的结果时,可以使用滚动数组将二维DP优化为一维DP。

// 二维DP
vector<vector<int>> dp(n+1, vector<int>(m+1));
for (int i = 1; i <= n; i++) {
    for (int j = 1; j <= m; j++) {
        dp[i][j] = max(dp[i-1][j], dp[i][j-1]);
    }
}

// 优化为一维DP(滚动数组)
vector<int> dp(m+1);
for (int i = 1; i <= n; i++) {
    for (int j = 1; j <= m; j++) {
        dp[j] = max(dp[j], dp[j-1]);  // 注意更新顺序
    }
}

变量替代

当状态只依赖于前一个或前几个状态时,可以使用变量替代数组,进一步降低空间复杂度。

// 斐波那契数列的空间优化
// 普通DP: O(n)空间
vector<int> dp(n+1);
dp[0] = 0; dp[1] = 1;
for (int i = 2; i <= n; i++) {
    dp[i] = dp[i-1] + dp[i-2];
}

// 变量替代: O(1)空间
int a = 0, b = 1, c;
for (int i = 2; i <= n; i++) {
    c = a + b;
    a = b;
    b = c;
}

状态压缩

对于某些具有特殊结构的状态,可以使用位运算等方式进行压缩,减少状态的存储空间。

// 用一个整数表示状态集合
// 例如:用二进制位表示哪些元素被选中
int state = 0b1010;  // 表示第2和第4个元素被选中

2. 时间优化

动态规划的时间优化通常涉及减少状态转移的计算量,常见的时间优化技巧包括:

前缀/后缀优化

当状态转移需要计算区间内的最大值或最小值时,可以使用前缀或后缀数组预先计算,将O(n)的转移优化为O(1)。

// 优化前:O(n²)
for (int i = 1; i <= n; i++) {
    for (int j = 1; j <= i; j++) {
        dp[i] = max(dp[i], dp[j] + val[j][i]);
    }
}

// 优化后:O(n),使用前缀最大值
vector<int> prefix_max(n+1);
for (int i = 1; i <= n; i++) {
    prefix_max[i] = max(prefix_max[i-1], dp[i] - val[0][i]);
    dp[i] = prefix_max[i] + val[0][i];
}

单调队列优化

对于某些具有滑动窗口性质的状态转移,可以使用单调队列维护可能的最优解,将O(n)的转移优化为O(1)。

适用场景:状态转移方程形如dp[i] = max/min(dp[j] + val[j][i]),且j的范围是[i-k, i-1]等滑动窗口形式。

斜率优化

对于可以转化为线性函数形式的状态转移方程,可以使用凸包或单调队列维护斜率,将O(n²)的时间复杂度优化为O(n)。

适用场景:状态转移方程形如dp[i] = min/max(a[i] * b[j] + c[j] + d[i]),其中a[i]和d[i]只与i有关,b[j]和c[j]只与j有关。

3. 状态设计优化

良好的状态设计可以显著降低问题的复杂度,常见的状态设计优化技巧包括:

减少状态维度

通过分析问题的性质,寻找状态之间的依赖关系,减少不必要的状态维度。

例如:在某些问题中,三维状态可以通过数学关系简化为二维状态。

重新定义状态

从不同角度定义状态,可能会得到更简单的状态转移方程。

例如:对于"最长递增子序列"问题,除了定义"以第i个元素结尾的LIS长度",还可以定义"长度为i的LIS的最小末尾元素",从而得到更高效的解法。

利用对称性

如果问题具有对称性,可以利用对称性减少需要计算的状态数量。

例如:在某些矩阵问题中,从左上角到右下角和从右下角到左上角的路径可能具有对称性,可以利用这一点减少计算。

4. 其他实用技巧

除了上述优化技巧外,还有一些实用的DP技巧:

路径还原

在求得最优解后,有时需要还原最优解的具体路径。可以通过记录每个状态的前驱或决策来实现。

// 记录决策
vector<int> dp(n+1);
vector<int> prev(n+1, -1);  // 记录每个状态的前驱

for (int i = 1; i <= n; i++) {
    for (int j = 0; j < i; j++) {
        if (dp[j] + val[j][i] > dp[i]) {
            dp[i] = dp[j] + val[j][i];
            prev[i] = j;  // 记录前驱
        }
    }
}

// 还原路径
vector<int> path;
int cur = n;
while (cur != -1) {
    path.push_back(cur);
    cur = prev[cur];
}

记忆化搜索与剪枝

在自顶向下的实现中,可以结合剪枝技巧,避免计算不必要的状态。

例如:如果已经找到一个可行解,对于明显不可能优于该解的子问题,可以直接剪枝。

多维DP的降维

对于多维DP,可以通过改变迭代顺序或利用问题特性,将高维DP降为低维DP。

关键是分析状态之间的依赖关系,确定哪些维度可以合并或消除。

常见的动态规划类型

1. 线性DP

线性DP是最基础的动态规划类型,状态通常定义在一维或二维的线性结构上,如数组、字符串等。

特点:状态之间存在线性的依赖关系,通常按顺序依次计算
常见形式:一维DP、二维DP(通常与两个线性结构相关)
状态定义:dp[i]表示前i个元素的最优解;dp[i][j]表示前i和前j个元素的最优解

典型例题

  • 爬楼梯问题
  • 最长递增子序列
  • 最长公共子序列
  • 编辑距离
  • 最大子数组和
核心思想:从左到右或从上到下逐步计算每个状态,利用已经计算的左侧或上方状态来推导当前状态。

2. 背包DP

背包DP是一类专门解决资源分配和选择问题的动态规划方法,以"背包问题"为代表。

特点:涉及选择物品(或其他元素)并使其总价值最大(或最小),同时受限于某些约束条件(如重量、体积)
常见形式:0-1背包、完全背包、多重背包、分组背包等
状态定义:dp[i][j]表示考虑前i个物品,总重量(或其他约束)为j时的最大价值

典型例题

  • 0-1背包问题
  • 完全背包问题
  • 多重背包问题
  • 二维背包问题
  • 分组背包问题
核心思想:对于每个物品,决定是否选择它(或选择多少),并更新相应状态。通常可以通过滚动数组优化空间复杂度。

3. 区间DP

区间DP主要用于解决区间上的最优问题,通常状态定义在一个区间上,通过合并子区间的解来得到更大区间的解。

特点:问题与区间的结构密切相关,通常需要将区间分解为更小的子区间
常见形式:基于区间长度和区间起点的二维DP
状态定义:dp[i][j]表示区间[i, j]上的最优解

典型例题

  • 矩阵链乘法
  • 最长回文子序列
  • 石子合并问题
  • 括号匹配问题
  • 区间最大价值问题
核心思想:按照区间长度从小到大的顺序计算,对于每个区间,尝试所有可能的分割点,通过合并子区间的解来得到当前区间的解。

4. 树形DP

树形DP是在树结构上进行的动态规划,通常结合深度优先搜索(DFS)进行计算。

特点:状态定义在树的节点上,通常需要先计算子节点的状态,再计算父节点的状态
常见形式:基于节点和子树的DP,通常为后序遍历
状态定义:dp[u][k]表示以u为根的子树,在状态k下的最优解

典型例题

  • 树的最大独立集
  • 树的直径
  • 树上背包问题
  • 二叉树中的最大路径和
  • 树的着色问题
核心思想:通过DFS遍历树,在回溯过程中计算每个节点的状态,利用子节点的状态来更新当前节点的状态。

经典实例解析(完整代码)

1. 0-1背包问题

问题:有n件物品和容量为V的背包,第i件物品重量w[i],价值v[i]。每件物品只能用一次,求最大价值。

完整代码实现

#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

// 0-1背包问题求解函数
// 参数:物品重量数组、物品价值数组、背包容量
// 返回:最大价值
int knapsack(vector<int>& w, vector<int>& v, int V) {
    int n = w.size();
    // 边界条件处理
    if (n == 0 || V <= 0) return 0;
    
    // 滚动数组优化空间复杂度
    vector<int> dp(V + 1, 0);
    
    // 遍历每件物品
    for (int i = 0; i < n; i++) {
        // 逆序更新,避免覆盖需要的前一轮数据
        for (int j = V; j >= w[i]; j--) {
            dp[j] = max(dp[j], dp[j - w[i]] + v[i]);
        }
    }
    return dp[V];
}

// 示例用法
int main() {
    // 示例输入
    vector<int> weights = {2, 3, 4, 5};  // 物品重量
    vector<int> values = {3, 4, 5, 6};   // 物品价值
    int capacity = 8;                    // 背包容量
    
    // 计算并输出结果
    int max_value = knapsack(weights, values, capacity);
    cout << "背包能容纳的最大价值为:" << max_value << endl;
    // 预期输出:9(选择重量为3和5的物品,价值4+5=9)
    
    return 0;
}

代码说明:

  • 使用滚动数组将空间复杂度从O(nV)优化为O(V)
  • 内层循环采用逆序遍历,避免同一物品被多次选择
  • 包含完整的边界条件处理和示例调用

2. 最长递增子序列

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

完整代码实现

#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

// 最长递增子序列求解函数
// 参数:整数数组
// 返回:最长递增子序列的长度
int lengthOfLIS(vector<int>& nums) {
    // 边界条件处理
    if (nums.empty()) return 0;
    
    int n = nums.size();
    // dp[i]表示以nums[i]结尾的最长递增子序列长度
    vector<int> dp(n, 1);  // 初始化为1,每个元素自身是一个长度为1的子序列
    int max_len = 1;       // 记录全局最大值
    
    // 计算每个位置的最长递增子序列
    for (int i = 1; i < n; i++) {
        for (int j = 0; j < i; j++) {
            // 如果前面的元素小于当前元素,可以形成更长的子序列
            if (nums[j] < nums[i]) {
                dp[i] = max(dp[i], dp[j] + 1);
            }
        }
        // 更新全局最大值
        max_len = max(max_len, dp[i]);
    }
    return max_len;
}

// 示例用法
int main() {
    // 示例输入
    vector<int> nums = {10, 9, 2, 5, 3, 7, 101, 18};
    
    // 计算并输出结果
    int result = lengthOfLIS(nums);
    cout << "最长递增子序列的长度为:" << result << endl;
    // 预期输出:4(例如:2,3,7,18)
    
    return 0;
}

代码说明:

  • 时间复杂度O(n²),空间复杂度O(n)
  • dp[i]定义为"以第i个元素结尾的最长递增子序列长度"
  • 通过两层循环比较所有可能的前驱元素,找到最长序列

3. 石子合并(区间DP)

问题:N堆石子排成一排,每次只能合并相邻两堆,合并代价为两堆数量之和。求最小总代价。

完整代码实现

#include <iostream>
#include <vector>
#include <climits>  // 用于INT_MAX
using namespace std;

// 石子合并问题求解函数
// 参数:石子数量数组
// 返回:最小合并代价
int stoneMerge(vector<int>& stones) {
    int n = stones.size();
    // 边界条件处理
    if (n <= 1) return 0;
    
    // dp[i][j]表示合并第i到第j堆石子的最小代价
    vector<vector<int>> dp(n, vector<int>(n, 0));
    // 前缀和数组,用于快速计算区间和
    vector<int> prefix(n + 1, 0);
    
    // 计算前缀和
    for (int i = 0; i < n; i++) {
        prefix[i + 1] = prefix[i] + stones[i];
    }
    
    // 按区间长度递增计算
    for (int len = 2; len <= n; len++) {  // 区间长度从2开始
        for (int i = 0; i + len <= n; i++) {  // 区间起点
            int j = i + len - 1;  // 区间终点
            dp[i][j] = INT_MAX;   // 初始化为最大值
            
            // 尝试所有分割点
            for (int k = i; k < j; k++) {
                dp[i][j] = min(dp[i][j], dp[i][k] + dp[k+1][j]);
            }
            // 加上当前区间的总石子数(合并的代价)
            dp[i][j] += prefix[j + 1] - prefix[i];
        }
    }
    
    return dp[0][n - 1];
}

// 示例用法
int main() {
    // 示例输入
    vector<int> stones = {3, 4, 5, 6};
    
    // 计算并输出结果
    int min_cost = stoneMerge(stones);
    cout << "最小合并代价为:" << min_cost << endl;
    // 预期输出:36
    // 合并步骤:(3+4)=7,(5+6)=11,(7+11)=18,总代价7+11+18=36
    
    return 0;
}

代码说明:

  • 典型的区间DP问题,时间复杂度O(n³),空间复杂度O(n²)
  • 使用前缀和数组优化区间和的计算,从O(n)降至O(1)
  • 按区间长度递增的顺序计算,确保子问题先于父问题解决

练习题(含参考答案)

1. 打家劫舍(LeetCode 198)

你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。给定一个代表每个房屋存放金额的非负整数数组,计算你 不触动警报装置的情况下 ,一夜之内能够偷窃到的最高金额。

参考答案

#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

int rob(vector<int>& nums) {
    if (nums.empty()) return 0;
    if (nums.size() == 1) return nums[0];
    
    int n = nums.size();
    // 优化空间,只用两个变量记录前两个状态
    int prev_prev = nums[0];                  // 前两间的最大金额
    int prev = max(nums[0], nums[1]);         // 前一间的最大金额
    
    for (int i = 2; i < n; i++) {
        int current = max(prev, prev_prev + nums[i]);
        prev_prev = prev;
        prev = current;
    }
    
    return prev;
}

int main() {
    vector<int> nums = {2, 7, 9, 3, 1};
    cout << "能够偷窃到的最高金额为:" << rob(nums) << endl;  // 预期输出:12
    return 0;
}

2. 完全背包问题

有n种物品和一个容量为V的背包,每种物品都有无限件可用。第i种物品的重量是w[i],价值是v[i]。求解将哪些物品装入背包可使这些物品的重量总和不超过背包容量,且价值总和最大。

参考答案

#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

int completeKnapsack(vector<int>& w, vector<int>& v, int V) {
    int n = w.size();
    if (n == 0 || V <= 0) return 0;
    
    vector<int> dp(V + 1, 0);
    
    // 与0-1背包的区别:内层循环正向遍历
    for (int i = 0; i < n; i++) {
        for (int j = w[i]; j <= V; j++) {
            dp[j] = max(dp[j], dp[j - w[i]] + v[i]);
        }
    }
    
    return dp[V];
}

int main() {
    vector<int> weights = {2, 3, 4};
    vector<int> values = {3, 5, 6};
    int capacity = 10;
    
    cout << "完全背包的最大价值为:" << completeKnapsack(weights, values, capacity) << endl;
    // 预期输出:16(选择5个重量为2的物品,价值3×5=15;或2个重量为3和1个重量为4,价值5×2+6=16)
    
    return 0;
}

完整动态规划教程 © 2023

咸宁燃冰电脑培训 www.rborg.cn