历届试题 数字三角形
问题描述
在下面的数字三角形中寻找一条从顶部到底边的路径,使得路径上所经过的数字之和最大。路径上的每一步都只能往左下或 右下走。只需要求出这个最大和即可,不必给出具体路径。 三角形的行数大于 1 小于等于 100,数字为 0–99。
输入格式:
第一行为一个整数 n n n,表示接下来将要输入的三角形行数。
接下来是 n n n 行,每行输入当前行数的个数个数。输出格式:
输出一个数字,表示最大和。
样例输入:
如上图
样例输出:
30
数据说明:
最大路径为(7-3-8-7-5)从上至下。
—— 初入江湖之动态规划 ——
分析:
这是一道经典的动态规划问题,我们还是从最浅显的算法出发,由浅到深慢慢研究。首先最容易想到的是暴力搜索算法。我们先用一个二维数组map来存放上面的数字三角形,于是有:
map[1][1]=7
map[2][1]=3 map[2][2]=8
map[3][1]=8 map[3][2]=1 map[3][3]=0
……
然后我们假设,这里有一个名为 dfs(x,y) 的函数,该函数能从上述三角形中位置为 (x,y) 处出发,往其下自动寻找最大路径和,并返回这个和。那么对于题目输入的任何数字三角形,我们只需要输出 dfs(1,1) 即可。接下来的重点就在于如何设计这个 dfs 函数上(这样的转换和汉诺塔问题类似)。
通过题目给出的图可以知道,在 map[1][1] 下面,只有两条路:要么选择 map[2][1],要么选择 map[2][2]。也就是说,最大路径应该在这两者之间产生,那么我们为了选择最大路径和,当然是选较大的,即:
return max( dfs(2,1),dfs(2,2) ) + map[1][1]
同样地,在 dfs(2,1) 和 dfs(2,2) 中也一样,都是选择在其下方中的较大者,于是可以得出递归式:
int dfs(int i,int j)
{
return max( dfs(i+1,j),dfs(i+1,j+1) ) + map[i][j];
}
上述递归式实现了程序自顶向下寻找最大路径和的过程,但是却没有给出中止条件。细想,当 i i i 走到最后一行时(即 i = n i=n i=n),dfs 函数就不能再往下搜索,而是直接返回。基于此,可以写出采用递归方法求解的完整代码为:
#include<iostream>
using namespace std;
const int N=105;
int n,map[N][N];
int dfs(int i,int j)
{
if(i==n) return map[i][j];
else return max(dfs(i+1,j),dfs(i+1,j+1))+map[i][j];
}
int main()
{
cin>>n;
for(int i=1;i<=n;i++)
for(int j=1;j<=i;j++)
cin>>map[i][j];
cout<<dfs(1,1)<<endl;
return 0;
}
此代码在蓝桥杯的OJ中只能得到 57 分(过了 4/7 的数据)。出现这样的结果是由于较深层次的递归会使用大量堆栈空间,从而造成栈溢出(通常情况下,递归树超过 50 层就会出现溢出)。此外,从前面 动态规划之斐波那契数列 部分中对递归算法的分析可知,递归算法会执行大量重复工作,而这会耗费大量时间。因此,为了能通过所有测试数据,就必须用一个数组来保存前面已经算出的结果,从而使递归变为递推,以在时间和空间上进行优化,下面我们来分析如何用动态规划的思路来求解这个问题。
首先,依然是填表。根据上面 dfs 算法的思路,程序总会搜索到最后一行,然后开始选择最大值,那么我们的表格也应该是从最后一行开始进行填写,如下图所示,我们首先把最下面一行填入表格中:
然后开始填写倒数第二行。先分析第一个数字 2,由于 2 可以和最后一行的 4 相加,也可以和最后一行的 5 相加,但是显然其与 5 相加的和更大(和为 7),因此我们可以先将 7 保存起来;接着分析第二个数字 7,由于 7 可以和最后一行的 5 相加,也可以和最后一行的 2 相加,很显其与 5 相加的和更大(和为 12),因此我们可以先将 12 保存起来……,以此类推,最终便可以得到下图所示的内容:
然后按同样的方法填写倒数第三行和倒数第四行,直至第一行,我们可以依次得到下图所示的内容:
如果设 maxSum(i,j) 表示坐标为
(
i
,
j
)
(i,j)
(i,j) 的位置通向最后一行所寻找到的最大路径和,那么根据上面的推导过程,我们可以得到其递推公式为:
m a x S u m [ i ] [ j ] = m a x ( m a x S u m [ i + 1 ] [ j ] , m a x S u m [ i + 1 ] [ j + 1 ] ) + m a p [ i ] [ j ] ; maxSum[i][j] = max( maxSum[i+1][j],maxSum[i+1][j+1] ) + map[i][j]; maxSum[i][j]=max(maxSum[i+1][j],maxSum[i+1][j+1])+map[i][j];
根据这样的思路可写出如下完整代码:
#include<iostream>
using namespace std;
const int N=105;
int n,map[N][N],maxSum[N][N];
void DP()
{
for(int i=1;i<=n;i++) maxSum[n][i]=map[n][i];
for(int i=n-1;i>=1;i--)
for(int j=1;j<=i;j++)
maxSum[i][j] = max(maxSum[i+1][j],maxSum[i+1][j+1]) + map[i][j];
}
int main()
{
cin>>n;
for(int i=1;i<=n;i++)
for(int j=1;j<=i;j++)
cin>>map[i][j];
DP();
cout<<maxSum[1][1]<<endl;
return 0;
}
—— 翻山越岭之内存优化 ——
实际上,这里没有必要用一个 maxSum[N][N] 数组来进行递推,直接在 map[N][N] 数组上就行。更进一步,甚至连二维矩阵都不需要,直接用两个一维数组就行,一个一维数组 num[N] 用于存放当前输入的某一行数字;另一个一维数组 lastNum[N] 用于存放对某行进行操作后的情况。显然,这种处理办法是基于对输入的数字三角形进行行处理而得到的,但是题目给出的数字三角形的输入是从上往下进行的,而上面介绍的所有求解办法都是从下往上进行的。因此,在介绍用两个一维数组进行处理的办法前,需要先介绍下从上往下解答的算法。老规矩,从填表开始:
第一次,由于第一行只有一个数字,因此得到的表格如下图所示:
第二次,此时往下走,由于出发点只有一个数字 7,且终点也只有两个数字(分别为 3 和 8),因此从第一行往二行走的办法只有两条,无需选择,于是得到的表格如下图所示:
第三次,此时出发点就有两个了,我们把目光主要放在第三行,比如现在针对第三行第一列的数字 8,其只能由其上方的数字 3 走来,于是得到第三行第一列的路径和为 10+8=18;然后看第三行第二列的数字 1,其可以由其上方的数字 3 和数字 8 走来,但是显然,从数字 8 走来会使得路径和更大,因此第三行第二列的路径和为 15+1=16;最后是第三行第三列的数字 0,其也只能从其上方的数字 8 走来,于是得到第三行第三列的路径和为 15+0=15。最终得到的表格如下图所示:
按照这样的思路继续填表,依次得到的表格内容如下图所示:
最终,表格的最后一行将装填从整个数字三角形顶端到底端各个出口的最大路径和。如果我们要求解这其中的最大值就还需要写一个取数组 maxSum[n][n]={24,30,27,26,24} 最大值的函数。
实际上,在上面的填表过程中,状态转移方程变成了:
m a x S u m [ i ] [ j ] = m a x ( m a x S u m [ i − 1 ] [ j − 1 ] , m a x S u m [ i − 1 ] [ j ] ) + m a p [ i ] [ j ] maxSum[i][j] = max( maxSum[i-1][j-1],maxSum[i-1][j] ) + map[i][j] maxSum[i][j]=max(maxSum[i−1][j−1],maxSum[i−1][j])+map[i][j]
注:原始数据存放在 map[N][N] 中,所填的表格为 maxSum[N][N],数据的索引从 1 开始。
同样地,这里我们也可以不用 maxSum[N][N] 数组,而是直接在 map[N][N] 中进行状态转移,即:
m a p [ i ] [ j ] = m a x ( m a p [ i − 1 ] [ j − 1 ] , m a p [ i − 1 ] [ j ] ) + m a p [ i ] [ j ] map[i][j] = max( map[i-1][j-1],map[i-1][j] ) + map[i][j] map[i][j]=max(map[i−1][j−1],map[i−1][j])+map[i][j]
采用这种方法求解本题的完整代码如下:
#include<iostream>
using namespace std;
const int N=105;
int n,map[N][N];
void DP()
{
for(int i=2;i<=n;i++)
for(int j=1;j<=i;j++)
map[i][j] = max(map[i-1][j-1],map[i-1][j]) + map[i][j];
}
int maxValue()
{
int max=map[n][1];
for(int i=2;i<=n;i++)
if(map[n][i]>max) max=map[n][i];
return max;
}
int main()
{
cin>>n;
for(int i=1;i<=n;i++)
for(int j=1;j<=i;j++)
cin>>map[i][j];
DP();
cout<<maxValue()<<endl;
return 0;
}
—— 登峰造极之递推求解 ——
接下来我们对上面的代码进行优化,以将一个二维数组优化为两个一维数组。此时,可以用一个一维数组 num[N] 用于存放当前输入的某一行数字(相当于一个临时中转站);再用一个一维数组 lastNum[N] 用于存放对某行进行寻找最大路径和后的情况。同样地,填表。
首先,将第一行的数据输入 num[N] 数组中(注:初始情况下 num[N] 和 lastNum[N] 中的数据全为 0),如下图所示:
然后再更新 num[N] 数组中的某个元素 num[i] 为 max( lastNum[i], lastNum[i-1] )+num[i] ,如下图所示:
最后将 num[N] 数组中的内容复制到 lastNum[N] 数组中,如下图所示:
然后是第 2 行,同样先将整行数据输入 num[N] 数组中,即 num[N]={3,8},如下图所示:
接着将 num[N] 数组中的某个元素 num[i] 更新为 max( lastNum[i], lastNum[i-1] )+num[i],如下图所示:
最后将 num[N] 数组中的内容复制到 lastNum[N] 数组中,如下图所示:
按照这样的方式不断更新 lastNum[N] 数组, 最终,在 lastNum[N] 数组中存储的就是该数字三角形自顶向下往金字塔底部每个数字出去的最大路径和,为了取得这之间的最大值我们还需要写一个求解数组中最大值的函数。
注意:上述算法在将 num[N] 数组中的内容复制到 lastNum[N] 数组中时,不必进行真正的复制,而可以采用指针进行地址交换来完成这项工作(即通过指针操作来改变指向的数组),从而避免整个数组的直接复制,以节约时间。
下面给出改进后求解本题的完整代码:
#include<iostream>
using namespace std;
const int N=105;
int n,*num,*lastNum,ary1[N],ary2[N];
void dp()
{
num=ary1,lastNum=ary2;
for(int i=1;i<=n;i++){
for(int j=1;j<=i;j++){
cin>>num[j];
num[j]=max(lastNum[j],lastNum[j-1])+num[j];
}
swap(num,lastNum);
}
}
int maxValue()
{
int max=lastNum[1];
for(int i=2;i<=n;i++)
if(lastNum[i]>max) max=lastNum[i];
return max;
}
int main()
{
cin>>n;dp();
cout<<maxValue()<<endl;
return 0;
}