动态规划-数字三角形问题

有一个由非负整数组成的三角形,第一行只有一个数,除了最下行之外每个数的左下方和右下方各有一个数.

    1

   3 2

 4 10 1

4 3 2 20

从第一行的数开始,每次可以往左下或右下走一格,直到走到最下行,把沿途经过的数全部加起来,如何走才能使得这个和尽量大?

输入:三角形的行数n,数字三角形的各个数(从上到下,从左到右)

输出:最大的和。

运行结果:

如果熟悉回溯法,可能会立刻发现这是一个动态的决策问题:每次有两种选择-左下或右下。如果用回溯法求出所有可能的路线,就可以从中选出最优路线。但和往常一样,回溯法的效率太低;一个n层数字三角形的完整路线有2^{n-1}条,当n很大时回溯法的速度将让人无法忍受。

为了得到高效的算法,需要用抽象的方法思考问题:把当前的位置(i,j)看成一个状态(还记得么?)然后定义状态(i,j)的指标函数d(i,j)为从格子(i,j)出发时能得到的最大和(包括格子(i,j)本身的值)。在这个状态定义下,原问题的解是d(1,1)

下面看看不同状态之间是如何转移的。从格子(i,j)出发有两种决策。如果往左走,则走到(i+1,j)后需要求“从(i+1,j)”出发后能得到的最大和“这一问题,即d(i+1,j)。类似得,往右走之后需要求解d(i+1,j+1)。由于可以在这两个决策中自由选择,所以应选择d(i+1,j)和d(i+1,j+1)中较大的一个,换句话说,得到了所谓的状态转移方程:

d(i,j) = a(i,j) + max{d(i+1,j),d(i+1,j+1)}

如果往左走,那么最好情况等于(i,j)格子里的值a(i,j)与”从(i+1,j)出发的最大总和“之和,此时需注意这里的最大二字。如果连”从(i+1,j)出发走到底部“这部分的和都不是最大的,加上a(i,j)之后肯定也不是最大的。这个性质称为最优子结构,也可以描述成全局最优解包含局部最优解。不管怎样,状态和状态转移方程一起完整的描述了具体的算法。

int a[100][100],n,d[100][100];

第一种方法是递归计算(需注意边界处理)。

int solve1(int i,int j)
{
    /* 递归 (重复计算,效率低) O(2^n)
    把当前位置(i,j)看成一个状态 d[i][j]为从格子出发能得到的最大和 解为d[1][1]
    d(i,j)=a(i,j) +max {d(i+1,j),d(i+1,j+1)} */
    return a[i][j]+(i==n? 0 : max(solve1(i+1,j),solve1(i+1,j+1)));
}

这样做是正确的,但时间效率太低,其原因在于重复计算。在solve(1,1)对应的调用关系树中,solve(3,2)被计算了两次(一次是solve(2,1)需要的,一次是solve(2,2)需要的)。也许读者会认为重复算一两个数没有太大影响,但事实是:这样的重复不是单个结点,而是一颗子树。如果原来的三角形有n层,则调用关系树也会有n层,一共有2^{n}-1个结点。

递推计算(需再次注意边界处理):

int solve2()
{
    /*递推 (逆序枚举) O(n^2)
    i是逆序枚举的,计算d[i][j]前 所需要的d[i+1][j]
    和d[i+1][j+1]一定计算出来了*/
    int i,j;
    for(j=1;j<=n;j++)
        d[n][j]=a[n][j];
    for(i=n-1;i>=1;i--)
        for(j=1;j<=i;j++)
           d[i][j]=a[i][j]+max(d[i+1][j],d[i+1][j+1]);
    return d[1][1];
}

程序的时间复杂度显然是O(n^{2}),但为什么可以这样计算呢?原因在于:i是逆序枚举的,因此在计算d[i][j]前,它所需要的d[i+1][j]和d[i+1][j+1]一定已经计算出来了。

可以用递推法计算状态转移方程。递推的关键是边界和计算顺序。在多数情况下,递推法的时间复杂度是:状态总数 * 每个状态的决策个数 * 决策时间。如果不同状态的决策个数不同,需具体问题具体分析。

记忆化搜索。程序分成两部分。首先用 memset(d,-1,sizeof(d)); 把d全部初始化为-1,然后编写递归函数。

int solve3(int i,int j)
{
    //记忆化搜索 O(n^2)
     //题目中所说的各个数都是非负的 因此如果计算过某个d[i][j] 则应该非负
    if(d[i][j]>=0) //已经计算过
        return d[i][j];
    return d[i][j]=a[i][j]+(i==n? 0 : max(solve1(i+1,j),solve1(i+1,j+1)));
}

上述程序仍然是递归的,但同时也把计算结果保存在数组d中。题目中所各个数都是非负的,因此如果已经计算过某个d[i][j],则它应是非负的。这样,只需把所有d初始化为-1,即可通过判断是否d[i][j]>=0得知它是否已经被计算过。

最后,千万不要忘记把计算之后把它保存在d[i][j]中,根据c语言 赋值语句本身有返回值的规定,可以把保存d[i][j]的工作合并到函数的返回语句中。

上述程序的方法称为记忆化,它虽然不像递推法那样显式地指明了计算顺序,然仍然可以保证每个结点只访问一次。

由于i和j都在1-n之间,所有不相同的结点一共只有O(n^{2})个。无论以怎样的顺序访问,时间复杂度均为O(n^{2})。从2^{n}-n^{2}是一个巨大的优化,这正是利用了数字三角形具有大量重叠子问题的特点。

相关推荐
©️2020 CSDN 皮肤主题: 书香水墨 设计师:CSDN官方博客 返回首页