动态规划(Dynamic Programming)
一、动态规划相关的知识
- 动态规划是一种思想,它没有固定的写法、及其灵活,常常需要具体问题具体分析。
动态规划
是一种用来解决一类最优化问题
的算法思想。动态规划
将一个复杂的问题分解成若干个子问题,通过综合子问题的最优解来得到原问题的最优解。需要注意的是,动态规划
会将每个求解过的子问题的解记录下来,这样当下次碰到同样的子问题时,就可以直接使用之前记录的结果,而不是重复计算。- 重叠子问题:如果一个问题可以被分解为若干个子问题,且这些子问题会重复出现,那么称这个问题拥有重叠子问题(Overlapping Subproblems)。动态规划通过记录重叠子问题的解,来使下次碰到相同的子问题时直接使用之前记录的结果,以此避免大量重复计算。因此,一个问题必须拥有重叠子问题,才能使用动态规划去解决。
- 最优子结构:如果一个问题的最优解可以由其子问题的最优解有效的构造出来,那么称这个问题拥有最优子结构(Optimal Substructure)。最优子结构保证了动态规划中原问题的最优解可以由子问题的最优解推导出来。因此,一个问题必须拥有最优子结构,才能使用动态规划去解决。
- 一个问题必须拥有重叠子问题和最优子结构,才能使用动态规划去解决。
- 分治与动态规划。
- 分治和动态规划都是将问题分解为子问题,然后合并子问题的解得到原问题的解。
- 不同的是,分治法分解出的子问题是不重叠的,因此分治法解决的问题不拥有重叠子问题,而动态规划解决的问题拥有重叠子问题。
- 分治解决的问题不一定是最优化问题,而动态规划解决的问题一定是最优化问题。
- 贪心与动态规划。
- 贪心和动态规划都要求原问题必须拥有最优子结构。
- 贪心总是只在上一步选择的基础上继续选择,因此整个过程以一种单链的流水方式进行,显然这种所谓的 ”最优选择“ 的正确性需要用归纳法证明。
- 动态规划总是会考虑所有子问题,并选择继承能得到最优结果的那个,对暂时没被选继承到的子问题,由于重叠子问题的存在后期可能会再次考虑它们,因此还有机会成为全局最优解的一部分,不需要放弃。
- 贪心是一种壮士断腕的决策,只要进行了选择,就不后悔;而动态规划则要看哪个选择笑到了最后,暂时的领先说明不了什么。
- 一般使用递归或者递推的写法来实现动态规划,其中递归写法又称为记忆化搜索。
- 递推和递归的区别:使用递推写法的计算方式是自底向上(Bottom-up Approach),即从边界开始,不断向上解决问题,直到解决到目标问题;而使用递归写法的计算方式是自顶向下(Top-down Approach),即从目标问题开始,将它分解成子问题的组合,直到分解至边界为止。
- 状态的无后效性: 当前状态记录了历史信息,一旦当前状态确定,就不会再改变,且未来的决策只能在已有的一个或若干个状态的基础上进行,历史信息只能通过已有的状态去影响未来的决策。
- 对动态规划可解的问题来说,总会有很多设计状态的方式,但并不是所有的状态都具有无后效性,因此必须设计一个拥有无后效性的状态以及相应的状态转移方程,否则动态规划就没有办法得到正确结果。
- 如何设计状态和状态转移方程,才是动态规划的核心,而它们也是动态规划最难的地方。
- 多阶段动态规划问题:
- 有一类动态规划可解的问题,它可以描述成若干个有序的阶段,且每个阶段的状态只和上一个阶段的状态有关,一般把这类问题称为多阶段动态规划问题。
- 显然,对这种问题,只需要从第一个问题开始,按照阶段的顺序解决每个阶段中状态的计算,就可以得到最后一个阶段中的状态的解。
二、经典的动态规划模型
(1)数塔 DP
问题描述:将一些数字排列成数塔的形状,其中第一层有一个数字,第二层有两个数字 … 第n层有n个数字。现在要从第一层走到第 n 层,每次只能走向下一层连接的两个数字中的一个,问:最后将路径上所有数字相加后得到的和最大是多少?
-
状态: 令 dp[i][j] 表示从第 i 行第 j 个数字出发的到达最底层的所有路径上所能得到的最大和。
-
状态转移方程:
dp[i][j] = max(dp[i+1][j], dp[i+1][j+1]) + f[i][j];
-
边界: 数塔的最后一层的 dp 值总是等于元素本身。
-
结果: dp[1][1] 的值就是最后的结果。
(2)最大连续子序列和
问题描述:给定一个数字序列 A1、A2、… 、An ,求 i, j(1 <= i <= j <= n),使得 Ai + … + Aj 最大,输出这个最大的和。
-
状态: 令 dp[i] 表示以 A[i] 作为末尾的连续序列的最大和。
-
状态转移方程:
dp[i] = max(A[i], dp[i-1]+A[i]);
-
边界: dp[0] = 0
-
结果: 要求的最大和其实就是 dp 数组中的最大值,因为到底以哪个元素结尾未知。
-
总结:
- 求最大连续子序列和的时候其实可以不需要一个动态数组来进行专门的存储,只需要一个变量在寻找的过程中记录中间最大的结果就可以找出最大连续子序列。
(3)最大子矩阵之和
问题描述:输入一个m*n的矩阵,找出在矩阵中,所有元素加起来之和最大的子矩阵。
- 将二维的转化为一维的最大连续子序列和,进行行压缩。
- 枚举两列假设,然后将行看成是求一维的最大连续和的方式进行求解就好。
- 其实就有点相当于一行一行的加,直到找到最大的值为止啦。
- 这个问题进行暴力求解的时间复杂度为 O(n的六次方)
- 这个问题进行一维前缀和优化求解的时间复杂度为 O(n的五次方)
- 这个问题进行二维前缀和优化求解的时间复杂度为 O(n的四次方)
- 这个问题进行动态规划求解的时间复杂度为 O(n的3次方)
(4)最长不下降(上升)子序列(LIS)
问题描述:在一个数字序列中,找到一个最长的子序列(可以不连续),使得这个子序列是不下降(非递减)的。
- 状态: 令 dp[i] 表示以 A[i] 结尾的最长不下降子序列的长度。
- 状态转移方程:
dp[i] = max(1, dp[j] + 1); (j = 1,2,..,i-1 && A[j] <= A[i])
- 边界: dp[i] = 1
- 结果: 要求的最大长度其实就是 dp 数组中的最大值,因为到底以哪个元素结尾未知。
(5)最长公共子序列(LCS)
问题描述:给定两个字符串(或数字序列)A 和 B,求一个字符串,使得这个字符串是 A 和 B 的最长公共部分(子序列可以不连续)。
- 状态: 令 dp[i][j] 表示字符串 A 的 i 号位和字符串 B 的 j 号位之前的 LCS 长度(下标从 1 开始)。
- 状态转移方程:
//如果 A[i] == B[i] dp[i][j] = dp[i-1][j-1] + 1; //如果 A[i] != B[i] dp[i][j] = max(dp[i-1][j], dp[i][j-1]);
- 边界: dp[i][0] = dp[0][j] = 0 (0 <= i <= n, 0 <= j <= m)
- 结果: 最终 dp[n][m] 就是需要的答案。
(6)最长回文字符串
问题描述:给出一个字符串 S ,求 S 的最长回文字符串。
-
状态: 令 dp[i][j] 表示 S[i] 至 S[j] 所表示的字串是否是回文子串,是则为 1, 不是则为 0。
-
状态转移方程:
//S[i] == S[j] dp[i][j] = dp[i+1][j-1]; //S[i] != S[j] dp[i][j] = 0; //如果按照i 和 j 从小到大的顺序来枚举字串的两个端点,然后更新 dp[i][j],会无法保证 dp[i+1][j-1] 已经被计算过,从而无法得到正确的 dp[i][j]。 //事实上,无论对 i 和 j 的枚举顺序做何调整,都无法调和这个矛盾,因此必须想办法寻找新的枚举方式。 //根据递推写法从边界出发的原理,注意到边界表示的是长度为 1 和 2 的字串,且每次转移时都对字串的长度减了 2 ,因此不妨考虑按字串的长度和字串的初始位置进行枚举。 //最长回文字串问题,可以使用二分 + 字符串 hash 的做法,复杂度为 O(nlogn),Manacher算法的复杂度为 O(n),动态规划的复杂度为 O(n的二次方)。
-
边界: dp i i = 1,dp i i+1 = (S[i] == S[i+1]) ? 1 : 0。
-
结果: 动态二维数组中元素为 1 的所在行数就是存在回文子字符串,所以行数最大的就是最大的回文子字符串。
(7)DAG(有向无环图)最长路
问题描述:
- 状态:
- 状态转移方程:
- 边界:
- 结果:
(8)01 背包问题
问题描述:有 n 件物品的重量为 w[i] ,价值为 c[i]。现有一个容量为 V 的背包,问如何选取物品放入背包,使得背包内物品的总价值最大。其中每种物品都只有一件。**
- 状态: 令 dp i v 表示前 i 件物品 ( 1 <= i <= n,0 <= v <= V) 恰好装入背包容量为 v 的背包中所能获得的最大价值。
- 状态转移方程:
dp[i][v] = max(dp[i-1][v], dp[i-1][v-w[i]] + c[i]); //(1 <= i <= n, w[i] <= v <= V),这是使用二维动态数组的情况,这里逆序或者顺序都无所谓。 dp[v] = max(dp[v], dp[v-w[i]] + c[i]) //(i <= i<= n, w[i] <= v <= V),这是使用一维动态数组的情况,枚举方向改变为 i 从 1 到 n, v 从 V 到 0(逆序)。 //v 的枚举顺序变为从右往左,dp[i][v] 右边的部分为刚计算过的需要保存给下一行使用的数据,而 dp[i][v] 左边的部分为当前需要使用的上一行保存的数据,这里的数组称为滚动数组。 //特别说明:如果是用二维数组存放, v 的枚举是顺序还是逆序都无所谓;如果使用一维数组存放,则 v 的枚举必须是逆序! //01 背包中的每个物品都可以看作一个阶段,这个阶段中的状态有 dp[i][0]~dp[i][V],它们均由上一个阶段的状态得到。 //事实上,对能够划分阶段的问题来说,都可以尝试把阶段作为状态的一维,这可以使我们更方便地得到满足无后效性的状态。 //如果当前设计的状态不满足无后效性,那么不妨把状态进行升维,即增加一维或若干维来表示相应的信息,这样可能就满足无后效性了。
- 边界: dp 0 v = 0 (0 <= v <= V)
- 结果: 由于 dp i v 表示的是恰好为 v 的情况,所以需要枚举 dp n v (0 <= v <= V),取其最大值才是最后的结果。(我感觉最后的 dp n V 就是最大值了)。
(9)完全背包问题
问题描述: 有 n 种物品,每种物品的单件重量为 w[i],价值为 v[i]。现有一个容量为 V 的背包,问如何选取物品放入背包,使得背包内物品的总价值最大。其中每种物品有无穷件。
-
状态: 令 dp i v 表示前 i 件物品恰好放入容量为 v 的背包中能获得最大价值。
-
状态转移方程:
dp[i][v] = max(dp[i-1][v], dp[i][v-w[i]] + c[i])//(1 <= i <= n, w[i] <= v <= V) //其实唯一的区别就在于 max 的第二个参数是 dp[i] 而不是 dp[i-1]。 dp[v] = max(dp[v], dp[v-w[i]] + c[i]) //(i <= i<= n, w[i] <= v <= V),这是使用一维动态数组的情况,枚举方向改变为 i 从 1 到 n, v 从 0 到 V(顺序)。 //求解 dp[i][v] 需要它左边的 dp[i][v-w[i]] 和 它上方的 dp[i-1][v],显然如果让 v 从小到大枚举,dp[i][v-w[i]] 就总是已经计算出来的结果;而计算出来 dp[i][v]之后 dp[i-1][v] 就再也用不到了,可以直接覆盖掉。
-
边界: dp 0 v = 0 (0 <= v <= V)
-
结果: 由于 dp i v 表示的是恰好为 v 的情况,所以需要枚举 dp n v (0 <= v <= V),取其最大值才是最后的结果。(我感觉最后的 dp n V 就是最大值了)。
三、总结
- 在大多数情况下,都可以把
动态规划
可解的问题看作一个有向无环图(DAG)
,图中的结点就是状态
,边就是状态转移
的方向,求解问题的顺序就是按照DAG
的拓扑排序
进行求解。可以通过这个角度辅助理解动态规划问题。 - 当题目与序列或字符串(记为A)有关时,可以考虑把状态设计成下面两种形式,然后根据端点特点去考虑状态转移方程。
- 令 dp[i] 表示以 A[i] 结尾或开头的 xxxx。
- 令dp[i][j] 表示 A[i] 至 A[j] 区间的 xxxx。
- 其中 xxxx 均为原问题的表述。
- 分析题目中的状态需要几维来表示,然后对其中的每一维采取下面的某一个表述:
- 恰好为 i。
- 前 i。
- 在每一维的含义设置完毕以后,dp 数组的含义就可以设置成 “令 dp 数组表示恰好为 i (或前 i )、恰好为 j(或前 j )… 的 xxxx",其中 xxxx 为原问题的描述。接下来就可以通过端点的特点去考虑状态转移方程。
四、动态规划相关的题目
1.【DP】第十四届蓝桥杯省赛C++ B组《接龙数列》(C++)
【题目描述】
对于一个长度为 K 的整数数列:A1,A2,…,AK,我们称之为接龙数列当且仅当 的首位数字恰好等于 的末位数字 (2≤i≤K)。
例如 12,23,35,56,61,11 是接龙数列;12,23,34,56 不是接龙数列,因为 56 的首位数字不等于 34 的末位数字。
所有长度为 1 的整数数列都是接龙数列。
现在给定一个长度为 N 的数列 A1,A2,…,AN,请你计算最少从中删除多少个数,可以使剩下的序列是接龙序列?
【输入格式】
第一行包含一个整数 N。
第二行包含 N 个整数 A1,A2,…,AN。
【输出格式】
一个整数代表答案。
【数据范围】
对于 20% 的数据,1≤N≤20。
对于 50% 的数据,1≤N≤10000。
对于 100% 的数据,1≤N≤10的5次方,1≤Ai≤10的9次方。所有 Ai 保证不包含前导 0。
【输入样例】
5
11 121 22 12 2023
【输出样例】
1
【样例解释】
删除 22,剩余 11,121,12,2023 是接龙数列。
- 【代码】
#include<bits/stdc++.h> using namespace std; //全局变量会赋初始值,初始值为 0 int n, arr[10], res; string str; int main(){ cin>>n; for(int i=0; i<n; i++){ cin>>str; int a = str[0] - '0',b = str[str.size() - 1] - '0'; int f = arr[a] + 1; arr[b] = max(arr[b], f); res = max(res, f); } cout<<n-res; return 0; }
五、参考
[1]. 第十四届蓝桥杯省赛C++ B组所有题目以及题解(C++)【编程题均通过100%测试数据】