简单动态规划(真的简单)

简单动态规划(Dynamic Programming)

引入

该部分举例来源于中国大学MOOC:浙江大学《数据结构》

在解决一些最优解问题时,我们经常会需要进行动态规划的求解,以期望避免暴力搜索消耗的超长时间.
我们先来看一个最简单的例子.

现有一个每个值都已经确定的数列an,定义子数列为从这个数列中抽取的任意1~n个元素,按相对顺序不变的条件组成的一个新数列.
例如,对于数列(9,8,3,9,2,6),其一个子列可以是(9),(3),(9,8,6),(9,8,3,9,2),等等. 而(3,8)不属于其子列.
现在,求一个由an中任意个连续元素构成的子列的最大和.
输入:输入包含两行,第一行是一个整数,表示数列的长度n.第二行是n个数,表示这个数列的各个值.
输出:输出包含一个数,表示其最大的一个连续子列的和.
测试用例保证1≤n≤100000(105).
时间限制:1000ms
输入样例
8
1 4 -1 -5 8 0 3 1
输出样例
12

解释:从数列中抽取8,0,3,1这四个连续元素构成的子列有最大和12.

如何求解?首先想到暴力搜索:遍历求出所有子列的和,找到最大值.
C/C++代码如下:

#define MAXN 100001
int a[MAXN];
int main()
{
	int n = 0;
	scanf("%d", &n);
	int ans = 0;//存最终答案
	for(int i = 0; i < n; ++i) scanf("%d", &a[i]);//输入数列
	for(int len = 1; len <= n; ++len)//按数列的长度遍历.
	{
		for(int i = 0; i + len - 1 < n; ++i)//求出所有长度
		{									//为len的子列的和.
			int sum = 0;
			for(int j = i; j <= len + i - 1; ++j)
				sum += a[j];
			if(sum > ans) ans = sum;
		}
	}
	printf("%d", ans);
	return 0;
}

暴力搜索共用了3层循环. 其时间复杂度是O(n3).
利用文件输入,输入n=1400的测试样例:
在这里插入图片描述
程序运行了1.107s,已经超时. 所以,要用暴力通过这道时间限制为1秒、数据规模在10万的题是完全不可行的.

——这个时候,我们不妨优化算法,使用动态规划.
假设f(i)表示数列从1到 i 的最大子列和,那么我们要求的就是F(n).
求F(n)需要哪些值?
当然,我们需要知道F(n - 1),并且判断目前的 a(n) 能不能插进来使子列和更大.

例如,对于数列1 4 -1 -5 8 0 3 1,我们从左边开始,
容易知道F(1) = 1.

由于4+1 = 5 > F(1),所以F(2)是5.
由于-1+5 = 4 < F(2),所以F(3)不应该是4,而应该与F(2)一致,为5.
由于-5+4 = -1 < F(3),所以F(4)不是-1,仍然是5.

这个时候,我们从左到右算出来的和-1已经小于0了,如果我们继续算后边的和,加上这个“-1”显然会使总的和更小,因此我们抛弃掉这个-1,将储存的和重置为0,再继续.

由于0+8 > F(4),所以F(5)是8.
由于0+8 = F(5),F(6)也是8.
由于3+8 > F(6),F(7)是11.
由于1+11>F(7),F(8)是12.

至此,我们已经算出了F(7),而且只用了7步!这说明,采用该方法计算最大子列和,其时间复杂度是O(n) ! 3次方复杂度转化为线性复杂度!

因为我们需要的只是F(n),因此我们将F简化为一个变量ans,核心代码如下:

typedef long long ll;
ll ans = 0;
ll sum = 0;
for(int i = 0; i < n; ++i)
{
	if(sum + a[i] > ans)
		ans = sum + a[i];
	sum += a[i];
	if(sum < 0) sum = 0;
}

对10万规模样例的运行效果:
在这里插入图片描述

