观察下面的数字金字塔。写一个程序查找从最高点到底部任意处结束的路径,使路径经过数字的和最大。每一步可以从当前点走到左下方的点也可以到达右下方的点。
输入:
第一个行包含R(1<= R<=1000),表示行的数目。
后面每行为这个数字金字塔特定行包含的整数。
所有的被供应的整数是非负的且不大于100。
输出:
单独的一行,包含那个可能得到的最大的和。
方法1:搜索
问题要求的是从最高点按照规则走到最低点的路径的最大的权值和,路径起点终点固定,走法规则明确,可以考虑用搜索来解决。
定义递归函数void Dfs(int x,int y,int Curr),其中x,y表示当前已从(1,1)走到(x,y),目前已走路径上的权值和为Curr。
当x=N时,到达递归出口,如果Curr比Ans大,则把Ans更新为Curr;否则向下一行两个位置行走,即递归执行Dfs(x+1,y,Curr+A[x+1][y])和Dfs(x+1,y+1,Curr+A[x+1][y+1])
#include <iostream>
using namespace std;
const int MAXN = 1005;
int A[MAXN][MAXN],F[MAXN][MAXN],N,Ans;
void Dfs(int x,int y,int Curr)
{
if (x==N)
{
if (Curr>Ans)Ans=Curr;
return;
}
Dfs(x+1,y,Curr+A[x+1][y]);
Dfs(x+1,y+1,Curr+A[x+1][y+1]);
}
int main()
{
cin >> N;
for(int i = 1;i <= N;i ++)
for(int j = 1;j <= i;j ++)
cin >> A[i][j];
Ans =0;
Dfs(1,1,A[1][1]);
cout<<Ans<<endl;
return 0;
}
该方法实际上是把所有路径都走了一遍,由于每一条路径都是由N-1步组成,每一步有“左”、“右”两种选择,因此路径总数为2N-1,所以该方法的时间复杂度为O(2N-1),超时。
方法2:记忆法搜索
方法1之所以会超时,是因为进行了重复搜索,如样例中从(1,1)到(3,2)有“左右”和“右左”两种不同的路径,也就是说搜索过程中两次到达(3,2)这个位置,那么从(3,2)走到终点的每一条路径就被搜索了两次,我们完全可以在第一次搜索(3,2)到终点的路径时就记录下(3,2)到终点的最大权值和,下次再次来到(3,2)时就可以直接调用这个权值避免重复搜索。我们把这种方法称为记忆化搜索。
记忆化搜索需要对方法一中的搜索进行改装。由于需要记录从一个点开始到终点的路径的最大权值和,因此我们重新定义递归函数Dfs。
定义Dfs(x,y)表示从(x,y)出发到终点的路径的最大权值和,答案就是Dfs(1,1)。计算Dfs(x,y)时考虑第一步是向左还是向右,我们把所有路径分成两大类:
①第一步向左:那么从(x,y)出发到终点的这类路径就被分成两个部分,先从(x,y)到(x+1,y)再从(x+1,y)到终点,第一部分固定权值就是A[x][y],要使得这种情况的路径权值和最大,那么第二部分从(x+1,y)到终点的路径的权值和也要最大,这一部分与前面的Dfs(x,y)的定义十分相似,仅仅是参数不同,因此这一部分可以表示成Dfs(x+1,y)。综上,第一步向左的路径最大权值和为A[x][y]+Dfs(x+1,y);
②第一步向右:这类路径要求先从(x,y)到(x+1,y+1)再从(x+1,y+1)到终点,分析方法与上面一样,这类路径最大权值和为A[x][y]+Dfs(x+1,y+1);
为了避免重复搜索,我们开设全局数组F[x][y]记录从(x,y)出发到终点路径的最大权值和,一开始全部初始化为-1表示未被计算过。在计算Dfs(x,y)时,首先查询F[x][y],如果F[x][y]不等于-1,说明Dfs(x,y)之前已经被计算过,直接返回 F[x][y]即可,否则计算出Dfs(x,y)的值并存储在F[x][y]中。
#include <iostream>
#include <algorithm>
using namespace std;
const int MAXN = 505;
int A[MAXN][MAXN],F[MAXN][MAXN],N;
int Dfs(int x,int y)
{
if (F[x][y]==-1)
{
if (x==N)F[x][y]=A[x][y];
else F[x][y]=A[x][y]+max(Dfs(x+1,y),Dfs(x+1,y+1));
}
return F[x][y];
}
int main()
{
cin >> N;
for(int i = 1;i <= N;i ++)
for(int j = 1;j <= i;j ++)
cin >> A[i][j];
for(int i = 1;i <= N;i ++)
for(int j = 1;j <= i;j ++)
F[i][j] = -1;
Dfs(1,1);
cout << F[1][1] << endl;
return 0;
}
由于F[x][y]对于每个合法的(x,y)都只计算过一次,而且计算是在O(1)内完成的,因此时间复杂度为O(
)。
方法3:顺推法
方法2通过分析搜索的状态重复调用自然过渡到记忆化搜索,而记忆化搜索本质上已经是动态规划了。下面我们完全从动态规划的算法出发换一个角度给大家展示一下动态规划的解题过程,并提供动态规划的迭代实现法。
①确定状态:
题目要求从(1,1)出发到最底层路径最大权值和,路径中是各个点串联而成,路径起点固定,终点和中间点相对不固定。因此定义F[x][y]表示从(1,1)出发到达(x,y)的路径最大权值和。最终答案Ans=max{F[N][1],F[N][2],...,F[N][N]}。
②确定状态转移方程和边界条件:
不去考虑(1,1)到(x,y)的每一步是如何走的,只考虑最后一步是如何走,根据最后一步是向左还是向右分成以下两种情况:
向左:最后一步是从(x-1,y)走到(x,y),此类路径被分割成两部分,第一部分是从(1,1)走到(x-1,y),第二部分是从(x-1,y)走到(x,y),要计算此类路径的最大权值和,必须用到第一部分的最大权值和,此部分问题的性质与F[x][y]的定义一样,就是F[x-1,y],第二部分就是A[x][y],两部分相加即得到此类路径的最大权值和为F[x-1,y]+A[x,y];
向右:最后一步是从(x-1,y-1)走到(x,y),此类路径被分割成两部分,第一部分是从(1,1)走到(x-1,y),第二部分是从(x-1,y)走到(x,y),分析方法如上。此类路径的最大权值和为F[x-1,y-1]+A[x,y];
F[x][y]的计算需要求出上面两种情况的最大值。综上,得到状态转移方程如下:
F[x][y]=max{F[x-1,y-1],F[x-1][y]}+A[x,y]
与递归关系式还需要递归终止条件一样,这里我们需要对边界进行处理以防无限递归下去。观察发现计算F[x][y]时需要用到F[x-1][y-1]和F[x-1,y],是上一行的元素,随着递归的深入,最终都要用到第一行的元素F[1][1],F[1][1]的计算不能再使用状态转移方程来求,而是应该直接赋予一个特值A[1][1]。这就是边界条件。
综上得:
状态转移方程:F[x][y]=max{F[x-1][y-1],F[x-1][y]}+A[x,y]
边界条件:F[1][1]=A[1][1]
现在让我们来分析一下该动态规划的正确性,分析该解法是否满足使用动态规划的两个前提:
最优化原理:这个在分析状态转移方程时已经分析得比较透彻,明显是符合最优化原理的;
无后效性:状态转移方程中,我们只关心F[x-1][y-1]与F[x-1][y]的值,计算F[x-1][y-1]时可能有多种不同的决策对应着最优值,选哪种决策对计算F[x][y]的决策没有影响,F[x-1][y-1]也是一样。这就是无后效性。
③程序实现:
由于状态转移方程就是递归关系式,边界条件就是递归终止条件,所以可以用递归来完成,递归存在重复调用,利用记忆化可以解决重复调用的问题,方法二已经讲过。记忆化实现比较简单,而且不会计算无用状态,但递归也会受到“栈的大小”和“递推+回归执行方式”的约束,另外记忆化实现调用状态的顺序是按照实际需求而展开,没有大局规划,不利于进一步优化。
这里介绍一种迭代法。与分析边界条件方法相似,计算F[x][y]用到状态F[x-1][y-1]与F[x-1][y],这些元素在F[x][y]的上一行,也就是说要计算第x行的状态的值,必须要先把第x-1行元素的值计算出来,因此我们可以先把第一行元素F[1][1]赋为 A[1][1],再从第二行开始按照行递增的顺序计算出每一行的有效状态即可。时间复杂度为O(
)。
#include <iostream>
#include <algorithm>
using namespace std;
const int MAXN = 1005;
int A[MAXN][MAXN],F[MAXN][MAXN],N;
int main(){
cin >> N;
for(int i = 1;i <= N;i ++)
for(int j = 1;j <= i;j ++)
cin >> A[i][j];
F[1][1] = A[1][1];
for(int i = 2;i <= N;i ++)
for(int j = 1;j <= i;j ++)
F[i][j]=max(F[i-1][j-1],F[i-1][j])+A[i][j];
int ans =0;
for(int i = 1;i <= N;i ++)
ans = max(ans,F[N][i]);
cout << ans << endl;
return 0;
}
方法4:逆推法
①贪心法往往得不到最优解:本题若采用贪心法则:13-11-12-14-13,其和为63,但存在另一条路:13-8-26-15-24,其和为86。
②动态规划求解:动态规划求解问题的过程归纳为:自顶向下的分析,自底向上计算。
其基本方法是:
划分阶段:按三角形的行,划分阶段,若有n行,则有n-1个阶段。
A.从根结点13出发,选取它的两个方向中的一条支路,当到倒数第二层时,每个结点其后继仅有两个结点,可以直接比较,选择最大值为前进方向,从而求得从根结点开始到底端的最大路径。
B.自底向上计算:(给出递推式和终止条件)
①从底层开始,本身数即为最大数;
②倒数第二层的计算,取决于底层的数据:12+6=18,13+14=27,24+15=39,24+8=32;
③倒数第三层的计算,取决于底二层计算的数据:27+12=39,39+7=46,39+26=65
④倒数第四层的计算,取决于底三层计算的数据:46+11=57,65+8=73
⑤最后的路径:13——8——26——15——24
C.数据结构及算法设计
①图形转化:直角三角形,便于搜索:向下、向右
②用三维数组表示数塔:a[x][y][1]表示行、列及结点本身数据,a[x][y][2]能够取得最大值,a[x][y][3]表示前进的方向——0向下,1向右;
③算法:
数组初始化,输入每个结点值及初始的最大路径、前进方向为0;从倒数第二层开始向上一层求最大路径,共循环N-1次;从顶向下,输出路径:究竟向下还是向右取决于列的值,若列的值比原先多1则向右,否则向下。
#include<iostream>
#include<cstring>
using namespace std;
int main()
{
int n,x,y;
int a[51][51][3];
cout<<"please input the number of rows:";
cin>>n;
memset(a,0,sizeof(0));
for (x=1;x<=n;x++) //输入数塔的初始值
for (y=1;y<=x;y++)
{
cin>>a[x][y][1];
a[x][y][2]=a[x][y][1];
a[x][y][3]=0; //路径走向,默认向下
}
for (x=n-1;x>=1;x--)
for (y=1;y<=x;y++)
if (a[x+1][y][2]>a[x+1][y+1][2]) //选择路径,保留最大路径值
{ a[x][y][2]=a[x][y][2]+a[x+1][y][2]; a[x][y][3]=0; }
else { a[x][y][2]=a[x][y][2]+a[x+1][y+1][2]; a[x][y][3]=1; }
cout<<"max="<<a[1][1][2]<<endl; //输出数塔最大值
y=1;
for (x=1;x<=n-1;x++) //输出数塔最大值的路径
{
cout<<a[x][y][1]<<"->";
y=y+a[x][y][3]; //下一行的列数
}
cout<<a[n][y][1]<<endl;
}
输入:
5 //数塔层数
13
11 8
12 7 26
6 14 15 8
12 7 13 24 11
输出结果:
max=86
13->8->26->15->24