9.2 DAG上的动态规划
阅读原文时间:2023年07月10日阅读:1

在有向无环图上的动态规划是学习动态规划的基础,很多问题都可以转化为DAG上的最长路,最短路或路径计数问题

9.2.1 DAG模型

嵌套矩形问题:

矩形之间的可嵌套关系是一种典型的二元关系,二元关系可以用图来建模。如果矩形X可以嵌套在矩形Y里面,就从X到Y有一条有向边。这个有向图是无环的,因为一个矩形无法直接或间接地嵌套在自己内部(严格嵌套地时候,注意该种关系,这是保证前驱结点不影响后继节点的关键,否则记忆化搜索很容易出现错误)

换句话说,他是一个DAG,这样,所要求的便是DAG上的最长路径

硬币问题:

此问题尽管看上去和嵌套矩形问题很不一样,但问题的本质也是DAG上的路径问题。将每种面值看作一个点,表示还需要凑足的面值,则初始状态为s,目标状态为0,若在当前状态i,每使用一个硬币j,状态便转移到i-Vj

这个模型和上一题类似,但也有一些明显的不同之处:上题并没有确定路径的起点和终点(可以把任意矩形放在第一个和最后一个),而本题的起点必须为S,终点必须为0;点固定之后“最短路才有意义”,上题中,很显然,最短序列必然是空的(如果不允许为空,那么就是单个矩形,不管怎样都是显而易见的),而本题的最短路却不容易确定

9.2.2 最长路及其字典序

首先自考嵌套矩形,如何求DAG中不固定起点的最长路径呢?仿照数字三角形做法,设d(i)表示从结点i出发的最长路长度,应该如何写状态转移方程呢?第一步只能走到他的相邻点,因此:

d(i) = max{d(j)+1|(i,j)属于E}

这边笔者可以提供一个递推思路,建立在记忆化搜索的前提下,因为是一个DAG,因此可以反向建图,即原本存在从X到Y的一条路径X->Y,那么此时我们程序中建立的应该是从Y->X的路径,同时计入每个点i的入度,接着不断刷新每个结点的最大值就可以了,但是该方法并不能保证不受后继结点的影响,因此并不是一个合格的dp,读者可以当作一种思路看看即可

笔者对于以上优化的考虑如下,参考拓扑排序的递归方式,我们继续进行下去的关键是该点入度为0,注意整体采用bfs的形式,一开始将所有的点入队(优先队列,通过各点的入度维护,入度越小的点优先度越高),接下来只需遍历每一个点就可以了,总体时间复杂度上略高于dp,因为多了排序插入的环节

其中E为边集。最终答案是所有d(i)中的最大值。此时就可以尝试按照递推或者记忆化搜索的方式计算上式子,不管怎样,都需要先把图建立出来,假设用邻接矩阵保存在矩阵G中(在编写主程序之前需要测试和调试程序,以确保建图过程正确无误),接下来编写记忆化搜索程序(调用前序初始化d数组的所有值为0)

点击查看代码

int dp(int i) {
  int& ans = d[i];//注意这边是引用,可能是为了减少偏移运算符消耗的效率
  if(ans > 0) return ans;//记忆化搜索的体现
  ans = 1;//表示自己占了一个坑
  for(int j = 1; j <= n; j++)
    if(G[i][j]) ans = max(ans, dp(j)+1);//开始状态转移
  return ans;
} 

这里用到了一个技巧:为表项d[i]声明一个引用ans。这样,任何对ans的读写实际上都是在对d[i]进行,当d[i][j][k][l][m][n]这样很长的名字时,该技巧的优势就会很明显

在记忆化搜索中,可以为正在处理的表项声明一个引用,简化对他的读写操作

原题还有一个要求,如果有多个解,矩形编号的字典序应最小,第6章中的例题“理想路径”,二者方法类似。将所有d值计算出来之后,选择最大的d[i]所对应的i,如果有多个i,则选择最小的i,这样才能保证字典序最小。接下来可以选择d(i) = d(j)+1且(i,j)属于E的任何一个j。为了让方案的字典序最小,应该选择其中最小的j,程序如下(本质上还是一种递归):

点击查看代码

