算法分析与设计——动态规划

版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
本文链接: https://blog.csdn.net/qq_40438165/article/details/101074511

\quad 动态规划是将一个问题分解为若干不重合子问题,通过求解每个子问题的解来得到问题的解。

问题一、带权区间调度问题

\quad 给定n个区间,每个区间包含起始时间、结束时间和权重,目的是找出若干个不重合的区间,使得这些区间权重和最大。不带权重的区间调度问题我们可以用贪心算法求解,即将区间按照结束时间从小到大排序,按照这个顺序依次选择与当前区间集不冲突的区间,这样能得到最多的不冲突的区间数量。
\quad 带权区间需要处理两个问题,一是不产生冲突,而是选出来的区间权重值和最大。我们用dp[i]表示前i个区间的最大权重和,p[i]表示与第i个区间不冲突的区间j,该区间是[1,i-1]这i-1个区间内与i不冲突的结束时间最接近i开始时间的区间。例如下图中p[2]=0,p[6]=2,p[8]=1。我们在计算每个区间的第一个不冲突的前驱区间时可以用二分查找,即将每个区间的结束时间排好序放在一个数组里面,每次查询小于等于当前区间开始时间的最后一个值即可。在具体程序实现时我们可以用lower_bound找出第一个大于等于当前区间开始时间的第一个值,再判断若该值大于当前区间的开始时间则往后退。
在这里插入图片描述
\quad 通过分析可以得到状态转移方程为 d p [ i ] = m a x ( d p [ i 1 ] , w [ i ] + d p [ p [ i ] ] ) dp[i]=max(dp[i-1],w[i]+dp[p[i]]) 。最大值就存放在 d p [ n ] dp[n] 中,要找出具体是选择了哪些区间,可以对 d p [ n ] d p [ n 1 ] dp[n]和dp[n-1] 大小进行判断,若二者不等,说明选择了第n个区间,且下次n为p[n],若相等,则没选择该区间,令n=n-1即可。程序如下:

while(dp[n]!=0)
{
	if(dp[n]!=dp[n-1]){
		// n区间被选择,输出
		cout << n << endl;
		n = interval[n].p;  // n=p[n]
	}
	else n = n-1;  // n区间没被选择,n=n-1
}

\quad 整体程序如下,程序中给的输入区间数目n,再依次输入每个区间的开始时间,结束时间和权重。输出分两行,第一行输出选择的活动,第二行输出最大权重和的值。

#include <iostream>
#include <algorithm>
using namespace std;
const int maxn = 1e5+10;
struct Interval
{
	int s, f, w;  // 区间开始时间、结束时间和权重
	int id, p;  // 区间编号和该区间前一个不冲突的区间
}interval[maxn];
int temp[maxn];  // 存放各个区间的结束时间,用于二分查询
bool cmp(const Interval &a, const Interval &b)
{
	return a.f<b.f;  // 按照结束时间排序
}
int dp[maxn];
int main()
{
	int n;
	cin >> n;
	for(int i = 1; i <= n; i++){
		cin >> interval[i].s >> interval[i].f >> interval[i].w;
		interval[i].id = i;
	}
    sort(interval+1, interval+n+1, cmp);
    for(int i = 1; i <= n; i++)
    	temp[i] = interval[i].f;
    for(int i = 1; i <= n; i++)
    {
    	// 找出第一个大于等于当前区间开始时间的第一个值
    	int index = lower_bound(temp+1, temp+i+1, interval[i].s)-temp;
    	// 若该值大于当前区间的开始时间则往后退,直到满足小于等于则停止
    	while(temp[index]>interval[i].s) index--;
    	interval[i].p = index;  // 存放每个区间的最后一个前驱区间
    }
    dp[0] = 0;
    for(int i = 1; i <= n; i++)
    {
    	dp[i] = max(dp[i-1], interval[i].w+dp[interval[i].p]);
    }
    int flag = n;
    while(dp[n]!=0)
    {
    	if(dp[n]!=dp[n-1]){
    		// n区间被选择,输出
    		cout << n << " ";
    		n = interval[n].p;  // n=p[n]
    	}
    	else n = n-1;  // n区间没被选择,n=n-1
    }
    cout << endl << dp[flag] << endl;  // 输出最大区间权重和
	return 0;
}
/*
输入
8
1 4 12
3 5 20
0 6 23
4 7 13
3 8 26
5 9 20
6 10 11
8 11 16
输出
8 5
42
*/

问题二、最大子数组和