只用时50ms.
为什么动态规划更快?因为我们这里没有像暴力运算中多次回去访问各个a[i]的值,每个值只访问一次. 暴力搜索中多次询问了“求某个和需要哪些值”的信息,而我们需要知道的只是答案,多次询问属于冗余信息.
因此,我们换一个思路,想要知道F(n),只需要知道与F(n)有关的几个值,至于这几个值怎么求出来的,我们在求出来之后就可以扔掉了,而不需要在求F(n)的后续操作中重复回溯.
例如,求解1,2,3,4的最大子列和,就是求F(4). 我们如果要知道F(3),只需要知道F(2)和a(3),也就是3(前两项的最大子列和)和3(第三项).在暴力算法中,我们需要再将前两项的和再加一遍,而在动态规划中,我们已经清楚地知道“这个已经计算过了”,因此直接拿来用,并且在求出F(3)后,我们也就可以将F(2)丢弃了.

动态规划

为了更好地理解动态规划,我们再看一个题:
现有若干个1元、5元、7元的纸币,要凑整10元,至少需要几张纸币?
如果不采用编程的角度,而是在日常生活中看这个问题,我们都会很自然地先拿出7元纸币——因为它最大. 但稍微多思考一下,我们就会发现:这显然不是问题的最优解. 使用7元纸币需要补3张1元纸币,共4张,而使用5元纸币只需要2张就能搞定.
实际上,从算法的角度讲,这是一种“贪心”策略,为了求解一个问题,贪心算法总是先最大程度地解决它的子问题. 在很多场合,贪心算法是实用的. 它既能够得出最优解,又能在效率上远胜于暴力搜索法.
但是“贪心”也有走到死路的时候,比如这里的凑面值问题,贪心策略求出的解,已经是一个错误解了.

那么如何凑呢?

我们依然用之前线性遍历的思路. 设凑n块钱需要的纸币个数为f(n).
为了求出f(n),我们需要哪些值?

因为我们有1元、5元、7元的纸币,所以我们需要知道,f(n - 7), f(n - 5), f(n - 1) 这些值分别是多少.

至于它们如何求出来?我们不关心,在它们求出来之后,我们就把求出这3个值所需要的的东西一一丢掉了.

知道这3个值之后,我们得找到它们之间的最小值. 例如,当n = 10,f(n - 5) 当然是最小的,仅为1. 找到最小值之后,我们再补一张——无论你选择的是 3 个值中的哪一个,都只需要补一张——因为我们拥有任意多的1元、5元、7元纸币!
所以,我们可以得到公式:

f(n) = min{ f(n - 1), f(n - 5), f(n - 7) } + 1.

这种在线性规划中表示出f(n)的递推关系的公式我们也称为“状态转移方程”.

由此得到的“凑n元钱”的代码:

#include <cstdio>
#include <cmath>
#include <iostream>
int main()
{
	int n = 0;
	cin >> n;
	int f[n] = {0};
	f[1] = 1, f[5] = 1, f[7] = 1;
	for(int i = 2; i < n; ++i)
	{
		int minF = f[i - 1];
		if(i == 5 || i == 7) continue;
		if(i > 5) minF = min(f[i - 5], minF);
		if(i > 7) minF = min(f[i - 7], minF);
		f[i] = minF + 1;
	}
	cout << f[n] << endl;
	return 0;
}

仍然只有一套for循环,复杂度依然是O(n).

贪心算法的思想是先访问小于n的最大纸币面值x1,然后将n对x1取余,再寻找下一个x2……复杂度上已经高于线性复杂度了,何况在本题的面值情况下是会算出错解的!

性质

在我们已经讨论过的“最大子列和”和“凑面值”两个问题中,动态规划都有两个特点:

  1. 把求n的最优解的问题分解为求1到n - 1的最优解的问题.
  2. 对于n的最优解,只关心与它直接相关的值,不关心这些值是怎么推出来的.

实际上,第二个特点与递推算法的思想是一致的.

而这属于动态规划问题的一个重要性质:无后效性.

无后效性是指如果在某个阶段上过程的状态已知,则从此阶段以后过程的发展变化仅与此阶段的状态有关,而与过程在此阶段以前的阶段所经历过的状态无关。利用动态规划方法求解多阶段决策过程问题,过程的状态必须具备无后效性。
————百度百科《无后效性》词条

无后效性,即子问题的解一旦确定,就不再改变,不受在这之后、包含它的更大的问题的求解决策影响
————维基百科《动态规划》词条

