算法笔记之蓝桥杯&pat系统备考(2)
多训练、多思考、多总结٩(๑•̀ω•́๑)۶
八、深搜和广搜
8.1DFS
dfs是一种枚举完所有完整路径以遍历所有情况的搜索方法,可以理解为每次都是一条路走到黑的犟种。
以老朋友斐波那契额数列为例,F(0)和F(1)可以视为死胡同,对于F(n)可以再走F(n-1)和F(n-2),依次类推。
简单应用:01背包
#include<iostream>
using namespace std;
const int maxn = 210;
int maxv = 0, n, m;
int w[maxn], v[maxn];
void dfs(int index, int sumW, int sumC){
if(index == n){//完成对n间物品的选择,来到了死胡同
if(sumW <= m && sumC > maxv) maxv = sumC;//出现不超过背包容量,且价值更大的方案则更新
return;
}
dfs(index + 1, sumW + w[index], sumC + v[index]);//放入第index物品试试
dfs(index + 1, sumW, sumC);//选择不放入第index件物品
}
int main(){
scanf("%d%d", &n, &m);
for(int i = 0; i < n; i++){
scanf("%d%d", w + i, v + i);
}
dfs(0, 0, 0);
printf("%d", maxv);
return 0;
}
每件物品有两种选择,则上述代码的时间复杂度为O(2n)。当前想法是对所有物品确定后才判断是否超重,其实很有可能在过程中就已经超重了,完全可以在对每个物品的判断中都先确定不会超重的基础上继续选择。
#include<iostream>
using namespace std;
const int maxn = 210;
int maxv = 0, n, m;
int w[maxn], v[maxn];
void dfs(int index, int sumW, int sumC){
if(index == n) return;
if(sumW + w[index] <= m){
if(sumC + v[index] > maxv) maxv = sumC + v[index];//更新最大价值
dfs(index + 1, sumW + w[index], sumC + v[index]);//放入试试
}
dfs(index + 1, sumW, sumC);//选择不放入
}
int main(){
scanf("%d%d", &n, &m);
for(int i = 0; i < n; i++){
scanf("%d%d", w + i, v + i);
}
dfs(0, 0, 0);
printf("%d", maxv);
return 0;
}
这种通过题目条件的限制来节省计算量的方法称为剪枝。
上述的01背包中是一类常见的DFS问题的解决方法,即给定一个序列,枚举这个序列的所有子序列(可以不连续)。
牛刀小试:
蓝桥杯算法提高VIP-01背包
蓝桥杯-分糖果
蓝桥杯-飞机降落
蓝桥杯-小朋友崇拜圈
蓝桥杯-正则问题
1103 Integer Factorization
蓝桥杯-买瓜
8.2BFS
BFS广度优先搜索,当碰到岔道口时,先依次访问该岔道口能直接到达的所有结点,然后再访问这些结点能直接到达的结点,以此类推。
BFS一般由队列实现,且总是按层次的 顺序进行遍历,其基本写法为:
void bfs(int s){
queue<int> q;//定义队列q
q.push(s);//把起点s入队
while(!q.empty()){
取出队首元素top;
访问队首元素top;
把队首元素出队;
把top的下一层结点中没入队的结点全部入队,并设置为已入队;
}
}
即若干个(> 0)相邻的1为一个块,所谓相邻包括上下左右四个位置
#include<iostream>
#include<queue>
using namespace std;
const int maxn = 100;
struct node{//结点(x, y)
int x,y;
}Node;
int n, m, a[maxn][maxn], flag[maxn][maxn] = {0};
int X[4] = {0, 0, -1, 1};//增量数组
int Y[4] = {-1, 1, 0, 0};//左 右 上 下
bool judge(int x, int y){//判断是否需要访问点(x, y)
if(x < 0 || x >= n || y < 0 || y >= m) return false;//越界,非法坐标
if(!a[x][y] || flag[x][y]) return false;//当前位置为0或者已经访问过,则跳过该点即可
return true;
}
void bfs(int x, int y){
queue<node> q;//辅助队列
Node.x = x;//当前节点为(x, y)
Node.y = y;
flag[x][y] = 1;
q.push(Node);//当前结点入队
while(!q.empty()){//队列非空时循环
node top = q.front();//取出队首元素
q.pop();//队首元素出队
for(int i = 0; i < 4; i++){//得到上下左右的点坐标
int newX = top.x + X[i];
int newY = top.y + Y[i];
if(judge(newX, newY)){//若需要访问则入队
Node.x = newX;
Node.y = newY;
q.push(Node);
flag[newX][newY] = 1;
}
}
}
}
int main(){
int ans = 0;//总块数
scanf("%d%d", &n, &m);//矩阵规模
for(int i = 0; i < n; i++){
for(int j = 0; j < m; j++){
scanf("%d", &a[i][j]);
}
}
for(int i = 0; i < n; i++){//枚举每一个位置
for(int j = 0; j < m; j++){
if(a[i][j] && !flag[i][j]){//当前位置是1且不在其他已计数的块中
ans++;//块数加一
bfs(i, j);//访问整个块,把它们的位置都标记为1已出现
}
}
}
printf("%d", ans);
return 0;
}
- tips:涉及到最小操作步骤问题,优先考虑bfs(联想洪水流向四面八方,最先流到即最小操作步骤),例如八数码问题
- 可以借助map绑定状态和相应次数
bfs是对于其相邻结点,再依次递推对相邻结点的相邻结点进行处理。所有它的应用也是对于周边下手的类型,八数码问题空格按照某种规则向相邻位置移动,数1的块数问题是一坨相邻的1算是一块。
十一、动态规划
11.1动态规划的递归写法和递推写法
11.1.1啥是动态规划
动态规划(Dynamic Programming ,DP)是一种用来解决一类最优化问题的算法思想。简言之,动态规划把一个复杂的问题分解为若干个子问题,通过综合子问题的最优解来得到原问题的最优解。其中,动态规划会把每个求解过的子问题的解记录下来,这样下次用到同样的子问题时可以直接使用之前记录的结果,而非笨笨地重复计算。不过嘛,虽然动态规划采用该方式来提高计算效率,但不能说这就是动态规划的核心(后续会说明这一点)。
一般可使用递归或者递推的写法来实现动态规划,其中递归写法又称为记忆化搜索。
11.1.2动态规划的递归写法
先来瞄瞄递归写法,通过这块呢我们可以理解下动态规划是咋记录子问题的解,来避免下次遇到相同的子问题的重复计算的。
以老朋友斐波那契数列为例,定义为f0 = 1, f1 = 1, fn = fn-1+fn-2(n >= 2)。直接递归实现:
int f(int n){
if(n == 0 || n == 1) return 1;
return f(n-1) + f(n-2);
}
会发现包含了许多重复的计算,时间复杂度是O(2n),即每次都会计算f(n-1)和f(n-2)这两个分支,基本不能负担n较大的情况。
为了避免重复计算,可以开一个一维数组dp,用以保存已经计算过的结果,其中dp[n]记录f(n)的结果,用dp[n] = -1表示f(n)当前还未被计算过。
int dp[maxn];
可以再递归中判断dp[n]是否为-1,若不是,说明已经计算过,直接返回dp[n]即可;否则,按照递归式进行递归
int f(int n){
if(n == 0 || n == 1) return 1;//递归边界
if(dp[n] != -1) return dp[n]; //已计算过,直接返回结果,无需重复计算
esle{
dp[n] = f(n - 1) + f(n - 2);//计算f(n),并保存到dp[n]
return dp[n];//返回f(n)的结果
}
}
上述把已经计算过的内容记录下来,之后再用到时相同内容时直接使用上次计算的结果即可,省去了无效计算,也就是所谓的记忆化搜索的由来。通过记忆化搜索,时间复杂度从O(2n)降到了O(n)。
通过该例子可以引申出一个概念:若一个问题可以被分解为若干个子问题,且这些子问题会重复出现,则称这个问题具有重叠子问题(Overlapping Subproblems)。动态规划通过记录重叠子问题的解,使后续碰到相同的子问题时直接使用之前记录的结果,来避免大量重复计算。故,一个问题必须拥有重叠子问题,才能去使用动态规划解决。
11.1.3动态规划的递推写法
以经典的数塔问题为例,
观察下面的数字金字塔。
写一个程序来查找从最高点到底部任意处结束的路径,使路径经过数字的和最大。每一步可以走到左下方的点也可以到达右下方的点。
在上面的样例中,从 7 → 3 → 8 → 7 → 5 7 \to 3 \to 8 \to 7 \to 5 7→3→8→7→5 的路径产生了最大权值。
- 输入格式
第一个行一个正整数 r r r ,表示行的数目。
后面每行为这个数字金字塔特定行包含的整数。
- 输出格式
单独的一行,包含那个可能得到的最大的和。
-
样例 #1
-
样例输入 #1
5
7
3 8
8 1 0
2 7 4 4
4 5 2 6 5
- 样例输出 #1
30
- 提示
【数据范围】
对于
100
%
100\%
100% 的数据,
1
≤
r
≤
1000
1\le r \le 1000
1≤r≤1000,所有输入在
[
0
,
100
]
[0,100]
[0,100] 范围内。(end)
首先如果尝试穷举所有路径,再去得到路径上数字和的最大值,则由于每层中的数字都会有两条分支路径,时间复杂度为O(2n)。显然n一大就跑不起来了。
一开始,从第一层的7出发,按7->3->1的路线来到1,并枚举从1出发的到达最底层的所有路径。但是当按7->8->1的路线再次来到1时,又会去枚举从1出发到达最底层的所有路径,这就导致了从1出发的到达最底层的所有路径时就把路径上能产生的最大和记录下来,这样当再次访问到1这个数字时就可以直接获取这个最大值,避免重复计算。
基于上述分析,不妨令dp[i][j]表示从第i行第j个数字出发的到达最底层的所有路径中能得到的最大和,例如说dp[3][2]就是图中的1到最底层的路径最大和。再定义这个数组后,dp[1][1]就射最终目标答案。若想求出“从位置(1,1)到达最底层的最大和”dp[1][1],则一定要先求出它的两个子问题“从位置(2,1)到达最底层的最大和dp[2][1]”和“从位置(2,2)到达最底层的最大和dp[2][2]”,即进行了一次决策:走数字7的左下还是右下。则dp[1][1]就是dp[2][1]和dp[2][2]的较大值加上7,写为式子:
dp[1][1] = max(dp[2][1], dp[2][2]) + f[1][1]
进一步可以归纳为,若想求除dp[i][j],则要先求出它的两个子问题“从位置(i + 1, j)到达最底层的最大和dp[i + 1, j]”和“从位置(i + 1, j + 1)到达最底层的最大和dp[i + 1][j + 1]”,即进行了一次决策:走位置(i,j)的左下还是右下。
dp[i][j] = max(dp[i + 1][j], dp[i + 1][j + 1]) + f[i][j]
把dp[i][j]称为问题的状态,上式称为状态转移方程,把状态dp[i][j]转译为dp[i+1][j]和dp[i+1][j+1]。可发现,状态dp[i][j]只和第i+1层的状态有关,与其他层状态无关,则层号i的状态可以有层号i+1的两个子状态得到。关于边界,可以发现数塔的最后一层的dp值总是等于元素本身,即dp[n][j]==f[n] j ,把这种可直接确定其结果的部分称为边界,而动态规划的递推写法总是从这些边界出发,通过状态转移方程扩散到整个dp数组。
如此就能从最底层各位置的dp值开始,不断往上求出每一层各位置的dp值,最后得到dp[1][1],即为所求。
#include<iostream>
#include<algorithm>
using namespace std;
const int maxn = 1010;
int a[maxn][maxn], dp[maxn][maxn];
int main(){
int r;
scanf("%d", &r);
for(int i = 1; i <= r; i++){
for(int j = 1; j <= i; j++){
scanf("%d", &a[i][j]);
}
}
for(int i = 1; i <= r; i++){//边界
dp[r][i] = a[r][i];//最底层木有路,路径和为本身
}
for(int i = r; i >= 1; i--){//从第n-1层不断往上计算出dp[i][j]
for(int j = 1; j <= i; j++){
dp[i][j] = max(dp[i + 1][j], dp[i + 1][j + 1]) + a[i][j];//状态转移方程
}
}
printf("%d", dp[1][1]);
return 0;
}
显然,使用递归也可以实现上面的例子
#include<iostream>
#include<algorithm>
using namespace std;
const int maxn = 1010;
int r, a[maxn][maxn], dp[maxn][maxn];
int f(int x, int y){
if(x == r) return a[x][y];
if(dp[x][y] != -1) return dp[x][y];
else{
dp[x][y] = max(f(x + 1, y), f(x + 1, y + 1)) + a[x][y];
return dp[x][y];
}
}
int main(){
scanf("%d", &r);
for(int i = 1; i <= r; i++){
for(int j = 1; j <= i; j++){
scanf("%d", &a[i][j]);
if(i == r) dp[i][j] = a[i][j];
else dp[i][j] = -1;
}
}
printf("%d", f(1, 1));
return 0;
}
两者的区别在于:使用递推写法的计算方式是自底向上(Bottom-up Approach),即从边界开始,不断向上解决问题,直到解决了目标问题;而使用递归写法的计算方式是自顶向下(Top-down Approach),即从目标问题开始,把它分解为子问题的组合,直到分解至边界为止。
通过上述例子再引申出一个概念:若一个问题的最优解可以由其子问题的最优解有效地构造出来,则称这个问题拥有最优子结构(Optimal Substructure)。最优子结构保证了动态规划中原问题的最优解可以由子问题的最优解推导而来。一个问题必须拥有最优子结构,才能使用动态规划来解决。例如数塔问题中,每一个位置的dp值都可以由它的两个子问题推导得到。
需要指出,一个问题必须拥有重叠子问题和最优子结构,才能使用动态规划去解决。
-
分治vs动态规划
- 相同点:都是把问题分解为子问题,再合并子问题的解得到原问题的解
- 不同点:
- 分治法分解出的子问题是不重叠的,因而分治法解决的问题不拥有重叠子问题。例如归并排序和快速排序都是分别处理左序列和右序列,再把左右序列的结果合并,过程中不出现重叠子问题,故它们使用的都是分治法。
动态规划解决的问题拥有重叠子问题。 - 分治法解决的问题不一定是最优化问题,而动态规划解决的问题一定是最优化问题。
- 分治法分解出的子问题是不重叠的,因而分治法解决的问题不拥有重叠子问题。例如归并排序和快速排序都是分别处理左序列和右序列,再把左右序列的结果合并,过程中不出现重叠子问题,故它们使用的都是分治法。
-
贪心vs动态规划
- 相同点:贪心和动态规划都要求原问题必须拥有最优子结构。
- 不同点:
- 贪心法采用的计算方式类似于上面介绍的“自顶向下”,但是并不等待子问题求解完毕后再选择使用哪一个,而是通过一种策略直接选择一个子问题去求解,没被选择的子问题就不去求解了,直接丢掉。换言之,贪心总是只在上一步选择的基础上继续选择,故整个过程以一种单链的流水方式进行,显然这种所谓的“最优选择”的正确性需要用归纳法证明。例如对数塔问题而言,贪心法从最上层开始,每次选择左下和右下两个数字中较大的一个,一只到最底层得到最后结果,显然不一定得到最优解。
动态规划不管是采用自底向上还是自顶向下的计算方式,都是从边界开始向上得到目标问题的解。换言之,它总是会考虑所有子问题,并选择继承能得到最优结果的那个,对暂时没有继承的子问题,由于重叠子问题的存在,后期可能会再次考虑它们,还有机会成为全局最优的一部分,不需要放弃。所以,贪心就是只要进行了选择,就不后悔;动态规划要看哪个选择笑到了最后,暂时的领先不算啥。
- 贪心法采用的计算方式类似于上面介绍的“自顶向下”,但是并不等待子问题求解完毕后再选择使用哪一个,而是通过一种策略直接选择一个子问题去求解,没被选择的子问题就不去求解了,直接丢掉。换言之,贪心总是只在上一步选择的基础上继续选择,故整个过程以一种单链的流水方式进行,显然这种所谓的“最优选择”的正确性需要用归纳法证明。例如对数塔问题而言,贪心法从最上层开始,每次选择左下和右下两个数字中较大的一个,一只到最底层得到最后结果,显然不一定得到最优解。
11.2最大连续子序列和
给定一个数字序列a1, a2, ……an,求i,j(1<=i<=j<=n)使得ai+……+aj最大,输出这个最大和。
令状态dp[i]表示为a[i]作为结尾的连续序列的最大和,则只有两种情况:
- 该最大和的连续序列只有一个元素,即以a[i]开始,以a[i]结尾
- 该最大和的连续序列有多个元素,即从前面某处a[p]开始(p < i),一直到a[i]结尾
得到状态转移方程dp[i] = max(a[i], dp[i - 1] + a[i])
这个式子只和i和i之前的元素有关,且边界为dp[0] = a[0],则从小到大枚举i,即可得到整个dp数组,数组中的最大值即为最大连续子序列和。
#include<iostream>
#include<algorithm>
using namespace std;
const int maxn = 10010;
int a[maxn], dp[maxn];//a[i]存放序列,dp[i]存放以a[i]为结尾的连续序列的最大和
int main(){
int n, ans;
scanf("%d", &n);
for(int i = 0; i < n; i++)//读入序列
scanf("%d", &a[i]);
ans = dp[0] = a[0];//边界
for(int i = 1; i < n; i++){
dp[i] = max(a[i], dp[i - 1] + a[i]);//状态转移方程
if(dp[i] > ans) ans = dp[i];
}
printf("%d", ans);
return 0;
}
顺带再了解下无后效性这个概念。
状态的无后效性:当前状态记录了历史信息,一旦当前状态确定,就不会再改变,且未来的决策只能在已有的一个或若干个状态的基础上进行,历史信息只能通过已有的状态去影响未来的决策。
例如宇宙的历史可以看作一个关于时间的线性序列,对每一个时刻而言,宇宙的现状就是这个时刻的状态,显然宇宙过去的信息蕴含在当前状态中,并只能通过当前状态来影响下一个时刻的状态,则从这个角度来说宇宙的关于时间的状态并无后效性。对于刚说的最大连续子序列和而言,每次计算状态dp[i],都只会涉及dp[i - 1]而不直接用到dp[i-1]蕴含的历史信息。
对动态规划可解的问题而言,总会有很多设计状态的方式,但并不是所有状态都具有无后效性,则必须设计一个拥有无后效性的状态以及相应的状态转移方程,否则动态规划就无法得到正确结果。事实上呀,如何设计状态和状态转移方程,才是动态规划的核心,而它们也是动态规划最难的地方。
11.3最长不降子序列(LIS)
最长不降子序列(Longest Increasing Sequence, LIS):在一个数字序列中,找到一个最长的子序列(可以不连续),使得这个子序列是不下降(非递减)的。例如对于序列a = {1, 2, 3, 4, -1, -2, 7, 9}的最长不下降序列是{1, 2, 3, 4, 7, 9},长度为5.
令dp[i]表示以a[i]为结尾的最长不下降子序列长度,有两种可能:
- 存在a[i]之前的元素a[j],满足a[j] <= a[i]且dp[j] + 1 > dp[i](即把a[i]跟在a[j]为结尾的LIS后面能比当前记录的以a[i]为结尾的LIS更长),则更新为a[i]跟在a[j]后的LIS长度
- 若a[i]之前的元素都比a[i]大,则只能a[i]自己形成一条LIS,长度为1,即这个子序列中只有一个a[i]
状态转移方程
dp[i] = max (1, dp[j]+1)(0<=j<=i-1)
#include<iostream>
#include<algorithm>
using namespace std;
const int maxn = 1010;
int a[maxn], dp[maxn];//dp[i]以a[i]为结尾的最长不降子序列长度
int main(){
int n, ans = -1;
scanf("%d", &n);
for(int i = 0; i < n; i++){
scanf("%d", &a[i]);
}
for(int i = 0; i < n; i++){
dp[i] = 1;//默认只有a[i]
for(int j = 0; j < i; j++){
if(a[i] >= a[j] && (dp[j] + 1 > dp[i])) dp[i] = dp[j] + 1;//状态转移方程,用以更新dp[i]
}
ans = max(ans, dp[i]);
}
printf("%d", ans);
return 0;
}
11.4最长公共子序列(LCS)
最长公共子序列(Longest Common Subsequence, LCS):给定两个字符串(或数字序列)A和B,求一个字符串,使得这个字符串是A和B的最长公共部分(子序列可以不连续)。例如,kid和killed的最长公共子序列为kid,长度为3.
令dp[i][j]表示字符串A的i号位和字符串B的j号位之前的LCS长度(下标从1开始),根据a[i]和b[j]的情况,分为两种决策:
- a[i] == b[j],则字符串a和字符串b的LCS增加了一位,即有dp[i][j] = dp[i-1][j-1] + 1
- a[i] != b[j],则字符串a的i号位和字符串b的j号位之前的LCS无法延长,故dp[i][j]继承dp[i-1][j]和dp[i][j-1]中的较大值
状态转移方程为:
#include<iostream>
#include<string>
#include<algorithm>
using namespace std;
int main(){
string a, b;
cin >> a >> b;
a = " " + a;
b = " " + b;
int la = a.size(), lb = b.size();
int dp[la + 1][lb + 1];
for(int i = 0; i < la; i++){
dp[i][0] = 0;
}
for(int i = 0; i < lb; i++){
dp[0][i] = 0;
}
for(int i = 1; i < la; i++){
for(int j = 1; j < lb; j++){
if(a[i] == b[j]) {
dp[i][j] = dp[i - 1][j - 1] + 1;
}
else dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]);
}
}
printf("%d", dp[la - 1][lb - 1]);
return 0;
}
11.7背包问题
11.7.1多阶段动态规划问题
有一类动态规划可解的问题,可以描述为若干个有序的阶段,且每个阶段的状态只和上一个阶段的状态有关,一般把这类问题称为多阶段动态规划问题。
11.7.2 01背包问题
有n件物品,每件物品的重量为w[i],价值为c[i]。现有一个容量为v的背包,如何选取物品放入背包,使得背包内物品的总价值最大。其中每种物品都只有1件。(所谓01的由来)
令dp[i][v]表示对于前i件物品装入容量为v的背包中所能获得的最大价值。
对于第i件物品的选择,有两种策略:
- 不放第i件物品,问题转化为前i-1件物品恰好装入容量为v的背包中所能获得的最大价值,dp[i-1][v]
- 放第i件物品,问题转化为前i-1件物品恰好装入容量为v-w[i]的背包中所能获得的最大价值,即dp[i-1][v-w[i]] + c[i]
状态转移方程为:
#include<iostream>
#include<algorithm>
using namespace std;
const int maxn = 210, maxv = 5010;
int w[maxn], v[maxn], dp[maxn][maxv];
int main(){
int n, allV;
scanf("%d%d", &n, &allV);
for(int i = 1; i <= n; i++){
scanf("%d%d", &w[i], &v[i]);
}
for(int i = 0; i <= allV; i++){//边界,没有物品时对于所有容量都只能有0价值
dp[0][i];
}
for(int i = 1; i <= n; i++){//状态转移方程
for(int j = 1; j <= allV; j++){
if(j < w[i]) dp[i][j] = dp[i - 1][j];
else dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - w[i]] + v[i]);//有两种决策,不放或者放
}
}
printf("%d", dp[n][allV]);
return 0;
}
其实手动模拟会发现,每次更新都只需要上一行的内容,考虑只维持一维数组。注意dp[v - w[i]]用到的是旧值,所以为了防止被刷新为新值出错,必须使用逆序枚举!
#include<iostream>
#include<algorithm>
using namespace std;
const int maxn = 210,maxv = 5010;
int w[maxn], v[maxn], dp[maxv];
int main(){
int n, m, ans = -1;
scanf("%d%d", &n, &m);
for(int i = 1; i <= n; i++){
scanf("%d%d", &w[i], &v[i]);
}
for(int i = 0; i <= m; i++){//边界
dp[i] = 0;
}
for(int i = 1; i <= n; i++){
for(int j = m; j >= w[i]; j--){//滚动数组
dp[j] = max(dp[j], dp[j - w[i]] + v[i]);
}
}
for(int i = 0; i <= m; i++){
ans = max(ans, dp[i]);
}
printf("%d", ans);
return 0;
}
动态规划是如何避免重复计算的问题在01背包中很明显。在一开始暴力枚举每件物品放或者不放背包时,忽略了一个特性:第i件物品放或者不放而产生的最大值完全可以由前i-1件物品的最大值而决定,而暴力忽略了这点。
此外,01背包中的每个物品都可以看作一个阶段,这个阶段中的状态有dp[i][0]~dp[i][V],均由上一个阶段的状态得到。其实,对能够划分阶段的问题而言,都可以尝试把阶段作为状态的一维,使得更方便地得到满足无后效性的状态。另一方面,若当前设计的状态不满足无后效性,不妨把状态进行升维,即增加一维或多维来表示相应的信息,这样也许就能满足无后效性了。
11.7.3完全背包问题
有n件物品,每件物品的重量为w[i],价值为c[i]。现有一个容量为v的背包,如何选取物品放入背包,使得背包内物品的总价值最大。其中每种物品都有无穷件。
dp[i][v]表示对前i件物品进行选取放入容量为v的背包中能获得的最大价值。
对于每种物品也是有两种策略:
- 不放第i件物品,则dp[i][v] = dp[i-1][v]
- 放入第i件物品,是转移到dp[i][v-w[i]](01背包中每个物品只能选择一次,选择放入第i件物品后必须转移到dp[i-1][v-w[i]]),因为每种物品在容量允许的情况下能够放任意件,已经放了第i件物品后还可以继续放第i件物品,直到第二维的v-w[i]无法保持大于等于0为止。
#include<iostream>
#include<algorithm>
using namespace std;
const int maxn = 35, maxv = 210;
int w[maxn], c[maxn], dp[maxn][maxv];
int main(){
int n, m;
scanf("%d%d", &m, &n);
for(int i = 1; i <= n; i++){
scanf("%d%d", &w[i], &c[i]);
}
for(int i = 0; i <= m; i++){//边界
dp[0][i] = 0;
}
for(int i = 1; i <= n; i++){//状态转移方程
for(int j = 1; j <= m; j++){
if(j < w[i]) dp[i][j] = dp[i - 1][j];
else dp[i][j] = max(dp[i - 1][j], dp[i][j - w[i]] + c[i]);
}
}
printf("max=%d", dp[n][m]);
return 0;
}
dp[i][v- w[i]]需要用新值更新
==> 一维形式必须正序枚举
#include<iostream>
#include<algorithm>
using namespace std;
const int maxn = 35, maxv = 210;
int w[maxn], c[maxn], dp[maxv];
int main(){
int n, m;
scanf("%d%d", &m, &n);
for(int i = 1; i <= n; i++){
scanf("%d%d", &w[i], &c[i]);
}
for(int i = 0; i <= m; i++){
dp[i] = 0;
}
for(int i = 1; i <= n; i++){
for(int j = w[i]; j <= m; j++){
dp[j] = max(dp[j], dp[j - w[i]] + c[i]);
}
}
printf("max=%d", dp[m]);
return 0;
}
动态规划的方案输出,记录每一步选择了哪个策略,然后从最终态(例如背包问题中的总容量v)倒着判断即可
#include<iostream>
#include<algorithm>
using namespace std;
const int maxn = 1e4 + 10, maxv = 110;
int w[maxn], dp[maxv], choice[maxn][maxv], ans[maxn];
bool cmp(int a, int b){
return a > b;
}
int main(){
int n, m, num = 0;
scanf("%d%d", &n, &m);
for(int i = 1; i <= n; i++){
scanf("%d", &w[i]);
}
sort(w + 1, w + n + 1, cmp);//从大到小排序
for(int i = 0; i <= m; i++){//边界,常规01背包
dp[i] = 0;
}
for(int i = 1; i <= n; i++){
for(int j = m; j >= w[i]; j--){
dp[j] = max(dp[j], dp[j - w[i]] + w[i]);
if(dp[j] > dp[j - w[i]] + w[i]) choice[i][j] = 0;//不放
else choice[i][j] = 1; //注意等于的时候也放哦
}
}
if(dp[m] != m) printf("No Solution");
else{//从小到大输出选择的物品
int tn = n, tm = m;
while(tm){//从终态倒着判断
if(choice[tn][tm] == 1){
ans[num++] = w[tn];
tm -= w[tn];
}
tn--;
}
for(int i = 0; i < num; i++){
if(i) printf(" ");
printf("%d", ans[i]);
}
}
return 0;
}
11.8总结
其中,递推顺序是正推还是逆推,是从小到大还是从大到小.
- 最大连续子序列和
dp[i]表示以a[i]为结尾的连续序列的最大和
dp[i] = max(a[i], dp[i - 1] + a[i]) - 最长不下降子序列(LIS)
dp[i]表示以a[i]为结尾的最长不下降子序列长度
dp[i] = max (1, dp[j]+1)(0<=j<=i-1) - 最长公共子序列(LCS)
dp[i][j]表示字符串A的i号位和字符串B的j号位之前的LCS长度(下标从1开始)
- 数塔DP
dp[i][j]表示从第i行第j个数字出发的到达最底层的所有路径上所能得到的最大和。
dp[i][j] = max(dp[i + 1][j] , dp[i+1][j+1]) + a[i][j] - 01背包
dp[i][v]表示前i件物品进行选择装入容量为v的背包中能获得的最大价值
- 完全背包
dp[i][v]表示前i件物品进行选择装入容量为v的背包中能获得的最大价值
前三个都是关于序列或字符串的问题(一般而言,“子序列”可以不连续,“子串”必须连续)。
前两个的设计状态的方法都是“令dp[i]表示以a[i]为结尾的……”,其中……即为原问题的描述,再分析a[i]的情况来进行状态转移。
第三个的LCS原问题本身就有二维性质,使用了“令dp[i][j]表示i号位和j号位之间……”的状态设计方式,其中……为原问题的描述。
现在可以小结一下:
- 当题目和序列或字符串(记为A)有关时,可以考虑把状态设计为下面两种形式,再根据端点特点去考虑状态转移方程
- 令dp[i]表示以A[i]为结尾(或开头)的……
- 令dp[i][j]表示A[i]到A[j]区间的……
接着盘4-6,发现它们的状态设计都包含了某种“方向”。数塔DP设计中从点(i, j)出发到最底层的最大和,背包问题设计为dp[i][v]表示前i件物品恰好放入容量为v的背包中能获得的最大价值。
再次小结出一类动态规划问题的状态设计方法:
- 分析题目中的状态需要几维来表示,然后对其中的每一维采取下面的某一个表述:
- 恰好为
- 前i
在每一维的含义设置完毕之后,dp数组的含义就可以设置为"令dp数组表示恰好为i(或前i)、恰好为j(或前j)……的……"其中后一个……为原问题的描述。接下来就可以通过端点的特点去考虑状态转移方程。