\quad 给定一个数字序列 A 1 , A 2 , , A n A_1,A_2,\cdots,A_n ,求 i , j ( 1 i j n ) i,j(1\le i \le j \le n) ,使 A i + + A j A_i+\cdots +A_j 最大,求这个最大值和索引 i , j i,j 。举个例子,给出序列 A = [ 2 , 11 , 4 , 13 , 5 , 2 ] A=[-2,11,-4,13,-5,-2] ,显然 11 + ( 4 ) + 13 = 20 11+(-4)+13=20 为和的最大选取情况。
\quad d p [ i ] dp[i] 表示以 A [ i ] A[i] 作为末尾的连续序列的最大和(这里 A [ i ] A[i] 必须作为连续序列的末尾),则有两种情况

  • 这个最大和的连续序列只有一个元素,即只有 A [ i ] A[i]
  • 这个最大和的连续序列有多个元素,即从前面某处 A [ p ] ( p i ) A[p](p\le i) 开始,一直到 A [ i ] A[i] 结束。

\quad 对于上面两种情况,第一种最大和为 A [ i ] A[i] 本身,第二种为 A [ p ] + + A [ i 1 ] + A [ i ] A[p]+\cdots+A[i-1]+A[i] ,即为 d p [ i 1 ] + A [ i ] dp[i-1]+A[i] 。综合这两种情况,我们可以得到状态转移方程 d p [ i ] = m a x ( A [ i ] , d p [ i 1 ] + A [ i ] ) dp[i]=max(A[i],dp[i-1]+A[i])
\quad 这个最大值即为 d p [ n ] dp[n] 中值最大的那个值res,同是这个最大子序列的结束索引 j j 为使得 d p [ n ] dp[n] 值最大的那个下标。从 j j 开始对数组开始求和 A [ j ] + A [ j 1 ] + + A [ i ] A[j]+A[j-1]+\cdots+A[i] ,当该和等于res时对应的数组下标即为 i i 。这样,我们就完成了找出这样一个序列使得和最大的任务。
\quad 在我的程序中,先输入数的数量n,再依次输入n个数。输出第一行为所求子数组下标,第二行为该子序列和。请看程序,所用测试用例如下图所示
在这里插入图片描述

#include <iostream>
using namespace std;

const int maxn = 1e6+10;
int a[maxn];
int dp[maxn];
int main()
{
	int n;
	cin >> n;
	for(int i = 1; i <= n; i++) cin >> a[i];
	dp[0] = 0;
	int j = 0, res = 0;  // 记录最大值res和子序列的结束下标j
	for(int i = 1; i <= n; i++)
	{
		dp[i] = max(dp[i-1]+a[i], a[i]);
		if(res<dp[i])
		{
			res = dp[i];
			j = i;
		}
	}
	int i=j, sum = a[i];  // 记录子序列开始的下标i和数组a[j]+a[j-1]+...+a[i]的和
	while(sum!=res)
	{
		i--;
		sum += a[i];
	}
	cout << i << " " << j << endl;
	cout << res << endl;
	return 0;
}
/*
输入
15
12 5 -1 31 -61 59 26 -53 58 97 -93 -23 84 -15 6
输出
6 10
187
*/

问题三、最大子矩阵和

\quad 如下图所示,最大子矩阵和目标即为在一个矩阵中,找出一个子矩阵,这个子矩阵和最大。这是一个微软的面试题,值得一看。
在这里插入图片描述
\quad 我们能够很快想到一个暴力的解法,即枚举子矩阵的起始行起始列和终止行终止列,再将该子矩阵和求出来,找出最大的子矩阵即可。这样我们需要四个循环来遍历子矩阵起始行起始列和终止行终止列,对于每个子矩阵还需要计算它的和,假设是一个n*n的方阵,那么时间复杂度为 O ( n 4 n 2 ) = O ( n 6 ) O(n^4*n^2)=O(n^6) ,这显然是不能接受的。
\quad 针对于上面的暴力解法,我们可以用二维前缀和进行优化,使得我们可以在 O ( 1 ) O(1) 的时间复杂度内求得每个子矩阵和的值。设原始矩阵a大小为 n m n*m ,我们初始化一个全为0的,大小为 ( n + 1 ) ( m + 1 ) (n+1)*(m+1) 的前缀和矩阵p。 p [ i ] [ j ] p[i][j] 记录原始矩阵中前i行前j列组成的子矩阵的和。例如在上图中, p [ 1 ] [ 2 ] = 2 + 5 = 3 , p [ 2 ] [ 2 ] = 2 + 5 + 4 + ( 3 ) = 4 p[1][2]=-2+5=3,p[2][2]=-2+5+4+(-3)=4 p [ i ] [ j ] = p [ i 1 ] [ j ] + p [ i ] [ j 1 ] p [ i 1 ] [ j 1 ] + a [ i 1 ] [ j + 1 ] p[i][j]=p[i-1][j]+p[i][j-1]-p[i-1][j-1]+a[i-1][j+1] 。获得前缀和的程序如下

