动态规划——线性DP

动态规划——线性DP

最长不下降序列(LIS)

暴力搜索:由可行的所有起点出发,搜索出所有的路径。

在这里插入图片描述

但是深搜的算法时间复杂度要达到 O ( 2 n ) O(2^n) O(2n) (每个数都有选或不选的两个选择),指数级的时间复杂度在本题中( n ≤ 100 n≤100 n100)显然是不能接受的。那么再观察这个这棵递归树,可以发现其中有很多重复的地方。

在这里插入图片描述

那么如何优化呢?

首先可以使用数组将重复的部分记录下来,此后遇到相同的状态直接引用已经记录在数组中的数据即可,这样的方法叫做记忆化搜索,也叫剪枝(后面我们再细讲)。

所以,如果按照上面的思路将需要计算的部分用数组记录,那么就可以省略那些重复的部分,所以最终我们需要计算的就只剩下以从 1 1 1 n n n 的每个点为起点的最长不下降序列。

以序列 1 , 5 , 2 , 4 , 3 1, 5, 2, 4, 3 1,5,2,4,3 为例:

首先将以每个点为起点的所有长度都初始化为 1 1 1,所有下一步都初始化为 0 0 0

起点下标起点数值最长不下降序列的长度最长不下降序列的起点的下一步的下标
5 5 5 3 3 3 l e n ( 3 ) = 1 len(3)=1 len(3)=1 0 0 0
4 4 4 4 4 4 l e n ( 4 ) = 1 len(4)=1 len(4)=1 0 0 0
3 3 3 2 2 2 l e n ( 2 ) = m a x ( l e n ( 4 ) , l e n ( 3 ) ) + 1 = 2 len(2)=max(len(4), len(3))+1=2 len(2)=max(len(4),len(3))+1=2 4 / 5 4/5 4/5
2 2 2 5 5 5 l e n ( 5 ) = 1 len(5)=1 len(5)=1 0 0 0
1 1 1 1 1 1 l e n ( 1 ) = m a x ( l e n ( 5 ) , l e n ( 2 ) , l e n ( 4 ) , l e n ( 3 ) ) + 1 = 3 len(1)=max(len(5), len(2), len(4), len(3))+1=3 len(1)=max(len(5),len(2),len(4),len(3))+1=3 3 3 3

综上,算法分析

根据动态规划的原理,由后往前进行搜索(当然从前往后也一样)。

  1. b ( n ) b(n) b(n) 来说,由于它是最后一个数,所以当从 b ( n ) b(n) b(n) 开始查找时,只存在长度为 1 1 1 的不下降序列;

  2. 若从 b ( n − 1 ) b(n-1) b(n1) 开始查找,则存在下面的两种可能性:

    ①若 b ( n − 1 ) < b ( n ) b(n-1)<b(n) b(n1)<b(n) ,则存在长度为 2 2 2 的不下降序列 b ( n − 1 ) b(n-1) b(n1) b ( n ) b(n) b(n)

    ②若 b ( n − 1 ) > b ( n ) b(n-1)>b(n) b(n1)>b(n) ,则存在长度为 1 1 1 的不下降序列 b ( n − 1 ) b(n-1) b(n1) b ( n ) b(n) b(n)

  3. 一般若从 b ( i ) b(i) b(i) 开始,此时最长不下降序列应该按下列方法求出:

    b ( i + 1 ) , b ( i + 2 ) , … , b ( n ) b(i+1),b(i+2),…,b(n) b(i+1),b(i+2),,b(n) 中,找出一个比 b ( i ) b(i) b(i) 大的且最长的不下降序列,作为它的后继。

数据结构

为算法上的需要,定义一个整数类型二维数组 b ( N , 3 ) b(N,3) b(N,3)

  1. b ( i , 1 ) b(i,1) b(i,1) 表示第 i i i 个数的数值本身;
  2. b ( i , 2 ) b(i,2) b(i,2) 表示从 i i i 位置到达 N N N 的最长不下降序列长度;
  3. b ( i , 3 ) b(i,3) b(i,3) 表示从 i i i 位置开始最长不下降序列的下一个位置,若 b [ i , 3 ] = 0 b[i,3]=0 b[i,3]=0 则表示后面没有连接项。
