动态规划学习之子序列系列(线性DP)

目录

1.最长上升子序列

1.1明确一些关键词

1. 2 明确解题思路

1. 3代码展示

2.最长上升子序列优化版(贪心+二分查找)

2.1整体目标

2.2核心思路:维护 tails 数组

1. tails 数组的含义

2. 插入元素到 tails 数组

2.3举例说明

 2.4代码展示

3.补充:c++二分查找 

3.1 std::lower_bound

3.2 std::upper_bound

3.3 std::equal_range

4.最长公共子序列

4.1明确一些关键词

4.2题目描述

4.3 解题思路(状态表示+状态计算)

4.4 代码展示

4.4拓展应用

1. 编辑距离(Levenshtein 距离)

2. 两个字符串的删除操作

3. 最长回文子序列

4. 最大子数组和的公共子数组

5. DNA 序列比对

5.最大子段和 

5.1题目描述

5.2代码展示


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 数组在遍历过程中的变化:

 
  1. 处理 10tails = [10]
  2. 处理 9tails = [9](因为 9 小于 10,用 9 替换 10
  3. 处理 2tails = [2](用 2 替换 9
  4. 处理 5tails = [2, 5]5 大于 2,添加到末尾)
  5. 处理 3tails = [2, 3](用 3 替换 5
  6. 处理 7tails = [2, 3, 7]7 大于 2 和 3,添加到末尾)
  7. 处理 101tails = [2, 3, 7, 101]101 大于 23 和 7,添加到末尾)
  8. 处理 18tails = [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;
}

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值