void print_ans(int i) {
  printf("%d ", i);
  for(int j = 1; j <= n; j++) if(G[i][j] && d[i] == d[j]+1) {
    print_ans(j);//保证是最小的j打印输出,因为编号的唯一性,因此不用记录多条路径,答案是唯一的
    break;
  }
} 

根据各个状态的指标值可以依次确定各个最优决策,从而构造出完整方案。由于决策是依次决定的,所以很容易按照字典序打印出所有方案

注意上述思想在本题和ideal path中的体现,二者都有记录当前结点到达(目标结点)的长度(耗费),并且很容易通过该点推知下一点的耗费应该为多少,最后维护一下图的连通性,即可解决此类题目

如果程序向上述一样输出,那么输出的最后会有一个多余空格,并且没有回车符,在使用时,应在主程序调用print_ans后加一个回车符,如果比赛明确规定行末不允许有多余空格,则可以像前面介绍的那样加一个变量first来帮助判断,也就是让后继结点输出空格,第一个结点不输出空格,这样就能保证输出的最后没有多余空格的出现

注意当找到一个满足d[i]==d[j]+1的结点j后就应该立刻递归打印从j开始的路径,并在递归返回后退出循环,如果要打印所有方案,只把break语句删掉是不够的(那是因为前面的结点都没有记录,此时输出的话会缺失前驱结点的值,因此需要一个数组来进行记录),正确的方式是记录路径上的所有点,在递归结束时才一次性输出整条路径

有趣的是,如果把状态定义成“d(i)表示以结点i为终点的最长路径长度”,也能顺利求出最优值,却难以打印出字典序最小的方案

这边的状态转移方程应为d(j) = max{d(i)+1|(i,j)属于E}

这是因为此时贪心策略失效,一味追求当前子节点的最小并不会带来最终从起点开始的最小字典序,同时如果追求最大的点,那么得出的更不可能是最小的字典序,也就是此时字典序输出的最优子结构因为终点状态而被打破,因此对此类题目的分析,于是需要将状态和输出相结合,如果有特定的输出要求,应尽量维护该输出要求的状态下进行状态转移

9.2.3 固定终点的最长路和最短路

接下来考虑“硬币问题”,最长路和最短路的求法是类似的,下面只考虑最长路。由于终点固定,d(i)的确切含义变为“从结点i出发到结点0的最长路径长度”。下面是求最长路的代码:

点击查看代码

int dp(int S) {//S表示此时剩下的钱
  int& ans = d[S];
  if(ans >= 0) return ans;
  ans = 0;
  for(int i = 1; i <= n; i++) if(S >= V[i]) ans = max(ans, dp(S-V[i])+1);
  //表示对于可以减去S的判断,将ans自己的值和后继结点的最大值进行比较
  return ans;
} 

由于在本题中,路径长度是可以为0的(S本身可以是0),所以不能再用d=0表示“这个d值还没有算过”。相应地,初始化时也不能再把d全设为0,,而要设置一个负值——在正常情况下时取不到的,一般的做法就是用-1来表示没有计算过,则初始化时只要使用memset(d, -1, sizeof(d))即可。至此,已完整解释了上面的代码为什么时if(ans>=0)而不是if(ans>0)

当程序中需要用到特殊值时,应确保改值在正常情况下不会被渠道,这不仅意味着特殊值不能有正常的理解方式,而且也不能在正常运算中“意外得到”

但是上述代码仍然存在问题,即由于结点S不一定真的能到达结点0,所以需要用特殊的d[S]值表示无法到达,但在上数代码章,如果S根本无法往前走,返回值是0,会被误判为“不用走,已经到达终点的意思”。如果把ans初始化为-1,但是-1表示的是“还没有计算过”,所以返回-1就代表着告诉计算机这儿没有来过,是放弃自己前面的劳动成果。如果把ans初始化为一个很大的数,那么ans = max(ans, dp(i)+1)就难已记录准确值了,如果改成很小的整数,从目前来看,他应该也会被认为是还没算过,但至少可以和所有d的初值分开————只需把代码中if(ans>=0) 改为if(ans!=-1)就可以了,如下所示:

点击查看代码

int dp(int S) {
  int& ans = d[S];
  if(ans != -1) return ans;//-1表示还未访问过
  ans = -(1<<30);//构建表示此路不通的标志,-2^30
  for(int i = 1; i <= n; i++) if(S >= V[i]) ans = max(ans, dp(S-V[i])+1);//刷新
  return ans;
} 

