从动态规划的视角重新理解问题

ChangYo ·
·

最近在复习算法课的知识,有一节内容专门就是讲基于归纳的算法。所谓归纳,其实是数学上的表述,我们也可以成为动态规划,它们的本质,都可以通过状态转移方程来表示。而这篇文章,就是想深入规范地分析一下在常见的问题中,我们究竟如何来列出状态转移方程。

何为状态转移方程?

顾名思义,状态转移方程其实就是描述不同"状态“之间关系的方程。我们看待问题时,不再是之前的线性思维,而是将所有出现的可能看作一个个互相关联的状态。很多时候我们需要的解(比如全局最优,序列最长等等),就藏在某个状态之中。

状态,是对问题在某一时刻的完整描述

它必须要满足无后效性。也就是说,未来的决策不会受到到达该状态历史的影响。通俗点说,在做未来的决策时,我们不需要考虑状态是如何形成的,我们只要知道这个状态的情况。

这里的描述有点类似于数学中的数学归纳法:假设对于前 n-1 个规模的数据结论成立,在此基础上证明对规模为 n 的数据也成立。

一般形式的状态转移方程如下:

$$ dp(state) = opt\left\{dp(pre_state) + cost\right\} $$
  • dp是定义在状态上的值函数,可以代表最优值,可达性…
  • opt是聚合操作:$max、min、 \sum 、OR$等
  • prev_state是状态转移来源,必须满足拓扑序
  • cost就是从前驱状态转移到当前状态的成本

在这里的命名似乎会让人有一种从后往前递归的错觉,在大部分情况下可以这么理解,但其实只要能够满足拓扑序即可(这里相当于吧每个状态理解为了一个节点,形成了一张图)

此外还有一个比较重要的参数:

  • 边界条件:用于递归方程的终止,一般也作为算法的起点或终点

How to write a DP equation

我们可以以背包问题来描述写状态转移方程的流程。

  1. 定义状态

这是一个显然的回答,不过很多人容易和下面的定义值函数语义混在一起。具体来说,状态就是对这个问题的缩小版的信息聚合。我们可以问自己:“在不回看历史的情况下,我需要知道那些信息,才能做出最优决策?

在背包问题中,我们需要知道的就是能够取哪些物品,以及背包剩余容量

其实也可以这样理解状态的定义,它就是对原问题的一个复述,只不过有些信息需要改变,而这个需要改变的信息,其实就是我们的状态。

  1. 定义值函数 dp

我们必须用十分精确的自然语言回答清楚,dp(state)代表什么?。这是最关键的工作,往往也觉得能否成功写出正确的状态转移方程。例如:

dp[i][j]代表只考虑前i件物品,背包容量为j是,能取得的最大值

而不是当第i件物品必须放进容量为j的背包时,能取到的最大值。这或许也是一个状态,但是写出状态转移方程却很困难

  1. 枚举“最后一步决策”

枚举最后一步的可能,每一种可能其实就对应着一个子问题,从而对应着一个状态。

比如在背包问题中,所谓的最后一步决策只有两种,第i件物品放入背包 和 第i件物品不放入背包。于是这就得到了我们所需要的两中 prev_state。

  1. 确定边界和拓扑序

确保计算 dp(state)时,所有的 dp(prev_state)都可以视为已经计算好的,尤其是边界情况,需要手动标注一下。例如 dp[0][j]=dp[i][0]=0.

这样,我们可以得到0-1背包问题的状态转移方程如下:

$$ dp[i][j] = \max \left\{ dp[i-1][j] , dp[i-1][j-weight[i]] + value[i]\right\} $$

不同问题结构下DP方程形式

状态和值函数的定义是十分灵活的,上面的背包问题只是其中一种形式,下面是按照不同的问题结构来分类的一些例题

线性 DP:状态沿序列推进

最长递增子序列

dp[i]代表以第i个数结尾的最擦行递增子序列长度

$$ dp[i] = \max_{j<i,a[j]<a[i]} \left\{dp[j] +1 \right\} $$

最长公共子序列

dp[i][j]代表字符串s前i个字符和字符串t前j个字符的最擦行公共子序列长度。

$$ dp[i][j]= \begin{cases} dp[i-1][j-1] + 1 & s[i]=s[j] \\ \max (dp[i-1][j],dp[i][j-1]) & otherwise \end{cases} $$

背包 DP:资源约束下的选择

刚才的0-1背包问题已经作为例子了,下面考虑完全背包问题(每个物品可以选无数次)

dp[i][j]依然表示只考虑前i个物品,背包容量为j时的最大价值

$$ dp[i][j] = \max (dp[i − 1][j], f[i][j − weight[i]] + value[i]) $$

可以发现,它和0-1背包问题的主要区别就是,原来的dp[i][j-weight[i]]变成了f[i][j − weight[i]],这意味着第i个物品可以继续选。

区间DP:问题定义在区间上

状态形如 dp[i][j],表示区间[i , j]上的最优解。这个时候一般最后一步决策不在i或者j,而是区间中的某个位置 k.

我们以矩阵链乘问题为例:

dp[i][j]表示将第 i 个矩阵到第j个矩阵相差的最小运算次数。

解决这类问题是将区间划分成两端,在计算分割点处的代码。所以需要枚举位置k.

$$ dp[i][j]=\max_{i\le k <j} \left\{ dp[i][k] + dp[k+1][j] + p_{i} \cdot p_{k+1} \cdot p_{j+1} \right\} $$

矩阵链乘问题

给定一个矩阵链 $A_1,A_2,A_3 ... A_n$ ,找到完全括号化(即确定乘法顺序)的方式,使得标量乘法的总次数最少。

树形 DP:状态定义在树节点上

以节点u 为根的⼦树作为⼦问题,利⽤树的递归结构。这里以树的最大独立集为例:

dp[u][0/1]表示以u为根的子树中,u不选(或选)时,子树的最大权重。如果选了u,则子节点不能选取,否则子节点自由选取。

$$ dp[u][0]=\sum_{v \in children(u)} \max (dp[v][0],dp[u][1]) \\ dp[u][1]=\sum_{v \in children(u)} (dp[v][0]+weight[u]) $$

树的最大独立集问题

在一棵树中,找到一个顶点子集,使得子集中任意两个顶点都不相邻(即没有边直接连接它们),并且该子集的大小最大

计数 DP:值函数是⽅案数

把 max/min 切换为 $\sum$ ,问题就从最优化变成计数了。

比如常见的斐波那契数列:

$$ f[i]=f[i-1]+f[i-2] $$

还有整数划分问题(将 n 拆分为正整数之和的⽅案数):

dp[i][j]表示⽤不超过i 的正整数组成 j 的⽅案数

$$ dp[i][j]=dp[i-1][j]+dp[i][j-1] $$

dp[i-1][j]表示没用到i,dp[i][j-1]表示用到了i. 其实可以理解为完全背包问题的变体。


总之,介绍了这么多种动态规划的方法,想必读者们对于状态转移方程的写法十分熟悉了。

从状态的角度来分析问题,可以为问题提供一种新的视角。有些问题如果正面入手其实是很难直接想出一个算法的,但是动态规划让我们能够将所有的复杂度一层层解耦,我们可以专注于状态间的转移,而所有的状态可以通过递归去求解出来。