9.3 多阶段决策问题

还记得“多阶段决策问题”吗?在回溯法中曾提到过该问题。简单地说,每做一次决策就可以得到解的一部分,当所有决策做完之后,完整的解就“浮出水面”了。在回溯法中,每次决策对应于给一个结点产生新的子树,而解的生成过程对应一棵解答树,结点的层数就是“下一个待填充位置”cur。

9.3.1 多段图的最短路

多段图是一种特殊的DAG,其结点可以划分成若干个阶段,每个阶段只由上一个阶段所决定。下面举一个例子:

例题9-4 单向TSP(Unidirectional TSP, UVa 116)

给一个mn列(m≤10,n≤100)的整数矩阵,从第一列任何一个位置出发每次往右、右上或右下走一格,最终到达最后一列。要求经过的整数之和最小。整个矩阵是环形的,即第一行的上一行是最后一行,最后一行的下一行是第一行。输出路径上每列的行号。多解时输出字典序最小的。图9-5中是两个矩阵和对应的最优路线(唯一的区别是最后一行)。

 

图9-5 矩阵对应的最优路线

【分析】

在这个题目中,每一列就是一个阶段,每个阶段都有3种决策:直行、右上和右下。

提示9-13:多阶段决策的最优化问题往往可以用动态规划解决,其中,状态及其转移类似于回溯法中的解答树。解答树中的“层数”,也就是递归函数中的“当前填充位置”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};
          if(i == 0) rows[1] = m-1;         //第0行"上面"是第m-1行
          if(i == m-1) rows[2] = 0;         //第m-1行"下面"是第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", first+1);                 //输出第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背包问题是最广为人知的动态规划问题之一,拥有很多变形。尽管在理解之后并不难写出程序,但初学者往往需要较多的时间才能掌握它。在介绍0-1背包问题之前,先来看一个引例。

物品无限的背包问题。n种物品,每种均有无穷多个。第i种物品的体积为Vi,重量为Wi。选一些物品装到一个容量为C的背包中,使得背包内物品在总体积不超过C的前提下重量尽量大。1≤n≤100,1≤ViC≤10000,1≤Wi≤106

【分析】

很眼熟是吗?没错,它很像9.2节中的硬币问题,只不过“面值之和恰好为S”改成了“体积之和不超过C”,另外增加了一个新的属性——重量,相当于把原来的无权图改成了带权图(weighted graph)。这样,问题就变为了求以C为起点(终点任意)的、边权之和最大的路径。

与前面相比,DAG从“无权”变成了“带权”,但这并没有带来任何困难,此时只需将某处代码从“+1”变成“+W[i]”即可。你能找到吗?

提示9-14:动态规划的适用性很广。不少可以用动态规划解决的题目,在条件稍微变化后只需对状态转移方程做少量修改即可解决新问题。

0-1背包问题。n种物品,每种只有一个。第i种物品的体积为Vi,重量为Wi。选一些物品装到一个容量为C的背包,使得背包内物品在总体积不超过C的前提下重量尽量大。1≤n≤100,1≤ViC≤10000,1≤Wi≤10 6

【分析】

不知读者有没有发现,刚才的方法已经不适用了:只凭“剩余体积”这个状态,无法得知每个物品是否已经用过。换句话说,原来的状态转移太乱了,任何时候都允许使用任何一种物品,难以控制。为了消除这种混乱,需要让状态转移(也就是决策)有序化。

引入“阶段”之后,算法便不难设计了:用d(i,j)表示当前在第i层,背包剩余容量为j时接下来的最大重量和,则,边界是ind(i, j)=0,j<0时为负无穷(一般不会初始化这个边界,而是只当jV[i]时才计算第二项)。

说得更通俗一点,d(i, j)表示“把第i, i+1, i+2,…, n个物品装到容量为j的背包中的最大总重量”。事实上,这个说法更加常用——“阶段”只是辅助思考的,在动态规划的状态描述中最好避免“阶段”、“层”这样的术语。很多教材和资料直接给出了这样的状态描述,而本书中则是花费了大量的篇幅叙述为什么会想到要划分阶段以及和回溯法的内在联系——如果对此理解不够深入,很容易出现“每次碰到新题自己都想不出来,但一看题解就懂”的尴尬情况。

提示9-15:学习动态规划的题解,除了要理解状态表示及其转移方程外,最好思考一下为什么会想到这样的状态表示。

和往常一样,在得到状态转移方程之后,还需思考如何编写程序。尽管在很多情况下,记忆化搜索程序更直观、易懂,但在0-1背包问题中,递推法更加理想。为什么呢?因为当有了“阶段”定义后,计算顺序变得非常明显。

提示9-16:在多阶段决策问题中,阶段定义了天然的计算顺序。

下面是代码,答案是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的背包中的最大总重量”,其状态转移方程也不难得出:

边界是类似的:i=0时为0,j<0时为负无穷,最终答案为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(i, j)的计算过程,如图9-6所示。

   

图9-6 0-1背包问题的计算顺序

f数组是从上到下、从右往左计算的。在计算f(i, j)之前,f[j]里保存的就是f(i-1, j)的值,而f[jW]里保存的是f(i-1, jW)而不是f(i, jW)——别忘了j是逆序枚举的,此时f(i, jW)还没有算出来。这样,f[j] =(max[j], f[jV]+W)实际上是把保存在f[j]中,覆盖掉f[j]原来的f(i-1, j)。

提示9-17:在递推法中,如果计算顺序很特殊,而且计算新状态所用到的原状态不多,可以尝试用滚动数组减少内存开销。

滚动数组虽好,但也存在一些不尽如人意的地方,例如,打印方案较困难。当动态规划结束之后,只有最后一个阶段的状态值,而没有前面的值。不过这也不能完全归咎于滚动数组,规划方向也有一定责任——即使用二维数组,打印方案也不是特别方便。事实上,对于“前i个物品”这样的规划方向,只能用逆向的打印方案,而且还不能保证它的字典序最小(字典序比较是从前往后的)。

提示9-18:在使用滚动数组后,解的打印变得困难了,所以在需要打印方案甚至要求字典序最小方案的场合,应慎用滚动数组。

例题9-5 劲歌金曲(Jin Ge Jin Qu [h]ao, Rujia Liu's Present 6, UVa 12563)

如果问一个麦霸:“你在KTV里必唱的曲目有哪些?”得到的答案通常都会包含一首“神曲”:古巨基的《劲歌金曲》。为什么呢?一般来说,KTV不会在“时间到”的时候鲁莽地把正在唱的歌切掉,而是会等它放完。例如,在还有15秒时再唱一首2分钟的歌,则实际上多唱了105秒。但是融合了37首歌曲的《劲歌金曲》长达11分18秒(5),如果唱这首,相当于多唱了663秒!

假定你正在唱KTV,还剩t秒时间。你决定接下来只唱你最爱的n首歌(不含《劲歌金曲》)中的一些,在时间结束之前再唱一个《劲歌金曲》,使得唱的总曲目尽量多(包含《劲歌金曲》),在此前提下尽量晚的离开KTV。

输入nn≤50),tt≤109)和每首歌的长度(保证不超过3分钟(6)),输出唱的总曲目以及时间总长度。输入保证所有n+1首曲子的总长度严格大于t

【分析】

虽说t≤109,但由于所有n+1首曲子的总长度严格大于t,实际上t不会超过180n+678。这样就可以转化为0-1背包问题了。细节留给读者思考。