动态规划( Dongtai Planning Dynamic Programming,简称DP)
多阶段决策过程的最优化问题
在现实生活中,有一类活动的过程,由于它的特殊性,可将过程分成若干个互相联系的阶段,在它的每一阶段都需要作出决策,从而使整个过程达到最好的活动效果。当然,各个阶段决策的选取不是任意确定的,它依赖于当前面临的状态,又影响以后的发展,当各个阶段决策确定后,就组成一个决策序列,因而也就确定了整个过程的一条活动路线,这种把一个问题看作是一个前后关联具有链状结构的多阶段过程就称为多阶段决策过程,这种问题就称为多阶段决策问题。如下图所示:
多阶段决策过程,是指这样的一类特殊的活动过程,问题可以按时间顺序分解成若干相互联系的阶段,在每一个阶段都要做出决策,全部过程的决策是一个决策序列。
基本概念
动态规划是解决 “多阶段决策问题”的一种高效算法。
动态规划是通过合理组合子问题的解从而解决整个问题解的过程。
动态规划是通过拆分问题,定义问题状态和状态之间的关系,使得问题能够以递推(或者说分治)的方式去解决。
即把一个问题转化为若干个形式相同,但规模更小的子问题,从而递归解决整个问题。
其中的子问题并不是独立的,这些子问题又包含有公共的子子问题......
动态规划算法对每个子问题只求一次,并将其结果保存在一张表中(数组),以后再用到时直接从表中拿过来使用,避免重复计算相同的子问题。
“不做无用功”的求解模式,大大提高了程序的效率。
如何拆分问题,才是动态规划的核心。
而拆分问题,靠的就是状态的定义和状态转移方程的定义。
真正含义
在一个困难的嵌套决策链中,决策出最优解。
本质
对问题状态的定义和状态转移方程的定义。
状态转移的实质
决策
动态规划的基本概念和基本模型构成
阶段、状态 、决策、策略 、状态转移方程
阶段和阶段变量
用动态规划求解一个问题时,需要将所给问题的全过程恰当地分成若干个相互联系的阶段,以便按一定的次序去求解。
过程不同,阶段数就可能不同。
描述阶段的变量称为阶段变量。在多数情况下,阶段变量是离散的,用k表示。
阶段的划分一般是根据时间和空间的自然特征来划分。
阶段的划分要便于把问题转化成多阶段决策问题。
状态和状态变量
某一阶段的出发位置称为状态,通常一个阶段有多个状态。
一般地,状态可以用一个或一组数(变量)来描述,用来描述状态的变量称为状态变量。
决策、决策变量和决策允许集合
一个阶段的状态给定以后,从该阶段的每一个状态出发,通过一次选择性的行动转移至下一阶段的相应状态称为决策。
或者说在对问题的处理中作出的每种选择性的行动就是决策。
一个实际问题可能要有多次决策和多个决策点,在每一个阶段的每一个状态中都需要有一次决策。
决策可以用变量来描述,这种描述决策的变量称为决策变量。
在实际问题中,决策变量的取值往往限制在某一个范围之内,此范围称为允许决策集合。
策略和最优策略
全过程中各阶段决策变量所组成的有序总体称为策略。
所有阶段的决策有序组合构成一个策略。
在实际问题中,最优效果的策略叫最优策略。
状态转移方程
前一阶段的终点就是后一阶段的起点,对前一阶段的状态作出某种决策, 产生后一阶段的状态,这种关系描述了由k阶段到k+1阶段状态的演变规律,称为状态转移方程。
条件
拓扑图(DAG,有向无环图)(可拓扑排序)
最优子结构
即,子问题的最优解是整个问题的最优解的一部分。
无后效性
性质
布尔性
动态规划和递推有些相似(尤其是线性动规),但是不同于递推的是:
递推求出的是数据,所以只是针对数据进行操作;而动态规划求出的是最优状态,所以必然也是针对状态的操作,而状态自然可以出现在最优解中,也可以不出现——这便是决策的特性(布尔性)。
批判性继承思想
状态转移方程可以如此定义:
下一状态最优值=最优比较函数(已经记录的最优值,可以由先前状态得出的最优值)
——即动态规划具有判断性继承思想
可推导性
由于每个状态均可以由之前的状态演变形成,所以动态规划有可推导性。
最优化原理
整个过程的最优策略具有:无论过去的状态和决策如何,对前面的决策所形成的状态而言,余下的决策必须构成最优策略的性质。
即,子问题的局部最优将导致整个问题的全局最优。
即,问题具有最优子结构的性质,
也就是说一个问题的最优解只取决于其子问题的最优解,而非最优解对问题的求解没有影响。
无后效性原则
某阶段的状态一旦确定,则此后过程的演变不再受此前各状态及决策的影响。
即每个当前状态会且仅会决策出下一状态,而不直接对未来的所有状态负责,
也就是说,“未来与过去无关”,当前的状态是此前历史的一个完整的总结,此后的历史只能通过当前的状态去影响过程未来的演变。
可以浅显地理解为:
Future never has to do with past time ,but present does.
现在决定未来,未来与过去无关。
若直接缩小规模而划分出的子问题不满足最优子结构
引入更多用于区分不同子问题的“状态”。
对于不能划分阶段的问题,不能运用动态规划来解;
对于能划分阶段,但不符合最优化原理的,也不能用动态规划来解;
既能划分阶段,又符合最优化原理的,但不具备无后效性原则,不能用动态规划来解。
方式
正推:
从初始状态开始,通过对中间阶段的决策的选择,达到结束状态。我们也称之为递推。
倒推:
从结束状态开始,通过对中间阶段的决策的选择,达到初始状态。我们可以称之为记忆化搜索。
把大象装进冰箱 写出一个DP需要几步?
划分阶段
确定状态和状态变量
除了“问题的规模”这一直接的状态,还应考虑一些附加的,用来满足“最优子结构”这一性质的额外状态。
确定决策并写出状态转移方程
根据状态的实际意义去转移,一般有两种考虑方式:“如何分解”和“如何合并”,根据实际选择。
寻找边界条件
分析复杂度
时间复杂度=状态总数x单次转移复杂度
编程实现程序(正推或倒推)
注意各类边界,注意数据类型(爆int?double精度?)
优化
削减状态
优化转移
应用
计数类问题(统计方案总数)
最优决策类问题 (最大值或最小值)
记忆化搜索
记忆化搜索=搜索的形式+动态规划的思想。
记忆化搜索的思想是,在搜索过程中,会有很多重复计算,如果我们能记录一些状态的答案,就可以减少重复搜索量
近似于暴力
线性DP
综合难度在所有动规题里最为简单。
线性动规既是一切动规的基础,同时也可以广泛解决生活中的各项问题——比如在我们所在的三维世界里,四维的时间就是不可逆式线性。
线性动态规划是在线性结构上进行状态转移,这类问题不像背包问题、区间DP等有固定的模板。
线性动态规划的目标函数为特定变量的线性函数,约束是这些变量的线性不等式或等式,目的是求目标函数的最大值或最小值。
例题
子序列问题
LIS (Longest Increasing Subsequence,最长上升子序列)
最长上升子序列的元素不一定相邻
最长上升子序列一定是原序列的子集。
给定n个元素的数列,求最长的上升子序列长度。
这类动态规划问题的状态一般是一维的f[i],第i个元素的最优值只与前i-1个元素的最优值 (正推)或第i+1个元素之后的最优值 (倒推) 有关。
n^2做法
首先,对于每一个元素来说,其最长上升子序列就是其本身。那我们便可以维护一个dp数组,使得dp[i]表示以第i元素为结尾的最长上升子序列长度,那么对于每一个dp[i]而言,初始值即为1;
那么dp数组怎么求呢?我们可以对于每一个i,枚举在i之前的每一个元素j,然后对于每一个dp[j],如果元素i大于元素j,那么就可以考虑继承,而最优解的得出则是依靠对于每一个继承而来的dp值取max。
1 for(int i=1;i<=n;i++) 2 { 3 dp[i]=1;//初始化 4 for(int j=1;j//枚举i之前的每一个j 5 if(data[j]1) 6 /*用if判断是否可以拼凑成上升子序列, 7 并且判断当前状态是否优于之前枚举 8 过的所有状态,如果是,则↓*/ 9 dp[i]=dp[j]+1;//更新最优状态 10 11 }
最后,因为我们对于dp数组的定义是到i为止的最长上升子序列长度,所以我们最后对于整个序列,只需要输出dp[n](n为元素个数)即可。
nlogn 做法
我们其实不难看出,对于n^2做法而言,其实就是暴力枚举:将每个状态都分别比较一遍。但其实有些没有必要的状态的枚举,导致浪费许多时间,当元素个数到了10^4-10^5以上时,就已经超时了。而此时,我们可以通过另一种动态规划的方式来降低时间复杂度:
将原来的dp数组的存储由数值换成该序列中,上升子序列长度为i的上升子序列的最小末尾数值。
这其实就是一种几近贪心的思想:我们当前的上升子序列长度如果已经确定,那么如果这种长度的子序列的结尾元素越小,后面的元素就可以更方便地加入到这条我们臆测的、可作为结果的上升子序列中。
1 int n; 2 cin>>n; 3 for(int i=1;i<=n;i++) 4 { 5 cin>>a[i]; 6 f[i]=0x7fffffff; 7 //初始值要设为INF 8 /*原因很简单,每遇到一个新的元素时,就跟已经记录的f数组当前所记录的最长 9 上升子序列的末尾元素相比较:如果小于此元素,那么就不断向前找,直到找到 10 一个刚好比它大的元素,替换;反之如果大于,么填到末尾元素的下一个q,INF 11 是为了方便向后替换*/ 12 } 13 f[1]=a[1]; 14 int len=1;//通过记录f数组的有效位数,求得个数 15 /*因为上文中所提到我们有可能要不断向前寻找, 16 所以可以采用二分查找的策略,这便是将时间复杂 17 度降成nlogn级别的关键因素。*/ 18 for(int i=2;i<=n;i++) 19 { 20 int l=0,r=len,mid; 21 if(a[i]>f[len])f[++len]=a[i]; 22 //如果刚好大于末尾,暂时向后顺次填充 23 else 24 { 25 while(l<r) 26 { 27 mid=(l+r)/2; 28 if(f[mid]>a[i])r=mid; 29 /*如果仍然小于之前所记录的最小末尾,那么不断 30 向前寻找(因为是最长上升子序列,所以f数组必 31 然满足单调)*/ 32 else l=mid+1; 33 } 34 f[l]=min(a[i],f[l]);//更新最小末尾 35 } 36 } 37 cout<
Another Situation
但是事实上,nlogn做法偷了个懒,没有记录以每一个元素结尾的最长上升子序列长度。那么我们对于n^2的统计方案数,有很好想的如下代码(再对第一次的dp数组dp一次):
1 for(i = 1; i <= N; i ++){ 2 if(dp[i] == 1) f[i] = 1 ; 3 for(j = 1; j <= N: j ++) 4 if(base[i] > base[j] && dp[j] == dp[i] - 1) f[i] += f[j] ; 5 else if(base[i] == base[j] && dp[j] == dp[i]) f[i] = 0 ; 6 if(f[i] == ans) res ++ ; 7 }
nlogn虽然好像也可以做,但是想的话会比较麻烦,在这里就暂时不讨论了QwQ,
但这件事的目的是为了论证一个观点:
时间复杂度越高的算法越全能。
输出路径
只要记录前驱,然后递归输出即可(也可以用栈的)。
n^2的完整代码
1 #include2 using namespace std; 3 const int MAXN = 1000 + 10; 4 int n, data[MAXN]; 5 int dp[MAXN]; 6 int from[MAXN]; 7 void output(int x) 8 { 9 if(!x)return; 10 output(from[x]); 11 cout<" "; 12 //迭代输出 13 } 14 int main() 15 { 16 cin>>n; 17 for(int i=1;i<=n;i++)cin>>data[i]; 18 19 // DP 20 for(int i=1;i<=n;i++) 21 { 22 dp[i]=1; 23 from[i]=0; 24 for(int j=1;j) 25 if(data[j]1) 26 { 27 dp[i]=dp[j]+1; 28 from[i]=j;//逐个记录前驱 29 } 30 } 31 32 int ans=dp[1], pos=1; 33 for(int i=1;i<=n;i++) 34 if(ans<dp[i]) 35 { 36 ans=dp[i]; 37 pos=i;/*由于需要递归输出 38 所以要记录最长上升子序列的最后一 39 个元素,来不断回溯出路径来*/ 40 } 41 cout< endl; 42 output(pos); 43 44 return 0; 45 }
补:
最长上升子序列长度 <
最长不下降子序列长度 <=
最长下降子序列长度 >
最长不上升子序列长度 >=
最长公共子序列(LCS)
我们可以用dp[i][j]来表示第一个串的前i位,第二个串的前j位的LCS的长度,那么我们是很容易想到状态转移方程的:
如果当前的A1[i]和A2[j]相同(即是有新的公共元素) 那么
dp[ i ] [ j ] = max(dp[ i ] [ j ], dp[ i-1 ] [ j-1 ] + 1);
如果不相同,即无法更新公共元素,考虑继承:
dp[ i ] [ j ] = max(dp[ i-1 ][ j ] , dp[ i ][ j-1 ]);
1 #include2 using namespace std; 3 int dp[1001][1001],a1[2001],a2[2001],n,m; 4 int main() 5 { 6 //dp[i][j]表示两个串从头开始,直到第一个串的第i位 7 //和第二个串的第j位最多有多少个公共子元素 8 cin>>n>>m; 9 for(int i=1;i<=n;i++)scanf("%d",&a1[i]); 10 for(int i=1;i<=m;i++)scanf("%d",&a2[i]); 11 for(int i=1;i<=n;i++) 12 for(int j=1;j<=m;j++) 13 { 14 dp[i][j]=max(dp[i-1][j],dp[i][j-1]); 15 if(a1[i]==a2[j]) 16 dp[i][j]=max(dp[i][j],dp[i-1][j-1]+1); 17 //因为更新,所以++; 18 } 19 cout<<dp[n][m]; 20 }
对于洛谷P1439而言,不仅是卡上面的朴素算法,也考察到了全排列的性质:
对于这个题而言,朴素算法是n^2的,会被10^5卡死,所以我们可以考虑nlogn的做法:
因为两个序列都是1~n的全排列,那么两个序列元素互异且相同,也就是说只是位置不同罢了,那么我们通过一个map数组将A序列的数字在B序列中的位置表示出来——
因为最长公共子序列是按位向后比对的,所以a序列每个元素在b序列中的位置如果递增,就说明b中的这个数在a中的这个数整体位置偏后,可以考虑纳入LCS——那么就可以转变成nlogn,即求用来记录新的位置的map数组中的LIS。
1 #include2 #include 3 using namespace std; 4 int a[100001],b[100001],map[100001],f[100001]; 5 int main() 6 { 7 int n; 8 cin>>n; 9 for(int i=1;i<=n;i++){scanf("%d",&a[i]);map[a[i]]=i;} 10 for(int i=1;i<=n;i++){scanf("%d",&b[i]);f[i]=0x7fffffff;} 11 int len=0; 12 f[0]=0; 13 for(int i=1;i<=n;i++) 14 { 15 int l=0,r=len,mid; 16 if(map[b[i]]>f[len])f[++len]=map[b[i]]; 17 else 18 { 19 while(l<r) 20 { 21 mid=(l+r)/2; 22 if(f[mid]>map[b[i]])r=mid; 23 else l=mid+1; 24 } 25 f[l]=min(map[b[i]],f[l]); 26 } 27 } 28 cout<<len; 29 return 0 30 }
坐标DP
在二维坐标系内,规定了方向,求最优值问题
比较容易根据方向写出动态规划方程
一般方程也是二维的f[i][j]
二维模型f[i][j]
例题
最长公共子序列模型LCS
区间DP
区间型动态规划是线性动态规划的拓展,它将区间长度作为阶段,长区间的答案与短区间有关。
区间dp就是在区间上进行动态规划,求解一段区间上的最优解。主要是通过合并小区间的最优解进而得出整个大区间上最优解的dp算法。
在求解长区间答案前需先将短区间答案求出。
背包DP
0/1背包
每个物体只能拿一次,要求在一定的空间内,拿物体使得到的价值最大。
完全背包
每个物体可以拿无数次,要求在一定的空间内,拿物体使得到的价值最大。
多重背包
每个物体最多可以拿c【i】次,即次数限制可能不同。要求在一定的空间内,拿物体使得到的价值最大。
树上背包
大部分给你一棵树让你做DP的题,都是先从子树开始考虑,然后子树合并……
混合背包
树型DP
树型动态规划就是在“树”的数据结构上的动态规划,平时作的动态规划都是线性的或者是建立在图上的,线性的动态规划有二种方向既向前和向后,相应的线性的动态规划有二种方法既顺推与逆推,而树型动态规划是建立在树上的,所以也相应的有二个方向:
叶->根:在回溯的时候从叶子节点往上更新信息 ;
根 - >叶:往往是在从叶往根dfs一遍之后(相当于预处理),再重新往下获取最后的答案。
两者根据需要采用。
树自带了递归结构,因此一般会按照子树去定义状态。
转移一般分为两部分:对不同子树的合并和加入根节点。
状压DP(状态压缩DP)
状态压缩动态规划,就是我们俗称的状压DP,是利用计算机二进制的性质来描述状态的一种DP方式。
很多棋盘问题都运用到了状压,状压也很经常和BFS及DP连用。
状压dp其实就是将状态压缩成2进制来保存 其特征就是看起来有点像搜索,每个格子的状态只有1或0 ,是另一类非常典型的动态规划。
概率/期望DP
数学期望 P=Σ每一种状态*对应的概率。
参考了各位大佬的博客/题解/代码,并非自主创作,请见谅
摘抄一句很喜欢的话:
Although there′re difficulties ahead of us , remember :
就算出走半生,归来仍要是少年