动态规划问题需要的另一个重要性质:最优子结构

简单地说,如果一个问题的最优解可以由其子问题的最优解递推得出,我们就称这个问题具有最优子结构. 上述两个问题都是典型的最优子结构问题.


一个最优解问题,只要它具备了最优子结构和无后效性两个性质,它就可以使用动态规划解决.

例题

对于开头的最大连续子列和问题,不妨去HDOJ看看这题的加强版:

http://acm.hdu.edu.cn/showproblem.php?pid=1231

至于其题解,网上有很多啦~

为了 巩固 入土 入门动态规划的相关知识,我们不妨再来看几个例题:

一、求最长上升子序列

POJ上的题目评测:
http://poj.org/problem?id=2533

子列的定义见本博文开头. 现给出一个序列,求这个序列最大的一个子列,使得这个子列是单调递增的. 例如,给定长度7的序列1,7,3,5,9,4,8,其一个最长子列是1,3,4,8,也可以是1,3,5,9、1,3,5,8. 编写程序求最长子序列的长度.

要使用动态规划解题,首先看目标问题是否具有线性规划题的两个性质:

无后效性:一个确定的序列,其最长上升子序列一定是确定的,无论在之后做什么添加都不影响. 譬如,对数列1,2,3,在其后插入4,5,不影响前面1,2,3的最长上升子序列.

最优子结构:如果我们记F(i)为某个确定序列前 i 个数组成的子列的最大上升子序列长度,那么F(n)就是问题的最终解了. 但是求出F(n)需要哪些子问题?看上去似乎难以解决.

我们不妨换一个角度.
设序列第i项为a(i). 记F(i)为以a(i)结尾的最长上升子列的长度. 那么我们要求解的是F(n)……是F(n)吗?不是. 最长上升子列不一定以最后一项结尾,而可能是中间的任何一项.
因此,问题的解是max{F(1), F(2), … , F(n)}.

为什么要这样设计问题?因为我们的目标是使问题具有最优子结构——一个问题的解可以由它的子问题推导出.
现在,我们要求F(n),需要知道哪些东西呢?
对于前面的n - 1项,只要满足a(n) ≥ a(i),那么a(n)就可以排在前面项的最后面,构成一个新的上升列,这个序列的长度应当是F(i) + 1.
那么,求F(n),我们只需要知道在所有满足a(n) 大于等于 a(k)、k < n 的情况下,所有f(k) + 1的最大值. 于是得到状态转移方程:

F(n) = max{F(k)} + 1, k < n, a(k) < a(n)

于是动态规划的条件充分了,问题求解也已经明晰了.
给出POJ上能通过的完整C++代码:

#include <iostream>
#include <cmath>
#include <cstdio>
#include <algorithm>
#define LEN 1001
using namespace std;
int main()
{
	int a[LEN];
	int f[LEN] = {0};
	int n = 0;
	scanf("%d", &n);
	for(int i = 0; i < n; ++i) scanf("%d", &a[i]);
	for(int i = 0; i < n; ++i)
	{
		f[i] = 1;
		for(int j = 0; j < i; ++j)
			if(a[j] < a[i]) f[i] = max(f[i], f[j] + 1);
	}
	int ans = *max_element(f, f + n);
	printf("%d", ans);
	return 0;
}

通过这个例子,我们发现,并不是只要用了动态规划就能达到线性复杂度. 很多问题下,简单的线性规划对时空的开销可能也并不小,也并不是用了就一定好. 很多时候,贪心也能够解决问题,且代码更简单,甚至有时候暴力搜索也能完胜动态规划等“奇技淫巧”.

简单动态规划介绍到这里. 实际上,动态规划有很多复杂的分支:树形动态规划、状压动态规划、区间动态规划,等等.

为了巩固练习简单动态规划,下面还给出了几个经典题,不妨练一练手:

http://acm.hdu.edu.cn/showproblem.php?pid=2602(01背包问题)

https://www.luogu.com.cn/problem/P1020

http://poj.org/problem?id=1239

总结:我真的要开始复习四级了呜呜呜

  • 2
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 4
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值