打家劫舍
198. 打家劫舍 ●●
你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。
给定一个代表每个房屋存放金额的非负整数数组,计算你 不触动警报装置的情况下 ,一夜之内能够偷窃到的最高金额。
–
输入:[2,7,9,3,1]
输出:12
解释:偷窃 1 号房屋 (金额 = 2), 偷窃 3 号房屋 (金额 = 9),接着偷窃 5 号房屋 (金额 = 1)。 偷窃到的最高金额 = 2 + 9 + 1 = 12 。
- dp[j]:考虑下标 j(包括j)以内的房屋,最多可以偷窃的金额为dp[j]。
dp[j] = max(dp[j-1], dp[j-2] + nums[j]);
考虑偷或不偷下标 j- dp[0] = nums[0];
dp[1] = max(nums[0], nums[1]); - 从前往后遍历
class Solution {
public:
int rob(vector<int>& nums) {
int n = nums.size();
if (n == 0) return 0;
if (n == 1) return nums[0];
vector<int> dp(n, 0); // dp[j]
dp[0] = nums[0];
dp[1] = max(nums[0], nums[1]); // 0 和 1 只能偷一个
for(int j = 2; j < n; ++j){ // 偷 或 不偷 nums[j]
dp[j] = max(dp[j-1], dp[j-2] + nums[j]);
}
return dp[n-1];
}
};
213. 打家劫舍 II ●●
你是一个专业的小偷,计划偷窃沿街的房屋,每间房内都藏有一定的现金。这个地方所有的房屋都 围成一圈 ,这意味着第一个房屋和最后一个房屋是紧挨着的。同时,相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警 。
给定一个代表每个房屋存放金额的非负整数数组,计算你 在不触动警报装置的情况下 ,今晚能够偷窃到的最高金额。
本题中房屋首尾连接成环了,因此,将房屋分为两种情况,转化为两个不成环的打家劫舍问题单独计算:
- 考虑(但不一定取)第一个房屋,不考虑最后一个房屋;
- 考虑(但不一定取)最后一个房屋,不考虑第一个房屋。
class Solution {
public:
int subRob(vector<int>& nums, int strat, int end){
int n = end - strat + 1; // 子集个数
if(n == 1) return nums[strat];
vector<int> dp(n, 0);
dp[0] = nums[strat];
dp[1] = max(nums[strat], nums[strat + 1]);
for(int j = 2; j < n; ++j){
dp[j] = max(dp[j-1], dp[j-2] + nums[j+strat]);
}
return dp[n-1];
}
int rob(vector<int>& nums) {
int n = nums.size();
if(n == 1) return nums[0];
int strat = subRob(nums, 0, n-2); // [0, n-2]
int end = subRob(nums, 1, n-1); // [1, n-1]
return max(strat, end);
}
};
337. 打家劫舍 III ●●
小偷又发现了一个新的可行窃的地区。这个地区只有一个入口,我们称之为 root 。
除了 root 之外,每栋房子有且只有一个“父“房子与之相连。一番侦察之后,聪明的小偷意识到“这个地方的所有房屋的排列类似于一棵二叉树”。 如果 两个直接相连的房子在同一天晚上被打劫 ,房屋将自动报警。
给定二叉树的 root 。返回 在不触动警报的情况下 ,小偷能够盗取的最高金额 。
后序遍历,通过递归函数的返回值来做下一步计算。
如果偷了当前节点,两个孩子就不能动,并递归到孙子节点;
如果不偷当前节点,就可以考虑偷左右孩子(注意这里说的是“考虑”)
1. 记忆化递归搜索
避免节点的重复计算,用哈希表存储计算过的节点金额。
- 时间复杂度: O ( n ) O(n) O(n)
- 空间复杂度: O ( log n ) O(\log n) O(logn),算上递推系统栈的空间
class Solution {
public:
unordered_map<TreeNode*, int> map;
int rob(TreeNode* root) {
if(root == nullptr) return 0;
if(root->left == nullptr && root->right == nullptr) return root->val; // 叶子节点,直接返回值
if(map[root]) return map[root]; // 记忆化搜索哈希
// 偷父节点,递归遍历孙子节点
int val1 = root->val;
if(root->left != nullptr) val1 += rob(root->left->left) + rob(root->left->right);
if(root->right != nullptr) val1 += rob(root->right->left) + rob(root->right->right);
// 不偷父节点,递归遍历孩子节点
int val2 = rob(root->left) + rob(root->right);
// 取最大值,并存储记忆
map[root] = max(val1, val2);
return map[root];
}
};
2. 动态规划(树形dp)
以上记忆化搜索对一个节点 偷与不偷 得到的最大金钱都没有做记录,而是需要实时计算比较。
而动态规划其实就是使用状态转移容器数组dp[]
来记录状态的变化,
dp[0]
表示不偷该节点得到的最大金额,
dp[1]
表示偷该节点得到的最大金额。
- 时间复杂度: O ( n ) O(n) O(n),每个节点只遍历了一次
- 空间复杂度: O ( log n ) O(\log n) O(logn),算上递推系统栈的空间
class Solution {
public:
vector<int> robTree(TreeNode* root) {
if(root == nullptr) return {0, 0};
vector<int> dp(2, 0);
// 考虑孩子节点
vector<int> left = robTree(root->left);
vector<int> right = robTree(root->right);
// 不偷该节点,下标为0,孩子节点可偷可不偷
dp[0] = max(left[0], left[1]) + max(right[0], right[1]);
// 偷该节点,下标为1,不能偷孩子节点
dp[1] = root->val + left[0] + right[0];
return dp;
}
int rob(TreeNode* root) {
vector<int> ans = robTree(root);
return max(ans[0], ans[1]);
}
};
股票问题
121. 买卖股票的最佳时机 ●
给定一个数组 prices ,它的第 i 个元素 prices[i] 表示一支给定股票第 i 天的价格。
你只能选择 某一天买入这只股票,并选择在 未来的某一天卖出 该股票。设计一个算法来计算你所能获取的最大利润。
返回你可以从这笔交易中获取的最大利润。如果你不能获取任何利润,返回 0 。
1. 贪心
股票就买卖一次,那么贪心的想法很自然就是取最左最小值,取最右最大值,那么得到的差值就是最大利润。
class Solution {
public:
int maxProfit(vector<int>& prices) {
int low = INT_MAX;
int result = 0;
for (int i = 0; i < prices.size(); i++) {
low = min(low, prices[i]); // 取最左最小价格
result = max(result, prices[i] - low); // 直接取最大区间利润
}
return result;
}
};
2. DP
- dp[j][0]表示未持有 j 时的利润,dp[j][1]表示持有 j 时的利润;
- 持有和未持有的两个状态根据前一天的结果进行计算,
---- 未持有 j : j-1 持有,j 卖出; j-1 未持有,j 不动
dp[j][0] = max(dp[j-1][1] + prices[j], dp[j-1][0]);
---- 持有 j : j-1 持有,j 不动; j-1 未持有,j 第一次买入
dp[j][1] = max(dp[j-1][1], -prices[j]);
- dp[0][0] = 0;
dp[0][1] = -prices[0]; - 从前往后遍历
- 最后输出最后一天未持有的利润金额。
- 时间复杂度: O ( n ) O(n) O(n)
- 空间复杂度:
O
(
n
)
O(n)
O(n)
class Solution {
public:
int maxProfit(vector<int>& prices) {
int n = prices.size();
vector<vector<int>> dp(n, vector<int>(2, 0)); // dp[j][0]表示未持有j的现金,dp[j][1]表示持有j的现金
dp[0][1] = -prices[0];
for(int j = 1; j < n; ++j){
// 未持有j: j-1持有,j卖出; j-1未持有,j不动
dp[j][0] = max(dp[j-1][1] + prices[j], dp[j-1][0]);
// 持有j: j-1持有,j不动; j-1未持有,j买入
dp[j][1] = max(dp[j-1][1], -prices[j]);
}
return dp[n-1][0];
}
};
- 空间复杂度优化 O ( 1 ) O(1) O(1)
状态的更新只依赖于前一个状态,创建一维滚动数组 pre[]
class Solution {
public:
int maxProfit(vector<int>& prices) {
int n = prices.size();
vector<int> pre(2, 0);
// dp[j][0]表示未持有j的现金,dp[j][1]表示持有j的现金
pre[1] = -prices[0];
for(int j = 1; j < n; ++j){
// 未持有j: j-1持有,j卖出; j-1未持有,j不动
pre[0] = max(pre[1] + prices[j], pre[0]);
// 持有j: j-1持有,j不动; j-1未持有,j买入
pre[1] = max(pre[1], -prices[j]);
}
return pre[0];
}
};
122. 买卖股票的最佳时机 II ●●
给定一个数组 prices ,其中 prices[i] 表示股票第 i 天的价格。
在每一天,你可能会决定购买和/或出售股票。你在任何时候 最多 只能持有 一股 股票。你也可以购买它,然后在 同一天 出售。
返回 你能获得的 最大 利润 。
–
输入: prices = [7,1,5,3,6,4]
输出: 7;1->5 + 3->6 = 4 + 3 = 7.
- 一维滚动数组,pre[0]表示未持有 i 时的利润,pre[1]表示持有 i 时的利润;
- 持有和未持有的两个状态根据前一天的结果进行计算,
---- 未持有 i : i-1 未持有,i 不动; i-1 持有,i 卖出
pre[0] = max(pre[0], pre[1] + prices[i]);
---- 持有 i : i-1 持有,i 不动; i-1 未持有,i 买入
pre[1] = max(pre[1], pre0 - prices[i]);
- pre[0] = 0;
pre[1] = -prices[0]; - 从前往后遍历
- 最后输出最后一天未持有的利润金额。
- 时间复杂度:O(n)
- 空间复杂度:O(1)
class Solution {
public:
int maxProfit(vector<int>& prices) {
vector<int> pre(2, 0);
pre[1] = -prices[0];
for(int i = 1; i < prices.size(); ++i){
int pre0 = pre[0]; // 暂存pre[0]
// 未持有 i: i-1 未持有,i 不动; i-1 持有,i 卖出
pre[0] = max(pre[0], pre[1] + prices[i]);
// 持有 i: i-1 持有,i 不动; i-1 未持有,i 买入
pre[1] = max(pre[1], pre0 - prices[i]); // 可多次买卖
}
return pre[0]; // max(pre[0], pre[1]);
}
};
714. 买卖股票的最佳时机含手续费 ●●
给定一个整数数组 prices,其中 prices[i]表示第 i 天的股票价格 ;整数 fee 代表了交易股票的手续费用。
你可以无限次地完成交易,但是你每笔交易都需要付手续费。如果你已经购买了一个股票,在卖出它之前你就不能再继续购买股票了。
返回获得利润的最大值。
注意:这里的一笔交易指买入持有并卖出股票的整个过程,每笔交易你只需要为支付一次手续费。
1. DP
将手续费在股票卖出时进行计算,
- 一维滚动数组,pre[0]表示未持有 i 时的利润,pre[1]表示持有 i 时的利润;
- 持有和未持有的两个状态根据前一天的结果进行计算,
---- 未持有 i : i-1 未持有,i 不动; i-1 持有,i 卖出(包含手续费)
pre[0] = max(pre[0], pre[1] + prices[i] - fee);
---- 持有 i : i-1 持有,i 不动; i-1 未持有,j 买入
pre[1] = max(pre[1], pre0 - prices[i]);
- pre[0] = 0;
pre[1] = -prices[0]; - 从前往后遍历
- 最后输出最后一天未持有的利润金额。
- 时间复杂度:O(n)
- 空间复杂度:O(1)
class Solution {
public:
int maxProfit(vector<int>& prices) {
vector<int> pre(2, 0);
pre[1] = -prices[0];
for(int i = 1; i < prices.size(); ++i){
int pre0 = pre[0]; // 暂存pre[0]
// 未持有 i: i-1 未持有,i 不动; i-1 持有,i 卖出(包含手续费)
pre[0] = max(pre[0], pre[1] + prices[i] - fee);
// 持有 i: i-1 持有,i 不动; i-1 未持有,i 买入
pre[1] = max(pre[1], pre0 - prices[i]); // 可多次买卖
}
return pre[0]; // max(pre[0], pre[1]);
}
};
2. 贪心
将手续费在股票买入时进行计算,假买假卖。
123. 买卖股票的最佳时机 III ●●●
给定一个数组,它的第 i 个元素是一支给定的股票在第 i 天的价格。
设计一个算法来计算你所能获取的最大利润。你最多可以完成 两笔 交易。
注意:你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。
二维数组dp
(1)确定dp数组以及下标的含义
一天一共就有五个状态,
0:没有操作
1:第一次买入
2:第一次卖出
3:第二次买入
4:第二次卖出
dp[i][j]中 i 表示第 i 天,j 为 [0 - 4] 五个状态,dp[i][j] 表示第 i 天状态 j 所剩最大现金。
(2)确定递推公式
本次持有:上一次持有,本次保持;上一次卖出,本次买入
dp[i][1] = max(dp[i-1][1], dp[i-1][0] - prices[i]);
dp[i][3] = max(dp[i-1][3], dp[i-1][2] - prices[i]);
本次卖出:上一次持有,本次卖出;上一次卖出,本次保持
dp[i][2] = max(dp[i-1][1] + prices[i], dp[i-1][2]);
dp[i][4] = max(dp[i-1][3] + prices[i], dp[i-1][4]);
(3)初始化
dp[i][0] = 0;
dp[0][买入] = - prices[0];
(第一天买入)
dp[0][卖出] = 0;
(第一天买,第一天卖)
(4)遍历顺序,从前往后遍历
- 时间复杂度:O(n)
- 空间复杂度:O(n × 5)
class Solution {
public:
int maxProfit(vector<int>& prices) {
int n = prices.size();
vector<vector<int>> dp(n, vector<int>(5, 0));
dp[0][1] = -prices[0];
dp[0][3] = -prices[0]; // 1:第一次持有; 2:第一次卖出;3:第二次持有;4:第二次卖出
for(int i = 1; i < n; ++i){
dp[i][1] = max(dp[i-1][1], dp[i-1][0] - prices[i]); // 第一次(持有)买入,dp[i-1][0] = 0
dp[i][2] = max(dp[i-1][1] + prices[i], dp[i-1][2]);
dp[i][3] = max(dp[i-1][3], dp[i-1][2] - prices[i]);
dp[i][4] = max(dp[i-1][3] + prices[i], dp[i-1][4]);
}
return dp[n-1][4];
}
};
一维滚动数组dp
每一天的状态都由前一天的状态推导得到,因此可以创建一维滚动数组优化空间复杂度。
class Solution {
public:
int maxProfit(vector<int>& prices) {
int n = prices.size();
vector<int> dp(5, 0);
vector<int> pre(5,0);
dp[1] = -prices[0];
dp[3] = -prices[0]; // 1:第一次持有; 2:第一次卖出;3:第二次持有;4:第二次卖出
for(int i = 1; i < n; ++i){
// 暂存上一次的值
pre = dp;
// 更新该次的值
dp[1] = max(pre[1], - prices[i]); // 第一次(持有)买入
dp[2] = max(pre[1] + prices[i], pre[2]);
dp[3] = max(pre[3], pre[2] - prices[i]);
dp[4] = max(pre[3] + prices[i], pre[4]);
}
return dp[4];
}
};
但是,无论题目中是否允许「在同一天买入并且卖出」这一操作,最终的答案都不会受到影响,这是因为这一操作带来的收益为零。
所以,在更新状态时,无需暂存上一次的状态,直接用更新过的前一状态进行更新当前状态同样成立。
比如 dp[1] = max(dp[1], dp[0]- prices[i]);
如果取 dp[1],则dp[2]的dp[1]就相当于前一天的状态,无影响;
如果取 dp[0]- prices[i],那么此时为前一天不持有股票,在第 i 天买入,若dp[2] 中取dp[1] + prices[i],即当天买入卖出,收益为0,对现金没有影响,保持了前一天的状态。
class Solution {
public:
int maxProfit(vector<int>& prices) {
int n = prices.size();
vector<int> dp(5, 0);
dp[1] = -prices[0];
dp[3] = -prices[0]; // 1:第一次持有; 2:第一次卖出;3:第二次持有;4:第二次卖出
for(int i = 1; i < n; ++i){
// 更新状态值
dp[1] = max(dp[1], dp[0]- prices[i]); // 第一次(持有)买入
dp[2] = max(dp[1] + prices[i], dp[2]);
dp[3] = max(dp[3], dp[2] - prices[i]);
dp[4] = max(dp[3] + prices[i], dp[4]);
}
return dp[4];
}
};
188. 买卖股票的最佳时机 IV ●●●
给定一个整数数组 prices ,它的第 i 个元素 prices[i] 是一支给定的股票在第 i 天的价格。
设计一个算法来计算你所能获取的最大利润。你最多可以完成 k 笔交易。
注意:你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。
对于每一天买入和卖出的状态,
0: 表示不操作
1: 第一次买入
2: 第一次卖出
3: 第二次买入
4: 第二次卖出
…
除了0以外,偶数就是卖出,奇数就是买入。
因此定义大小为2 * k + 1的数组,再循环中遍历更新。
class Solution {
public:
int maxProfit(int k, vector<int>& prices) {
int n = prices.size();
if(n == 0) return 0;
vector<vector<int>> dp(n, vector<int>(2*k+1, 0));
for(int j = 1; j <= k; ++j){
dp[0][2*j-1] = -prices[0]; // 奇数持有,偶数卖出
}
for(int i = 1; i < n; ++i){
for(int j = 1; j <= k; ++j){
// 本次持有:上一次持有,本次保持;上一次卖出,本次买入
dp[i][2*j-1] = max(dp[i-1][2*j-1], dp[i-1][2*j-2] - prices[i]);
// 本次卖出:上一次持有,本次卖出;上一次卖出,本次保持
dp[i][2*j] = max(dp[i-1][2*j-1] + prices[i], dp[i-1][2*j]);
}
}
return dp[n-1][k*2]; // 最后一天卖出
}
};
- 一维滚动数组
class Solution {
public:
int maxProfit(int k, vector<int>& prices) {
int n = prices.size();
if(n == 0) return 0;
vector<int> dp(2*k+1, 0);
for(int j = 1; j <= k; ++j){
dp[2*j-1] = -prices[0]; // 奇数持有,偶数卖出
}
for(int i = 1; i < n; ++i){
for(int j = 1; j <= k; ++j){
// 本次持有:上一次持有,本次保持;上一次卖出,本次买入
dp[2*j-1] = max(dp[2*j-1], dp[2*j-2] - prices[i]);
// 本次卖出:上一次持有,本次卖出;上一次卖出,本次保持
dp[2*j] = max(dp[2*j-1] + prices[i], dp[2*j]);
}
}
return dp[k*2]; // 最后一天卖出
}
};
309. 最佳买卖股票时机含冷冻期 ●●
给定一个整数数组prices,其中第 prices[i] 表示第 i 天的股票价格 。
设计一个算法计算出最大利润。在满足以下约束条件下,你可以尽可能地完成更多的交易(多次买卖一支股票):
卖出股票后,你无法在第二天买入股票 (即冷冻期为 1 天)。
注意:你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。
本题与前面的股票问题多了一个冷冻期,因此状态也更多了,需要先分清楚状态,然后再对应写出递推公式即可。
四个状态:
- 0:买入股票状态(今天买入股票,或者是之前就买入了股票然后没有操作)
- 卖出股票状态,这里就有两种卖出股票状态
1:今天卖出了股票
2:两天前就卖出了股票,度过了冷冻期,一直没操作,今天保持卖出股票状态 - 3:今天为冷冻期状态,但冷冻期状态不可持续,只有一天!
对应的状态更新方程为:
// 0持有
dp[i][0] = max(max(dp[i-1][0], dp[i-1][2] - prices[i]), dp[i-1][3] - prices[i]);
// 1今天卖出
dp[i][1] = dp[i-1][0] + prices[i];
// 2冷冻期后未持有
dp[i][2] = max(dp[i-1][2], dp[i-1][3]);
// 3冷冻期
dp[i][3] = dp[i-1][1];
class Solution {
public:
int maxProfit(vector<int>& prices) {
int n = prices.size();
vector<vector<int>> dp(n, vector<int>(4, 0));
dp[0][0] = -prices[0]; // 0持有,1今天卖出,2冷冻期后未持有,3冷冻期
for(int i = 1; i < n; ++i){
// 0持有
dp[i][0] = max(max(dp[i-1][0], dp[i-1][2] - prices[i]), dp[i-1][3] - prices[i]);
// 1今天卖出
dp[i][1] = dp[i-1][0] + prices[i];
// 2冷冻期后未持有
dp[i][2] = max(dp[i-1][2], dp[i-1][3]);
// 3冷冻期
dp[i][3] = dp[i-1][1];
}
return max(dp[n - 1][3],max(dp[n - 1][1], dp[n - 1][2]));
}
};
- 三个状态
0:买入或保持持有
1:今天卖出
2:持续未持有(冷冻或非冷冻期)
class Solution {
public:
int maxProfit(vector<int>& prices) {
int n = prices.size();
vector<vector<int>> dp(n, vector<int>(4, 0));
dp[0][0] = -prices[0]; // 0持有,1今天卖出,2持续未持有(冷冻或非冷冻期)
for(int i = 1; i < n; ++i){
dp[i][0] = max(dp[i-1][0], dp[i-1][2] - prices[i]);
dp[i][1] = dp[i-1][0] + prices[i];
dp[i][2] = max(dp[i-1][2], dp[i-1][1]);
}
return max(dp[n - 1][1], dp[n - 1][2]);
}
};
子序列问题
300. 最长递增子序列 ●●
给你一个整数数组 nums ,找到其中最长严格递增子序列的长度。
子序列 是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。例如,[3,6,2,7] 是数组 [0,3,1,6,2,2,7] 的子序列。
–
输入:nums = [10,9,2,5,3,7,101,18]
输出:4
解释:最长递增子序列是 [2,3,7,101],因此长度为 4 。
1. DP
- dp[i] 表示以第 i 个数字nums[i]为结尾的最长递增序列长度;
if (nums[i] > nums[j]) dp[i] = max(dp[i], dp[j] + 1);
位置i的最长升序子序列等于j从0到i-1各个位置的最长升序子序列 + 1 的最大值。
注意这里不是要dp[i] 与 dp[j] + 1进行比较,而是我们要取dp[j] + 1的最大值。- 每一个 i,对应的dp[i](即最长上升子序列)起始大小至少都是1;
- 双层循环,从前往后遍历。
- 时间复杂度: O ( n 2 ) O(n^2) O(n2),其中 n 为数组 nums 的长度。动态规划的状态数为 n,计算状态 dp[i] 时,需要 O(n) 的时间遍历 dp[0…i−1] 的所有状态。
- 空间复杂度:O(n),需要额外使用长度为 n 的 dp 数组。
class Solution {
public:
int lengthOfLIS(vector<int>& nums) {
int n = nums.size();
int ans = 1;
vector<int> dp(n, 1); // dp[i] 表示以第i个数字为结尾的最长递增序列长度
for(int i = 1; i < n; ++i){ // nums[i]结尾
for(int j = 0; j < i; ++j){
if(nums[i] > nums[j]){
dp[i] = max(dp[i], dp[j] + 1); // 升序时,0到i-1各个位置的最长升序子序列 + 1
}
}
ans = max(ans, dp[i]); // 取最大值
}
return ans;
}
};
2. DP + 二分查找
通过重新设计状态定义,使整个 dp 为一个排序列表;这样在计算每个 dp[k] 时,就可以通过二分法遍历 [0,k) 区间元素,将此部分复杂度由 O(N) 降至 O(logN)。
定义 dp[i] 表示最长递增序列长度为 i+1 的最小末尾数,如 {1, 2, 4, 3} 中,最长递增序列长度为 3 的最小末尾数 dp[2] = 3;
此时 dp 数组一定是严格递增的,即 k < n 时有dp[ k ] < dp[ n ],当尽可能使每个子序列尾部元素值最小的前提下,子序列越长,其序列尾部元素值一定更大。(可用反证法证明)。
步骤为:
- 新建dp数组,用于存放最长递增序列长度为 i+1 的最小末尾数,初始化dp[0] = nums[0];
- 遍历数组nums,对每个元素进行比较插入操作,
1)如果dp中元素都小于它,则尾部插入新元素;
2)否则,用二分法找到第一个大于等于 nums[i] 的位置,进行替换。
总之,思想就是让 dp[i] 存储序列长度为 i+1 的最小末尾数。这样,dp 未必是真实的最长上升子序列,但其长度就是最终的子序列长度。
- 时间复杂度 O ( N l o g N ) O(NlogN) O(NlogN) : 遍历 nums 列表需 O(N),在每个 nums[i] 二分法需 O(logN)。
- 空间复杂度 O(N) : tails 列表占用线性大小额外空间。
class Solution {
public:
int lengthOfLIS(vector<int>& nums) {
vector<int> dp; // dp[i] 表示最长递增序列长度为i+1的最小末尾数
dp.emplace_back(nums[0]); // dp初始化
for(int i = 1; i < n; ++i){
int l = 0;
int r = dp.size()-1;
if(dp[r] < nums[i]){ // dp数组最大数小于nums[i],尾部插入元素
dp.emplace_back(nums[i]);
continue;
} // nums[i]在dp数组范围内
while(l <= r){ // 二分查找第一个大于等于nums[i]的位置,进行替换
int m = l + (r-l) / 2;
if(dp[m] >= nums[i]){ // 若非要求严格递增,将此行>=改为>,行10的<改为<=
r = m - 1;
}else{
l = m + 1;
}
}
dp[l] = nums[i]; // 元素替换
}
return dp.size();
}
};
674. 最长连续递增序列 ●
给定一个未经排序的整数数组,找到最长且 连续递增的子序列,并返回该序列的长度。
连续递增的子序列 可以由两个下标 l 和 r(l < r)确定,如果对于每个 l <= i < r,都有 nums[i] < nums[i + 1] ,那么子序列 [nums[l], nums[l + 1], …, nums[r - 1], nums[r]] 就是连续递增子序列。
–
输入:nums = [1,3,5,4,7]
输出:3
解释:最长连续递增序列是 [1,3,5], 长度为3。
尽管 [1,3,5,7] 也是升序的子序列, 但它不是连续的,因为 5 和 7 在原数组里被 4 隔开。
1. DP
- dp[i] 表示以第 i 个数字nums[i]为结尾的连续递增序列长度;
if (nums[i] > nums[i-1]) dp[i] = dp[i-1] + 1;
本题要求连续数组,因此只需要与前一个元素比较即可,而不需要像300. 最长递增子序列 ●●利用双层循环与前面所有元素进行比较。- 每一个 i,对应的 dp[i] 起始大小至少都是1;
- 单层循环,从前往后遍历。
class Solution {
public:
int findLengthOfLCIS(vector<int>& nums) {
vector<int> dp(nums.size(), 1);
int ans = 1;
for(int i = 1; i < nums.size(); ++i){
if(nums[i] > nums[i-1]){
dp[i] = dp[i-1] + 1;
}else{
dp[i] = 1;
}
ans = max(ans, dp[i]);
}
return ans;
}
};
- 贪心,空间复杂度优化 O ( 1 ) O(1) O(1)
class Solution {
public:
int findLengthOfLCIS(vector<int>& nums) {
int count = 1; // 存放上一次的长度
int ans = 1; // 最长长度
for(int i = 1; i < nums.size(); ++i){
if(nums[i] > nums[i-1]){
count += 1; // 长度+1
}else{
count = 1; // 长度重置 1
}
ans = max(ans, count); // 保存最大元素
}
return ans;
}
};
718. 最长重复子数组 ●●
给两个整数数组 nums1 和 nums2 ,返回 两个数组中 公共的 、长度最长的子数组的长度 。
–
输入:nums1 = [1,2,3,2,1], nums2 = [3,2,1,4,7]
输出:3
解释:长度最长的公共子数组是 [3,2,1] 。
1. 滑动窗口
通过滑动数组来遍历所有数组重叠的情况,然后在重叠部分找最大重复数组的长度。
如图可知,枚举 数组 所有的重叠(对齐)方式主要分为两步:
(1)nums1 头部遍历 nums2 的每一个元素;
(2)nums2 头部遍历 nums1 的每一个元素。
每次滑动操作需要更新两个数组的重叠起始位置和长度。
- 时间复杂度: O ( ( N + M ) × min ( N , M ) ) O((N + M) \times \min(N, M)) O((N+M)×min(N,M))。
- 空间复杂度: O ( 1 ) O(1) O(1)。
class Solution {
public:
int maxLen(vector<int>& nums1, vector<int>& nums2, int start1, int start2, int len){
int maxSub = 0; // start为重叠数组的起始位置,len为重叠长度
int count = 0;
for(int i = 0; i < len; ++i){ // 遍历比较重叠长度为len的数组,注意条件 i < len
if(nums1[start1 + i] == nums2[start2 + i]){
count += 1;
}else{
count = 0;
}
maxSub = max(count, maxSub); // 找到重叠数组的最大重复长度
}
return maxSub;
}
int findLength(vector<int>& nums1, vector<int>& nums2) {
int n1 = nums1.size();
int n2 = nums2.size();
int maxmin = min(n1, n2);
int ans = 0;
// nums1 头部遍历 nums2 的每一个元素
for(int i = 1; i <= n2; ++i){
int len = min(n1, i); // 重叠的长度
int maxSub = maxLen(nums1, nums2, 0, n2 - i, len);
ans = max(ans, maxSub);
if(ans == maxmin) return ans; // 剪枝
}
// nums2 头部遍历 nums1 的每一个元素
for(int i = 1; i <= n1; ++i){
int len = min(n2, i); // 重叠的长度
int maxSub = maxLen(nums1, nums2, n1 - i, 0, len);
ans = max(ans, maxSub);
if(ans == maxmin) return ans; // 剪枝
}
return ans;
}
};
2. DP
dp[i][j]
表示以nums1[i-1],nums2[j-1]为末尾的最长重复长度if(nums1[i-1] == nums2[j-1]) dp[i][j] = dp[i-1][j-1] + 1;
如果当前两数相等,那么重复长度应该为上一重复子数组+1;
如果两数不相等,则上一重复子数组不再连续,当前子数组重复长度为0,不更改.- dp 初始化为0
- 双层循环,从前往后遍历
- 时间复杂度: O ( n × m ) O(n × m) O(n×m),n 为A长度,m为B长度
- 空间复杂度:
O
(
n
×
m
)
O(n × m)
O(n×m)
class Solution {
public:
int findLength(vector<int>& nums1, vector<int>& nums2) {
int n1 = nums1.size();
int n2 = nums2.size();
int ans = 0;
vector<vector<int>> dp(n1+1, vector<int>(n2+1, 0)); // dp[i][j]表示以nums1[i-1],nums2[j-1]为末尾的最长重复长度
for(int i = 1; i <= n1; ++i){
for(int j = 1; j <= n2; ++j){
// 如果当前两数相等,那么重复长度应该为上一重复子数组+1
if(nums1[i-1] == nums2[j-1]){
dp[i][j] = dp[i-1][j-1] + 1;
}
// 如果两数不相等,则上一重复子数组不再连续,当前子数组重复长度为0,不更改
ans = max(ans, dp[i][j]); // 保存最大值
}
}
return ans;
}
};
- 一维滚动数组 O ( m ) O(m) O(m)
dp[i][j] 都是由dp[i - 1][j - 1]推出。那么压缩为一维数组,也就是dp[j]都是由dp[j - 1]推出。
也就是相当于可以把上一层dp[i - 1][j]拷贝到下一层dp[i][j]来继续用。
此时遍历B数组的时候,就要从后向前遍历,这样避免重复覆盖。
class Solution {
public:
int findLength(vector<int>& nums1, vector<int>& nums2) {
int n1 = nums1.size();
int n2 = nums2.size();
int ans = 0;
vector<int> dp(n2+1, 0); // dp[j]表示以nums1[i-1],nums2[j-1]为末尾的最长重复长度
for(int i = 1; i <= n1; ++i){
for(int j = n2; j > 0; --j){ // 内层从后往前遍历
if(nums1[i-1] == nums2[j-1]){
dp[j] = dp[j-1] + 1; // 重复长度+1
}else{
dp[j] = 0; // 元素不相等,重复长度为0
}
ans = max(ans, dp[j]);
}
}
return ans;
}
};
3. 二分法 + 哈希
假设当前存在长度为 len 的重复子数组,那么肯定存在比 len 更短的子数组,也就是只取 len 中的其中一些数,而有没有比len还要长的并不确定,还要继续搜索,因此循此过程可找到最长重复子数组长度。
class Solution {
public:
bool lenGet(vector<int>& nums1, vector<int>& nums2, int len){
set<vector<int>> hash;
// 遍历 nums1 中所有长度为len的子数组,并加入哈希表
for(int i = 0; i + len <= nums1.size(); ++i){
hash.insert(vector<int>(nums1.begin() + i, nums1.begin() + i + len));
}
// 遍历 nums2 中所有长度为len的子数组,并判断是否重复
for(int j = 0; j + len <= nums2.size(); ++j){
if(hash.find(vector<int>(nums2.begin() + j, nums2.begin() + j + len)) != hash.end()){
return true;
}
}
return false;
}
int findLength(vector<int>& nums1, vector<int>& nums2) {
int n1 = nums1.size();
int n2 = nums2.size();
int left = 0;
int right = min(n1, n2);
// 二分法,查找存在的最大重复长度
// (left, right]
while(left < right){
int mid = left + (right - left + 1) / 2; // // 向上取整,找最长
if(lenGet(nums1, nums2, mid) == true){
left = mid; // 如果存在这个长度的子数组,增大left继续找更长
}else{
right = mid - 1; // 找不到则缩小长度
}
}
return left;
// [left, right]
// int left = 1;
// int right = min(n1, n2);
// while(left <= right){
// int mid = left + (right - left) / 2;
// if(lenGet(nums1, nums2, mid) == true){
// left = mid + 1;
// }else{
// right = mid - 1;
// }
// }
// return left-1;
}
};
1143. 最长公共子序列 ●●
给定两个字符串 text1 和 text2,返回这两个字符串的最长 公共子序列 的长度。如果不存在 公共子序列 ,返回 0 。
一个字符串的 子序列 是指这样一个新的字符串:它是由原字符串在不改变字符的相对顺序的情况下删除某些字符(也可以不删除任何字符)后组成的新字符串。
–
例如,“ace” 是 “abcde” 的子序列,但 “aec” 不是 “abcde” 的子序列。
两个字符串的 公共子序列 是这两个字符串所共同拥有的子序列。
–
输入:text1 = “abcde”, text2 = “ace”
输出:3
解释:最长公共子序列是 “ace” ,它的长度为 3 。
dp[i][j]
表示以 text1[i-1], text2[j-1]为末尾的最长公共子序列长度if(text1[i-1] == text2[j-1]) dp[i][j] = dp[i-1][j-1] + 1;
如果当前这对字符相等,那么公共长度应该为上组子序列公共长度+1;
if(text1[i-1] != text2[j-1]) dp[i][j] = max(dp[i][j-1], dp[i-1][j]);
如果字符不相等,则取前面公共序列长度最大的那个;- 边界条件:dp 初始化为0
- 双层循环,从前往后遍历
- 时间复杂度: O ( n × m ) O(n × m) O(n×m),n 为A长度,m为B长度
- 空间复杂度: O ( n × m ) O(n × m) O(n×m)
class Solution {
public:
int longestCommonSubsequence(string text1, string text2) {
int n1 = text1.length();
int n2 = text2.length();
vector<vector<int>> dp(n1+1, vector<int>(n2+1, 0));
for(int i = 1; i <= n1; ++i){
for(int j = 1; j <= n2; ++j){
if(text1[i-1] == text2[j-1]){
dp[i][j] = dp[i-1][j-1] + 1;
}else{
dp[i][j] = max(dp[i][j-1], dp[i-1][j]);
}
}
}
return dp[n1][n2];
}
};
1035. 不相交的线 ●●
在两条独立的水平线上按给定的顺序写下 nums1 和 nums2 中的整数。
现在,可以绘制一些连接两个数字 nums1[i] 和 nums2[j] 的直线,这些直线需要同时满足满足:
nums1[i] == nums2[j]
且绘制的直线不与任何其他连线(非水平线)相交。
请注意,连线即使在端点也不能相交:每个数字只能属于一条连线。
以这种方法绘制线条,并返回可以绘制的最大连线数。
–
输入:nums1 = [1,4,2], nums2 = [1,2,4]
输出:2
直线不能相交,这就是说明在 数组 A 中 找到一个与 数组 B 相同的子序列,且这个子序列不能改变相对顺序,只要相对顺序不改变,连接相同数字的直线就不会相交。
因此,本题可以转化为求两个数组的最长公共子序列长度。
解题过程与上一题1143. 最长公共子序列 ●●类似。
class Solution {
public:
int maxUncrossedLines(vector<int>& nums1, vector<int>& nums2) {
vector<vector<int>> dp(nums1.size()+1, vector<int>(nums2.size()+1, 0));
for(int i = 1; i <= nums1.size(); ++i){
for(int j = 1; j <= nums2.size(); ++j){
if(nums1[i-1] == nums2[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[nums1.size()][nums2.size()];
}
};
53. 最大子数组和 ●
给你一个整数数组 nums ,请你找出一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。
子数组 是数组中的一个连续部分。
–
输入:nums = [-2,1,-3,4,-1,2,1,-5,4]
输出:6
解释:连续子数组 [4,-1,2,1] 的和最大,为 6 。
- dp[i]:表示以nums[i]结尾的最大连续数组和;
dp[i] = max(nums[i], dp[i-1] + nums[i]);
两种选择:以 nums[i] 结尾 或 重新开始 (dp[i-1] < 0);dp[0] = nums[0];
- 从前往后遍历,遍历时用
ans
变量保存最大和。
class Solution {
public:
int maxSubArray(vector<int>& nums) {
int n = nums.size();
vector<int> dp(n, 0); // dp[i]表示以nums[i]结尾的最大连续数组和
dp[0] = nums[0];
int ans = nums[0];
for(int i = 1; i < n; ++i){ // 从前往后遍历
dp[i] = max(nums[i], dp[i-1] + nums[i]);
ans = max(dp[i], ans); // 保存最大值
}
return ans;
}
};
- 空间复杂度优化 O ( 1 ) O(1) O(1)
class Solution {
public:
int maxSubArray(vector<int>& nums) {
int n = nums.size();
int pre = nums[0];
int ans = nums[0];
for(int i = 1; i < n; ++i){ // 从前往后遍历
pre = max(nums[i], pre + nums[i]);
ans = max(pre, ans); // 保存最大值
}
return ans;
}
};
- 贪心
… 当和出现负数时曾将计数置零,相当于动态更新起点。
392. 判断子序列 ●
给定字符串 s 和 t ,判断 s 是否为 t 的子序列。
字符串的一个子序列是原始字符串删除一些(也可以不删除)字符而不改变剩余字符相对位置形成的新字符串。(例如,"ace"是"abcde"的一个子序列,而"aec"不是)。
1. 双指针
- 时间复杂度:O(n+m),其中 n 为 s 的长度,m 为 t 的长度。每次无论是匹配成功还是失败,都有至少一个指针发生右移,两指针能够位移的总距离为 n+m。
- 空间复杂度:O(1)。
class Solution {
public:
bool isSubsequence(string s, string t) {
int slen = s.size();
int tlen = t.size();
if(slen == 0) return true;
if(tlen == 0) return false;
int j = 0;
for(int i = 0; i < tlen; ++i){
if(t[i] == s[j] && j < slen){
++j;
if(j == slen) return true;
}
}
return false;
}
};
2. DP
- 1、按照1143. 最长公共子序列 ●●的思路找到最大公共序列长度,最后判断是否等于 slen。
class Solution {
public:
bool isSubsequence(string s, string t) {
int tlen = t.length();
int slen = s.length();
vector<vector<int>> dp(slen+1, vector<int>(tlen+1, 0));
for(int i = 1; i <= slen; ++i){
for(int j = 1; j <= tlen; ++j){
if(s[i-1] == t[j-1]){
dp[i][j] = dp[i-1][j-1] + 1;
}else{
dp[i][j] = max(dp[i][j-1], dp[i-1][j]);
}
}
}
return dp[slen][tlen] == slen;
}
};
- 2、编辑距离类型入门题目
dp[i][j]
表示以 s[i-1], t[j-1]为末尾的相同子序列长度if(s[i-1] == t[j-1]) dp[i][j] = dp[i-1][j-1] + 1;
如果当前这对字符相等,那么相同长度应该为上组子序列公共长度+1;
if(s[i-1] != t[j-1]) dp[i][j] = dp[i][j-1]);
如果字符不相等,则相当于 t 要删除元素,继续匹配,继承 j-1 的长度。(这里因为只判断 s 为 t 的子串,因此只需要对 t 进行删减操作)- 边界条件:dp 初始化为0
- 双层循环,从上到下,从左到右
时间复杂度:
O
(
n
×
m
)
O(n × m)
O(n×m)
空间复杂度:
O
(
n
×
m
)
O(n × m)
O(n×m)
class Solution {
public:
bool isSubsequence(string s, string t) {
int tlen = t.length();
int slen = s.length();
vector<vector<int>> dp(slen+1, vector<int>(tlen+1, 0));
for(int i = 1; i <= slen; ++i){
for(int j = 1; j <= tlen; ++j){
if(s[i-1] == t[j-1]){
dp[i][j] = dp[i-1][j-1] + 1; // 上一对相同长度+1
}else{
dp[i][j] = dp[i][j-1]; // 删除 t[j-1],继承dp[i][j-1]
}
}
}
return dp[slen][tlen] == slen;
}
};
3. 贪心
进阶:
如果有大量输入的 S,称作 S1, S2, … , Sk 其中 k >= 10亿,你需要依次检查它们是否为 T 的子序列。在这种情况下,你会怎样改变代码?
-
将长字符串 t 进行预处理,便于后续大量字符串的匹配:
在字符串 t 前增加首位空格符,建立一个 tlen * 26 大小的矩阵 map, map[ i ][‘a’ - ‘a’] 表示在 下标 i 之后下一个字符 a 出现的位置。
对于t = ’ ’ + “abadb”,其对应map为:
map[0][a] = 1;
map[1][b] = 2;
map[3][a] = 4;
map[4][d] = 5;
map[5][b] = 6; 其余均为-1. -
遍历要匹配的段字符串,当出现某一字符下一个出现的位置为 -1,表示长字符串再没有该字符时,返回 false;正常遍历完则返回 true。
class Solution {
public:
bool isSubsequence(string s, string t) {
t = " " + t; // 增加首空格
int tlen = t.length();
vector<vector<int>> map(tlen+1, vector<int>(26, -1));
for(int i = tlen-1; i > 0; --i){ // 增加了首空格,[1, tlen-1] 记录下一个出现的位置,从后往前遍历
for(char ch = 'a'; ch <= 'z'; ++ch){
if(t[i] == ch){
map[i-1][ch-'a'] = i; // 下标索引往后挪一位,相当于 t 字符串首插入空字符
}else{
map[i-1][ch-'a'] = map[i][ch-'a'];
}
}
}
int index = 0;
for(char ch : s){
if(map[index][ch-'a'] == -1) return false;
index = map[index][ch-'a'];
}
return true;
}
};
115. 不同的子序列 ●●●
给定一个字符串 s 和一个字符串 t ,计算在 t 的子序列中 s 出现的个数。
题目数据保证答案符合 32 位带符号整数范围。
–
输入:t = “rabbbit”, s = “rabbit”
输出:3
解释:
如下图所示, 有 3 种可以从 t 中得到 “rabbit” 的方案。
rabbbit
rabbbit
rabbbit
dp[i][j]
表示以 s[i-1], t[j-1]为末尾时 s 在 t 中出现的次数;if(s[i-1] == t[j-1]) dp[i][j] = dp[i-1][j-1] + dp[i][j-1];
如果当前这对字符相等,那么出现次数为以 s[i-2], t[j-2]为末尾的出现次数
(t[j-2]范围内出现过s[i-2]字符串的次数 与 当前匹配到的字符的组合) +以 s[i-1], t[j-2]为末尾的出现次数
t[j-2]范围内出现过s[i-1]字符串的次数)
if(s[i-1] != t[j-1]) dp[i][j] = dp[i][j-1]);
如果字符不相等,则相当于 t 要删除元素,继续匹配,继承 j-1 的长度。- 边界条件初始化:
dp[0][j] = 1;
意味着空字符 s 都能够被匹配一次;
dp[i][0] = 0;
意味着空字符 t 无法匹配其他字符串; - 双层循环,从上到下,从左到右
时间复杂度:
O
(
n
×
m
)
O(n × m)
O(n×m)
空间复杂度:
O
(
n
×
m
)
O(n × m)
O(n×m)
class Solution {
public:
int numDistinct(string t, string s) {
int slen = s.length();
int tlen = t.length();
vector<vector<uint64_t>> dp(slen+1, vector<uint64_t>(tlen+1, 0));
for(int j = 0; j <= tlen; ++j) dp[0][j] = 1;
for(int i = 1; i <= slen; ++i){
for(int j = 1; j <= tlen; ++j){
if(s[i-1] == t[j-1]){
dp[i][j] = dp[i-1][j-1] + dp[i][j-1];
}else{
dp[i][j] = dp[i][j-1];
}
}
}
return dp[slen][tlen];
}
};
583. 两个字符串的删除操作 ●●
给定两个单词 word1 和 word2 ,返回使得 word1 和 word2 相同所需的最小步数。
每步 可以删除任意一个字符串中的一个字符。
–
输入: word1 = “sea”, word2 = “eat”
输出: 2
解释: 第一步将 “sea” 变为 “ea” ,第二步将 "eat "变为 “ea”
- 1、动态规划删除字符串字符
dp[i][j]
表示以 s[i-1], t[j-1]为末尾时所需删除步数;if(s[i-1] == t[j-1]) dp[i][j] = dp[i-1][j-1];
如果当前这对字符相等,等于上一组数的步数,即都不删除
if(s[i-1] != t[j-1]) dp[i][j] = min(dp[i][j-1], dp[i-1][j]) + 1;
如果字符不相等,则根据步数来判断删除行还是列上的字符串。- 边界条件初始化:
dp[0][j] = j;
dp[i][0] = i;
意味着空字符与其他字符串匹配的步数; - 双层循环,从上到下,从左到右。
时间复杂度:
O
(
n
×
m
)
O(n × m)
O(n×m)
空间复杂度:
O
(
n
×
m
)
O(n × m)
O(n×m)
class Solution {
public:
int minDistance(string word1, string word2) {
int len1 = word1.length();
int len2 = word2.length();
vector<vector<int>> dp(len1+1, vector<int>(len2+1, 0));
for(int i = 0; i <= len1; ++i) dp[i][0] = i; // 边界条件初始化
for(int j = 0; j <= len2; ++j) dp[0][j] = j;
for(int i = 1; i <= len1; ++i){
for(int j = 1; j <= len2; ++j){
if(word1[i-1] == word2[j-1]){
dp[i][j] = dp[i-1][j-1]; // 相等时,等于上一组数的步数
}else{
dp[i][j] = min(dp[i][j-1], dp[i-1][j]) + 1; // 根据更小的步数来判断删除行还是列上的字符串
}
}
}
return dp[len1][len2];
}
};
- 2、按照1143. 最长公共子序列 ●●的思路找到最大公共序列长度,最后总长度进行相减。
class Solution {
public:
int minDistance(string word1, string word2) {
int len1 = word1.length();
int len2 = word2.length();
vector<vector<int>> dp(len1+1, vector<int>(len2+1, 0));
for(int i = 1; i <= len1; ++i){
for(int j = 1; j <= len2; ++j){
if(word1[i-1] == word2[j-1]){
dp[i][j] = dp[i-1][j-1] + 1;
}else{
dp[i][j] = max(dp[i][j-1], dp[i-1][j]);
}
}
}
return len1 + len2 - 2 * dp[len1][len2]; // 各减去最长公共序列长度后相加
}
};
72. 编辑距离 ●●●
给你两个单词 word1 和 word2, 请返回将 word1 转换成 word2 所使用的最少操作数 。
你可以对一个单词进行如下三种操作:
-插入一个字符
-删除一个字符
-替换一个字符
–
输入:word1 = “horse”, word2 = “ros”
输出:3
解释:
horse -> rorse (将 ‘h’ 替换为 ‘r’)
rorse -> rose (删除 ‘r’)
rose -> ros (删除 ‘e’)
dp[i][j]
表示以 s[i-1], t[j-1]为末尾时所需操作步数;if(s[i-1] == t[j-1]) dp[i][j] = dp[i-1][j-1];
如果当前这对字符相等,等于上一组数的步数,即都不操作
if(s[i-1] != t[j-1]) dp[i][j] = min(dp[i-1][j-1], min(dp[i][j-1], dp[i-1][j])) + 1;
如果字符不相等,则根据步数来判断删除当前遍历行还是列上的字符(在字符串1删除元素,等效于在另一字符串增加元素),或者在上一组的基础上替换其中一个字符。- 边界条件初始化:
dp[0][j] = j;
dp[i][0] = i;
意味着空字符与其他字符串匹配的步数; - 双层循环,从上到下,从左到右。
时间复杂度:
O
(
n
×
m
)
O(n × m)
O(n×m)
空间复杂度:
O
(
n
×
m
)
O(n × m)
O(n×m)
class Solution {
public:
int minDistance(string word1, string word2) {
int len1 = word1.length();
int len2 = word2.length();
vector<vector<int>> dp(len1+1, vector<int>(len2+1, 0));
for(int i = 0; i <= len1; ++i) dp[i][0] = i; // 边界条件初始化
for(int j = 0; j <= len2; ++j) dp[0][j] = j;
for(int i = 1; i <= len1; ++i){
for(int j = 1; j <= len2; ++j){
if(word1[i-1] == word2[j-1]){
dp[i][j] = dp[i-1][j-1]; // 相等时,等于上一组数的步数
}else{
dp[i][j] = min(dp[i-1][j-1], min(dp[i][j-1], dp[i-1][j])) + 1; // 根据更小的步数来判断操作
}
}
}
return dp[len1][len2];
}
};
647. 回文子串 ●●
给你一个字符串 s ,请你统计并返回这个字符串中 回文子串 的数目。
回文字符串 是正着读和倒过来读一样的字符串。
子字符串 是字符串中的由连续字符组成的一个序列。
具有不同开始位置或结束位置的子串,即使是由相同的字符组成,也会被视作不同的子串。
–
输入:s = “aaa”
输出:6
解释:6个回文子串: “a”, “a”, “a”, “aa”, “aa”, “aaa”
1. 暴力遍历
两层 for 循环,遍历区间起始位置和终止位置,然后判断这个区间是不是回文。
时间复杂度: O ( n 3 ) O(n^3) O(n3)
class Solution {
public:
bool isValid(string s, int start, int end){ // 回文字符串 判断
for(int i = 0; i <= (end - start) / 2; ++i){
if(s[start + i] != s[end-i]) return false;
}
return true;
}
int countSubstrings(string s) {
int len = s.length();
int ans = 0;
for(int i = 0; i < len; ++i){ // 以s[i]开头的字符串判断
++ans;
for(int j = i+1; j < len; ++j){ // 遍历到末尾
if(isValid(s, i, j)) ++ans; // 回文子串判断
}
}
return ans;
}
};
2. DP
- dp[i][j] 表示 [i, j] 范围内的子串是否为回文串
- 遍历过程有三种情况:
1)只有一个字符,属于回文串
2)s[i] != s[j],非回文串
3)s[i] == s[j],判断中间部分 [i+1, j-1] 是否为回文串(只有两个字符时单独讨论) - dp 初始化为 false
- dp[i][j] 可能要根据 dp[i+1, j-1] 进行判断,因此外层起始位置 i 从后往前遍历,内层终止位置 j 从前往后遍历
时间复杂度:
O
(
n
2
)
O(n^2)
O(n2)
空间复杂度:
O
(
n
2
)
O(n^2)
O(n2)
class Solution {
public:
int countSubstrings(string s) {
int len = s.length();
int ans = 0;
vector<vector<bool>> dp(len, vector<bool>(len, false)); // dp[i][j] 表示[i, j]范围内的子串是回文串
for(int i = len-1; i >= 0; --i){ // 起始位置 从后往前遍历
dp[i][i] = true; // 单个字符
++ans;
for(int j = i+1; j < len; ++j){ // 结束位置 从前往后遍历
if(s[i] == s[j]){ // 首尾相等的前提下
if(j - i == 1){
dp[i][j] = true; // 2个字符,回文
++ans;
}else if(dp[i+1][j-1]){ // 3个字符及以上,判断中间是否回文
dp[i][j] = true;
++ans;
}
} // 其余情况则非回文,不操作
}
}
return ans;
}
};
3. 双指针(中心扩展)
枚举所有的中心点,然后用两个指针分别向左右两边拓展,当两个指针指向的元素相同的时候就拓展,否则停止拓展。
对一个字符串 ababa
,选择最中间的 a
作为中心点,往两边扩散,第一次扩散发现 left 指向的是 b
,right 指向的也是 b
,所以是回文串,继续扩散,同理 ababa 也是回文串。
如何有序地枚举所有可能的回文中心,我们需要考虑回文长度是奇数和回文长度是偶数的两种情况。如果回文长度是奇数,那么回文中心是一个字符;如果回文长度是偶数,那么中心是两个字符;所以最终的中心点有 2 * len - 1
个,分别是 len 个单字符和 len - 1 个双字符。
- 时间复杂度: O ( n 2 ) O(n^2) O(n2),枚举回文中心的是 O ( n ) O(n) O(n) 的,对于每个回文中心拓展的次数也是 O ( n ) O(n) O(n)。
- 空间复杂度: O ( 1 ) O(1) O(1)
单个字符和两个字符单独计算:
class Solution {
public:
int countSubstrings(string s) {
int ans = 0;
for(int i = 0; i < s.length(); ++i){
ans += centerCount(s, i, i); // 单个字符为中心
ans += centerCount(s, i, i+1); // 两个字符为中心
}
return ans;
}
int centerCount(string s, int left, int right){ // 中心扩展
int ans = 0;
while(left >= 0 && right < s.length() && s[left] == s[right]){
--left; // 两边扩展
++right;
++ans; // 个数 + 1
}
return ans;
}
};
字符串中心合并计算:
遍历2 * len - 1
个中心点,left = i / 2
;right = left + i %2
;
class Solution {
public:
int countSubstrings(string s) {
int ans = 0;
for(int i = 0; i < 2 * s.length() + 1; ++i){ // 遍历2 * len - 1个中心点
int left = i / 2;
int right = left + i % 2;
while(left >= 0 && right < s.length() && s[left] == s[right]){
--left;
++right;
++ans;
}
}
return ans;
}
};
516. 最长回文子序列 ●●
给你一个字符串 s ,找出其中最长的回文子序列,并返回该序列的长度。
子序列定义为:不改变剩余字符顺序的情况下,删除某些字符或者不删除任何字符形成的一个序列。
–
输入:s = “bbbab”
输出:4
解释:一个可能的最长回文子序列为 “bbbb” 。
回文子串是要连续的,回文子序列可不是连续的!
- dp[i][j] 表示 [i, j] 范围内的子串中最长回文子序列数
- 遍历过程有两种情况:
1)s[i] == s[j],则中间部分 [i+1, j-1] 回文子序列长度 + 2
dp[i][j] = dp[i+1][j-1] + 2;
2)s[i] != s[j],选择 去首 或 去尾 的子串中最长的回文子序列长度。
dp[i][j] = max(dp[i+1][j], dp[i][j-1]);
- dp 初始化为 0,dp[i][i] 为 1
- dp[i][j] 可能要根据 dp[i+1, j-1] 进行判断,因此外层起始位置 i 从后往前遍历,内层终止位置 j 从前往后遍历
时间复杂度:
O
(
n
2
)
O(n^2)
O(n2)
空间复杂度:
O
(
n
2
)
O(n^2)
O(n2)
class Solution {
public:
int longestPalindromeSubseq(string s) {
int len = s.length();
vector<vector<int>> dp(len, vector<int>(len, 0));
for(int i = len - 1; i >= 0; --i){ // 外层起始位置 i 从后往前遍历
dp[i][i] = 1;
for(int j = i+1; j < len; ++j){ // 内层终止位置 j 从前往后遍历
if(s[i] == s[j]){
dp[i][j] = dp[i+1][j-1] + 2; // 去掉首尾的回文子序列长度 + 1
}else{
dp[i][j] = max(dp[i+1][j], dp[i][j-1]); // 选择 去首 或 去尾
}
}
}
return dp[0][len-1]; // 范围整个字符串范围内的最长子序列长度
}
};
5. 最长回文子串 ●●
给你一个字符串 s,找到 s 中最长的回文子串。
–
输入:s = “babad”
输出:“bab”
解释:“aba” 同样是符合题意的答案。
动态规划思路与647. 回文子串 ●●类似;
- dp[i][j] 表示 [i, j] 范围内的子串是否为回文串
- 遍历过程有三种情况,当长度为最大时更新返回字符串 ans;
1)只有一个字符,属于回文串
2)s[i] != s[j],非回文串
3)s[i] == s[j],判断中间部分 [i+1, j-1] 是否为回文串(只有两个字符时单独讨论) - dp 初始化为 false
- dp[i][j] 可能要根据 dp[i+1, j-1] 进行判断,因此外层起始位置 i 从后往前遍历,内层终止位置 j 从前往后遍历
时间复杂度:
O
(
n
2
)
O(n^2)
O(n2)
空间复杂度:
O
(
n
2
)
O(n^2)
O(n2)
class Solution {
public:
string longestPalindrome(string s) {
int len = s.length();
vector<vector<bool>> dp(len, vector<bool>(len, false));
int maxL = 1;
string ans(1, s[0]);
for(int i = len-1; i >= 0; --i){ // 外层从后往前遍历
dp[i][i] = true; // 单个字符
for(int j = i+1; j < len; ++j){ // 内层从前往后
if(s[i] == s[j]){
if(j - i == 1){ // 两个字符
dp[i][j] = true;
if(maxL < 2){ // 判断长度
maxL = 2;
ans = string(s.begin() + i, s.begin() + j + 1);
}
}else if(dp[i+1][j-1]){ // 3个字符及以上,判断中间部分
dp[i][j] = true;
if(maxL < j - i + 1){ // 判断长度
maxL = j - i + 1;
ans = string(s.begin() + i, s.begin() + j + 1);
}
}
}
}
}
return ans;
}
};