「动态规划」线性DP:最长上升子序列(LIS)|编辑距离 / LeetCode 300|72(C++)
概述
DP,即动态规划是解决最优化问题的一类算法,我们要通过将原始问题分解成规模更小的、相似的子问题,通过求解这些易求解的子问题来计算原始问题。
线性DP是一类基本DP,我们来通过它感受DP算法的奥义。
最长上升子序列(LIS)
LIS问题,即最长上升子序列问题,是指找到原始序列中最长的单调元素序列这些(这些元素在原始序列中不一定是挨在一起的)。
LeetCode 2521:
给你一个整数数组
nums
,找到其中最长严格递增子序列的长度。子序列 是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。例如,
[3,6,2,7]
是数组[0,3,1,6,2,2,7]
的子序列。示例 1:
输入:nums = [10,9,2,5,3,7,101,18] 输出:4 解释:最长递增子序列是 [2,3,7,101],因此长度为 4 。示例 2:
输入:nums = [0,1,0,3,2,3] 输出:4
思路
我们来找到规模更小的子问题。
对于本题来说,什么是规模更小的子问题?
如果在故事的最后我们希望找到整个数组的LIS,不妨先找到整个数组中更小规模的上升序列。
如果我们用称可能的答案为一个状态,那么我们需要两个要素来表示这个状态,一个是描述规模的范围,一个描述这一段上升序列的长度。
那我们不妨定义 int dp[N],其中dp[i] = num,表示从原始数组中以nums[i]结尾的LIS为num。
当我们未开始计算时,dp[i] = 1,因为至少nums[i]这一个元素算是一个长度为1的递增子序列。
具体的,
const int n = nums.size(); // 获取数组的长度
vector<int> dp(n, 1); // 考虑到n是一个运行期变量,我们使用vector
现在,我们来求解dp[i] = ? 的子问题。
算法过程
每个子问题都要被求解,前提是比它小的问题被求解了,而不论大小规模,这些问题的求解过程是一样的。
求解DP问题,这被称为状态转移,也就是我们从小规模的状态推导出大规模的状态。
对于下标i来说, dp[i] = dp[j] + 1,其中 j < i && nums[j] < nums[i],考虑到可能有多个j符合情况,我们取最大值。
从0计算到n - 1,当计算 i 时,j 一定计算过了,所以这是合理的。
直观来说,我们需要一个二重循环:
for (int i = 0; i < n; i++) {for (int j = 0; j < i; j++)if (nums[j] < nums[i])dp[i] = max(dp[i], dp[j] + 1);}
Code
class Solution {
public:int lengthOfLIS(vector<int>& nums) {const int n = nums.size();vector<int> dp(n, 1);int ans = INT_MIN;for (int i = 0; i < n; i++) {for (int j = 0; j < i; j++)if (nums[j] < nums[i])dp[i] = max(dp[i], dp[j] + 1);ans = max(ans, dp[i]);}return ans;}
};
这种枚举是O(n²),怎么优化呢?
优化方案
还记得二分查找吗?
「数组」二分查找模版|二段性分析|恢复二段性 / LeetCode 35|33|81(C++)
有两件事:
- LIS一定是单调的。
- 子问题LIS的起点越小、差分(LIS[i] - LIS[i - 1])越小,即上升越缓,越有利于后续的元素追加到末尾。
这两件事很直观,几乎无须证明。
在遍历到i时,我们不妨记录当前LIS,然后这么做:
- 若nums[i]可以追加,则直接追加。
- 若nums[i]不可以追加,则二分找到LIS中当前大于nums[i]的元素,如果这个元素的上一个元素小于nums[i],则用nums[i]替换这个元素,这就使得LIS的差分减小。
二分是由LIS的单调性保证的。
Code
class Solution {
public:int lengthOfLIS(vector<int>& nums) {const int n = nums.size();vector<int> LIS(n);int len = 0;for (int i = 0; i < n; i++)if (!len || nums[i] > LIS[len - 1])LIS[len++] = nums[i];else {auto pos = upper_bound(LIS.begin(), LIS.begin() + len, nums[i]);if(pos == LIS.begin() || *prev(pos) < nums[i])*pos = nums[i];}return len;}
};
复杂度
时间复杂度: O(nlogn) //需求解n个状态,每次求解通过二分进行。
空间复杂度: O(n) //预留dp数组空间
编辑距离
编辑距离,也称为Levenshtein Distance,是衡量两个字符串差异的一种度量方法。它定义为将一个字符串转换成另一个字符串所需的最少编辑操作次数,包括插入、删除和替换字符。编辑距离越小,两个字符串越相似。
LeetCode 72:
给你两个单词
word1
和word2
, 请返回将word1
转换成word2
所使用的最少操作数 。你可以对一个单词进行如下三种操作:
- 插入一个字符
- 删除一个字符
- 替换一个字符
示例 1:
输入:word1 = "horse", word2 = "ros" 输出:3 解释: horse -> rorse (将 'h' 替换为 'r') rorse -> rose (删除 'r') rose -> ros (删除 'e')示例 2:
输入:word1 = "intention", word2 = "execution" 输出:5 解释: intention -> inention (删除 't') inention -> enention (将 'i' 替换为 'e') enention -> exention (将 'n' 替换为 'x') exention -> exection (将 'n' 替换为 'c') exection -> execution (插入 'u')
思路
依旧要将原始问题处理成规模更小的子问题。
上一题倒是很爽,要存储两个信息,于是就用下标和值处理了,但这次我们需要三个信息,就需要二维数组了。
那我们不妨定义 `int dp[N][M]`,其中dp[i][j] = num,
表示从数组1中[1, i]子数组与数组2中[1, j]子数组的编辑距离为num。
做如下初始化:
for (int i = 1; i <= n; i++)dp[i][0] = i;
for (int j = 1; j <= m; j++)dp[0][j] = j;
*注意*:dp[i]对应原始的word[i - 1],我们从i = 1开始循环,即做了一格偏移,这是为了处理边界,直观来讲,可以认为dp[i]代表原始数组的前i个字符。
解题过程
我们能想到很直观的二维循环。
有两种可能:
- word[i] == word[j],这是好事啊,编辑距离无需增加,dp[i][j] = dp[i - 1][j - 1];
- word[i] != word[j],dp[i][j] 至少为 dp[i - 1][j - 1],然后考虑:删除/替换/插入,这三者不过都是让二者相等的手段,我们只需要考虑之前的事,然后+1就行了。
- 考虑删掉word1[i - 1]/在word2[j - 1]后插入word1[i - 1],则dp[i][j] = dp[i - 1][j] + 1;
- 考虑删掉word2[j - 1]/在word1[i - 1]后插入word2[j - 1],则dp[i][j] = dp[i][j - 1] + 1;
- 考虑将word1[i - 1]替换为word2[j - 1],则dp[i][j] = dp[i - 1][j - 1] + 1;
注意这里dp[i]对应word[i - 1]。
以上三者,取最大值:
for (int i = 1; i <= n; i++)for (int j = 1; j <= m; j++) {dp[i][j] = dp[i - 1][j - 1];if (word1[i - 1] != word2[j - 1])dp[i][j] = min({dp[i][j], dp[i - 1][j], dp[i][j - 1]}) + 1;}
计算 i 和 j 时,i - 1 和 j - 1一定计算过了,这是合法的。
Code
class Solution {
public:int minDistance(string word1, string word2) {const int n = word1.size(), m = word2.size();vector dp(n + 1, vector(m + 1, 0));for (int i = 1; i <= n; i++)dp[i][0] = i;for (int j = 1; j <= m; j++)dp[0][j] = j;for (int i = 1; i <= n; i++)for (int j = 1; j <= m; j++) {dp[i][j] = dp[i - 1][j - 1];if (word1[i - 1] != word2[j - 1])dp[i][j] = min({dp[i][j], dp[i - 1][j], dp[i][j - 1]}) + 1;}return dp.back().back();}
};
复杂度
时间复杂度: O(nm) //需求解n*m个状态。
空间复杂度: O(nm) //预留n*m个空间。
总结
线性dp的状态转移依靠的是非常直观的线性增长的问题规模。
dp算法都是通过求解子问题进而求解原始问题的一类算法,希望你了解线性dp这类基本dp后有所体会。