目录
1.最长上升子序列
1.1明确一些关键词
- 上升子序列:指子序列中的元素是严格递增的。比如,
[2, 5, 7, 101]
就是一个上升子序列。- 最长上升子序列:就是在所有上升子序列中长度最长的那个。对于序列
[10, 9, 2, 5, 3, 7, 101, 18]
,其最长上升子序列是[2, 3, 7, 101]
,长度为 4。
1. 2 明确解题思路
本题使用dp的解题思路,从两个方面考虑分别是状态表示和状态计算
- 状态表示:使用一维的dp数组,dp[i]表示所有以第 i 个数结尾的上升子序列的集合中的最长序列的长度。
举个例子:[ 3,1 , 2 ,1 , 8 , 5 , 6 ]中dp[5]代表以第五个数结尾的上升子序列的集合中的最
长序列的长度。其中集合中的内容有{ [ 8 ] , [ 3, 8 ] ,[ 2, 8 ], [1, 8 ] , [ 3 , 2, 8 ] , [ 1 , 2, 8 ] } ,集合中的最长序列的长度为3,所以dp[5]为3.
- 状态计算:对应集合的划分,如何划分?以第i个数前边的数为划分依据,举个例子[ 3,1 ,2, 9,1,8,5,6 ] dp[5]以第5个数前边的数为划分依据,前边的数依次可以为3,1,2,1(注意如果前边的数大于当前的数,则前边这个数不参与划分,比如 “9” 就不参与划分)
由此可以写出对应的状态转移方程 dp[i] = max(dp[ j ] + 1 ,dp[i]) (其中j=1,2,3,4...)
1. 3代码展示
#include<iostream>
#include<vector>
#include<algorithm>
#define int long long
#define x first
#define y second
using namespace std;
const int N = 1005;
int a[N], n, dp[N];
void cal() {
for (int i = 1; i <= n; i++) {
dp[i] = 1;
}
int ma = -1e9;
for (int i = 1; i <= n; i++) { //循环遍历每一个数
for (int j = 1; j <= i - 1; j++) { //集合的划分
if (a[j] < a[i]) { //一定要注意条件!!!
dp[i] = max(dp[j] + 1, dp[i]);
}
}
ma = max(dp[i], ma);
}
cout << ma;
}
signed main()
{
cin >> n;
for (int i = 1; i <= n; i++) {
cin >> a[i];
}
cal();
return 0;
}
2.最长上升子序列优化版(贪心+二分查找)
2.1整体目标
求解一个给定数组的最长上升子序列(LIS)的长度,也就是在数组里找出一个最长的子序列,让这个子序列中的元素是严格递增的。
2.2核心思路:维护 tails
数组
在贪心 + 二分查找的方法中,我们主要依靠维护一个名为
tails
的数组来解决问题。tails
数组有着特殊的含义:tails[i]
代表长度为i + 1
的上升子序列的末尾元素的最小值。下面详细解释这个思路的原理和好处。1.
tails
数组的含义
- 长度对应关系:
tails
数组的索引i
和上升子序列的长度存在对应关系。具体来说,tails[i]
对应的是长度为i + 1
的上升子序列。例如,tails[0]
对应长度为 1 的上升子序列,tails[1]
对应长度为 2 的上升子序列,依此类推。- 末尾元素最小化:对于每种长度的上升子序列,我们只记录其末尾元素的最小值。这是贪心策略的体现,因为在后续构建更长的上升子序列时,末尾元素越小,就越有可能让更多的元素加入到这个子序列中,从而有更大的机会形成更长的上升子序列。
2. 插入元素到
tails
数组在遍历给定数组中的每个元素时,我们会尝试把这个元素插入到
tails
数组的合适位置。具体的插入规则如下:
- 大于
tails
数组所有元素:如果当前元素比tails
数组中的所有元素都大,这就意味着我们可以把这个元素添加到tails
数组的末尾,形成一个更长的上升子序列。例如,若tails = [2, 3]
,当前元素是7
,由于7
大于2
和3
,所以可以将7
加入tails
数组,得到tails = [2, 3, 7]
,这表示我们找到了一个长度为 3 的上升子序列。- 不大于
tails
数组所有元素:如果当前元素不大于tails
数组中的所有元素,我们需要使用二分查找来找到tails
数组中第一个大于等于该元素的位置,然后用这个元素替换该位置的值。这样做的目的是保证tails
数组中的元素尽可能小。例如,若tails = [2, 3, 7]
,当前元素是5
,通过二分查找会找到7
的位置,然后用5
替换7
,得到tails = [2, 3, 5]
。虽然此时tails
数组的长度没有改变,但长度为 3 的上升子序列的末尾元素变小了,这有利于后续形成更长的上升子序列。
2.3举例说明
假设给定数组
nums = [10, 9, 2, 5, 3, 7, 101, 18]
,下面展示tails
数组在遍历过程中的变化:
- 处理
10
:tails = [10]
- 处理
9
:tails = [9]
(因为9
小于10
,用9
替换10
)- 处理
2
:tails = [2]
(用2
替换9
)- 处理
5
:tails = [2, 5]
(5
大于2
,添加到末尾)- 处理
3
:tails = [2, 3]
(用3
替换5
)- 处理
7
:tails = [2, 3, 7]
(7
大于2
和3
,添加到末尾)- 处理
101
:tails = [2, 3, 7, 101]
(101
大于2
、3
和7
,添加到末尾)- 处理
18
:tails = [2, 3, 7, 18]
(用18
替换101
)最终,
tails
数组的长度为 4,所以最长上升子序列的长度为 4。通过维护
tails
数组并使用二分查找插入元素,我们可以在 \(O(n log n)\) 的时间复杂度内求解最长上升子序列的长度,相比于朴素的动态规划方法(时间复杂度为 \(O(n^2)\)),效率有了显著提升。
2.4代码展示
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std; // 添加命名空间声明
// 贪心 + 二分查找求解最长上升子序列的长度
int lengthOfLIS(const vector<int>& nums) {
int n = nums.size();
if (n == 0) return 0;
vector<int> tails;
for (int num : nums) {
// 二分查找第一个大于等于 num 的位置
auto it = lower_bound(tails.begin(), tails.end(), num);
if (it == tails.end()) {
// 如果 num 大于 tails 数组中的所有元素,将 num 添加到 tails 数组末尾
tails.push_back(num);
} else {
// 用 num 替换第一个大于等于 num 的元素
*it = num;
}
}
// tails 数组的长度即为最长上升子序列的长度
return tails.size();
}
int main() {
vector<int> nums = {10, 9, 2, 5, 3, 7, 101, 18};
int result = lengthOfLIS(nums);
cout << "最长上升子序列的长度为: " << result << endl; // 使用 cout 代替 std::cout
return 0;
}
3.补充:c++二分查找
在 C++ 里,二分查找是一种高效的查找算法,它的时间复杂度为 \(O(log n)\),前提是数据必须是有序的。
3.1 std::lower_bound
该函数用于在指定的有序区间内查找第一个大于等于给定值的元素的迭代器。如果所有元素都小于给定值,就返回区间的末尾迭代器。
#include <iostream>
#include <vector>
#include <algorithm>
int main() {
std::vector<int> vec = {1, 3, 5, 7, 9}; //按照升序排列
int target = 4;
auto it = std::lower_bound(vec.begin(), vec.end(), target);
if (it != vec.end()) {
std::cout << "第一个大于等于 " << target << " 的元素是 " << *it << std::endl;
} else {
std::cout << "没有找到大于等于 " << target << " 的元素" << std::endl;
}
return 0;
}
3.2 std::upper_bound
此函数用于在指定的有序区间内查找第一个大于给定值的元素的迭代器。要是所有元素都小于等于给定值,就返回区间的末尾迭代器。
#include <iostream>
#include <vector>
#include <algorithm>
int main() {
std::vector<int> vec = {1, 3, 5, 7, 9};
int target = 5;
auto it = std::upper_bound(vec.begin(), vec.end(), target);
if (it != vec.end()) {
std::cout << "第一个大于 " << target << " 的元素是 " << *it << std::endl;
} else {
std::cout << "没有找到大于 " << target << " 的元素" << std::endl;
}
return 0;
}
3.3 std::equal_range
这个函数会返回一个包含两个迭代器的
std::pair
,其中第一个迭代器是std::lower_bound
的结果,第二个迭代器是std::upper_bound
的结果。它可以用于查找所有等于给定值的元素范围。
注意:
std::equal_range
返回一个std::pair
,其中:
pair.first
:是一个迭代器,指向区间[first, last)
中第一个大于等于value
的元素,等同于std::lower_bound(first, last, value)
的返回值。pair.second
:是一个迭代器,指向区间[first, last)
中第一个大于value
的元素,等同于std::upper_bound(first, last, value)
的返回值。
#include <iostream>
#include <vector>
#include <algorithm>
int main() {
std::vector<int> vec = {1, 3, 3, 5, 7, 9};
int target = 3;
auto range = std::equal_range(vec.begin(), vec.end(), target);
if (range.first != range.second) {
std::cout << "等于 " << target << " 的元素范围是从 " << *range.first << " 到 " << *(range.second - 1) << std::endl;
} else {
std::cout << "没有找到等于 " << target << " 的元素" << std::endl;
}
return 0;
}
4.最长公共子序列
4.1明确一些关键词
- 子序列:直接上例子-->对于序列 X = [1, 3, 4, 5, 6, 7, 7, 8],[1, 4, 8]就是它的一个子序列。
- 公共子序列:例如,若 X = [1, 3, 4, 5, 6, 7, 7, 8],Y = [3, 5, 7, 4, 8, 6, 7, 8, 2],那么 [3, 5, 7]、[3, 5, 8]等都是 X 和 Y 的公共子序列。
- 最长公共子序列:在 X 和 Y 的所有公共子序列中,长度最长的那个公共子序列就被称为 X 和 Y 的最长公共子序列。对于上面的 X 和 Y,它们的最长公共子序列是 [3, 5, 7, 8]。
4.2题目描述
4.3 解题思路(状态表示+状态计算)
- 状态表示:dp[ i ][ j ] 用二维来表示.
集合中的内容:所有在第一序列的前 i 个字符中出现,在第二个序列前 j 个字符中出现的子序列。
dp[ i ][ j ]的含义:集合中所有元素的长度最大值。
🌰例子:[ a , b , c , d , e ] 和 [ c , b , f , d , e ] ,dp[ 4 ][ 5 ]就是集合{ [ b ] , [ d ] , [ b , d ] }中所有元素长度的最大值,即2.
- 状态计算:
设第一序列为X,第二序列为Y。
🌻若 X[ i ] == Y[ j ] ,则 dp[ i ][ j ] = dp[ i - 1 ][ j - 1 ] + 1。这意味着当前元素相等时,最长公共子序列长度在之前的基础上增加 1。
🌻若 X[ i ] != Y[ j ],则 dp[ i ][ j ] = max(dp[ i - 1 ][ j ], dp[ i ][ j - 1] )。即取 X 的前 i - 1个元素和 Y 的前 j 个元素的最长公共子序列长度,与 X 的前 i 个元素和 Y 的前 j - 1 个元素的最长公共子序列长度中的较大值。
🎉🎉🎉通俗的解释第二个状态转移方程:
我们先设定两个序列
X
和Y
,假设X
是你喜欢的一系列电影,Y
是你的朋友喜欢的一系列电影。我们要找出你们两人都喜欢的电影中,能按顺序排列出的最长的电影序列,这就是最长公共子序列的问题。
dp[i][j]
表示当考虑X
序列的前i
个电影和Y
序列的前j
个电影时,你们两人能共同喜欢且按顺序的最长电影序列的长度。现在,如果
X
序列的第i
个电影(X[i]
)和Y
序列的第j
个电影(Y[j]
)不是同一部电影(X[i] != Y[j]
),那该怎么办呢?
dp[i - 1][j]
就好比是你少考虑一部自己喜欢的电影(即不考虑X
序列的第i
个电影),只看前i - 1
个电影,和你朋友的前j
个喜欢的电影,找出能共同喜欢且按顺序的最长电影序列的长度。dp[i][j - 1]
则像是你朋友少考虑一部他喜欢的电影(即不考虑Y
序列的第j
个电影),你看自己的前i
个喜欢的电影,和你朋友的前j - 1
个喜欢的电影,找出能共同喜欢且按顺序的最长电影序列的长度。因为现在第
i
个电影和第j
个电影不一样,我们不能直接把它们加到共同喜欢的电影序列里,所以只能在上述两种情况中选一个长度更长的,作为当前dp[i][j]
的值。也就是说,dp[i][j]
要取dp[i - 1][j]
和dp[i][j - 1]
这两个长度中的较大值,这样才能保证我们得到的是在这种情况下两人共同喜欢且按顺序的最长电影序列的长度
4.4 代码展示
#include<iostream>
#include<vector>
#include<algorithm>
#define int long long
#define x first
#define y second
using namespace std;
const int N = 1005;
int n, m;
string x, y;
int dp[N][N];
void cal() {
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) {
if (x[i - 1] == y[j - 1]) {
dp[i][j] = dp[i - 1][j - 1] + 1;
}
else {
dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]);
}
}
}
cout << dp[n][m];
}
signed main() {
cin >> n >> m;
cin >> x;
cin >> y;
cal();
return 0;
}
4.4拓展应用
最长公共子序列(LCS)的思想在许多经典算法题目和实际应用场景中都有广泛运用,以下为你详细介绍一些典型的题目:
1. 编辑距离(Levenshtein 距离)
- 题目描述:给定两个字符串
str1
和str2
,可以对一个字符串进行三种操作:插入一个字符、删除一个字符、替换一个字符,求将str1
转换成str2
所需的最少操作次数。- LCS 思想的运用:编辑距离问题可以通过动态规划解决,其思路与 LCS 相似。我们可以构建一个二维数组
dp
,其中dp[i][j]
表示将str1
的前i
个字符转换为str2
的前j
个字符所需的最少操作次数。当str1[i-1]
等于str2[j-1]
时,dp[i][j]
等于dp[i-1][j-1]
;否则,需要考虑插入、删除、替换三种操作,取其中的最小值加 1。实际上,LCS 中的公共部分可以减少转换所需的操作次数,两者的状态转移方程有一定的关联。2. 两个字符串的删除操作
- 题目描述:给定两个单词
word1
和word2
,找到使得word1
和word2
相同所需的最小步数,每步可以删除任意一个字符串中的一个字符。- LCS 思想的运用:该问题的关键在于找到两个字符串的最长公共子序列。因为删除操作的目的是让两个字符串变得相同,而最长公共子序列就是两个字符串中不需要删除的部分。所以,所需的最小删除步数等于两个字符串的长度之和减去它们的最长公共子序列长度的两倍。
3. 最长回文子序列
- 题目描述:给定一个字符串
s
,找出其中最长的回文子序列的长度。回文子序列是指正序和倒序读都是一样的子序列。- LCS 思想的运用:可以将该问题转化为 LCS 问题。将字符串
s
反转得到s'
,那么s
和s'
的最长公共子序列就是s
的最长回文子序列。因为回文序列正读和反读相同,所以s
和其反转字符串的公共部分必然是回文的。4. 最大子数组和的公共子数组
- 题目描述:给定两个整数数组
A
和B
,返回两个数组中公共的、长度最长的子数组的长度。- LCS 思想的运用:与 LCS 类似,使用动态规划来解决。定义一个二维数组
dp
,其中dp[i][j]
表示以A[i-1]
和B[j-1]
结尾的公共子数组的长度。当A[i-1]
等于B[j-1]
时,dp[i][j]
等于dp[i-1][j-1] + 1
;否则,dp[i][j]
为 0。通过遍历两个数组,不断更新dp
数组,最终得到最长公共子数组的长度。5. DNA 序列比对
- 题目描述:在生物信息学中,比较两个 DNA 序列的相似性,通常需要找出它们的最长公共子序列。
- LCS 思想的运用:DNA 序列由四种碱基(A、T、C、G)组成,将两个 DNA 序列看作两个字符串,使用 LCS 算法可以找出它们的最长公共部分,从而推断两个物种之间的进化关系、基因的相似性等。
这些题目都利用了 LCS 的动态规划思想,通过构建二维数组来保存子问题的解,从而逐步求解出最终的答案。
5.最大子段和
5.1题目描述
5.2代码展示
#include<iostream>
#include<vector>
#include<algorithm>
#include<cmath>
#include<map>
#include<queue>
#include<math.h>
#include<string>
#include <set>
#include<stack>
#define int long long
using namespace std;
int n;
int a[1000], dp[1000]; // dp[i]表示以第i个数字结尾的最大子段和
void cal() {
// 初始化全局最大值为负无穷,确保任何非负子段和都能更新它
int ma = -1e9;
// 动态规划计算以每个位置结尾的最大子段和
for (int i = 1; i <= n; i++) {
// 状态转移方程:
// 1. 延续前一个子段:dp[i-1] + a[i]
// 2. 以当前元素为起点开始新子段:a[i]
// 由于未初始化dp数组,可能存在随机值,因此取a[i]和dp[i-1]+a[i]的较大值
dp[i] = max(a[i], dp[i - 1] + a[i]);
// 更新全局最大子段和
ma = max(ma, dp[i]);
}
// 输出结果
cout << ma << endl;
}
signed main() {
cin >> n;
for (int i = 1; i <= n; i++) {
cin >> a[i];
}
cal();
return 0;
}