#include <iostream>
using namespace std;
int b[107][7];
int main()
{
    int n=0;
    while(cin>>b[++n][1]) //b[i][1]表示第i个数本身
    {
        b[n][2]=1; //b[i][2]表示以第i个数为起点的最长不下降序列的长度
        b[n][3]=0; //b[i][3]表示以第i个数为起点的最长不下降序列的起点下一步
    }
    int start=0;
    for(int i=n-1; i; --i)
    {
        int len=0, next=0; //用来记录以当前第i个点为起点的最长不下降序列的长度和下一步的下标
        for(int j=i+1; j<=n; ++j)
            if(b[i][1]<b[j][1] && b[j][2]>len) //满足这样的两个条件才能
                len=b[j][2], next=j;
        if(len) b[i][2]=len+1, b[i][3]=next;
        if(b[i][2]>b[start][2]) start=i; //找到长度最大的不下降序列,并记录当前序列的起点
    }
    cout<<b[start][2]<<endl;
    while(start)
    {
        cout<<b[start][1]<<" ";
        start=b[start][3];
    }
    return 0;
}

最长上升序列(LIS)模板:

简化题意:求一个序列的不下降序列的最大长度。

代码 1 1 1(枚举起点)

//枚举起点(从后往前枚举)
#include <iostream>
using namespace std;
const int N=1e3+7;
int a[N], f[N]; //f[i]表示以第i个数为起点的最长不下降序列的长度
int main()
{
    int n;
    cin>>n;
    for(int i=1; i<=n; ++i)
        cin>>a[i], f[i]=1; //将所有值初始化为1
    int ans=1;
    for(int i=n-1; i; --i) //从后往前枚举
    {
        for(int j=i+1; j<=n; ++j)
            if(a[i]<a[j] && f[j]+1>f[i])
                f[i]=f[j]+1;
        ans=max(ans, f[i]);
    }
    cout<<ans;
    return 0;
}

代码 2 2 2(枚举终点)

//枚举终点(从前往后枚举)
#include <iostream>
using namespace std;
const int N=1e3+7;
int a[N], f[N]; //f[i]表示以第i个数为终点的最长不下降序列的长度
int main()
{
    int n;
    cin>>n;
    for(int i=1; i<=n; ++i)
        cin>>a[i], f[i]=1;
    int ans=1;
    for(int i=2; i<=n; ++i) //从前往后枚举
    {
        for(int j=1; j<i; ++j)
            if(a[j]<a[i] && f[j]+1>f[i])
                f[i]=f[j]+1;
        ans=max(ans, f[i]);
    }
    cout<<ans;
    return 0;
}

模板优化

上述代码的时间复杂度为 O ( n 2 ) O(n^2) O(n2) ,所以如果把数据范围改为 n ≤ 100000 n≤100000 n100000 也会出现超时的情况,所以在这里对于求最长不下降序列长度的问题还可以进一步优化。在这里对上述代码 2 2 2 的思路进一步思考。(优化的过程其实就是一个去除冗余的过程。)

用以下样例作为引子:

Input:
7
3 1 2 1 8 5 6
Output:
4 

枚举每一个点作为终点,从第一个数 3 3 3 开始, 3 3 3 可以作为一个长度为 1 1 1 的最长不下降序列,接着到第二个数 1 1 1,也是一个长度为 1 1 1 的长度为 1 1 1 的最长不下降序列……,对于后面的每个数,如果它可以接到 3 3 3 的后面,那么它一定可以接到 1 1 1 的后面,所以以 3 3 3 为结尾长度为 1 1 1 的最长不下降序列就没有必要存下来了,因为 1 1 1 3 3 3 更优(后面可以接的数范围更广,即更小的数值作为结尾更优)。所以我们也没有必要将所有长度为 1 1 1 的序列都存下来,只需要每次存下那个相同长度下最优的情况的结尾数值即可。