// 计算矩阵的前缀和
void preSum()
{
    for(int i = 0; i <= n; i++) p[i][0] = 0;
    for(int j = 0; j <= m; j++) p[0][j] = 0;
    for(int i = 1; i <= n; i++)
        for(int j = 1; j <= m; j++)
            p[i][j] = p[i-1][j]+p[i][j-1]-p[i-1][j-1]+a[i][j];
}

\quad 获得前缀和数组后,我们可以很容易推出原矩阵中第a行到第b行,第c列到第d列的子矩阵和为 r e a c t S u m [ a , b , c , d ] = p [ b ] [ d ] p [ a 1 ] [ d ] p [ b ] [ c 1 ] + p [ a ] [ c ] reactSum[a,b,c,d]=p[b][d]-p[a-1][d]-p[b][c-1]+p[a][c] ,理解成一个 b d b*d 的大矩形的和减去两个小矩形 ( a 1 ) d , b ( c 1 ) (a-1)*d,b*(c-1) 的和,因为多减了 a c a*c ,所以需要加上去。这样我们可以在 O ( 1 ) O(1) 的时间复杂度内得到任意一个子矩阵的和,时间复杂度降为 O ( n 4 ) O(n^4)
\quad 我们再想想上一个问题——最大子数组和。我们可以枚举子矩阵的初始行和终止行,然后用动态规划对子矩阵的列进行计算。假设我们枚举子矩阵初始行和终止行分别为a,c。那么,我们可以把第a行到第c行中每一列的数字的和看作一个数,这样这个矩阵就变成了一个序列。我们对这个序列按照求最大子数组和的方式去求解,就能得到答案。这样,我们将时间复杂度降至 O ( n 3 ) O(n^3)
\quad 对于上一段的解答,我们只能求出子矩阵的初始行和结束行,初始列和最大和,无法得到终止列。我们还需要写一个函数来获得子矩阵的终止列。很简单啊,都知道一个矩阵的初始行和结束行,初始列和最大和的情况下,从初始列开始一列一列做累加,当累加得到的和等于最大和的时候所对应的列即为终止列。
\quad 程序里面输入矩阵大小n和m,再输入矩阵。首先计算前缀和数组p,再利用maxSum函数计算子矩阵最大和res和子矩阵对应的行范围和起始列,最后计算出终止列。程序第一行输出子矩阵行范围,第二行输出列范围,第三行输出最大子矩阵和。

#include <iostream>
using namespace std;

const int INF = 0x3f3f3f3f;
const int maxn = 510;
int n, m;
int a[maxn][maxn], p[maxn][maxn];  // 该矩阵和它的前缀和
int res = -INF;  // 存放子矩阵最大和的值
int row[2];  // 存放这个最大子矩阵在原矩阵中所在行的位置位置,第row[0]行到row[1]行
int col[2];  // 存放这个最大子矩阵在原矩阵中所在列的位置位置,第col[0]列到col[1]列
// 计算矩阵的前缀和
void preSum()
{
    for(int i = 0; i <= n; i++) p[i][0] = 0;
    for(int j = 0; j <= m; j++) p[0][j] = 0;
    for(int i = 1; i <= n; i++)
        for(int j = 1; j <= m; j++)
            p[i][j] = p[i-1][j]+p[i][j-1]-p[i-1][j-1]+a[i][j];
}
//  计算矩阵第a行到第c行的第i列元素之和
int getReactSum(int a, int c, int i)
{
    return p[c][i]-p[a-1][i]-p[c][i-1]+p[a-1][i-1];
}
void maxSum()
{
    for(int a = 1; a <= n; a++)
        for(int c = a; c <= n; c++)
        {
            int start = getReactSum(a, c, m);
            int all = getReactSum(a, c, m);
            for(int i = m-1; i >= 1; i--)
            {
                if(start<0) start = 0;
                start += getReactSum(a, c, i);
                if(start>all) all = start;
                if(all>res) {
                    // 更新子矩阵最大和的值和当前所在行范围以及所在列
                    res = all;
                    row[0] = a, row[1] = c;
                    col[0] = i;
                }
            }
        }
}
// 根据已得的最大子矩阵的行范围和起始列以及子矩阵的和得到子矩阵的终止列
void getAnotherCol(int a, int b, int col1)
{
    int sum = 0;
    for(int i = col1; i <= m; i++)
    {
        sum += getReactSum(a, b, i);
        if(sum==res){
            col[1] = i;
            break;
        }
    }
}
int main()
{
    cin >> n >> m;
    for(int i = 1; i <= n; i++)
        for(int j = 1; j <= m; j++)
            cin >> a[i][j];
    preSum();
    maxSum();
    getAnotherCol(row[0], row[1], col[0]);
    cout << row[0] << " " << row[1] << endl;
    cout << col[0] << " " << col[1] << endl;
    cout << res << endl;
    return 0;
}
/*
输入
7 7
-2 5 0 -5 -2 2 -3
4 -3 -1 3 2 1 -1
-5 6 3 -5 -1 -4 -2
-1 -1 3 -1 4 1 1
3 -3 2 0 3 -3 -2
-2 1 -2 1 1 3 -1
2 -4 0 1 0 -3 -1
输出
2 7
3 5
13
*/