在记忆化搜索中,如果用特殊值表示“还没算过”,则必须将其和其他特殊值(如无解)区分开来

也就是记忆化搜索过程中对于为遍历过的点,计算过的点,无效点的标记需要特别注意,一定要进行清晰的定义,否则容易出现各种各样的错误

以上的难题另一个解决方法是不用特殊值表示还没算过,而用另外一各数组vis[i]表示状态i是否被访问过,如下所示:

点击查看代码

int dp(int S) {
  if(vis[S]) return d[S];//类似搜索中,通过vis数组进行标记,来表示S已经访问过
  vis[S] = 1;
  int& ans = d[S];
  ans = -(1<<30);
  for(int i = 1; i <= n; i++) if(S >= V[i]) ans = max(ans, dp(S-V[i])+1);
  return ans;
} 

尽管多了一个数组,但可读性增强了许多,再也不用担心特殊值之间的冲突了,在任何情况下,记忆化搜索的初始化都可以用memset(vis, 0, sizeof(vis))实现

如果状态比较复杂,推荐用STL中的map而不是普通数组保存状态值,这样,判断S状态是否算过只需要使用if(d.count(S))即可

如果使用普通数组保存状态值,首先可能的状态数会造成大量内存的无效浪费,同时代码量增加,但是如果使用map在时间允许的条件下,其较快的速度既可以精简代码,同时可以使得内存使用尽可能少,实现动态内存的管理

在记忆化搜索中,可以用vis数组记录每个状态是否计算过,以占用一些内存为代价增强程序的可读性,同时减少出错的可能

注意上述记忆化搜索中是采用-1大数字表示未访问过,-2^30 表示此路不通(笔者认为这边仍然不能区分此路可通和直接到达终点的区别),最后将最大的值 + 2^30 即可获得最终答案?

在硬币问题中,可能需要自己特判一下是否能够到达终点,然后进行赋值操作,否则仍然保持-2^30的特殊状态?

本题要求最小,最大两个值,记忆化搜索就必须写两个,在这种情况下,递推更加方便(此时需要注意递推的顺序)

点击查看代码

minv[0] = maxv[0] = 0;
for(int i  =1; i <= S; i++) {
  minv[i] = INF; maxv[i] = -INF;//初始化
}
for(int i = 1; i <= S; i++)
  for(int j = 1; j <= n; j++)
    if(i >= V[j]) {
      minv[i] = min(minv[i], minv[i-V[j]]+1);//开始刷新minv[i]
      maxv[i] = max(maxv[i], maxv[i-V[j]]+1);//开始刷新maxv[i]
    }
printf("%d %d\n", minv[S], maxv[S]); 

本节讲述的是搜索和dp之间的过渡,搜索有时候可能会重复大量搜索已经访问过的结点,此时我们可以采用记忆化搜索来减少这些不必要的损耗,有时可以大大提高效率

记忆化搜索需要注意区分的是,分别为搜索过结点,无效结点和有效结点,否则很容易在特殊边界条件下暴毙

同理,输出字典序最小的方法如下:

点击查看代码

void print_ans(int* d, int S) {
  for(int i  = 1; i <= n; i++)//维护字典序
    if(S >= V[i] && d[S] == d[S-V[i]]+1) {//维护数据链合法
      printf("%d ", i);//打印输出
      printf_ans(d, S-V[i]);//递归整合
      break;
    }
} 

然后分别调用print_ans(min, S)(注意在后面要加一个回车符)和print_ans(max, S)即可。输出路径部分和上题的区别是,上题打印的是路径上的点,而这里打印的是路径上的边。(也就是dp状态可以通过边或者点来进行标识),数组可以作为指针来进行传递,数组作为指针传递时,不会赋值数组中的数据,因此不必担心这样会带来不必要的时间开销

当用递推法计算出各个状态的指标之后,可以用于记忆化搜索完全相同的打印方案(也就是对于状态d[i]和d[p],如果根据现有数据可以推出二者直接存在边(i,j)或者(j,i)属于E,那么我们就可以通过这种递归方法进行答案的输出,以及维护字典序)