那么对于当前的不下降子序列的长度 i i i,一定有一个结尾值 f [ i ] f[i] f[i],则有

结论:对于不同长度的序列的结尾数值一定是随着序列长度的增加而严格单调递增的。

证明(反证法):

假设存在长度为 i i i 的序列结尾数值 f [ i ] f[i] f[i] 小于或等于长度为 i − 1 i-1 i1 的序列结尾数值 f [ i − 1 ] f[i-1] f[i1] ,即 f [ i ] ≤ f [ i − 1 ] f[i]≤f[i-1] f[i]f[i1]

由于每个序列都是一个不下降序列,所以当前长度为 i i i 的序列的倒数第二个数 x x x 一定满足关系: x ≤ f [ i ] ≤ f [ i − 1 ] x≤f[i]≤f[i-1] xf[i]f[i1]。但若如此,在前面遍历的时候,对于长度为 i − 1 i-1 i1 的序列的结尾更优的结果应该选择的是比当前 f [ i − 1 ] f[i-1] f[i1] 更小的 x x x,所以这与我们最终选择的 f [ i − 1 ] f[i-1] f[i1] 相悖。

所以假设(存在长度为 i i i 的序列结尾数值 f [ i ] f[i] f[i] 小于或等于长度为 i − 1 i-1 i1 的序列结尾数值 f [ i − 1 ] f[i-1] f[i1] ,即 f [ i ] ≤ f [ i − 1 ] f[i]≤f[i-1] f[i]f[i1])不成立。

所以对于不同长度的序列的结尾数值一定是随着序列长度的增加而严格单调递增的结论成立。

在这里插入图片描述

有了这样的前提,如果想求以 a [ i ] a[i] a[i] 结尾的最长不下降子序列的长度应该如何解决呢?

届时,只需要找到一个最大的比 a [ i ] a[i] a[i] 小的数 f [ j ] f[j] f[j],并将 a [ i ] a[i] a[i] 接到其后面即可,这样所得到的以 a [ i ] a[i] a[i] 为结尾的最长不下降子序列的长度就是 j + 1 j+1 j+1。这时还需要更新一下, f [ j + 1 ] f[j+1] f[j+1],因为长度为 j + 1 j+1 j+1 的序列的结尾 a [ i ] a[i] a[i] 一定是小于先前的长度为 j + 1 j+1 j+1 的序列的结尾数的。

那么这个过程中如何找到这个最大的比 a [ i ] a[i] a[i] 小的数呢?肯定不能选择逐个遍历这样的方法的,因为这样的时间复杂度就又回到了优化前的 O ( n 2 ) O(n^2) O(n2)

这时就用到前面的结论了:对于不同长度的序列的结尾数值一定是随着序列长度的增加而严格单调递增的结论成立。既然是严格单调上升的序列,寻找其中一个数,我们就可以想到使用二分法。

简单介绍二分法(具体我们后面会在学习分治思想的时候继续讲):二分查找也称折半查找,顾名思义,就是每次查找去掉不符合条件的一半区间,直到找到答案(整数二分)或者和答案十分接近(浮点二分)。

#include <iostream>
using namespace std;
const int N=1e5+7;
int a[N], f[N]; //f[i]表示长度为i的最长不下降子序列的最后一个值
int main()
{
    int n;
    cin>>n;
    for(int i=1; i<=n; ++i) cin>>a[i];
    int len=0;
    f[1]=-2e9;
    for(int i=1; i<=n; ++i)
    {
        int l=0, r=len;
        while(l<r)
        {
            int mid=l+r+1>>1;
            if(f[mid]<a[i]) l=mid;
            else r=mid-1;
        }
        len=max(len, r+1);
        f[r+1]=a[i];
    }
    cout<<len;
    return 0;
}

最长公共子序列(LCS)

状态表示:

f [ i ] [ j ] f[i][j] f[i][j] 表示 s 1 s1 s1 的前 i i i 个字符与 s 2 s2 s2 的前 j j j 个字符的最大公共子序列的长度了

