dp的实现

dp的设计

dp的设计包括两个步骤:状态表示和状态计算。

设计状态表示时需要考虑的是:问题如何用集合来表达,问题的解如何用集合的属性来表达;设计状态计算时需要考虑的是:dp问题的答案如何由若干大集合的属性求出,大一级集合的属性如何由小一级集合的属性推导出来,原子集合的属性是多少。

总而言之,dp的设计是要根据题意设计出一个满足无后效性和最优子结构性的集合及其属性。具体如何设计没有固定的范式,主要依靠经验来确定。

dp的递推实现

dp的递推实现包括三个要素:阶段、状态和决策。具体来讲,应该是按照自底向上的顺序遍历所有阶段,每个阶段遍历其中的所有状态,利用过去阶段的状态计算当前阶段的状态(即为决策)。

数塔问题

A[1][1]
A[2][1] A[2][2]
A[3][1] A[3][2] A[3][3]
......
A[n][1] A[n][2] ...... A[n][n]

从顶层出发,每一结点可以选择移动至其左下方的结点或移动至其右下方的结点,一直走到底层,要求找出一条路径,使路径上的数字的和最大。

将问题的解用从顶层结点到底层结点的所有路径的最大的数字和这一集合属性来表达,dp的集合划分形成树。

原子集合属性(由底层结点A[n][j],1 $\leqslant$ j $\leqslant$ n,单个结点组成的路径的最大数字和)可以直接得出为A[n][j]

小一级集合的属性到大一级集合的属性的推导式为:

\[f[i][j] = max \left\{ \begin{aligned} &f[i+1][j]+A[i][j]&&\text{(移动到左下方结点)}\\ &f[i+1][j+1]+A[i][j]&&\text{(移动到右下方结点)} \end{aligned} \right.\]
// 原子集合初始化:f[n][j] = A[n][j] (1<=j<=n)

for (int i = n-1; i >= 1; i--)
    for (int j = 1; j <= i; j++)
        f[i][j] = max(f[i+1][j], f[i+1][j+1]) + A[i][j];

// 最终结果:f[1][1]

dp的递归实现

dp的递归实现时的注意点有:

1、递归函数实现的是转移到其对应的状态的推导式;

2、首先将所有状态标记为未计算,递归中遇到已经计算的结点进行剪枝,也就是直接返回其状态值;

3、原子集合的初始化是在到达递归边界,也就是叶子结点处进行,而不是递推那样预先进行;

4、主程序需要调用一下树的根结点(森林的所有根结点)处的dp函数,才能使状态被计算出来;如果根结点难以辨别,可以直接将全部结点处的dp函数全部调用一遍,不会增加运算量。

数岭问题

A[1][1] A[1][2] ...... A[1][n]
A[2][1] A[2][2] ...... A[2][n]
......
A[n][1] A[n][2] ...... A[m][n]

A[x][y]表示该点的高度。从某点出发,每次可以向上/下/左/右移动一个点(前提是去往点的高度低于当前点的高度),可以从任意点出发,要求移动路径的最大长度。

将问题用m*n个集合属性来表达,即从A[x][y](1 $\leqslant$ x $\leqslant$ m,1 $\leqslant$ y $\leqslant$ n)出发的最长移动长度,这m*n个最大长度的最大值就是问题的解,dp的集合划分形成森林, A[x][y](1 $\leqslant$ x $\leqslant$ m,1 $\leqslant$ y $\leqslant$ n)中位于峰顶的几个点的集合成为森林的根结点,也是其他集合的祖先结点。

原子集合属性(从A[x][y],1 $\leqslant$ x $\leqslant$ m,1 $\leqslant$ y $\leqslant$ n,出发没有后继点的路径的最大长度)可以直接得出为1。

小一级集合的属性到大一级集合的属性的推导式为:

\[f[x][y] = max \left\{ \begin{aligned} &1&&\text{(A[x][y]没有能前往的点)}\\ &f[a][b]+1, [a][b]\in\{[x-1][y], [x+1][y], [x][y-1], [x][y+1]\}&&\text{(A[x][y]前往的点为A[a][b])} \end{aligned} \right.\]

本题的技巧:

1、在二维矩阵A[1...m][1...n]外添加一圈表示边界的标记(这里采用-1),可以便于判断出界;

2、定义dx[4] = {-1, 1, 0, 0}, dy[4] = {0, 0, -1, 1},可以便于循环遍历上/下/左/右的四个点。

// 标记未计算:f[x][y] = -1 (1<=x<=m, 1<=y<=n)

int dp(int x, int y) {
    if (f[x][y] != -1)
        return f[x][y];

    f[x][y] = 1;  // 原子集合初始化

    for (int i = 0; i < 4; i++) {
        int a = x + dx[i], b = y + dy[i];
        if (A[a][b] != -1 && A[a][b] < A[x][y])
            f[x][y] = max(f[x][y], dp(a, b) + 1);
    }

    return f[x][y];
}

for (int x = 1; x <= m; x++)
    for (int y = 1; y <= n; y++)
        dp(x, y);

// 最终结果:max(f[x][y]) (1<=x<=m, 1<=y<=n)

dp的状态初始化

定义状态数组f时,即使没有显式初始化也会有初值存在(C/C++默认初始化),我们需要保证这个初值不会影响dp后续计算(例如:要计算的属性为max,初始化为较小值不会影响后续计算),有两种情况可以将全部状态都初始化为原子集合状态值:

1、原子集合可能无法直接获得(需要一套判断逻辑才能判断),如果,即使将非原子集合对应的状态初始化为原子集合对应的状态,也不会影响dp后续的计算的话,那么就可以将所有状态全部初始化为原子集合状态值,从而简化初始化的过程。

2、在数岭问题中,如果A[x][y]没有能前往的点,推导式是f[x][y] = 1,不过实际代码中我们是f[x][y] = max(f[x][y], dp(x, y) + 1),而不是f[x][y] = max(1, dp(x, y) + 1),这是因为推导式中有一个定值(也就是本题中1这个值),我们用这个定值来初始化了所有状态,从而导致状态推导发生了变化。所以,在推导式有定值的时候,就可以考虑用这个定值来初始化所有状态,从而简化初始化的过程。