当然很多用户喜欢另外一种打印路径的方法:递推时直接用min_coin[S]记录满足min[S]=min[S-V[i]]+1的最小的i,则打印路径时可以省去print_ans函数中的循环,并可以方便的将递归改成迭代,原来的也可以改成迭代,但是不够自然,具体来说就是将递推过程调整如下

点击查看代码

for(int i = 1; i <= S; i++)
  for(int j = 1; j <= n; j++)
    if(i >= V[j]) {
      if(min[i] > min[i-V[j]] + 1) {//注意等于的话就不是最小的字典序了
          min[i] = min[i-V[j]] + 1;
          min_coin[i] = j;
      }
      if(max[i] < max[i-V[j]] + 1) {
        max[i] = amx[i-V[j]] + 1;
        max_coin[i] = j;
      }
    } 

注意中间判断条件并没有使用>=或者<=,原因在于字典序最小解要求当min/max值相同时取最小的i值,原因在于”字典序最小解“要求当min/max值相同时取最小的i值,反过来,如果j是从大到小枚举的,就需要把">"和”>=“和"<="才能求出字典序最小解

在求出min_coin和max_coin之后,只需调用print_ans(min_coin, S)和print_ans(max_coin, S)即可

点击查看代码

void print_ans(int* d, int S) {
  while(S) {//通过数组移动来实现输出,减少了对于递归的栈空间的消耗
    printf("%d ", d[S]);
    S -= V[d[S]];
  }
} 

该方法是一个用空间换时间的经典例子——用min_coin和max_coin数组消除了原来print_ans中的循环

无论使用记忆化搜索还是递推,如果在计算最优值得同时“顺便”算出各个状态下的第一次最优决策,则往往能让打印方案得过程更加简单,高效。这是一个典型得利用空间换时间的例子

类似于前面图论中的fa[i]的使用

9.2.4 小结与应用举例

本节介绍了动态规划的经典应用:DAG中的最长路和最短路。和9.1节中的数字三角形问题一样,DAG的最长路和最短路都可以用记忆化搜索和递推两种实现方式

打印解时既可以根据d值重新计算出每一步的最优决策(递归输出),也可以在动态规划时“顺便”记录下每步的最优决策(循环输出)

由于DAG最长(短)路的特殊性,有两种“对称”的状态定义方式

状态1:设d(i)为从i出发的最长路,则d(i) = max{d(j)+1|(i,j)属于E}

状态2:设d(i)为以i结束的最长路,则d(i) = max{d(j)+1|(j,i)属于E}

对于路径上的状态存储,我们分为d(i)作为起点和d(i)作为终点的状态记录

如果使用状态2,“硬币问题”就变得和“嵌套矩形问题几乎一样了(唯一的区别是:“嵌套矩形问题”还需要取所有d(i)的最大值)!9.2.3节中有意介绍了比较麻烦的状态1,主要是为了展示一些常见技巧和陷阱,实际比赛中不推荐使用

简单来说,硬币问题可以看作固定起点终点的嵌套矩形问题

使用状态2,还会存在一个问题,状态转移方程可能不好计算,因为在很多时候,可以方便地枚举从某个结点i出发的所有边(i,j),却不方便反着枚举(j,i)

这时需要使用刷表法,传统的递推法可以表示成“对于每个状态,计算f(i)”,或者称为“填表法”。这需要对于每个状态i,找到f(i)以来的所有状态,在某些情况下并不方便。另一种方法是“对于每个状态i,更新f(i)所影响到的状态”,或者称为刷表法。对应到DAG最长路的问题中,就相当于按照拓扑排序枚举i,对于每个i,枚举边(i,j),然后更新d[j] = max(d[j],d[i]+1)。注意,一般不把这个式子叫做状态转移方程,因为它不是一个可以直接计算d[j]的方程,而只是一个更新公式

也就是说填表法是计算出当前状态,但是刷表法是更新当前结点的后继结点值

传统的递推法可以表示成“对于每个状态i,计算f(i)”,或者称为“填表法”。这需要对于每个状态i,找到f(i)依赖的所有状态,在某些时候并不方便。另一种方法是“对于每个状态i,更新f(i)所影响到的状态”,或者称为“刷表法”,有时比填表法方便。但需要注意的是,只有当每个状态所依赖的状态对它的影响相互独立时才能用刷表法,否则传递的信息会出现错误。