设字符串 s 1 s1 s1 s 2 s2 s2 的长度分别为 l e n 1 len1 len1 l e n 2 len2 len2,则 s 1 s1 s1 s 2 s2 s2 的最大公共子序列的长度为 f [ l e n 1 ] [ l e n 2 ] f[len1][len2] f[len1][len2]

状态初始化:

f [ l e n 1 ] [ 0 ] = 0 f[len1][0]=0 f[len1][0]=0 f [ 0 ] [ l e n 2 ] = 0 f[0][len2]=0 f[0][len2]=0

状态转移:(分为三种情况讨论)

s 1 [ i ] s1[i] s1[i] 不在公共子序列中:该情况下 f [ i ] [ j ] = f [ i − 1 ] [ j ] f[i][j]=f[i-1][j] f[i][j]=f[i1][j]

s 2 [ j ] s2[j] s2[j] 不在公共子序列中:该情况下 f [ i ] [ j ] = f [ i ] [ j − 1 ] f[i][j]=f[i][j-1] f[i][j]=f[i][j1]

s 1 [ i ] s1[i] s1[i] s 2 [ j ] s2[j] s2[j] 都在公共子序列中(且 s 1 [ i ] = s 2 [ j ] s1[i]=s2[j] s1[i]=s2[j]):该情况下 f [ i ] [ j ] − f [ i − 1 ] [ j − 1 ] + 1 f[i][j]-f[i-1][j-1]+1 f[i][j]f[i1][j1]+1