问题四、0-1背包

\quad 0-1背包问题背景如下:给你一个容量为W的背包和n个物品,每个物品有自己的价值和重量,要求我们选出其中部分物品使得价值最高,同时选出的物品的重量和不超过背包容量。设将i个物品装入容量为j的背包所能得到的最大价值为 d p [ i ] [ j ] dp[i][j] ,则背包问题的状态转移方程为 d p [ i ] [ j ] = m a x ( d p [ i 1 ] [ j ] , v [ i ] + d p [ i ] [ j w [ i ] ] ) dp[i][j]=max(dp[i-1][j],v[i]+dp[i][j-w[i]]) $$。
在这里插入图片描述
\quad 上图所示为有一个容量为11的背包,有5件物品, d p [ 5 ] [ 11 ] dp[5][11] 里面数值计算出来,我们如何根据这张表找出我们挑选出了哪些背包呢?很简单,若 d p [ n ] [ c ] = = d p [ n 1 ] [ c ] dp[n][c]==dp[n-1][c] ,说明第n个物品没被选上,令 n = n 1 n=n-1 继续执行;若不等,说明被选上了,此时另 c = c w [ n ] , n = n 1 c=c-w[n],n=n-1 继续重复此过程即可。直到 d p [ n ] [ c ] = 0 dp[n][c]=0 为止。得到所选物品序号的程序如下:

while(dp[n][c]!=0)
{
    if(dp[n][c]!=dp[n-1][c])
    {
        // 输出当前选中的物品序号
        cout << n << " ";
        c = c-w[n];
        n = n-1;
    }
    else n = n-1;
}

\quad 总程序第一行输入物品数n和背包容量c,接下来输入n个物品的价值和重量。输出第一行为选中的物品,第二行为选中物品的价值和。

#include <iostream>
using namespace std;

const int maxn = 1001;
const int maxV = 10001;
int v[maxn], w[maxn]; // 每件物品的价值和重量
int dp[maxn][maxV];
int main()
{
    int n, c;  // 物品数量和背包容量
    cin >> n >> c;
    for(int i = 1; i <= n; i++)
        cin >> v[i] >> w[i];
    for(int i = 0; i <= c; i++) dp[0][i] = 0;
    for(int i = 1; i <= n; i++)
        for(int j = 1; j <= c; j++)
            if(j>=w[i]) dp[i][j] = max(dp[i-1][j], v[i]+dp[i-1][j-w[i]]);
            else dp[i][j] = dp[i-1][j];
    int res = dp[n][c];
    while(dp[n][c]!=0)
    {
        if(dp[n][c]!=dp[n-1][c])
        {
            // 输出当前选中的物品序号
            cout << n << " ";
            c = c-w[n];
            n = n-1;
        }
        else n = n-1;
    }
    cout << endl << res << endl;
    return 0;
}
/*
输入
5 11
1 1
6 2
18 5
22 6
28 7
输出
4 3
40
*/

问题五、找零钱问题