A_Spy_in_the_Metro

点击查看笔者代码

#include<iostream>
#include<cstring>
#include<algorithm>
#include<iomanip>
using namespace std;

constexpr int MAXN = 50+5, MAXM = 50+5, MAXT = 200+10, BASE = 0x3f3f3f3f;//n means stations m means buses
bool vis[MAXN][MAXT][2];//0 means right 1 means left
int n, t, m1, m2, cost[MAXN], ans[MAXN][MAXT];

bool getD() {
  cin >> n;
  if(!n) return false;
  memset(vis, 0, sizeof(vis));
  memset(ans, 0x3f, sizeof(ans));
  cin >> t;
  for(int i = 0; i < n-1; i++) cin >> cost[i];
  cin >> m1;
  for(int i = 0; i < m1; i++) {
    int temp;
    cin >> temp;
    if(temp < t) ans[0][temp] = temp;
    for(int j = 0; j < n; j++) {
      temp += cost[j];
      if(temp > t) continue;
      vis[j+1][temp][0] = true;
    }
  }
  cin >> m2;
  for(int i = 0; i < m2; i++) {
    int temp;
    cin >> temp;
    for(int j = n-2; j >= 0; j--) {
      temp += cost[j];
      if(temp > t) continue;
      vis[j][temp][1] = true;
    }
  }
  return true;
}

void deal() {
  for(int j = 0; j <= t; j++) {
      for(int i = 0; i < n; i++) {
        if(vis[i][j][0]) ans[i][j] = min(ans[i][j], ans[i-1][j-cost[i-1]]);
      if(vis[i][j][1]) ans[i][j] = min(ans[i][j], ans[i+1][j-cost[i]]);
      if(j > 0) ans[i][j] = min(ans[i][j], ans[i][j-1]+1);
    }
  }
}

void print_ans() {
  int len = 4;
  for(int i = 0; i <= t; i++) cout << setw(len) << i; cout << endl;
  for(int i = 0; i < n; i++) {
    for(int j = 0; j <= t; j++) {
      if(ans[i][j] != BASE)cout << setw(len) << ans[i][j];
      else cout << setw(len) << -1;
    } cout << endl;
  }
  exit(0);
}

int main() {
// freopen("test.out", "w", stdout);
  int kase = 0;
  while(getD()) {
    deal();
//    print_ans();
    if(ans[n-1][t]!=BASE) cout << "Case Number " << ++kase << ": " << ans[n-1][t] << endl;
    else cout << "Case Number " << ++kase << ": impossible" << endl;
  }
  return 0;
} 

好难,qaq

笔者思考路线如下,本题因为时间是不可能倒流,因此确保了这一定是一个拓扑序,即满足DAG条件,因此可以套用DAGdp模型

我们使用数组ans[i][j]表示在第j个时间到达第i个车站最优的选择(注意此题笔者默认其符合最优子结构)

那么对于ans[i][j]的状态转移方程就是

这边cost[i]表示从i-1到达第i个车站的时间