f [ i ] [ j ] f[i][j] f[i][j] 取上述三种情况的最大值(第三种情况要求 s 1 [ i ] = s 2 [ j ] s1[i]=s2[j] s1[i]=s2[j]

s 1 = B D C A B A s1=BDCABA s1=BDCABA s 2 = A B C B D A B s2=ABCBDAB s2=ABCBDAB 两个字符串为例:

ABCBDAB
s 1 s1 s1\ s 2 s2 s2
00000000
B00111111
D00111222
C00122222
A01122233
B01223334
A01223344

代码

#include <iostream>
#include <cstring>
using namespace std;
const int N=207;
char s1[N], s2[N];
int f[N][N];
int main()
{
	scanf("%s%s", s1+1, s2+1); //注意将下标为0的位置空出来,避免后面的状态
	for(int i=1; s1[i]; ++i)
		for(int j=1; s2[j]; ++j)
			if(s1[i]==s2[j]) f[i][j]=f[i-1][j-1]+1;
			else f[i][j]=max(f[i][j-1], f[i-1][j]);
	int len1=strlen(s1+1), len2=strlen(s2+1);
	cout<<f[len1][len2];
	return 0;
}

最长公共上升子序列(LCIS)

在这里插入图片描述

朴素

首先按照上述思路用代码实现出来:

#include <iostream>
using namespace std;
const int N=3e3+7;
int a[N], b[N], f[N][N];
//f[i][j]表示所有在a[1~i]和b[1~j]中都出现过
//且以b[j]结尾的公共上升子序列的集合
int main()
{
	int n;
	cin>>n;
	for(int i=1; i<=n; ++i) cin>>a[i];
	for(int i=1; i<=n; ++i) cin>>b[i];
	for(int i=1; i<=n; ++i)
		for(int j=1; j<=n; ++j)
		{
			f[i][j]=f[i-1][j]; //a[i]!=b[j](以b[j]结尾且不包含a[i])
			if(a[i]==b[j])
			{
				int mx=0;
				for(int k=1; k<j; ++k) //遍历找出b[j]前一个是由哪个状态转移过来的
					if(b[k]<b[j]) //满足“上升”条件
						mx=max(f[i-1][k]+1, mx); //加上的1就是当前的b[j]
				f[i][j]=max(f[i][j], mx);
			}
		}
	int ans=0;
	for(int i=1; i<=n; ++i)
		ans=max(f[n][i], ans);
	cout<<ans;
	return 0;
}

优化

上述代码一个明显的弊端在于它的三层循环,时间复杂度达到了 O ( n 3 ) O(n^3) O(n3)。那么如何优化呢?显然可以从最内层的循环下手。

for(int k=1; k<j; ++k)
	if(b[k]<b[j])
		mx=max(f[i-1][k]+1, mx);

这里有一个小细节的处理:当前循环是在 a [ i ] = = b [ j ] a[i]==b[j] a[i]==b[j] 的条件下的,所以循环中的判断条件 b [ k ] < b [ j ] b[k]<b[j] b[k]<b[j] 也可以改成 b [ k ] < a [ i ] b[k]<a[i] b[k]<a[i],即:

for(int k=1; k<j; ++k)
	if(b[k]<a[i])
		mx=max(f[i-1][k]+1, mx);

思考:该循环的目的是什么呢?

每个 b [ k ] b[k] b[k] 都对应一个状态 f [ i − 1 , k ] f[i-1,k] f[i1,k]。那么当前循环相当于是在 b [ 1 ] , b [ 2 ] , b [ 3 ] , . . . , b [ j − 1 ] b[1], b[2], b[3],...,b[j-1] b[1],b[2],b[3],...,b[j1] 中找出所有小于 a [ i ] a[i] a[i] 的数,并在比较他们分别对应的 f [ i − 1 , k ] f[i-1,k] f[i1,k] 之后选出一个最大值,作为当前状态的上一个状态。这些状态的大小都是固定不变的,所以在当前一轮中我们比较了 f [ i − 1 , 1 ] , f [ i − 1 , 2 ] , f [ i − 1 , 3 ] , . . . , f [ i − 1 , j − 1 ] f[i-1, 1], f[i-1, 2], f[i-1, 3],..., f[i-1, j-1] f[i1,1],f[i1,2],f[i1,3],...,f[i1,j1] 之后,在下一轮比较 f [ i − 1 , 1 ] , f [ i − 1 , 2 ] , f [ i − 1 , 3 ] , . . . , f [ i − 1 , j − 1 ] , f [ i − 1 ] [ j ] f[i-1, 1], f[i-1, 2], f[i-1, 3],...,f[i-1, j-1], f[i-1][j] f[i1,1],f[i1,2],f[i1,3],...,f[i1,j1],f[i1][j] 的时候又将前面的这些拿过来重复对比了一次,所以这里多了很多重复的部分。那么想要将这层循环优化掉,则可以直接比较当前 b [ j ] b[j] b[j] a [ i ] a[i] a[i] 即可:只有 a [ i ] = b [ j ] a[i]=b[j] a[i]=b[j] 的时候才更新当前的 f [ i ] [ j ] f[i][j] f[i][j],只有当 b [ j ] < a [ i ] b[j]<a[i] b[j]<a[i] 的时候我们才要去更新前缀的最大值。

如图示:当前被✔的所有数,在之后比较中还会被再拿出来进行比较。

在这里插入图片描述

#include <iostream>
using namespace std;
const int N=3e3+7;
int a[N], b[N], f[N][N];
int main()
{
	int n;
	cin>>n;
	for(int i=1; i<=n; ++i) cin>>a[i];
	for(int i=1; i<=n; ++i) cin>>b[i];
	for(int i=1; i<=n; ++i)
	{
		int mx=1;
		for(int j=1; j<=n; ++j)
		{
			f[i][j]=f[i-1][j];
			if(a[i]==b[j]) f[i][j]=max(mx, f[i][j]); //更新f[i][j]这个状态值
			else if(b[j]<a[i]) mx=max(mx, f[i-1][j]+1); //更新前缀的最大值
		}
	}
	int ans=0;
	for(int i=1; i<=n; ++i)
		ans=max(f[n][i], ans);
	cout<<ans;
	return 0;
}

再优化

将状态数组由二维压缩成一维:

#include <iostream>
using namespace std;
const int N=3e3+7;
int a[N], b[N], f[N];
int main()
{
	int n;
	cin>>n;
	for(int i=1; i<=n; ++i) cin>>a[i];
	for(int i=1; i<=n; ++i) cin>>b[i];
	for(int i=1; i<=n; ++i)
	{
		int mx=1;
		for(int j=1; j<=n; ++j)
			if(a[i]==b[j])
				f[j]=max(mx, f[j]);
			else if(b[j]<a[i])
				mx=max(mx, f[j]+1);
	}
	int ans=0;
	for(int i=1; i<=n; ++i)
		ans=max(f[i], ans);
	cout<<ans;
	return 0;
}

拦截导弹(missile)

该问题的第一问是要求一个最长不上升序列的长度,典型的LIS问题。

第二问用贪心的思路做:

贪心流程:从前往后扫描每个数,对于每个数:

  • 情况 1 1 1:如果现有的子序列的结尾都小于当前数,则创建新的子序列
  • 情况 2 2 2:将当前数放到结尾大于等于它的最小子序列后面

A A A 为贪心算法得到的序列个数; B B B 表示最优解;如果能证明 A ≥ B A≥B AB 并且 A ≤ B A≤B AB,那么就可以得到 A = B A=B A=B

因为最优解一定是最少的答案,所以贪心算法的结果一定大于等于最优解,即 A ≥ B A≥B AB

那么接下来如何证明 A ≤ B A≤B AB 呢?可以使用调整法:假设最优解对应的方案数与贪心算法算出当前的方案不同。

找到第一个不同的数:假设当前贪心算法找到的方案应该将当前的 x x x 接到 a a a 的后面,即 a a a 是大于等于 x x x 的最小子序列的结尾;而最优解这个时候是将 x x x 接到 b b b 的后面,由于 a a a 是大于等于 x x x 的最小数,所以一定有 b ≥ a b≥a ba,那接下来两种结果引出来的序列,是可以进行交换的(因为 x ≤ a ≤ b x≤a≤b xab,所以 x x x 可以接在 a a a 后面,也可以接在 b b b 后面),调换后的结果并不影响最终的序列个数。
在这里插入图片描述

所以当前方案即使不同,贪心算法最终算出来的方案数也是与最优解的方案数是相同的。所以当前贪心算法就是最优算法。

代码 1 1 1

#include <iostream>
using namespace std;
const int N=1e3+7;
int n, a[N], f[N], g[N];
int main()
{
    while(cin>>a[++n]);
    int res=0;
    for(int i=1; i<=n; ++i)
    {
        for(int j=1; j<i; ++j)
            if(a[j]>=a[i])
                f[i]=max(f[i], f[j]+1);
        res=max(res, f[i]);
    }
    cout<<res<<endl;
    int cnt=0;
    for(int i=1; i<=n; ++i)
    {
        int k=0;
        while(k<cnt && g[k]<a[i]) k++;
        g[k]=a[i];
        if(k>=cnt) cnt++;
    }
    cout<<cnt<<endl;
    return 0;
}

对于一个序列,最少用到的可将其覆盖的非上升子序列的个数与最大上升子序列的长度是相同的,这两个问题是一个对偶问题(离散数学中的反链定理Dilworth定理)。

所采用的做法完全相同 = > => => 最终求得的结果完全相同 = > => => 解决的问题完全相同

代码 2 2 2

#include <iostream>
using namespace std;
const int N=1e3+7;
int a[N], f1[N], f2[N];
int main()
{
    int n=0, ans1=0, ans2=0;
    while(scanf("%d", &a[++n])!=EOF) f2[n]=1;
    for(int i=1; i<=n; i++)
    {
        for(int j=1; j<i; j++)
        {
            if(a[j]>=a[i]) f1[i]=max(f1[i], f1[j]+1);
            else f2[i]=max(f2[i], f2[j]+1);
        }
        ans1=max(ans1, f1[i]);
        ans2=max(ans2, f2[i]);
    }
    printf("%d\n%d", ans1, ans2);
    return 0;
}
  • 25
    点赞
  • 28
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值