序列类动态规划
序列类动态规划是指对给定的序列(如字符串、数组)进行计算,常见的题目包括:最长公共子序列(LCS)、最长递增子序列(LIS)、编辑距离等。这类问题的核心思想是将序列的前缀作为状态的一部分。
最长公共子序列(LCS)
问题定义
给定两个字符串 text1 和 text2,找出它们的最长公共子序列的长度。
子序列:一个字符串的子序列是指在该字符串中删除若干(可以为零)字符后,不改变剩余字符相对位置形成的新字符串。
示例:
text1 = "abcde",text2 = "ace"- 最长公共子序列是
"ace",长度为 3
问题分析
设 text1 长度为 ,text2 长度为 。考虑两个字符串末尾字符的关系:
- 如果
text1[i-1] == text2[j-1]:这个字符一定在 LCS 中,问题转化为求text1[0..i-2]和text2[0..j-2]的 LCS - 如果
text1[i-1] != text2[j-1]:LCS 不可能同时以这两个字符结尾,需要舍弃其中一个,取较大值
状态定义
设 表示 text1[0..i-1] 和 text2[0..j-1] 的最长公共子序列长度。
状态转移方程
边界条件:
- :text1 为空字符串
- :text2 为空字符串
实现
public int longestCommonSubsequence(String text1, String text2) {
int m = text1.length(), n = text2.length();
int[][] dp = new int[m + 1][n + 1];
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
if (text1.charAt(i - 1) == text2.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];
}
复杂度分析:
- 时间复杂度:
- 空间复杂度:
空间优化
由于 只依赖于 、 和 ,可以使用一维数组优化:
public int longestCommonSubsequence(String text1, String text2) {
int m = text1.length(), n = text2.length();
int[] dp = new int[n + 1];
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] 供下一轮使用
if (text1.charAt(i - 1) == text2.charAt(j - 1)) {
dp[j] = prev + 1;
} else {
dp[j] = Math.max(dp[j], dp[j - 1]);
}
prev = temp;
}
}
return dp[n];
}
如何求具体的 LCS?
需要回溯 DP 表:
public String getLCS(String text1, String text2) {
int m = text1.length(), n = text2.length();
int[][] dp = new int[m + 1][n + 1];
// 填充 DP 表
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
if (text1.charAt(i - 1) == text2.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]);
}
}
}
// 回溯找 LCS
StringBuilder lcs = new StringBuilder();
int i = m, j = n;
while (i > 0 && j > 0) {
if (text1.charAt(i - 1) == text2.charAt(j - 1)) {
lcs.append(text1.charAt(i - 1));
i--;
j--;
} else if (dp[i - 1][j] > dp[i][j - 1]) {
i--;
} else {
j--;
}
}
return lcs.reverse().toString();
}
最长递增子序列(LIS)
问题定义
给定一个数组 nums,找到其中最长严格递增子序列的长度。
示例:
nums = [10, 9, 2, 5, 3, 7, 101, 18]- 最长递增子序列是
[2, 3, 7, 101]或[2, 3, 7, 18],长度为 4
动态规划解法:
状态定义
设 表示以 nums[i] 结尾的最长递增子序列的长度。
状态转移方程
对于每个 nums[i],遍历之前的所有元素 nums[j](),如果 nums[j] < nums[i],则可以扩展以 nums[j] 结尾的递增子序列:
如果没有比 nums[i] 小的元素,则 。
实现
public int lengthOfLIS(int[] nums) {
int n = nums.length;
int[] dp = new int[n];
Arrays.fill(dp, 1); // 每个元素本身构成长度为1的子序列
int maxLen = 1;
for (int i = 1; i < n; i++) {
for (int j = 0; j < i; j++) {
if (nums[j] < nums[i]) {
dp[i] = Math.max(dp[i], dp[j] + 1);
}
}
maxLen = Math.max(maxLen, dp[i]);
}
return maxLen;
}
复杂度分析:
- 时间复杂度:
- 空间复杂度:
二分查找优化:
核心思想
维护一个数组 tails,其中 tails[i] 表示长度为 的递增子序列的最小末尾元素。
关键观察:
tails数组是严格递增的- 对于新元素
nums[i],如果它大于tails的最后一个元素,可以扩展最长子序列 - 否则,在
tails中找到第一个大于等于nums[i]的位置并替换
为什么这样做正确?
维护较小的末尾元素可以让后续元素更容易扩展子序列。例如,长度为 3 的递增子序列末尾是 5 比末尾是 7 更好,因为后续遇到 6 时只能跟在 5 后面。
实现
public int lengthOfLIS(int[] nums) {
int n = nums.length;
int[] tails = new int[n];
int size = 0; // tails 数组的实际长度
for (int num : nums) {
// 二分查找第一个 >= num 的位置
int left = 0, right = size;
while (left < right) {
int mid = left + (right - left) / 2;
if (tails[mid] < num) {
left = mid + 1;
} else {
right = mid;
}
}
tails[left] = num;
// 如果 num 大于 tails 的所有元素,扩展 size
if (left == size) {
size++;
}
}
return size;
}
复杂度分析:
- 时间复杂度:
- 空间复杂度:
如何求具体的 LIS?
二分优化方法只能求长度,求具体子序列需要使用 的 DP 方法并记录前驱:
public List<Integer> getLIS(int[] nums) {
int n = nums.length;
int[] dp = new int[n];
int[] prev = new int[n]; // 记录前驱索引
Arrays.fill(dp, 1);
Arrays.fill(prev, -1);
int maxLen = 1, endIdx = 0;
for (int i = 1; i < n; i++) {
for (int j = 0; j < i; j++) {
if (nums[j] < nums[i] && dp[j] + 1 > dp[i]) {
dp[i] = dp[j] + 1;
prev[i] = j;
}
}
if (dp[i] > maxLen) {
maxLen = dp[i];
endIdx = i;
}
}
// 回溯构造 LIS
List<Integer> lis = new ArrayList<>();
while (endIdx != -1) {
lis.add(nums[endIdx]);
endIdx = prev[endIdx];
}
Collections.reverse(lis);
return lis;
}
编辑距离(LeetCode 72)
问题定义
给定两个字符串 word1 和 word2,计算将 word1 转换为 word2 所需的最少操作次数。允许的操作有:
- 插入一个字符
- 删除一个字符
- 替换一个字符
示例:
word1 = "horse",word2 = "ros"- 最少操作次数为 3:
horse -> rorse (替换 h->r) -> rose (删除 r) -> ros (删除 e)
状态定义
设 表示将 word1[0..i-1] 转换为 word2[0..j-1] 的最小编辑距离。
状态转移方程
考虑 word1[i-1] 和 word2[j-1]:
-
如果
word1[i-1] == word2[j-1]:不需要操作 -
如果
word1[i-1] != word2[j-1]:需要选择一种操作- 插入:(在 word1 插入 word2[j-1])
- 删除:(删除 word1[i-1])
- 替换:(替换 word1[i-1] 为 word2[j-1])
边界条件:
- :空字符串转为长度为 的字符串,需要 次插入
- :长度为 的字符串转为空字符串,需要 次删除
实现
public int minDistance(String word1, String word2) {
int m = word1.length(), n = word2.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++) {
if (word1.charAt(i - 1) == word2.charAt(j - 1)) {
dp[i][j] = dp[i - 1][j - 1];
} else {
dp[i][j] = Math.min(Math.min(dp[i - 1][j], dp[i][j - 1]),
dp[i - 1][j - 1]) + 1;
}
}
}
return dp[m][n];
}
复杂度分析:
- 时间复杂度:
- 空间复杂度:
最长回文子序列(LeetCode 516)
问题定义
给定一个字符串 s,找出其中最长的回文子序列的长度。
示例:
s = "bbbab"- 最长回文子序列是
"bbbb",长度为 4
状态定义
设 表示 s[i..j] 中最长回文子序列的长度。
状态转移方程
考虑 s[i] 和 s[j]:
-
如果
s[i] == s[j]:两个字符可以构成回文的两端 (当 ) -
如果
s[i] != s[j]:需要舍弃其中一个
边界条件:
- :单个字符是长度为 1 的回文
遍历顺序
由于 依赖于 、 和 ,需要按子串长度从小到大遍历,或者按 递减、 递增的顺序遍历。
实现
public int longestPalindromeSubseq(String s) {
int n = s.length();
int[][] dp = new int[n][n];
// 单个字符是回文
for (int i = 0; i < n; i++) {
dp[i][i] = 1;
}
// 按子串长度从小到大遍历
for (int len = 2; len <= n; len++) {
for (int i = 0; i <= n - len; i++) {
int j = i + len - 1;
if (s.charAt(i) == s.charAt(j)) {
dp[i][j] = dp[i + 1][j - 1] + 2;
} else {
dp[i][j] = Math.max(dp[i + 1][j], dp[i][j - 1]);
}
}
}
return dp[0][n - 1];
}
二维矩阵路径问题
不同路径(LeetCode 62)
一个机器人位于 网格的左上角,每次只能向下或向右移动一步,问有多少条路径可以到达右下角。
状态定义: 表示到达 的路径数
状态转移:
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];
}
最小路径和(LeetCode 64)
给定一个包含非负整数的 网格,找到从左上角到右下角的路径,使得路径上的数字总和最小。
状态定义: 表示到达 的最小路径和
状态转移:
public int minPathSum(int[][] grid) {
int m = grid.length, n = grid[0].length;
int[][] dp = new int[m][n];
dp[0][0] = grid[0][0];
for (int i = 1; i < m; i++) dp[i][0] = dp[i - 1][0] + grid[i][0];
for (int j = 1; j < n; j++) dp[0][j] = dp[0][j - 1] + grid[0][j];
for (int i = 1; i < m; i++) {
for (int j = 1; j < n; j++) {
dp[i][j] = Math.min(dp[i - 1][j], dp[i][j - 1]) + grid[i][j];
}
}
return dp[m - 1][n - 1];
}
最大子数组和(LeetCode 53)
问题定义
给定一个整数数组 nums,找到一个具有最大和的连续子数组,返回其最大和。
示例:
nums = [-2, 1, -3, 4, -1, 2, 1, -5, 4]- 最大子数组是
[4, -1, 2, 1],和为 6
状态定义
设 表示以 nums[i] 结尾的最大子数组和。
状态转移方程
对于每个位置 ,有两种选择:
- 将
nums[i]加入前面的子数组: - 从
nums[i]开始新的子数组:
实现
public int maxSubArray(int[] nums) {
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;
}
空间优化
由于只依赖前一个状态,可以优化为 空间:
public int maxSubArray(int[] nums) {
int maxSum = nums[0];
int currentSum = nums[0];
for (int i = 1; i < nums.length; i++) {
currentSum = Math.max(currentSum + nums[i], nums[i]);
maxSum = Math.max(maxSum, currentSum);
}
return maxSum;
}
这个算法也称为 Kadane 算法。
最长回文子串(LeetCode 5)
问题定义
给定一个字符串 s,找到其中最长的回文子串。
状态定义: 表示 s[i..j] 是否是回文串。
状态转移:
- 如果
s[i] == s[j]且 ( 或 为真),则
public String longestPalindrome(String s) {
int n = s.length();
boolean[][] dp = new boolean[n][n];
int maxLen = 1, start = 0;
for (int j = 0; j < n; j++) {
for (int i = 0; i <= j; i++) {
if (s.charAt(i) == s.charAt(j) && (j - i < 2 || dp[i + 1][j - 1])) {
dp[i][j] = true;
if (j - i + 1 > maxLen) {
maxLen = j - i + 1;
start = i;
}
}
}
}
return s.substring(start, start + maxLen);
}
练习
- LeetCode 1143:最长公共子序列
- LeetCode 300:最长递增子序列
- LeetCode 72:编辑距离
- LeetCode 62/63:不同路径 I/II(含障碍)
- LeetCode 64:最小路径和
- LeetCode 5:最长回文子串
- LeetCode 516:最长回文子序列
- LeetCode 53:最大子数组和
- LeetCode 152:乘积最大子数组
- LeetCode 139:单词拆分