\quad 给定你n个面值的硬币coins,每种面值的硬币都有无数个,再给定你需要找的零钱数amount,要求用数目最少的硬币凑出amount,若无法凑出来,返回-1表示无法凑出。举个例子,coins=[2,5,6],amount=10,则只需要2个5分的硬币即可。这个问题无法用贪心求解,因为硬币的面值组合是不确定的,在某些面值的组合下可用贪心,但在大部分情况下用贪心无法求解。就比如上面那个例子,用贪心就会取1个6分和2个2分的硬币,就需要3个硬币,明显不是最优解。
\quad 我们用动态规划来解决这个问题,老规矩,我们定义 d p [ i ] dp[i] 为钱数为i时需要的最少硬币数,当i=0时, d p [ 0 ] = 0 dp[0]=0 ,这个毫无疑问。当i>0时,我们做以下尝试,对于任意一个硬币coins[j],我们有使用和不使用两种情况,所以比较的双方出来了, d p [ i ] dp[i] 代表使用coins[j], d p [ i c o i n s [ j ] ] + 1 dp[i-coins[j]]+1 ,那么这里为什么又要加1呢,因为对i-coins[j]来说它加上一个coins[j]就是i了,所以这里需要加上1,然后i-coins[j]再从前一个状态判断最小值,最后直到已知值 d p [ 0 ] dp[0]
\quad 其实真正的过程是i从0到amount记录每个状态的最小值,最后执行到 d p [ a m o u n t ] dp[amount] 时就是最后求的最小值,这也是动规真正的执行过程,不过我们习惯从最后开始分析问题,通过确定最后的状态与之前状态的关系,从而确定动态转移方程 d p [ i ] = m i n ( d p [ i ] , d p [ i c o i n s [ j ] ] + 1 ) dp[i]=min(dp[i],dp[i-coins[j]]+1)
\quad 在我的程序中,首先输入硬币面值数n和需要找的零钱数amount,再依次输入n个数代表n种不同面值。输出最少需要的硬币数量。

#include <iostream>
#include <cstring>
using namespace std;

const int maxn = 1e6+10;
int coins[maxn], dp[maxn];
int main()
{
	int n, amount;
	cin >> n >> amount;
	for(int i = 1; i <= n; i++) cin >> coins[i];
	fill(dp, dp+amount+1, 0x3f3f3f3f);
	dp[0] = 0;
	for(int j = 1; j <= n; j++)
		for(int i = coins[j]; i <= amount; i++)
			dp[i] = min(dp[i], dp[i-coins[j]]+1);
	if(dp[amount]==0x3f3f3f3f) cout << -1 << endl;
	else cout << dp[amount] << endl;
	return 0;
}
/*
输入1
3 10
2 5 6
输出1
2

输入2
1 3
2
输出
-1
*/

问题六、编辑距离

\quad 对用两个字符串s1和s2,我们可以对其中一个字符串进行以下三种操作

  • 插入一个字符
  • 删除一个字符
  • 替换一个字符

使得两个字符串相等,问最小操作次数。这个最小操作次数就反映了两个字符串之间的相似程度。在具体使用场景下,字符串中某个字符的缺失可能会比写错带来的误差更大,因此我们可以自己定义插入或者删除一个字符的代价为a,替换一个字符代价为b,这样来求解两个字符串之间的编辑距离。设两个字符串长度分别为n,m,我用 d p [ i ] [ j ] dp[i][j] 表示到s1的前i个字符与s2的前j个字符的编辑距离,状态转移方程如下:
d p [ i ] [ j ] = m i n ( d p [ i 1 ] [ j ] + a , d p [ i ] [ j 1 ] + a , s 1 [ i ] = = s 2 [ j ] b + d p [ i 1 ] [ j 1 ] ) dp[i][j]=min(dp[i-1][j]+a,dp[i][j-1]+a,s1[i]==s2[j]*b+dp[i-1][j-1])
\quad 在程序中,首先输入两个字符串,再输入插入或者删除一个字符的代价a,替换一个字符代价b。输出为两个字符串的编辑距离。

#include <iostream>
using namespace std;

const int maxn = 1001;
int dp[maxn][maxn];
int main()
{
	string s1, s2;
	cin >> s1 >> s2;
	int a, b;  // a表示增加或删除一个字符需要的代价,b表示修改一个字符需要的代价
	cin >> a >> b;
	int n = s1.length(), m = s2.length();
	for(int i = 1; i <= n; i++) dp[i][0] = a*i;
	for(int i = 1; i <= m; i++) dp[0][i] = a*i;
	for(int i = 1; i <= n; i++)
		for(int j = 1; j <= m; j++)
		{
			if(s1[i-1]==s2[j-1]) dp[i][j] = dp[i-1][j-1];
			else dp[i][j] = dp[i-1][j-1]+b;
			dp[i][j] = min(dp[i][j], min(dp[i-1][j]+a, dp[i][j-1]+a));
		}
	cout << dp[n][m] << endl;
	return 0;
}
/*
输入
PALETTE PALATE
2 1
输出
3
*/

\quad 看到这人恭喜你终于看完啦这篇又臭又长的文章,对上面叙述或者代码有问题的地方欢迎指出~

猜你喜欢

转载自blog.csdn.net/qq_40438165/article/details/101074511