9.3 多阶段决策问题
还记得“多阶段决策问题”吗,在回溯法中曾提到该问题。简单地说,每做一次决策就可以得到解的一部分,当所有决策做完之后,完整的解就浮出水面了(ps:解答树!)。在回溯法中,每次决策对应于给一个结点产生新的子树,而解的生成过程对应一棵解答树,节点的层数就是下一个待填充的位置cur
9.3.1 多段图的最短路
多段图是一种特殊的DAG,其结点可以划分成若干个阶段,每个阶段只有上一个阶段所决定,下面是一个例子:
Unidirectional_TSP:
yes,终于有点小空写掉这道题了,本题需要注意最小字典序的最佳构建方法是dp[i][j]表示以(i,j)为起点的最短路,而不是以(i,j)为终点的最短路径的状态表示,否则最后输出的时候,会导致答案的输出变得非常麻烦
点击查看笔者代码
#include<iostream>
#include<cstring>
#include<algorithm>
using namespace std;
//通过dp[i][j]以i和j为终点进行dp的是无法通过fa来实现记录字典路径,一定要通过起点来实现
//也就是dp[i][j]以(i,j)为起点的最小权,但是也可以表示为以(i,j)为终点的最小权,但是终点的
//话那么就会使得其最小字典序的输出非常困难,需要通过终点不断枚举,以及排列
constexpr int MAXM = 10+5, MAXN = 100+10;
int n, m, matrix[MAXM][MAXN], son[MAXM][MAXN], dp[MAXM][MAXN];
int dy[3] = {1, 0, -1};
bool getD() {
if(scanf("%d%d", &n, &m) != 2) return false;
memset(dp, 0x3f, sizeof(dp));
memset(matrix, 0, sizeof(matrix));
memset(son, 0x3f, sizeof(son));
for(int i = 0; i < n; i++)
for(int j = 0; j < m; j++) scanf("%d", &matrix[i][j]);
return true;
}
void deal() {
for(int i = 0; i < n; i++) dp[i][m-1] = matrix[i][m-1];
for(int j = m-1; j > 0; j--) {//刷表法
for(int i = 0; i < n; i++) {
for(int k = 0; k < 3; k++) {
int temp = (i+n+dy[k])%n;
if(dp[temp][j]+matrix[i][j-1] < dp[i][j-1]) {
dp[i][j-1] = dp[temp][j]+matrix[i][j-1];
son[i][j-1] = temp;
}
if(dp[temp][j]+matrix[i][j-1] == dp[i][j-1] && temp < son[i][j-1]) son[i][j-1] = temp;
}
}
}
}
void output() {
int pos = 0;
for(int i = 1; i < n; i++) if(dp[i][0] < dp[pos][0]) pos = i;
int ans = dp[pos][0];
cout << pos+1;
for(int i = 0; i < m-1; i++) {
cout << " " << son[pos][i]+1;
pos = son[pos][i];
}
cout << endl << ans << endl;
}
int main() {
while(getD()) {
deal();
output();
}
return 0;
}
本题的状态转移方程,笔者选择的是dp[i][j]表示以(i,j)为起点的最短路径
cost[i][j]表示(i,j)的耗费
状态转移方程为dp[i][j] = min(dp[k][j+1]+cost[i][j]|(i,k)属于E)
最后循环采用刷表法解决就可以了
一共有nm中状态,每种状态的决策数为3
所以时间复杂度为O(nm)
在这个题目中,每一列就是一个阶段,每个阶段都有3种决策:直行,右上和右下
多阶段决策的最优化问题往往可以用动态规划解决,其中,状态及其转移类似于回溯法中的解答树。解答树中的层数,也就是递归函数中的“当前填充位置”cur,描述的是即将完成的决策序号,在动态规划中被称为阶段
有了前面的经验,不难设计除状态:设d(i,j)为从格子(i,j)出发到最后一列的最小开销,但是本题不仅要输出解,还要求字典序最小,这就需要在计算d(i,j)的同时记录“下一列的行号”的最小值(当然是在满足最优性的前提下),细节参见下面作者的代码:
点击查看代码
int ans = INF, first = 0;
for(int j = n-1; j >= 0; j--) { //逆推
for(int i = 0; i < m; i++) {
if(j == n-1) d[i][j] = a[i][j]; //边界
else {
int rows[3] = {i, i-1, i+1};//第0行“上面”是第m-1行
if(i == 0) rows[i] = m-1; //第m-1行“下面”是第0行
if(i == m-1) rows[2] = 0;//重新排序,以便找到字典序最小的
sort(rows, rows+3);
d[i][j] = INF;
for(int k = 0; k < 3; k++) {
int v = d[rows[k]][j+1] + a[i][j];
if(v < d[i][j]) { d[i][j] = v; next[i][j] = rows[k]; }
}
}
if(j == 0 && d[i][j] < ans) { ans = d[i][j]; first = i; }
printf("%d", frist+1);//输出第一列
for(int i = next[first][0], j = 1; j < n; i = next[i][j], j++)
printf(" %d", i+1);//输出其他列
printf("\n%d\n", ans);
}
return 0;
}
9.3.2 0-1背包问题
0-1背包问题是最广为人知的动态规划问题之一,拥有很多变形。尽管在理解之后不难写出程序,但掌握背包问题往往需要较多的时间,先来看一个引例
物品无限的背包问题 :
他很像9.2中的硬币问题,只不过“面值之和恰好为S”改成了“体积之和不超过C”,另外增加了一个新的属性——重量,相当于把原先的无权图改成了带权图(weighted graph)。这样问题变味了求以C为起点(终点任意)的,边权之和最大的路径
DAG从无权变成了带权 ,但是这本质和原先的硬币问题仍然类似,将原先的状态注意方程中的价值1改成+W[i]就可以了
动态规划的适用性很广。不少可以用动态规划解决的题目,在条件稍微变化后,只需对状态转移方程做少量修改即可解决新问题
0-1背包问题:
当物体的数量进行限制的时候,刚才的方法已经不适用了:只凭剩余体积这个状态,无法得知每个物品是否已经用过。换句话说,原先的状态转移太乱了,任何时候都允许使用任何一种物品,难以控制,为了消除这种混乱,需要让状态转移(也就是决策)有序化
引入“阶段”之后,算法便不难设计了:用d(i,j)表示当前在第i层,背包剩余容量额外为j时接下来的最大重量和,则d(i,j)=max{d(i+1,j),d(i+1, j-V[i])+W[i]},边界是i>n时d(i,j)=0,j < 0时为负无穷(一般不会初始化这个边界,而是只当j>=V[i]时才计算第二项)
说得更通俗一点,d(i,j)表示“把第i,i+1,i+2,...,n个物品装到容量为j得背包中得最大总重量”。事实上,这个说法更加常用————阶段只是辅助思考的,在动态规划的状态描述中最好避免阶段,层这样的术语。要假设对于划分阶段以及和回溯法的内在练习————如果对此理解不深,很难举一反三。
学习动态规划的题解,除了要理解状态表示以及其转移方程外,最好思考一下为什么会想到这样的状态表示
和往常一样,在得到装药转移方程之后,还需要思考如何编写程序。尽管在很多情况下,记忆化搜索程序更直观易懂,但在0-1背包问题中,递推法更加理想。因为当有了阶段定义后,计算顺序变得非常明显
笔者是这样思考01背包的求解问题的,因为是多状态问题,假设我们的状态定义的足够好,那么我们就可以让每个状态的答案不影响前面状态的答案,也就是前面状态答案已经确定就不会再发生改变。那么抽象角度来说,这是一个天然的拓扑序,这是许多动态规划问题的关键,即保证前驱状态不被后继状态影响。否则,这应该就是一个NP——Hard问题,难以在多项式时间内求解
那么我们是通过j来记录当前还剩余的空间,因此我们就可以想当然的想到如何将记忆化搜索的思想进行状态压缩
那么就是对于第i层来说,其实就是判断dp[i][j] 和 dp[i+1][j-W[i]]+V[i]谁大,一次刷新dp[i][j],换句话说也就是对于每个剩余的体积j,我们都会进行一次判断,看此时消耗W[i]体积换取V[i]的收益是否更好,注意这边的i是逆序枚举的,正序枚举就要和dp[i-1][j-W[i]]+V[i]比较
在多阶段决策问题中,阶段定义了天然的计算顺序(也就是对于01背包,对于第i个物品,选取与否构成了多阶段决策)
下面的代码答案是d[1][C]
点击查看代码
for(int i = n; i >= 1; i--)//注意是逆序枚举
for(int j = 0; j <= C; j++) {
d[i][j] = (i == n ? 0 : d[i+1][j]);
if(j >= V[i]) d[i][j] = max(d[i][j], d[i+1][j-V[i]]+W[i]);//状态转移方程
}
边界本质上是对于搜索的边界的初始化,所以逆序枚举,本质上是对搜索树的状态的一种抽象数组表示方法
前面说过,i必须逆序枚举,但j的循环次序是无关紧要的
规划方向:
还有另外一种“对称”的状态定义:用f(i,j)表示“把前i个物品装到容量为j的背包中的最大总重量”,其状态转移方程不难得出:
f(i, j) = max{f(i-1,j), f(i-1, j-V[i])+W[i]}//两种决策,装还是不装,这边i的枚举就是正序枚举了
dp问题注意对于状态的决定,以及各种状态转移之间的决策的判断,状态的决策判断的时候不要例化,先从泛化入手可能会更好点
边界是类似的,最终答案为f(n, C)
点击查看代码
for(int i = 1; i <= n; i++)
for(int j = 0; j <= C; j++) {
f[i][j] = (i == 1 ? 0 : f[i-1][j]);
if(j >= V[i]) f[i][j] = max(f[i][j], f[i-1][j-V[i]]+W[i]);
}
看上去这两种方式是完全对称的,但其实存在细微区别:新的状态定义f(i, j)允许边读入边计算,而不必把V和W保存下来(正序对于内存友好)
点击查看代码
for(int i = 1; i <= n; i++) {
scanf("%d%d", &V, &W);//边读入边进行处理,应该属于正序输入,并且前面的数据不参于后面数据的计算
for(int j = 0; j <= C; j++) {
f[i][j] = (i == 1 ? 0 : f[i-1][j]);
if(j >= V) f[i][j] = max(f[i][j], f[i-1][j-V]+W);
}
}
滚动数组,更神奇的是,还可以把数组f变成一维的(滚动数组的成立条件需要对于状态的判定,需要前驱状态不会再受到后继状态的改变而改变,此时就可以采用滚动数组来减少内存开支)
点击查看代码
memset(f, 0, sizeof(f));
for(int i = 1; i <= n; i++) {
scanf("%d%d", &V, &W);//不仅边读入边处理,并且实现了对于内存空间的优化
for(int j = C; j >= 0; j--)
if(j >= V) f[j] = max(f[j], f[j-V]+W);
}
f数组是从上到下,从右往左计算的。在计算f(i,j)之前,f[j]里面保存的就是f(i-1, j)的值而不是f(i, j-W),别忘了j是逆序枚举的,此时f(i, j-W)还没有算出来。这样f[j]=max(f[j], f[j-V]+W)实际上是把 max{f(i-1, j), f(i-1, j-V)+W}保存在f[j]中,覆盖掉f[j]原来的f(i-1,j)
也就是说这边j的逆序枚举保证了状态转移方程中f(i-1, j-V)也就是f[j-V]还是上一个状态的值,并不会先被覆盖掉,因此可以通过滚动数组来实现对于内存的优化
在递推法中,如果计算顺序很特殊,而且计算新状态所用到的原状态不多,可以尝试用滚动数组减少内存开销
滚动数组虽好,但也存在一些不尽如人意的地方,例如,打印方案交困难。当动态规划结束之后,只有最后一个阶段的状态值,而没有前面的值。不过这也不能完全归咎于滚动数组,规划方向也有一定的责任——————即使用二维数组,打印方案也不是特别方便。事实上,对于前i个物品这样的规划方向,只能用逆向的打印方案,而且还不能保证它的字典序最小(字典序比较是从前往后的)
滚动数组会丢失前面状态的记录,同时不好的与出发方向相反的规划方向也会导致解的打印十分困难
在使用滚动数组后,解的打印变得困难了,所以在需要打印方案甚至要求字典序最小方案的场合,应慎用滚动数组
Jin Ge Jin Qu [h]ao:
本质上是对0-1背包问题的变形,需要注意的是0-1背包的原先的模板中dp[i][j]表示的是剩余量j的时候,将i个物品选择放入可以获得的最大价值,但是其并不保证这i个物体的总体积为j
这是和本题的区别,本题在0-1背包的基础上要求一定要让这i个物体的最优方案的体积为j
笔者原先的代码如下,就不再赘述了:
点击查看代码
#include<iostream>
#include<cstring>
#include<algorithm>
using namespace std;
constexpr int MAXN = 50+10, MAXT = 10000;
int cost[MAXN], dp[MAXN][MAXT];//dp[i][j]biaoshizuihaode zhuangquang zaochangwanchangdoushiyiyanngde
int main() {
// freopen("test.out", "w", stdout);
int kase = 0;
scanf("%d", &kase);
for(int i = 1; i <= kase; i++) {
memset(dp, 0, sizeof(dp));
int n, t;
scanf("%d%d", &n, &t);
for(int i = 1; i <= n; i++) scanf("%d", &cost[i]);
dp[0][0] = 1;//bound
for(int i = 1; i <= n; i++) {
for(int j = cost[i]; j <= t; j++) {
for(int k = 0; k < i; k ++)
if(dp[k][j-cost[i]]) dp[i][j] = max(dp[i][j], dp[k][j-cost[i]]+1);
}
}
int row = 0, col = 0;
for(int i = 1; i <= n; i++)
for(int j = 0; j < t; j++)
if(dp[i][j] > dp[row][col]) {
row = i; col = j;
}
else if(dp[i][j] == dp[row][col] && j > col) {
row = i; col = j;
}
cout << "Case " << i << ": " << dp[row][col] << " " << col+678 << endl;
}
return 0;
}
但是这并不简洁,复杂度高达O(n*n*n)
那么接下来我们将介绍一种0-1背包的变式
状态方程:f[i]表示时间刚好为i的歌曲数最大
状态转移方程:
(1)边界和初始化,首先f[0] = 0,除零外的f[i] = -INF,让其为负无穷大,反之影响后续结果(因为这时候负无穷大表示该背包并没有被装满,因此不管其可以放几首歌曲,都认为不能放
(2)状态转移,f[i] = max(f[i], f[i-cost[i]+1)
cost[i]表示dii首歌曲所耗费的时长
(3)最后循环进行输出就可以了
具体代码如下:
点击查看笔者代码
#include<iostream>
#include<cstring>
#include<algorithm>
#include<iomanip>
using namespace std;
constexpr int MAXT = 10000;
int dp[MAXT];//0x3f +INF 0x8f -INF
int main() {
// freopen("test.out", "w", stdout);
int kase;
scanf("%d", &kase);
for(int i = 1; i <= kase; i++) {
int n, t;
scanf("%d%d", &n, &t);
memset(dp, 0x8f, sizeof(dp));
dp[0] = 0;
for(int j = 0; j < n; j++) {
int cost;
scanf("%d", &cost);
for(int k = t-1; k >= cost; k--) dp[k] = max(dp[k], dp[k-cost]+1);
}
int pos = t-1;
for(int j = t-2; j >= 0; j--) if(dp[j] > dp[pos]) pos = j;
cout << "Case " << i << ": " << dp[pos]+1 << " " << pos+678 << endl;
}
return 0;
}
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 全程不用写代码,我用AI程序员写了一个飞机大战
· DeepSeek 开源周回顾「GitHub 热点速览」
· 记一次.NET内存居高不下排查解决与启示
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· .NET10 - 预览版1新功能体验(一)