ans[i][j] = min{ans[i-1][j-cost[i]], ans[i+1][j-cost[i+1], ans[i][j-1]+1}

也就是分为第j个时间点是否有左边的列车到达,有的话就能跟该列车的上一个结点进行刷新,同理右边的列车是否到达是一样的判断

最后还需要注意的是对前面状态的维护,也就是如果没有车到达,或许可能之前已经到过该站点选择停下驻留

以下是作者的解释:

时间是单向流逝的,是一个天然的序,影响到决策的只有当前时间和所处的车站(注意分析影响到决策的因素),所以可以用d(i,j)表示时刻i,你在车站j(编号为1-n),最少还需要等待多长时间。

边界条件是d(T, n) = 0,其他d(T, i)(i不等于n)为正无穷,有如下三种决策

决策1:等待1分钟

决策2:搭乘往右开的车(如果有)

决策3:搭乘往左开的车(如果有)

主过程的代码如下:

点击查看代码

for(int i = 1; i <= n-1; i++) dp[T][i] = INF;
dp[T][n] = 0;//这边作者是通过(T,n)终点一直搜索到起点(0,1) 

for(int i = T-1; i >= 0; i--)
  for(int j = 1; j <= n; j++) {
    dp[i][j] = dp[i+1][j] + 1;//等待一个单位(这是一定可以实现的)
    if(j < n && has_train[i][j][0] && i + t[j] <= T) //判断该时刻是否可以坐右边来的车,如果可以的话,刷新该节点
      dp[i][j] = min(dp[i][j], dp[i+t[j]][j+1]); // 右
    if(j > 1 && has_train[i][j][1] && i + t[j-1] <= T) //同样的处理方法
      dp[i][j] = min(dp[i][j], dp[i+t[j-1]][j-1]); //左
  } //维护最优子结构,同时实现记忆化搜索,最后依次遍历到(0,1)即可 

cout << "Case Number " << ++kase << ": ";
if(dp[0][1] >= INF) cout << "impossible\n";
else cout << dp[0][1] << "\n"; 

上面的代码中有一个has_train数组,其中has_train[t][i][0]表示时刻t,在车站i是否有往右开的火车,has_train[t][i][1]类似,不过记录的是往左开的火车。这个数组不难在输入时进行预处理

这边也可以对复杂程度进行分析,状态有O(nT)个,每个状态最多只有三个决策,因此总时间复杂度为O(nT)

The_Tower_of_Babylon

点击查看笔者代码

#include<iostream>
#include<map>
#include<vector>
#include<cstring>
#include<algorithm>
using namespace std;

constexpr int MAXN = 100;
int n, ans[MAXN];
vector<int> edge[MAXN];
bool vis[MAXN];

struct Node{
  int x, y;
  Node(int x = 0, int y = 0) : x(x), y(y) {}
  bool operator < (const Node& n) const {
    if(x != n.x) return x < n.x;
    return y < n.y;
  }
  bool operator == (const Node& n) const {
    return x == n.x && y == n.y;
  }
  bool operator > (const Node& n) const {
    return x > n.x && y > n.y;
  }
  friend ostream& operator << (ostream& out, const Node& n) {
    out << "(" << n.x << "," << n.y << ") ";
    return out;
  }
};
map<Node, int> m;
vector<Node> node;

bool getD() {
  cin >> n;
  if(!n) return false;
  memset(vis, 0, sizeof(vis));
  memset(ans, 0, sizeof(ans));
  for(int i = 0; i < MAXN; i++) edge[i].clear();
  node.clear();
  m.clear();
  for(int i = 0; i < n; i++) {
    int temp[3];
    for(int i = 0; i < 3; i++) cin >> temp[i];
    for(int i = 0; i < 3; i++) {
      int x = temp[i%3], y = temp[(i+1)%3], z = temp[(i+2)%3];
      if(x > y) x^=y^=x^=y;
      if(m.count(Node(x, y))) {
          if(z > m[Node(x, y)]) m[Node(x, y)] = z;
      } else {
          m[Node(x, y)] = z;
          node.push_back(Node(x, y));
      }
    }
  }
  int len = node.size();
  for(int i = 0; i < len; i++)
    for(int j = 0; j < len; j++) {
      if(node[i] > node[j]) edge[i].push_back(j);
    }
  return true;
}

int dfs(int pos) {
  if(vis[pos]) return ans[pos];
  int pre = m[Node(node[pos].x, node[pos].y)];
  vis[pos] = true;
  int len = edge[pos].size();
  for(int i = 0; i < len; i++) ans[pos] = max(ans[pos], pre+dfs(edge[pos][i]));
  return ans[pos] = max(ans[pos], pre);
}

void output(int& len, int& kase) {
  int out = ans[0];
  for(int i = 1; i < len; i++) if(ans[i] > out) out = ans[i];
  cout << "Case " << ++kase << ": maximum height = " << out << endl;
}

int main() {
  int kase = 0;
  while(getD()) {
    int len = node.size();
      for(int i = 0; i < len; i++) {
        if(vis[i]) continue;
        dfs(i);
    }
    output(len, kase);
  }
  return 0;
} 

本题笔者的思路如下:

首先仍然需要考虑到DAGdp模型,这边需要读懂无数个立方体的含义,该题本质上是套壳的嵌套矩形模板,这边和第六章的自组合的思想类似,同样是将立方体(正方形)拆分成点来理解,因为有无限个,那么立方体就会退化成矩形,因此本题的真正含义是有3_n个矩形(带权),求最长路

那么我们首先处理立方体化为矩形的问题,考虑到矩形数量较少,因此直接借用STL中的map,不需要自己手动hash压缩状态,当有新的矩形进来的时候刷新矩形库,如果有相同的矩形再次进入,刷新权值,保持权值的最大

接下来就是正常的矩形嵌套模型,状态转移方程如下:

我们首先借用离散化的思想,可以按照先后顺序对于矩形编号1-n

另T(n)为以第n个矩形为起点的最长路径

则,注意这边的cost[i]表示矩形i自身的权值

T(i) = max(cost[i]+T(j)|(i,j)属于E)

这样递归求解,本题就没什太大问题了

唔,本题还需要注意对于矩形存储的时候唯一性的保证,也就是通过另x <= y来实现的,否则会出现bug,首先(x,y)和(y,x)造成资源浪费,同时对于严格大于并不能简单理解为x>n.x&&y>n.y

这样总的决策数为3_n,决策下的可选方案为3*n,因此时间复杂度为O(n*n)

以下是作者的思路:

在任何时候,只有顶面的尺寸会影响到后续决策,因此可以用二元组(a,b)来表示“顶面尺寸为a_b”这个状态,因为每次增加一个立方体以后顶面的长和宽都会严格减小,所以这个图是DAG,可以套用前面学过的DAG最长路算法

这边需要注意的问题是,不能直接用d(a,b)表示状态值,因为a和b可能很大,可以用(idx, k)这个二元组来间接表达这个状态,其中idx为顶面立方体的编号,k是高的序号(不重不漏,是一种完美hash的体现(从属关系在hash中最为喜欢,可以较为简单的转换称为完美hash的关键))

状态总数是n,每个状态的决策有O(n)个,时间复杂度为O(n/_n)

Tour:

点击查看代码

#include<iostream>
#include<cmath>
#include<cstring>
#include<iomanip>
#include<algorithm>
using namespace std;

constexpr int MAXN = 1000 + 10;
constexpr double INF = 1e30;
struct Node{
  int x, y;
  Node(int x = 0, int y = 0) : x(x), y(y) {}
} node[MAXN];
int n;
double ans[MAXN][MAXN];//up i down j from i to j

inline double dis(Node& n1, Node& n2) {
  return sqrt((n1.x-n2.x)*(n1.x-n2.x)+(n1.y-n2.y)*(n1.y-n2.y));
}

bool getD() {
  if(scanf("%d", &n) != 1) return false;
  for(int i = 0; i < n; i++) cin >> node[i].x >> node[i].y;
  for(int i = 0; i < n; i++)
    for(int j = 0; j < n; j++) ans[i][j] = INF;
  for(int i = 0; i < 1; i++) ans[i][0] = dis(node[0], node[i]);
  return true;
}

int main() {
//  freopen("test.out", "w", stdout);
  while(getD()) {
      for(int i = 1; i < n; i++) {
        for(int j = 0; j < i; j++) { //ans[i][j] i > j
          ans[i][i-1] = min(ans[i][i-1], ans[i-1][j]+dis(node[j], node[i]));
          ans[i][j] = min(ans[i][j], ans[i-1][j] + dis(node[i-1], node[i]));
      }
    }
    for(int i = 0; i < n; i++) ans[n-1][n-1] = min(ans[n-1][n-1], ans[n-1][i]+dis(node[i], node[n-1]));
    cout << fixed << setprecision(2) << ans[n-1][n-1] << endl;
  }
  return 0;
} 

呜呜,洛谷上面的另外一道评测(旅行商简化版)需要注意其数据并不合乎题目要求,int并不能很好的实现对于数据的存储,同时他的x给的并不是从左到右的顺序qaq,笔者调试了半天才发现这个问题

这道题本身是一个nphard问题,应该最坏的情况是所有情况访问过去,也就是复杂度高达O(n!),很明显不能再一个多项式时间内解决此类问题

幸运的是,本题做出了限制,使得他的复杂度降低为了多项式时间,首先,注意题目中的描述,是从最左边走到最右边,之后才能回头,也就是不存在1-3-2-4-1这个序列一定是从1-n的递增序列然后并上n-1的递减序列才是一个合法的路径,这样子我们其实严格保证了后面的状态不会影响前面的状态,因此最优子结构问题开始显现(记忆化搜索yyds)

题目中要求每个点,除了出发点都只能经历一次,该条件确保了这条路线的拓扑序,分析到现在,很明显,一道DAGdp模板题已经摆出来了

接下来,仍然是老三样,状态存储,状态转移方程,递归循环求解

状态存储,这边笔者考虑的是通过f[i][j]数组,(这边保证i >= j,不保证也可以,不过最后刷新出来的答案矩阵是一个对称阵,会损耗一定的效率),f[i][j]表示当前以i,j两点为起点和端点的最短路径,这边建议初始值赋值一个极大值INF,方便后续的处理

状态转移方程:这边笔者采用的是填表法(刷表法暂无),那么对于一个状态f[i][j]分两种情况考虑

从f[i-1][j]到f[i][j],也就是将i-1和i连接在一起,那么这时候j的范围是1-i-1

这边dis(i, j)表示i和j之间的欧式距离

也就是f[i][j] = min(f[i-1][j]+dis(i-1,i)),j属于[0,i-1)

另外一种也就是对于f[i][i-1]状态的考虑

f[i][i-1] = min(f[j][i-1]+dis(i,j)|j属于[0,i-1))

这边同样的考虑固定i-1,变换j,将j和i连接,刷新最小值点

那么这样子关于i这个点的最优连接方法我们都已经刷新完毕,以此类推,到达最后一个n结点的时候,我们只需要再进行一个循环判断,

ans = min(f[n][i]+dis(i,n)|i属于[0,n))

即将链变成环,完成最后的收尾

对了,很明显,总状态数为n*n/2,决策数为2,因此时间复杂度为O(n*n)

以下是作者的解释:

“从左到右再回来”不太方便思考,可以改成,两个人同时从最左点出发,沿着两条不同的路径走,最后都走到最右点,且除了起点和终点外其余每个点恰好被一个人经过。这样就可以用d(i,j)表示第一个人走到i,第二个人走到j,还需要走多长的距离

状态转移方程的思考如下:

仔细思考后发现,好像很难保证两个人不会走到相同的点。例如计算状态d(i,j)时,能不能让i走到i+1呢,不知道,因为从状态里看不出来i+1有没有被j走过。换句话说,状态定义得不好,会使得转移困难,也就是说在动态规划中,对于状态的理解和定义其重要程度和状态转移方程的思考是等同的

下面修改一下:

d(i,j)表示1-max(i,j)全部走过,且每个人的当前位置分别是i和j,还需要走多长的距离,不难发现d(i,j)=d(j,i),因此从现在开始规定状态中i>j。这样不管是那个人,下一步只能走到i+1,i+2,…这些点。可是,如果走到i+2,情况变成了"1-i和i+2,但是i+1每走过",无法表示成状态!(注意,状态本质上是通过一种复杂的映射将搜索中的结点投到矩阵中,以此实现我们复杂情况下的记录,以及快速查询)因此我们需要禁止这样的决策!也就是说只允许其中一个人走到i+1,而不能走到i+2,i+3,换句话说,状态d(i,j)只能转移到d(i+1, j)和d(i+1, i)(第二个人走到i+1时,本应转移到d(i,i+1),但是因为i>j,因此必须写成d(i+1,i))。

可是这样又产生了新的问题,上述“霸道”的规定是否可能导致漏解呢?不会,因为如果第一个人直接走到了i+2,那么他再也无法走到i+1了,只能靠着第二个人走到i+1。既然如此,现在就让第二个人走到i+1,并不会丢解(不重不漏,搜索的重点要求,仍然需要考虑)

边界是d(n-1,j) = dist(n-1, n) + dist(j, n),其中dist(a,b)表示a和b之间的距离。因为根据定义,所有点都走过了,两个人只需直接走到终点。所求结果是dist(1,2)+dist(2,1),因为第一步一定是某个人走到了第二个点,根据定义,这就是d(2,1),上述是对边界条件的初始化问题

状态总数有O(n*n)个,每个状态的决策只有两个,因此总时间复杂度为O(n*n)

那么DAGdp模型到此结束,接下来向着多阶段决策问题出发!

手机扫一扫

移动阅读更方便

阿里云服务器
腾讯云服务器
七牛云服务器

你可能感兴趣的文章