一、线性动态规划的核心概念
1.1 什么是线性动态规划
线性动态规划(Linear DP)是动态规划的一种基本形式,其特点是状态沿着线性序列(如数组或字符串)单向推进。这类问题通常具有以下特征:
-
单维/双维状态空间:状态通常表示为
dp[i]
或dp[i][j]
-
线性依赖关系:当前状态仅依赖于序列中前一个或多个位置的状态
-
无后效性:当前状态确定后,后续状态发展不受之前状态影响
1.2 基本解题框架
// 1. 初始化状态数组 vector<int> dp(n, 0); // 2. 设置边界条件 dp[0] = base_case; // 3. 状态转移(核心) for(int i = 1; i < n; i++) { dp[i] = transition(dp[...]); // 状态转移方程 } // 4. 获取最终结果 return dp[n-1] or max(dp);
二、经典例题精讲
2.1 最大子段和(Maximum Subarray)
问题描述:给定整数数组,求连续子数组的最大和。
输入格式:第一行是一个正整数N,表示了序列的长度。(N≤200000)
第二行是用空格分开的N个整数,表示这个序列
输出格式:一个整数,为最大的子段和是多少,子段的最小长度为1
输入样例:7
2 -4 3 -1 2 -4 3
输出样例:4
状态定义:
-
dp[i]
:以第i
个元素结尾的最大子数组和
状态转移方程:
dp[i] = max(dp[i-1] + nums[i], nums[i])
推导过程:
-
选项1:将当前元素加入前一个子数组(
dp[i-1] + nums[i]
) -
选项2:从当前元素开始新子数组(
nums[i]
) -
取两者最大值作为新状态
代码实现:
int maxSubarraySum(int nums[], int n) { int dp[100]; dp[0] = nums[0]; int maxSum = dp[0]; for (int i = 1; i < n; i++) { dp[i] = max(dp[i-1] + nums[i], nums[i]); // 状态转移 maxSum = max(maxSum, dp[i]); // 更新全局最大值 } return maxSum; }
时间复杂度:O(n)
空间复杂度:O(n)(可优化为O(1))
2.2 最长上升子序列(LIS)
问题描述:求序列中最长的递增子序列长度,
输入格式:第1行是序列的长度N(1 <= N <= 1000)。
第2行给出序列中的N个整数,这些整数的取值范围都在0到10000之间
输出格式:一个整数,表示最长上升子序列的长度
输入样例:
7
2 7 3 1 9 4 2
输出样例:
3
状态定义:
-
dp[i]
:以第i
个元素结尾的最长上升子序列长度
状态转移方程:
dp[i] = max(dp[j]) + 1 (0 ≤ j < i 且 nums[j] < nums[i])
推导过程:
-
遍历所有
j < i
-
若
nums[j] < nums[i]
,则i
可以接在j
后面 -
在所有符合条件的
j
中,取dp[j]
的最大值加1
代码实现:
int longestIncreasingSubsequence(int a[], int n) { vector<int> dp(n, 1); // 初始化为1(每个元素自身是长度为1的序列) int maxV = 1; for (int i = 1; i < n; i++) { for (int j = 0; j < i; j++) { if (a[j] < a[i]) { dp[i] = max(dp[i], dp[j] + 1); } } maxV = max(maxV, dp[i]); } return maxV; }
时间复杂度:O(n²)
空间复杂度:O(n)
2.3 最长公共子序列(LCS)
问题描述:求两个序列的最长公共子序列长度。
输入格式:
两行。每行为由大写字母构成的长度不超过1000的字符串,表示序列X和Y。
输出格式:
第一行为一个非负整数。表示所求得的最长公共子序列的长度。若不存在公共子
序列,则输出文件一个整数0。
输入样例:
ABCBDAB
BDCABA
输出样例:
4
状态定义:
-
dp[i][j]
:X[0..i-1]
和Y[0..j-1]
的LCS长度
状态转移方程:
dp[i][j] = { dp[i-1][j-1] + 1, if X[i-1] == Y[j-1] max(dp[i-1][j], dp[i][j-1]), otherwise }
推导过程:
-
末尾字符相同:LCS长度+1
-
末尾字符不同:取两个子问题的最大值
-
忽略X的末尾(
dp[i-1][j]
) -
忽略Y的末尾(
dp[i][j-1]
)
-
代码实现:
int longestCommonSubsequence(string a, string b) { int m = a.size(), n = b.size(); vector<vector<int>> dp(m+1, vector<int>(n+1, 0)); for (int i = 1; i <= m; i++) { for (int j = 1; j <= n; j++) { if (a[i-1] == b[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]; }
时间复杂度:O(mn)
空间复杂度:O(mn)
2.4 编辑距离(Edit Distance)
问题描述:设A和B是两个字符串。我们要用最少的字符操作次数,将字符串A转换为字符串B。这里所说的字符操作共有三种:
1、删除一个字符;
2、插入一个字符
3、将一个字符改为另一个字符
对任意的两个字符串A和B,计算出将字符串A变换为字符串B所用的最少字符操作次数。
输入格式:
第一行为字符串A;第二行为字符串B;字符串A和B的长度均小于2000。
输出格式:
只有一个正整数,为最少字符操作次数。
输入样例:
sfdqxbw
gfdgw
输出样例:
4
状态定义:
-
dp[i][j]
:将A[0..i-1]
转换为B[0..j-1]
的最小操作数
状态转移方程:
dp[i][j] = { dp[i-1][j-1], if A[i-1] == B[j-1] min( dp[i-1][j] + 1, // 删除A[i] dp[i][j-1] + 1, // 插入B[j] dp[i-1][j-1] + 1 // 替换A[i]为B[j] ), otherwise }
推导过程:
-
字符相同:无需操作,继承前状态
-
字符不同:取三种操作的最小值
-
删除:消耗1次操作,转化为
dp[i-1][j]
-
插入:消耗1次操作,转化为
dp[i][j-1]
-
替换:消耗1次操作,转化为
dp[i-1][j-1]
-
代码实现:
int editDistance(string a, string b) { int m = a.size(), n = b.size(); vector<vector<int>> dp(m+1, vector<int>(n+1, 0)); // 初始化边界条件 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++) { if (a[i-1] == b[j-1]) { dp[i][j] = dp[i-1][j-1]; } else { dp[i][j] = min({ dp[i-1][j] + 1, // 删除 dp[i][j-1] + 1, // 插入 dp[i-1][j-1] + 1 // 替换 }); } } } return dp[m][n]; }
时间复杂度:O(mn)
空间复杂度:O(mn)
三、线性DP的优化技巧
3.1 空间优化:滚动数组
当状态只依赖前有限个状态时,可降维存储:
// 最长上升子序列优化(O(n)空间) int lisOptimized(int a[], int n) { vector<int> tail; // 存储潜在LIS的末尾元素 for (int x : a) { auto it = lower_bound(tail.begin(), tail.end(), x); if (it == tail.end()) { tail.push_back(x); // 可扩展LIS } else { *it = x; // 优化潜在序列 } } return tail.size(); }
3.2 时间优化:预处理与剪枝
-
前缀和/后缀和:用于子数组问题
-
单调队列/栈:优化滑动窗口类问题
-
二分查找:将O(n)查找优化为O(logn)
四、练习题库(难度递进)
4.1 基础练习
-
爬楼梯问题:每次爬1或2阶,求到n阶的方法数
状态方程:dp[i] = dp[i-1] + dp[i-2]
-
打家劫舍:不能偷相邻房屋,求最大收益
状态方程:dp[i] = max(dp[i-1], dp[i-2] + nums[i])
-
硬币找零:求组成金额的最少硬币数
状态方程:dp[i] = min(dp[i], dp[i-coin]+1)
4.2 进阶挑战
-
最长回文子序列
dp[i][j] = { 2 + dp[i+1][j-1], if s[i]==s[j] max(dp[i+1][j], dp[i][j-1]), otherwise }
-
乘积最大子数组
maxDP[i] = max(nums[i], maxDP[i-1]*nums[i], minDP[i-1]*nums[i]) minDP[i] = min(nums[i], maxDP[i-1]*nums[i], minDP[i-1]*nums[i])
五、总结与学习建议
5.1 线性DP解题四步法
-
定义状态:明确
dp[i]
或dp[i][j]
的含义 -
建立方程:找出状态间的递推关系
-
初始化:设置边界条件
-
确定顺序:选择正确的计算方向
5.2 核心思维训练
-
分解思想:将大问题拆解为线性子问题
-
状态设计:"以...结尾"是最常用状态定义
-
方程推导:思考最后一步操作的决策点